Compare commits
26
Commits
d88e56efcb
...
c1dab24de1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1dab24de1
|
||
|
|
b01bdabc37
|
||
|
|
1385dd3f5d
|
||
|
|
404be2bdd5
|
||
|
|
31ce520c5b
|
||
|
|
11269c1567
|
||
|
|
0aa9b3a567
|
||
|
|
52fe31319a
|
||
|
|
75c6f0168a
|
||
|
|
c9b7765646
|
||
|
|
1edf8cb225
|
||
|
|
ad5b5e377f
|
||
|
|
a8fb4793be
|
||
|
|
7f33c957e5
|
||
|
|
e4441f3c49
|
||
|
|
2bb252e018
|
||
|
|
97c6c7c440
|
||
|
|
daa4c3b6e4
|
||
|
|
91138dd39a
|
||
|
|
63a13404df
|
||
|
|
8c18abc24e
|
||
|
|
663021f712
|
||
|
|
c6ffda9aac
|
||
|
|
a5bc322814
|
||
|
|
3a2da3004b
|
||
|
|
79ff12548f
|
+2
-2
@@ -1,11 +1,11 @@
|
||||
# Раскладка av-dev в этом проекте: версия и настройки проверок.
|
||||
# Файл ведут скиллы плагина, править руками можно — комментарии свои.
|
||||
|
||||
version = 4 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
|
||||
version = 5 # версия раскладки; обратной совместимости нет, есть «приведён» и «нет»
|
||||
|
||||
[docs]
|
||||
# каталог миграций: по нему docs.py сверяет схему с database.md
|
||||
migrations = "internal/adapter/repo/pocketbase/migrations"
|
||||
migrations = "internal/adapter/repo/sqlite/migrations"
|
||||
|
||||
[tasks]
|
||||
# каталог задач от корня репозитория; имена частей — умолчания скрипта
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Что не уезжает в контекст сборки образа.
|
||||
#
|
||||
# Файл заведён не ради веса: без него `COPY web/ ./` кладёт каталог зависимостей
|
||||
# с машины собирающего **поверх** дерева, поставленного `npm ci` в контейнере, и
|
||||
# ступень собирает приложение из того, что лежит у него, а не из файла замка.
|
||||
# Сборка при этом зелёная — расхождение молчаливое.
|
||||
#
|
||||
# `.gitignore` этого не закрывает: docker его не читает.
|
||||
|
||||
# Зависимости и собранное приложение — ставит и собирает сама ступень образа.
|
||||
web/node_modules/
|
||||
web/embed/dist/
|
||||
web/*.tsbuildinfo
|
||||
|
||||
# Боевые и локальные данные: каталог записей и база того, кто запускал сервис
|
||||
# у себя. В образе им делать нечего.
|
||||
data/
|
||||
|
||||
# Настройки с секретами. Образ берёт конфиг на сервере, а не из дерева.
|
||||
config.toml
|
||||
.env
|
||||
|
||||
# История репозитория: в слой сборки не нужна.
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Каталоги процесса, а не сборки.
|
||||
openspec/
|
||||
tasks/
|
||||
docs/
|
||||
+16
-9
@@ -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/
|
||||
@@ -51,7 +46,19 @@ Thumbs.db
|
||||
# Config files
|
||||
config.toml
|
||||
|
||||
# Переменные окружения: сервис их не читает, настройки приезжают из TOML.
|
||||
# Строка стоит против того, чтобы секрет завёлся здесь руками: из этого файла
|
||||
# он попадает в git тем же способом, каким попал бы из конфига.
|
||||
.env
|
||||
|
||||
# Sample and test audio files
|
||||
*.m4a
|
||||
*.mp3
|
||||
*.ogg
|
||||
*.ogg
|
||||
|
||||
# Приложение: зависимости и собранное. Метка `web/embed/.gitkeep` остаётся в
|
||||
# git — без неё `go build ./...` отказывает у того, кто приложение не собирал.
|
||||
web/node_modules/
|
||||
web/embed/dist/
|
||||
# Слепок проверки типов: его пишет сборка, и в git он значил бы «собрано у меня».
|
||||
web/*.tsbuildinfo
|
||||
|
||||
+15
-2
@@ -64,8 +64,8 @@ linters:
|
||||
msg: 'пишем через slog, а не в stdout напрямую (docs/conventions/logging.md)'
|
||||
# Конфигурация приезжает из TOML. Перечислены все способы прочитать
|
||||
# окружение, а не один: `os.Getenv` без соседей обходится `os.LookupEnv`
|
||||
# одной правкой. Окружение читает только godotenv в main.go — он кладёт
|
||||
# .env в окружение процесса, а не в настройки.
|
||||
# одной правкой. Наш рабочий код окружение не читает вовсе — это
|
||||
# правило и держит.
|
||||
#
|
||||
# Чего правило не ловит: `fmt.Fprintln(os.Stdout, …)` и
|
||||
# `os.Stdout.WriteString` — первый аргумент по имени функции не судится.
|
||||
@@ -154,6 +154,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:
|
||||
|
||||
@@ -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,24 +26,45 @@ SpeechKit и отдаёт текст тому, кто запись загруз
|
||||
|
||||
## Стек
|
||||
|
||||
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
||||
панель администратора, — `aws-sdk-go-v2` для Object
|
||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
||||
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. Сборка —
|
||||
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
||||
|
||||
## Проектирование
|
||||
|
||||
**Чистая архитектура.** Зависимости направлены внутрь, к домену: домен не знает
|
||||
ни хранилища, ни транспорта, знание о внешнем мире приходит интерфейсом порта, а
|
||||
реализацию подставляет точка входа. Направления держат тесты-сканеры
|
||||
`internal/archrules`, а не договорённость.
|
||||
|
||||
**Модель домена ведётся тактическими шаблонами DDD** — сущность и корень
|
||||
агрегата, объект-значение, доменное событие, репозиторий, служба домена,
|
||||
фабрика. Анемичной модели не заводим: поведение записи живёт в домене, а
|
||||
прикладной слой назначает порядок шагов, а не правила.
|
||||
|
||||
Слои, их дома, что каждому знать нельзя и чем шаблон занят сегодня —
|
||||
[docs/architecture.md](docs/architecture.md), «Слои и модель домена». Здесь это
|
||||
не повторяется: перечень растёт вместе с моделью, и вторая копия разошлась бы с
|
||||
ним молча.
|
||||
|
||||
## Инварианты
|
||||
|
||||
Что нарушать нельзя.
|
||||
|
||||
- **Секрет не покидает конфиг.** Ключ SpeechKit, пара ключей Object
|
||||
Storage и секрет клиента OIDC не попадают в git, в лог, в ответ пользователю и
|
||||
- **Секрет не покидает конфиг.** Ключ SpeechKit и пара ключей Object
|
||||
Storage не попадают в git, в лог, в ответ пользователю и
|
||||
в колонку `error_text`. Нарушение необратимо: утёкший ключ отзывают и меняют
|
||||
вручную во всех местах выкладки. **critical**
|
||||
*Изъятие:* секрет клиента OIDC живёт ещё и в настройках коллекции
|
||||
пользователей хранилища — туда его кладёт приведение настроек при каждом
|
||||
подъёме, потому что применённый шаг схемы не переписывается и не пережил бы
|
||||
ротации. Чтение файла базы равносильно чтению этого секрета; перечисленные
|
||||
места запрета это не отменяет.
|
||||
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
|
||||
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
|
||||
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
|
||||
на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в
|
||||
базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью.
|
||||
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
|
||||
пользователя и его сообщение в лог не пишутся — только длина и
|
||||
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
|
||||
@@ -56,29 +78,42 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
||||
остаток — [docs/security.md](docs/security.md).
|
||||
- **Принятая запись не теряется молча.** Отказ на любом шаге либо оставляет
|
||||
запись пригодной к повтору, либо ставит на неё признак остановки с причиной —
|
||||
и тогда причина видна её владельцу опросом готовности, а владельцу сервиса
|
||||
и тогда причина видна её владельцу **карточкой записи**, а владельцу сервиса
|
||||
журналом. Молчаливый выход из шага без записи в лог и без смены состояния
|
||||
запрещён. Обязанность сменила направление 2026-08-14 вместе с убранным входом
|
||||
Telegram: прежде об отказе сообщали, теперь отказ доступен спросившему, и
|
||||
отправитель, который не спрашивает, о нём не узнаёт. **major**
|
||||
отправитель, который не спрашивает, о нём не узнаёт. Адрес, которым он
|
||||
спрашивает, сменился 2026-08-15: опрос готовности убран, и обязанность целиком
|
||||
переехала на карточку. **major**
|
||||
- **`NoopJobError` — не ошибка.** Значение «задач в этом состоянии нет» не
|
||||
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
|
||||
запись раз в секунду на каждый воркер. **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`. Из него
|
||||
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
|
||||
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
|
||||
@@ -88,15 +123,16 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
||||
- **Результат пишет только держатель захвата, и держатель узнаётся значением.**
|
||||
Признак захвата уникален для каждого захвата, и запись результата условна по
|
||||
нему, а не по занятости записи. Шаг, чей захват за время работы достался
|
||||
другому — по протуханию срока или после того, как человек снял признак
|
||||
остановки в панели, — завершается без записи результата. Условие
|
||||
другому — по протуханию срока или после того, как человек вернул запись в
|
||||
работу подкомандой оснастки, — завершается без записи результата. Условие
|
||||
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
|
||||
по очереди, портя её результат. **major**
|
||||
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
|
||||
запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и
|
||||
держит это схема хранилища, а не договорённость. Пока обязательность жила в
|
||||
одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила
|
||||
денег на распознавание и не доставалась потом никому. Правило со стороны
|
||||
запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и
|
||||
держит это схема, а не договорённость: колонка объявлена внешним ключом на
|
||||
учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью
|
||||
запись заводили руками мимо него, она уходила в конвейер, стоила денег на
|
||||
распознавание и не доставалась потом никому. Правило со стороны
|
||||
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
|
||||
записью, потому что схема запрещает **заводить** ничью, а это правило —
|
||||
**спрашивать** ничьим именем. **major**
|
||||
@@ -115,11 +151,17 @@ go test ./... # в гейте идёт с -race, и там нуже
|
||||
go vet ./...
|
||||
gofmt -l .
|
||||
golangci-lint run
|
||||
go run . -c config.toml # флаг -c или --config, по умолчанию config.toml
|
||||
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
|
||||
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
|
||||
task front # приложение: зависимости, Biome, юнит-тесты, сборка
|
||||
task image # docker-образ; тег и раскладка — docs/architecture.md
|
||||
task gate # весь набор проверок разом
|
||||
```
|
||||
|
||||
Node на машину **не ставится**: шаг сборки приложения зовёт его контейнером, а
|
||||
образ берёт из ступени `Dockerfile`. Требованием к машине разработчика поэтому
|
||||
становится docker — тот же, которым собирается образ.
|
||||
|
||||
Локальный запуск требует `ffmpeg` и `ffprobe` в `PATH` и своего `config.toml` —
|
||||
скопируй `config.example.toml` и заполни; известные прорехи образца перечислены
|
||||
в [docs/conventions/config.md](docs/conventions/config.md) строками
|
||||
@@ -151,7 +193,8 @@ task gate # весь набор проверок разом
|
||||
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
|
||||
любой отказ шага выходит с 201, а код шага печатает строкой
|
||||
(«exit status 3»), поэтому словарь читается по коду скрипта.
|
||||
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
|
||||
- **Что красит безусловно и почему:** красная сборка приложения, находка Biome,
|
||||
красный юнит-тест приложения, отказ сборки, тестов, `go vet`,
|
||||
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
|
||||
схемы,
|
||||
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
|
||||
@@ -167,7 +210,14 @@ task gate # весь набор проверок разом
|
||||
`gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком,
|
||||
сверка версий Go, сверки документов и `govulncheck`: они смотрят всё
|
||||
дерево либо требуют сети, а pre-commit обязан быть быстрым.
|
||||
- **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на
|
||||
- **Сетезависимые шаги названы здесь поимённо, и перечень этот закрыт.** Один из
|
||||
них — `front`: он ставит зависимости приложения из реестра пакетов, а docker до
|
||||
того тянет образ сборочного окружения. Отказ сети и реестра там — отказ окружения, код 3,
|
||||
отдельно от красной сборки, у которой код 1; полный кэш установщика снимает
|
||||
поход в сеть вовсе. Без короткого обращения-пробы шаг **висел** бы вместо
|
||||
отказа: установщик уходит в повторы с нарастающей паузой на каждом пакете, а
|
||||
гейт, который висит, хуже красного.
|
||||
- **Шагу `vulns` нужна сеть** тоже: база уязвимостей живёт на
|
||||
vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент
|
||||
ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он
|
||||
достижимость из кода: находка в модуле, чей уязвимый символ мы не вызываем,
|
||||
@@ -193,9 +243,16 @@ task gate # весь набор проверок разом
|
||||
Красный шаг означает поломку — свою или чужую, но поломку, а не наследство.
|
||||
Списывать отказ на долг больше нельзя: списывать не на что.
|
||||
|
||||
Два прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
|
||||
Три прежних долга закрыты и здесь названы, чтобы отказ на их месте читался как
|
||||
новый:
|
||||
|
||||
- `hadolint` давал `DL3066` на строке `USER transcriber` — «Non-numeric user-id
|
||||
may not be resolvable by host system». Отказ пришёл с обновлением `hadolint`,
|
||||
а не с правкой репозитория, и жил на `master` незамеченным. Закрыто решением
|
||||
владельца 2026-08-22: пользователь называется числом — `USER 1000:1000`.
|
||||
Владельца файлов в смонтированном каталоге это не двигает, потому что те же
|
||||
числа уже стояли при заведении пользователя (`-u 1000`, `-g 1000`);
|
||||
|
||||
- `golangci-lint run` давал 4 замечания — два непроверенных `Close` и два
|
||||
сравнения ошибок приведением типа. Закрыто задачей
|
||||
`errors-as-instead-of-typecast` 2026-08-11; тогда же у `errcheck` включена
|
||||
@@ -206,17 +263,25 @@ task gate # весь набор проверок разом
|
||||
## Запреты
|
||||
|
||||
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
|
||||
база (`data/data.db`), и записи живых людей
|
||||
(`data/storage/<коллекция>/<запись>/`). На стройке под ним пусто и сервис
|
||||
база (`data/transcriber.db`), и записи живых людей
|
||||
(`data/records/<запись>/`). На стройке под ним пусто и сервис
|
||||
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
|
||||
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
|
||||
его ронять и пересоздавать можно свободно.
|
||||
- **Локальный запуск не ходит наружу.** Секции `[auth]` и `[yandex]`
|
||||
проверяются на старте, но наружу при этом не обращаются, так что годятся
|
||||
выдуманные непустые значения — адреса `[auth]` должны лишь разбираться как
|
||||
ссылки. Расшифровка при выдуманных ключах не работает: её подменяют
|
||||
проверяются на старте, но наружу при этом не обращаются. У `[auth]` остался
|
||||
один ключ — перечень доверенных адресов, — и он проверяется на читаемость, а не
|
||||
на достижимость. Расшифровка при выдуманных ключах не работает: её подменяют
|
||||
`internal/adapter/recognizer/memory.go`. Подробности строками в
|
||||
`config.example.toml`.
|
||||
**На машине без прокси представиться нечем**: сервис узнаёт
|
||||
пришедшего по заголовку, который на сервере ставит Caddy, а браузер заголовков
|
||||
не ставит. Заголовок подставляет сам сервис — настройками, а не вторым
|
||||
процессом: рецепт из трёх правок записан связным блоком в
|
||||
`config.example.toml`, под перечнем доверенных адресов. Приложение при этом
|
||||
открывают по адресу сервиса, второго порта нет. Заполненная имитация при
|
||||
выключенном предохранителе роняет старт с именем ключа. Ключей боевого
|
||||
провайдера на машине разработчика не нужно вовсе — их больше нет и в конфиге.
|
||||
- **Yandex Cloud за деньги.** Распознавание и хранение в Object Storage
|
||||
оплачиваются по факту. Прогон на реальных ключах ради проверки кода запрещён —
|
||||
подставляй `internal/adapter/recognizer/memory.go`.
|
||||
|
||||
+38
-2
@@ -1,3 +1,28 @@
|
||||
# Front build stage
|
||||
#
|
||||
# Приложение собирается до бинарника: вшивание требует готового каталога.
|
||||
# Имя этого образа — единственное; шаг набора проверок берёт его отсюда же,
|
||||
# чтобы версия сборочного окружения не жила вторым числом в Taskfile.yml.
|
||||
#
|
||||
# Образ на glibc, а не на alpine, и разница здесь не в весе: musl шлёт запросы
|
||||
# `A` и `AAAA` разом и ждёт **оба** ответа, а DNS-сервер, который на `AAAA`
|
||||
# молчит, оставляет его без адреса вовсе — при живом `A`. `npm` на такой отказ
|
||||
# уходит в повторы с нарастающей паузой на каждом пакете, и сборка не краснеет,
|
||||
# а **висит**. Библиотека glibc довольствуется полученным `A` и собирает.
|
||||
# Ступень сборочная: в готовый образ её слои не едут, и лишний вес остаётся
|
||||
# ценой одной сборки, а не размером выкладки.
|
||||
FROM docker.io/library/node:24 AS front-build
|
||||
|
||||
WORKDIR /web
|
||||
|
||||
# Зависимости ставятся из файла замка командой, которая его не правит:
|
||||
# иначе собранное в образе перестаёт совпадать с собранным в наборе проверок.
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Build stage
|
||||
FROM docker.io/library/golang:1.26-alpine AS build-env
|
||||
|
||||
@@ -16,8 +41,14 @@ RUN go mod download
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Собранное приложение приезжает ступенью выше: в дереве сборки его нет,
|
||||
# а вшивание без него отдаёт бинарник, который отвечает «приложение не собрано».
|
||||
COPY --from=front-build /web/embed/dist ./web/embed/dist
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 go build -o transcriber .
|
||||
# Собирается одна точка входа из cmd/, а не весь пакет: соседний cmd/devtools —
|
||||
# оснастка разработчика (подставной прокси), и в образе ей делать нечего.
|
||||
RUN CGO_ENABLED=0 go build -o transcriber ./cmd/transcriber
|
||||
|
||||
# ----------------
|
||||
# Production stage
|
||||
@@ -60,7 +91,12 @@ COPY docker/entrypoint.sh /usr/bin/entrypoint
|
||||
RUN chmod 755 /usr/bin/entrypoint
|
||||
|
||||
# Set user
|
||||
USER transcriber
|
||||
#
|
||||
# Числом, а не именем: имя разрешает в идентификатор сам образ, и хост, которому
|
||||
# нужно понять владельца файлов в смонтированном каталоге, разрешить его не
|
||||
# может. Числа те же, что заданы выше при заведении пользователя (`-u 1000`,
|
||||
# `-g 1000`), поэтому владелец файлов не меняется — меняется только запись.
|
||||
USER 1000:1000
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
## Установка и запуск
|
||||
@@ -33,7 +34,7 @@
|
||||
```
|
||||
4. Запустите приложение:
|
||||
```bash
|
||||
go run . -c config.toml
|
||||
go run ./cmd/transcriber -c config.toml
|
||||
```
|
||||
|
||||
Сервер запустится на порту из `[server] port`, по умолчанию 8080. Нужен
|
||||
@@ -41,10 +42,23 @@
|
||||
|
||||
### Кого пускают
|
||||
|
||||
Приём и опрос закрыты сессией OIDC. Что её выдаёт и чем она предъявляется —
|
||||
Кто пришёл, сервис узнаёт из заголовка `Remote-User`, который ставит обратный
|
||||
прокси, сходив к Authelia; своего входа у сервиса нет. Кому верить, задаёт
|
||||
перечень доверенных адресов в секции `[auth]`. Подробности —
|
||||
[docs/security.md](docs/security.md), «Что разграничивает доступ»; известные
|
||||
прорехи образца конфига — [docs/conventions/config.md](docs/conventions/config.md).
|
||||
|
||||
Локально прокси нет, а браузер заголовков не ставит — заголовок подставляет сам
|
||||
сервис по своим настройкам. Второго процесса для этого не нужно: приложение
|
||||
открывают по адресу сервиса.
|
||||
|
||||
Рецепт целиком — связным блоком в `config.example.toml`, под перечнем доверенных
|
||||
адресов: там названы три правки, принимаемые имена заголовков и цена включения.
|
||||
|
||||
Заполненная секция имитации при выключенном предохранителе роняет
|
||||
старт с именем ключа: сервис с включённым предохранителем называет пришедшего
|
||||
сам, никого не спросив, и в бою этот ключ стоит `false`.
|
||||
|
||||
## Деплой
|
||||
|
||||
Деплой запускается из `pet-project-server`:
|
||||
@@ -59,13 +73,16 @@ inv pl -- transcriber
|
||||
|
||||
## HTTP API
|
||||
|
||||
Семь адресов приложения: `POST /api/audio` — приём записи, `GET /api/status/:id`
|
||||
— готовность задачи, `GET /auth/login`, `GET /auth/callback` и
|
||||
`POST /auth/logout` — вход через провайдера
|
||||
([access](openspec/specs/access/spec.md)), `GET /metrics` — метрики Prometheus с
|
||||
префиксом `transcriber_`, `GET /health` — проверка живости. Сверх них тем же
|
||||
портом отдаётся собственная поверхность встроенного хранилища и панель `/_/` —
|
||||
[docs/security.md](docs/security.md), «Из чего строятся пути и ключи».
|
||||
Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
|
||||
записи, `GET /app/audiorecords` — страница своих записей,
|
||||
`GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
|
||||
текст названного вида, `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): поля запроса и
|
||||
@@ -75,16 +92,19 @@ inv pl -- transcriber
|
||||
## Состояния задач
|
||||
|
||||
Перечень состояний, переходы между ними и число воркеров —
|
||||
[docs/database.md](docs/database.md), разделы «Коллекции» и «Представление
|
||||
[docs/database.md](docs/database.md), разделы «Таблицы» и «Представление
|
||||
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
transcriber/
|
||||
├── main.go # Точка входа: конфиг, миграции, сборка зависимостей, запуск
|
||||
├── cmd/
|
||||
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
|
||||
│ └── devtools/ # Оснастка разработчика: возврат остановленной записи в работу
|
||||
├── internal/
|
||||
│ ├── entity/ # Модели: задача, файл, результат распознавания
|
||||
│ ├── entity/ # Модели: запись, файл, результат распознавания
|
||||
│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID)
|
||||
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
|
||||
│ ├── config/ # Разбор config.toml
|
||||
│ ├── metrics/ # Метрики Prometheus
|
||||
@@ -96,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`, файл на шаг, версия шага — число в
|
||||
начале имени файла. Непринятые шаги накатываются при старте, прежде чем поднимутся
|
||||
входы и стартуют воркеры; отказ шага роняет старт. Применённый шаг не
|
||||
переписывается: изменение — только новым файлом шага.
|
||||
|
||||
Проверки перед коммитом — одной командой:
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ tasks:
|
||||
gate:
|
||||
desc: 'Все проверки разом. База диффа: task gate BASE=<rev>'
|
||||
cmds:
|
||||
# Приложение собирается первым: вшивание требует готового каталога, и
|
||||
# `go build` без него соберёт бинарник со вчерашней сборкой.
|
||||
- task: front
|
||||
- go build ./...
|
||||
- go vet ./...
|
||||
- |
|
||||
@@ -122,6 +125,79 @@ tasks:
|
||||
fi
|
||||
done
|
||||
|
||||
front:
|
||||
desc: 'Приложение: зависимости, проверки, сборка'
|
||||
vars:
|
||||
# Образ берётся из Dockerfile: там он объявлен ступенью сборки. Второй дом
|
||||
# версии сборочного окружения разошёлся бы с первым молча, а своего шага
|
||||
# сверки у него, в отличие от версий Go, нет.
|
||||
NODE_IMAGE:
|
||||
sh: grep -oP '^FROM \K\S*node:\S*' Dockerfile | head -1
|
||||
# Кэш установщика лежит вне дерева проекта: внутри контейнера он не пережил
|
||||
# бы прогон, и каждый набор проверок тянул бы зависимости заново.
|
||||
NPM_CACHE: '{{.NPM_CACHE | default "/tmp/transcriber-npm-cache"}}'
|
||||
cmds:
|
||||
# Node на машину не ставится — он зовётся контейнером, тем же образом,
|
||||
# каким собирается ступень образа. Требованием к машине остаётся docker.
|
||||
- |
|
||||
set -eu
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker не найден в PATH"
|
||||
echo "приложение собирается контейнером: https://docs.docker.com/engine/install/"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [ -z "{{.NODE_IMAGE}}" ]; then
|
||||
echo "в Dockerfile не нашлось ступени с образом node"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
mkdir -p "{{.NPM_CACHE}}"
|
||||
|
||||
run() {
|
||||
docker run --rm \
|
||||
-u "$(id -u):$(id -g)" \
|
||||
-e npm_config_cache=/npmcache \
|
||||
-v "{{.NPM_CACHE}}:/npmcache" \
|
||||
-v "$PWD/web:/web" \
|
||||
-w /web \
|
||||
"{{.NODE_IMAGE}}" \
|
||||
sh -c "$1"
|
||||
}
|
||||
|
||||
# Зависимости ставятся из файла замка командой, которая его не правит:
|
||||
# иначе набор проверок пачкал бы рабочее дерево, а собранное им
|
||||
# расходилось бы с собранным в образе.
|
||||
#
|
||||
# Сперва — установка из кэша, без единого обращения наружу: кэш лежит
|
||||
# вне дерева проекта и переживает прогоны, поэтому обычный случай сети
|
||||
# не требует вовсе.
|
||||
if ! run 'npm ci --offline' >/dev/null 2>&1; then
|
||||
# Кэша не хватило — значит нужна сеть, и её наличие проверяется одним
|
||||
# коротким обращением. Без этой проверки установщик уходит в повторы с
|
||||
# нарастающей паузой и **висит на каждом пакете**: гейт, который висит,
|
||||
# хуже красного — он не даёт ни исхода, ни причины.
|
||||
if ! run 'npm ping --fetch-timeout=15000 --fetch-retries=0' >/dev/null 2>&1; then
|
||||
echo "реестр пакетов недоступен"
|
||||
echo "шагу нужна сеть: он ставит зависимости приложения и тянет образ"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Сеть здесь уже заведомо есть — проба реестра прошла. Значит всякий
|
||||
# отказ установки это отказ проекта: замок разошёлся с package.json,
|
||||
# пакет снят из реестра, сломался его postinstall. По словарю кодов
|
||||
# это дрейф, а не окружение: код 3 отправил бы человека чинить docker
|
||||
# и сеть вместо `git diff web/package-lock.json`.
|
||||
if ! run 'npm ci'; then
|
||||
echo "зависимости приложения не установились, а реестр доступен"
|
||||
echo "смотри расхождение web/package-lock.json с web/package.json"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
run 'npm run check && npm run test && npm run build'
|
||||
|
||||
shell:
|
||||
desc: 'shellcheck на скрипты оболочки'
|
||||
cmds:
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Command devtools — оснастка разработчика: то, что нужно для локального
|
||||
// прогона и никогда не едет в боевой образ.
|
||||
//
|
||||
// Пакет один на все такие инструменты, а не по пакету на инструмент. Причина
|
||||
// счётная: каждый отдельный пакет стоит четырёх мест — строка сборки образа,
|
||||
// «Деплой» в устройстве, «Команды» в памятке, README, — и забытая строка сборки
|
||||
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
|
||||
//
|
||||
// Подкоманда одна — `resume`, возврат остановленной записи в работу. Она встала
|
||||
// на место панели владельца: панели у сервиса больше нет, а экраны правки
|
||||
// записи приносят отдельные задачи. Подставной обратный прокси жил здесь второй
|
||||
// подкомандой и убран 2026-08-23 задачей `config-test-headers-login`: заголовки
|
||||
// входа локального прогона подставляет сам сервис по своим настройкам.
|
||||
//
|
||||
// Вывод идёт stdlib-логом в поток ошибок, а не `slog`: его читает человек в
|
||||
// терминале, в сбор он не едет. Изъятие названо строкой в конвенции журнала.
|
||||
//
|
||||
// В образ пакет не едет: ступень сборки называет `./cmd/transcriber` поимённо.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(0)
|
||||
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "resume":
|
||||
runResume(os.Args[2:])
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `Оснастка разработчика.
|
||||
|
||||
Подкоманды:
|
||||
resume вернуть остановленную запись в работу
|
||||
|
||||
`)
|
||||
}
|
||||
@@ -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] <id>\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)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
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"
|
||||
)
|
||||
|
||||
// main держит одну обязанность: отказ подъёма пишется **одной** строкой и
|
||||
// кончается ненулевым кодом выхода.
|
||||
//
|
||||
// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit`
|
||||
// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента
|
||||
// распознавания.
|
||||
func main() {
|
||||
// Создаем структурированный логгер
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
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()
|
||||
|
||||
cfg, err := config.LoadConfig(*configPath)
|
||||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе
|
||||
// значило бы платить за настройку, которая не меняется.
|
||||
trustedNetworks, err := cfg.Auth.TrustedNetworks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Перечень называется строкой журнала: сервис, никого не узнающий из-за
|
||||
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
||||
// доходит вовсе, — а это разные поломки в разных местах.
|
||||
logger.Info("Trusted proxies configured", "trusted_proxies", cfg.Auth.TrustedProxies)
|
||||
|
||||
// Настройки отладочного входа: заполненная имитация без предохранителя, имя
|
||||
// заголовка, которого сервис не читает, и имитация без годного логина роняют
|
||||
// старт. Имена заголовков приходят проверке доводом — дом у них один,
|
||||
// константы транспорта, — а пакет настроек транспорта не знает.
|
||||
if err := cfg.ValidateTestHeaders(
|
||||
httpcontroller.IdentityHeaderNames(), httpcontroller.LoginHeader,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Представление предиката «подставляем ли» одно — непустота перечня, — и
|
||||
// судят его одинаково строка журнала ниже, слой подстановки и проверка выше.
|
||||
// Второе выражение того же предиката разошлось бы с первым молча.
|
||||
substitution := cfg.HeaderSubstitution()
|
||||
if len(substitution) > 0 {
|
||||
// Уровень предупреждающий: сервис называет пришедшего сам, никого не
|
||||
// спросив, — ровно то «может стать проблемой», ради которого заведён
|
||||
// этот уровень. Идут имена заголовков; значений нет — логин это ключ к
|
||||
// чужому архиву.
|
||||
logger.Warn("Identity headers are substituted from configuration",
|
||||
"headers", httpcontroller.SubstitutedHeaderNames(substitution),
|
||||
"capability", "access")
|
||||
}
|
||||
|
||||
// Числа конвейера проверяются здесь же: ноль воркеров — объявленный режим, а
|
||||
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с
|
||||
// ней значит остановить всякую запись первым же захватом.
|
||||
if err := cfg.Pipeline.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.Storage.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
|
||||
BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
|
||||
ReadConnections: cfg.Storage.ReadConnections,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
logger.Error("Failed to close the database", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Создаем контекст для graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ
|
||||
// роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом
|
||||
// на каждый запрос и на каждый прогон воркера — вместо одной строки о
|
||||
// причине их становятся сотни.
|
||||
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: 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()
|
||||
converter := ffmpegconv.NewFfmpegConverter()
|
||||
|
||||
recognizer, err := yandex.NewYandexAudioRecognizerService(yandex.YandexAudioRecognizerConfig{
|
||||
Region: cfg.Yandex.ObjStorageRegion,
|
||||
AccessKey: cfg.Yandex.ObjStorageAccessKey,
|
||||
SecretKey: cfg.Yandex.ObjStorageSecretKey,
|
||||
BucketName: cfg.Yandex.ObjStorageBucketName,
|
||||
Endpoint: cfg.Yandex.ObjStorageEndpoint,
|
||||
ApiKey: cfg.Yandex.SpeechKitAPIKey,
|
||||
FolderID: cfg.Yandex.FolderID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create audio recognizer: %w", err)
|
||||
}
|
||||
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
|
||||
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
|
||||
// при повторном закрытии, то есть запись говорит о нашей ошибке, а не о
|
||||
// недоступности Yandex.
|
||||
defer func() {
|
||||
if err := recognizer.Close(); err != nil {
|
||||
logger.Error("failed to close audio recognizer", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
transcribeService := service.NewTranscribeService(
|
||||
repos,
|
||||
metaviewer,
|
||||
converter,
|
||||
recognizer,
|
||||
cfg.Pipeline.StuckLimits(),
|
||||
logger,
|
||||
)
|
||||
|
||||
// Создаем WaitGroup для ожидания завершения всех воркеров
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Пул одинаковых воркеров: специализации у них нет, шаг выбирается по рубежу
|
||||
// самой записи. Число приходит настройкой, ноль — законное значение.
|
||||
pool := worker.NewPool(cfg.Pipeline.Workers, transcribeService.RunStep, logger)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pool.Start(ctx)
|
||||
}()
|
||||
|
||||
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему.
|
||||
metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
|
||||
|
||||
appHandler := httpcontroller.NewAppHandler(
|
||||
recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger,
|
||||
)
|
||||
|
||||
// Адресное пространство сервиса объявлено одним перечнем, и он порождает
|
||||
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не
|
||||
// получит обработчика вовсе. Отсюда же уровень журнала для адресов
|
||||
// наблюдения, правило неизвестного пути у раздачи приложения и область
|
||||
// действия узнавания.
|
||||
mounts := httpcontroller.ServiceMounts(
|
||||
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, substitution, logger),
|
||||
promhttp.Handler(),
|
||||
)
|
||||
|
||||
dist, appBuilt := web.Dist()
|
||||
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Server.Port),
|
||||
Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger),
|
||||
// Шесть часов записи по медленному каналу переживают любой фиксированный
|
||||
// таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне
|
||||
// модели угроз проекта.
|
||||
ReadTimeout: 0,
|
||||
}
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
logger.Info("Starting HTTP server", "port", cfg.Server.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
serveErr <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Настраиваем обработку сигналов для graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
logger.Info("Transcriber service started", "pipeline_workers", pool.Size())
|
||||
logger.Info("Press Ctrl+C to stop...")
|
||||
|
||||
// Ждем сигнал завершения либо отказ сервера
|
||||
var startupErr error
|
||||
select {
|
||||
case <-sigChan:
|
||||
logger.Info("Received shutdown signal, initiating graceful shutdown...")
|
||||
case err := <-serveErr:
|
||||
logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err)
|
||||
startupErr = err
|
||||
}
|
||||
|
||||
// Останавливаем HTTP сервер
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(
|
||||
context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
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")
|
||||
case <-time.After(time.Duration(cfg.Server.ForceShutdownTimeout) * time.Second):
|
||||
logger.Warn("Timeout reached, forcing shutdown")
|
||||
}
|
||||
|
||||
logger.Info("Transcriber service stopped")
|
||||
|
||||
return startupErr
|
||||
}
|
||||
+92
-25
@@ -4,11 +4,47 @@ port = 8080
|
||||
shutdown_timeout = 5
|
||||
force_shutdown_timeout = 20
|
||||
|
||||
# Storage configuration
|
||||
# Единственный каталог данных: под ним лежат и база, и файлы записей.
|
||||
# Предохранитель отладочного запуска. Значения: false (по умолчанию) и true.
|
||||
#
|
||||
# Означает он одно: прогон идёт на машине разработчика, и сервису позволено
|
||||
# подставить то, что в бою даёт обратный прокси, — заголовки входа из секции
|
||||
# [auth.test_headers] ниже. Перечня следствий сверх этого у него нет: уровня
|
||||
# журнала, текстов внутренних отказов, ограничителя частоты и подмены
|
||||
# распознавателя признак не касается.
|
||||
#
|
||||
# Цена включения названа прямо: сервис с true и заполненной имитацией называет
|
||||
# пришедшего сам, никого не спросив, и отдаёт архив всякому, чей запрос пришёл с
|
||||
# доверенного адреса. В бою доверенный адрес — это адрес обратного прокси, то
|
||||
# есть всякий, кто пришёл обычным путём. В боевом файле ключ стоит false.
|
||||
debug = false
|
||||
|
||||
# Хранилище: каталог данных и числа его базы.
|
||||
#
|
||||
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
|
||||
# записей. Двух путей у хранилища не бывает.
|
||||
[storage]
|
||||
data_dir = "data"
|
||||
|
||||
# Сколько ждать занятую базу, миллисекунды. Положительное число.
|
||||
#
|
||||
# База принимает **одного** писателя: драйвер пишет единственным соединением, и
|
||||
# несколько воркеров, пришедших писать разом, встают в очередь. Это число —
|
||||
# сколько ждущий готов простоять, прежде чем получить отказ «база занята».
|
||||
# Крутят его при таком отказе под несколькими воркерами; ноль означает «отказать
|
||||
# сразу» и потому не принимается.
|
||||
busy_timeout_ms = 5000
|
||||
|
||||
# Сколько соединений держит читающий пул. Положительное число.
|
||||
#
|
||||
# Чтение идёт отдельно от записи: в журнале упреждающей записи читатели не
|
||||
# мешают писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
|
||||
# Число выводят из числа воркеров плюс запас под запросы приложения.
|
||||
#
|
||||
# Пишущее соединение при этом всегда одно и настройкой не делается: второе
|
||||
# означало бы отказы по занятости на записи результата шага, то есть после
|
||||
# оплаченной работы.
|
||||
read_connections = 4
|
||||
|
||||
# Конвейер расшифровки.
|
||||
[pipeline]
|
||||
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
|
||||
@@ -60,28 +96,59 @@ object_storage_region = "ru-central1"
|
||||
# Endpoint Object Storage
|
||||
object_storage_endpoint = "https://storage.yandexcloud.net/"
|
||||
|
||||
# Вход через внешнего провайдера OIDC (Authelia).
|
||||
# Без заполненной секции сервис не поднимается: молча выключенный вход оставил бы
|
||||
# API открытым наружу.
|
||||
# Кому сервис верит на входе.
|
||||
#
|
||||
# Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком
|
||||
# `Remote-User`, сходив к Authelia. Здесь остаётся один ключ — перечень адресов,
|
||||
# чьему заголовку верить. Пустой перечень роняет старт: он значит «не верить
|
||||
# никому», то есть сервис, поднявшийся никого не узнающим.
|
||||
[auth]
|
||||
# Адрес, куда сервис уводит человека на вход
|
||||
auth_url = "https://auth.example.com/api/oidc/authorization"
|
||||
# Адреса и подсети, с которых приходит обратный прокси. Сверяется адрес самого
|
||||
# соединения, а не пересылаемый заголовок: пересылаемым распоряжается тот, кто
|
||||
# шлёт запрос.
|
||||
#
|
||||
# **Перечень задаёт адрес прокси, а не весь частный диапазон.** Всякий, кто
|
||||
# дотянулся до сервиса с адреса из этого перечня, называет себя кем угодно и
|
||||
# получает чужой архив; `172.16.0.0/12` означало бы «любой контейнер на хосте»,
|
||||
# включая чужие проекты. На сервере сюда ставят адрес сети, в которой стоит
|
||||
# Caddy, — узкий и свой.
|
||||
trusted_proxies = ["172.20.0.0/24"]
|
||||
|
||||
# Адрес, где код обменивается на токен
|
||||
token_url = "https://auth.example.com/api/oidc/token"
|
||||
|
||||
# Адрес, откуда берутся сведения о вошедшем
|
||||
user_info_url = "https://auth.example.com/api/oidc/userinfo"
|
||||
|
||||
# Идентификатор клиента, заведённого у провайдера
|
||||
client_id = "transcriber"
|
||||
|
||||
# Секрет клиента; приходит из выкладки, в git не коммитится
|
||||
client_secret = ""
|
||||
|
||||
# Адрес возврата; тот же, что записан клиенту у провайдера
|
||||
redirect_url = "https://transcriber.example.com/auth/callback"
|
||||
|
||||
# Признак `Secure` у куки сессии. Умолчание true; false только для локального
|
||||
# запуска по http://localhost, где браузер такую куку не сохранит
|
||||
secure_cookie = true
|
||||
# Локальный вход без Authelia — рецепт целиком.
|
||||
#
|
||||
# Прокси на машине разработчика нет, а браузер заголовков не ставит — значит
|
||||
# приложение локально не открылось бы вовсе. Заголовки входа подставляет сам
|
||||
# сервис: второго процесса и второго порта для этого не нужно, приложение
|
||||
# открывают по адресу сервиса.
|
||||
#
|
||||
# Три правки этого файла сверху вниз, и других не нужно:
|
||||
#
|
||||
# 1. Добавить в перечень выше пару петлевых адресов — обе записи, а не одну:
|
||||
#
|
||||
# trusted_proxies = ["172.20.0.0/24", "127.0.0.1", "::1"]
|
||||
#
|
||||
# Браузер разрешает localhost в IPv6 не реже, чем в IPv4, и перечень без
|
||||
# `::1` даёт неузнанный запрос. Отказ подстановки при этом виден строкой
|
||||
# журнала с адресом пира — по ней и опознаётся недостающая запись.
|
||||
#
|
||||
# 2. Поставить в секции [server] выше:
|
||||
#
|
||||
# debug = true
|
||||
#
|
||||
# 3. Раскомментировать секцию ниже и назвать в ней Remote-User. Ключ —
|
||||
# имя заголовка, значение — то, чем сервис назовёт пришедшего. Принимаются
|
||||
# три имени: Remote-User, Remote-Name, Remote-Email; иное роняет старт.
|
||||
# Ключ Remote-User обязателен: без него сервис подставит всё прочее и не
|
||||
# узнает никого.
|
||||
#
|
||||
# Второй вошедший получается другим значением Remote-User: логин и есть ключ
|
||||
# учётной записи.
|
||||
#
|
||||
# Заполненная секция при debug = false роняет старт с именем ключа
|
||||
# предохранителя: состояние «имитация есть, предохранителя нет» не читается
|
||||
# никак, а обе его прочтения — поломка.
|
||||
#
|
||||
# [auth.test_headers]
|
||||
# Remote-User = "local"
|
||||
# Remote-Name = "Разработчик"
|
||||
# Remote-Email = "local@example.com"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
- **Дата:** 2026-08-11
|
||||
- **Источник:** [../research/pocketbase.md](../research/pocketbase.md) — записка
|
||||
разведки `pocketbase-admin-fit`
|
||||
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
- **Дата:** 2026-08-11
|
||||
- **Источник:** [../research/job-queue.md](../research/job-queue.md) — записка
|
||||
разведки `job-queue-choice`
|
||||
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
- **Дата:** 2026-08-12
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-12-oidc-login/design.md](../../openspec/changes/archive/2026-08-12-oidc-login/design.md),
|
||||
раздел «Вход и возврат ведёт наш код, разбор ответа — хранилище»
|
||||
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
пункт 3
|
||||
- **Статус:** заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
раздел «Что изменило ревью кода», плюс отчёт триажа
|
||||
[../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md](../../openspec/changes/archive/2026-08-12-oidc-login/review/report.md),
|
||||
пункт 6
|
||||
- **Статус:** заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md)
|
||||
|
||||
## Решение
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Приложение живёт своим пространством адресов, а не общим с хранилищем
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||
раздел «Переезд в `/app/`, а слой сессии — на корень»
|
||||
|
||||
## Решение
|
||||
|
||||
Все адреса приложения переехали из `/api/` в собственный корень `/app/`, а слой
|
||||
предъявления сессии повешен на **группу корня**, а не на перечень адресов.
|
||||
|
||||
## Почему
|
||||
|
||||
Пространство `/api/` принадлежит хранилищу: оно вешает туда собственные наборы
|
||||
адресов, и поменять этот префикс нельзя — он литерал библиотеки, а не настройка.
|
||||
Свободных имён сегодня хватает, но соседство остаётся: обновление библиотеки
|
||||
вправе занять новое имя рядом с нашим, и разойдутся они молча — тем же адресом
|
||||
начнёт отвечать не тот обработчик.
|
||||
|
||||
Прецедент в проекте уже принят тем же доводом: адреса входа вынесены на `/auth/*`
|
||||
решением от 2026-08-12.
|
||||
|
||||
Слой на корень, а не на перечень: «перечень рос бы с каждым новым адресом
|
||||
приложения, и забытый в нём адрес молча перестал бы принимать куку».
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` соседство с чужими адресами кончилось: имя, занятое библиотекой, наших
|
||||
адресов больше не задевает;
|
||||
- `+` новый адрес приложения получает слой предъявления по построению, а не по
|
||||
памяти того, кто его добавил;
|
||||
- `−` правило неизвестного пути перечисляет теперь четыре корня сервиса вместо
|
||||
одного: `/api/`, `/app/`, `/auth/` и `/_/`;
|
||||
- `−` ограничитель частоты хранилища, настроенный на его собственный корень,
|
||||
наших адресов не покрывает — своё правило заводится нами, и его включение
|
||||
вводит в действие заодно умолчательные правила хранилища;
|
||||
- `−` ломка полная: прежние адреса приёма и опроса отвечают `404`. Оплачено
|
||||
стадией — на сервере данных нет, внешней программы на прежнем контракте не
|
||||
существует.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Страница архива задаётся ключом, а не номером
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||
раздел «Страница задаётся ключом, а не номером»
|
||||
|
||||
## Решение
|
||||
|
||||
Постраничное чтение своих записей идёт непрозрачным ключом по паре «время
|
||||
заведения и идентификатор». Номер страницы отвергнут.
|
||||
|
||||
## Почему
|
||||
|
||||
«Приём пишет в голову той же таблицы записей, которую читает список, и человек,
|
||||
загрузивший запись и листающий свой архив, — штатный сценарий. Номер страницы
|
||||
сдвинул бы окно на единицу: последний элемент первой страницы пришёл бы вторым
|
||||
разом первым элементом второй, а один элемент между ними не пришёл бы никогда.
|
||||
Отказ молчаливый — ни кода, ни строки в журнале, — и человек видел бы архив, в
|
||||
котором записи нет.»
|
||||
|
||||
Ключ полный: у записей, принятых одним запросом, время совпадает, и порядок
|
||||
между ними одним лишь временем не определён.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` запись, заведённая между двумя страницами, не даёт ни повтора, ни
|
||||
пропуска;
|
||||
- `+` порядок между записями с равным временем устойчив;
|
||||
- `−` экран с нумерацией страниц так не сделать — листать можно только
|
||||
«дальше». Архиву это не нужно;
|
||||
- `−` ключ приходит от клиента и потому разбирается: время приводится к виду
|
||||
хранилища, иначе побайтовое сравнение молча обращает условие в постоянную
|
||||
истину или ложь.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Node зовётся контейнером, а не ставится на машину разработчика
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md](../../openspec/changes/archive/2026-08-15-spa-skeleton/design.md),
|
||||
раздел «Node не ставится на машину, а зовётся контейнером»
|
||||
|
||||
## Решение
|
||||
|
||||
Шаг сборки приложения гоняет установщик пакетов и сборщик **внутри контейнера**,
|
||||
а не вызывает их из `PATH`:
|
||||
|
||||
> Требованием к машине разработчика становится docker, которым и так собирается
|
||||
> образ, — второго устанавливаемого окружения сверх `ffmpeg` не появляется
|
||||
> вовсе.
|
||||
|
||||
Образ сборочного окружения берётся из ступени `Dockerfile`, а не объявляется
|
||||
вторым числом в `Taskfile.yml`.
|
||||
|
||||
## Почему
|
||||
|
||||
Довод в дизайне назван прямо:
|
||||
|
||||
> Так снимается расхождение, которое иначе завелось бы молча: версия Node на
|
||||
> машине разработчика и версия в образе — два разных числа, и собранное ими
|
||||
> приложение различается ровно тогда, когда различаются они.
|
||||
|
||||
Отвергнуты два очевидных подхода, и оба с названной ценой. **Поставить Node на
|
||||
машину** — вводит второе устанавливаемое окружение и разъезжается с версией в
|
||||
образе. **Дать выбор — контейнер или локальный Node** — это второй способ делать
|
||||
одно и то же, и собранное ими различалось бы в зависимости от того, у кого что
|
||||
стоит.
|
||||
|
||||
## Почему это ADR
|
||||
|
||||
Запись проходит триггер **намеренным отказом** от очевидного подхода: поставить
|
||||
Node на машину — ровно то, что делают по умолчанию, и отказ от этого надо
|
||||
объяснить один раз, а не на каждом вопросе «почему у нас нельзя просто
|
||||
`npm run build`».
|
||||
|
||||
## Что это меняет в прежнем решении
|
||||
|
||||
[ADR-2026-08-11-spa-on-vue](ADR-2026-08-11-spa-on-vue.md) записал последствием,
|
||||
что «машина разработчика получает второе требуемое окружение сверх `ffmpeg`», и
|
||||
подразумевал под ним Node. Окружением оказался **docker**. Сам выбор фреймворка и
|
||||
наличие шага сборки это не пересматривает, поэтому статуса «заменено на» у той
|
||||
записи нет: заменена не она, а толкование одного её последствия.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` версия сборочного окружения живёт **одним** местом — ступенью
|
||||
`Dockerfile`, — и своего шага сверки ей не нужно.
|
||||
- `+` собранное в наборе проверок и собранное в образе совпадает, потому что
|
||||
совпадает окружение сборки, а не потому что «обычно совпадает».
|
||||
- `−` **набор проверок перестаёт работать без docker**, и отказ этот приходит
|
||||
кодом окружения. Тем же кодом приходит отказ реестра пакетов: сетезависимых
|
||||
шагов в наборе становится два вместо одного.
|
||||
- `−` контейнер ходит под тем же пользователем, что и вызвавший, а кэш
|
||||
установщика уводится наружу — обе частности обязательны: без них собранное
|
||||
ляжет от `root`, а зависимости будут тянуться заново каждый прогон.
|
||||
- `−` **вес и время самой ступени в образе неизвестны**: финальный образ от неё
|
||||
не растёт (ступень в рабочий слой не копируется), а время сборки решением
|
||||
владельца от 2026-08-15 не замеряется вовсе.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Длительность и размер лежат колонками записи, и равенство со строкой файла не поддерживается
|
||||
|
||||
- **Дата:** 2026-08-15
|
||||
- **Источник:** openspec/changes/archive/2026-08-15-app-json-contract/design.md,
|
||||
раздел «Три новых колонки записи и один шаг схемы»
|
||||
|
||||
## Решение
|
||||
|
||||
Длительность и размер принятого легли колонками аудиозаписи, хотя обе величины
|
||||
уже есть у строки её файла. Равенство между ними не поддерживается никем —
|
||||
намеренно. «Неизвестно» эти колонки не выражают: ноль означает ноль.
|
||||
|
||||
## Почему
|
||||
|
||||
Обе величины показываются в списке, а список по норме `storage` читается без
|
||||
содержимого. Ревью дизайна возражало: величины станут копиями, которые некому
|
||||
держать равными. Решением владельца колонки остались, а равенство объявлено
|
||||
**ненужным**: «на записи лежит снимок принятого, взятый приёмом один раз; на
|
||||
файле — величины той копии, которой файл является сейчас». Уточнение
|
||||
длительности — перечитали метаданные, сменили источник, нарезали длинную запись
|
||||
— меняет вторые и не трогает первые. Это разные вопросы: «что человек прислал» и
|
||||
«что лежит сейчас».
|
||||
|
||||
Отличимость «неизвестно» от нуля снята после ревью кода и по замеру: числовая
|
||||
колонка хранилища пустого значения не держит вовсе и кладёт пустое нулём.
|
||||
Платить за отличимость четвёртой колонкой-признаком либо текстовым типом у чисел
|
||||
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными
|
||||
метаданными отвергается отказом и не заводится.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` страница списка не читает по строке файла на каждую запись;
|
||||
- `+` смысл у двух пар чисел разный и записан нормой, а не подразумевается;
|
||||
- `−` в применённом шаге схемы навсегда остаются две колонки, повторяющие
|
||||
величины строки файла; расхождение между ними — не поломка, и заметить его
|
||||
нечем;
|
||||
- `−` запись, заведённая рукой в панели без величин, покажет человеку ноль.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC
|
||||
|
||||
- **Дата:** 2026-08-22
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md](../../openspec/changes/archive/2026-08-22-trusted-header-login/design.md), разделы Р1 и Р3
|
||||
|
||||
## Решение
|
||||
|
||||
Сервис перестаёт вести вход сам. Кто пришёл, он узнаёт из заголовка
|
||||
`Remote-User`, поставленного обратным прокси, который сходил к Authelia;
|
||||
заголовку верят только с адреса из объявленного перечня, а адрес берётся у
|
||||
самого соединения. Учётная запись заводится первым обращением с новым логином и
|
||||
находится по нему же дальше.
|
||||
|
||||
Убраны целиком: корень `/auth` с тремя адресами, куки `transcriber_session` и
|
||||
`transcriber_login`, сверка состояния и проверочный код PKCE, обмен кода
|
||||
внутрипроцессным запросом к роутеру хранилища, слои предъявления куки и запрета
|
||||
продления, приведение настроек провайдера к конфигу, секрет клиента и срок жизни
|
||||
сессии.
|
||||
|
||||
## Почему
|
||||
|
||||
Цитата из источника, раздел Р3:
|
||||
|
||||
> Сервис не выдаёт браузеру ни куки, ни токена. Каждый запрос узнаётся заново, по
|
||||
> заголовку, который прокси поставил, сходив к Authelia.
|
||||
>
|
||||
> Это и есть выгода задачи: отзыв доступа перестаёт ждать. Пока сервис выдавал
|
||||
> значение, живущее семь суток, отозванный у провайдера человек работал до
|
||||
> истечения этого значения, и другого канала отзыва не было.
|
||||
|
||||
Оттуда же, Р1 — почему доверие судится адресом соединения, а не пересылаемым
|
||||
заголовком:
|
||||
|
||||
> **`X-Forwarded-For` и его родня.** Значение целиком задаёт тот, кто шлёт
|
||||
> запрос. Барьер, который подделывается той же строкой, что и обходится, не
|
||||
> барьер вовсе.
|
||||
|
||||
Отвергнут промежуточный вариант — заголовок как вход, сессия хранилища как
|
||||
продолжение (Р3):
|
||||
|
||||
> Дешевле в работе (слой срабатывал бы раз в неделю, а не на каждом запросе), но
|
||||
> возвращает ровно то, что задача убирает: значение, переживающее отзыв. Семь
|
||||
> суток вернулись бы вместе с ним.
|
||||
|
||||
Контур к решению был готов заранее: обратный прокси уже отдавал `Remote-*` трём
|
||||
соседним сервисам того же контура, а правила для этого сервиса там не было
|
||||
вовсе — он не выложен.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Отзыв доступа действует со следующего запроса, а не через семь суток:
|
||||
Authelia судит каждое обращение.
|
||||
- `+` Секрет клиента исчез из конфига и из базы. Изъятие из инварианта «Секрет не
|
||||
покидает конфиг» снято: чтение файла базы больше не равносильно чтению
|
||||
секрета.
|
||||
- `+` Своего протокола входа у сервиса не осталось — вместе с ним исчезли пять
|
||||
накопившихся задач о его механике.
|
||||
- `+` Панель закрывается доменом, а не правилом на литерал пути; обход подменой
|
||||
знака перестаёт существовать.
|
||||
- `−` **Весь барьер держится на настройке прокси.** Прокси, добавляющий заголовок
|
||||
вместо замены, открывает сервис любому под любым именем. Половину беды сервис
|
||||
закрывает сам — запрос с двумя значениями заголовка не узнаёт никого, — вторую
|
||||
проверить отсюда нечем: правило живёт в `pet-project-server`.
|
||||
- `−` **Логин у провайдера переиспользуем**, и новый его владелец получает архив
|
||||
прежнего. Неизменяемого признака заголовок не приносит; не допускать
|
||||
переиспользования — работа провайдера. Обратная сторона: переименование
|
||||
заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни
|
||||
убрать.
|
||||
- `−` Поиск учётной записи идёт на каждом запросе к области приложения вместо
|
||||
раза в неделю. Уникальный индекс делает это одним обращением к базе; замера не
|
||||
требовалось — сервисом пользуются единицы человек.
|
||||
- `−` Половина работы лежит вне репозитория: до того как правило прокси и правило
|
||||
Authelia на домен заведут, сервис не узнает никого.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком
|
||||
|
||||
- **Дата:** 2026-08-22
|
||||
- **Источник:** [../research/storage-without-pocketbase.md](../research/storage-without-pocketbase.md) —
|
||||
записка разведки о выборе хранилища
|
||||
|
||||
## Решение
|
||||
|
||||
PocketBase уходит из проекта целиком: состояние записей и метаданные переезжают
|
||||
в SQLite, с которым сервис работает напрямую через `modernc.org/sqlite`, файлы
|
||||
записей — в свой каталог со своей раскладкой, маршруты и слои — на `net/http`,
|
||||
шаги схемы — на свой раннер. Панель администратора теряется и **не заменяется
|
||||
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
|
||||
базе.
|
||||
|
||||
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
|
||||
названо здесь.
|
||||
|
||||
Шаги схемы двигает библиотека `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) и
|
||||
[ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md).
|
||||
|
||||
**Что из заменённых решений подтверждается, а не отменяется.** Очередь остаётся
|
||||
своей таблицей, захват — одним запросом с `RETURNING`, готовую библиотеку
|
||||
очереди по-прежнему не берём: замер, которым это решено, снят на
|
||||
`modernc.org/sqlite` — том самом драйвере, который остаётся и после ухода.
|
||||
Отменяется у той записи одно слово: таблица перестаёт быть коллекцией.
|
||||
Приложение остаётся в своём корне `/app/`
|
||||
([ADR-2026-08-15-app-namespace](ADR-2026-08-15-app-namespace.md)). Файл записи
|
||||
остаётся закрытым — но проверкой владельца в своём обработчике, а не защищённым
|
||||
полем коллекции и коротким токеном.
|
||||
|
||||
## Почему
|
||||
|
||||
Разведка мерила не «хранилище против хранилища», а то, что библиотека держит в
|
||||
этом коде. Цитата из источника:
|
||||
|
||||
> Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
|
||||
> шесть ролей сразу, и только две из них про хранение.
|
||||
|
||||
Довод, на котором стоял перевод, отпал сам. Источник, раздел о трёх доводах:
|
||||
|
||||
> Вход делает само приложение с 2026-08-22 […]: пришедшего называет заголовок
|
||||
> прокси, а учётную запись заводит наш `EnsureUser`. Пользователи в коллекции
|
||||
> есть **потому, что их пишет наш код**, а не провайдер библиотеки. Довод,
|
||||
> которым отвергнут отвергнутый вариант, перестал быть верным.
|
||||
|
||||
Отвергнут вариант «уйти в два шага», оставив панель жить в промежутке, и
|
||||
отвергнут решением владельца: панель на стройке заменяется запросом к базе, а
|
||||
вторая порция работы стоит дороже, чем то, что она сберегает.
|
||||
|
||||
Обстоятельство, которое назначило момент:
|
||||
|
||||
> на сервере данных нет и сервис остановлен, поэтому смена стоит только кода.
|
||||
> Дешевле она не станет никогда — каталог `internal/controller/http` прирастает
|
||||
> кодом на чужих типах с каждой задачей.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` периметр сервиса становится только нашим. Панель `/_/` исчезает вместе с
|
||||
дефектом `/%5f/` из [../security.md](../security.md), а пространство
|
||||
хранилища `/api/` — вместе с необходимостью держать его открытым ради файлов.
|
||||
- `+` пропадает секрет, которого не было до перевода, — пароль суперпользователя
|
||||
панели.
|
||||
- `+` файлы ложатся своей раскладкой, и загрузка частями, узнавание по хеш-сумме,
|
||||
удаление записи и вторая копия рядом становятся обычной работой с файлами.
|
||||
- `+` из сборки уходят шесть модулей, достижимых только через библиотеку:
|
||||
`imaging`, `mailyak`, `jwt`, `fexpr`, `cobra`, драйвер MySQL.
|
||||
`modernc.org/sqlite` остаётся, и сборка по-прежнему обходится без CGO.
|
||||
- `−` владелец сервиса остаётся без панели. Правку записи, возврат остановленной
|
||||
в работу и просмотр очереди до появления экранов делает запрос к базе руками.
|
||||
Задачи `audiorecord-actions` и `play-recording-in-app` этим становятся не
|
||||
улучшением, а заменой утраченного инструмента.
|
||||
- `−` шаги схемы, отдачу файла, ограничитель частоты и настройку базы пишем и
|
||||
сопровождаем сами. Единственный писатель у `modernc.org/sqlite` — наша забота
|
||||
с этого дня.
|
||||
- `−` раскладка каталога данных меняется необратимо. Цена сегодня нулевая:
|
||||
стройка, на сервере пусто; после первой боевой записи она перестаёт быть
|
||||
нулевой.
|
||||
- `−` 1497 строк контроллера и 3075 строк его проверок написаны на
|
||||
`*core.RequestEvent` и переписываются целиком.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина
|
||||
|
||||
- **Дата:** 2026-08-23
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
|
||||
решение 4
|
||||
|
||||
## Решение
|
||||
|
||||
Отладочная подстановка заголовков входа не требует от настроек ничего сверх
|
||||
самого предохранителя `[server] debug`. Решение владельца на чекпоинте записано
|
||||
в источнике дословно:
|
||||
|
||||
> «предохранитель по адресам не делаем, полагаемся только на параметр debug».
|
||||
|
||||
Рассматривалось требование, чтобы при включённом предохранителе перечень
|
||||
доверенных адресов состоял только из петлевых записей; оно снято вместе с
|
||||
предикатом «петлевая запись», который заводился ровно ради него.
|
||||
|
||||
Согласованность с барьером узнавания при этом остаётся: подставленный заголовок
|
||||
проходит тот же перечень доверенных адресов, что и пришедший, и судит адрес та
|
||||
же функция. Предохранителем это не служит — «от конфига она не требует ничего и
|
||||
круга тех, кто мог назваться кем угодно, не расширяет».
|
||||
|
||||
## Почему
|
||||
|
||||
Решение покупает работоспособность отладочного входа там, где адрес пира не
|
||||
петлевой:
|
||||
|
||||
> отладочный вход работает **внутри контейнера** — адрес пира там принадлежит
|
||||
> сети докера, и она же стоит в боевом перечне, — а локальный прогон не
|
||||
> переставляет перечень доверенных адресов на петлевой: петлевые записи
|
||||
> добавляются к тем, что в нём уже стоят.
|
||||
|
||||
Цена названа в источнике прямо, и владелец принял именно её:
|
||||
|
||||
> Что этим потеряно, и это надо назвать прямо: боевую поломку больше не ловит
|
||||
> машина. Сервис, поднятый в бою с включённым предохранителем и заполненной
|
||||
> имитацией, отдаст архив всякому, кто дотянулся до него с доверенного адреса, —
|
||||
> а доверенный адрес в бою это адрес обратного прокси, то есть **любой запрос,
|
||||
> пришедший обычным путём**.
|
||||
|
||||
Между боевой выкладкой и открытым входом остаётся три вещи, и других нет:
|
||||
умолчание предохранителя «выключено»; отказ старта при заполненной имитации без
|
||||
предохранителя; боевой конфиг, который рендерит шаблон Ansible, а не
|
||||
копируют с машины разработчика.
|
||||
|
||||
Отвергнуты вместе с адресным предохранителем ещё два подхода. **Принудительно
|
||||
слушать петлевой адрес при включённом предохранителе** — «меняет поведение молча
|
||||
… и закрывает ровно то, что решение покупает: внутри контейнера сервис слушает не
|
||||
петлю». **Новый ключ `[server] listen`** — «публичная поверхность настроек ради
|
||||
предохранителя, которого решением владельца нет».
|
||||
|
||||
## Почему это ADR
|
||||
|
||||
Триггер — **намеренный отказ** от очевидного подхода. Требовать петлевой перечень
|
||||
при включённом отладочном входе — первое, что предлагает всякий, кто читает
|
||||
модель угроз; отказ от этого оставляет боевую поломку, которую машина не
|
||||
исключает, и объяснить его надо один раз здесь, а не на каждом ревью, которое
|
||||
эту дыру находит заново.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Отладочный вход работает и на машине разработчика, и внутри контейнера:
|
||||
перечень доверенных адресов остаётся границей доверия, а не признаком отладки.
|
||||
- `+` Локальный прогон не переставляет перечень на петлевой — петлевые записи к
|
||||
нему добавляются.
|
||||
- `+` Предиката «петлевая запись» в коде нет вовсе: он заводился ради одной этой
|
||||
проверки.
|
||||
- `−` **Машина не исключает боевую поломку «конфиг с `debug = true` и
|
||||
заполненной имитацией».** Такой сервис поднимется на любом перечне доверенных
|
||||
адресов и назовёт своим именем всякого, кто пришёл обычным путём. Записано это в модели
|
||||
угроз, [security.md](../security.md), «Периметр», и в спеке
|
||||
[access](../../openspec/specs/access/spec.md).
|
||||
- `−` Одна из трёх опор лежит вне репозитория: шаблон Ansible из
|
||||
`pet-project-server`. Проверить её отсюда нечем — тем же свойством обладает
|
||||
правило прокси про заголовки `Remote-*`.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс
|
||||
|
||||
- **Дата:** 2026-08-23
|
||||
- **Источник:** [../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/design.md),
|
||||
решения 1, 6, 7 и 10
|
||||
|
||||
## Решение
|
||||
|
||||
Заголовки входа на машине разработчика ставит сам сервис — отдельным слоем
|
||||
цепочки корня приложения, а не вспомогательным процессом рядом:
|
||||
|
||||
> отдельный слой цепочки корня приложения, стоящий **перед**
|
||||
> `TrustedHeaderIdentity` и **после** ограничителя частоты. Он правит заголовки
|
||||
> запроса и ничего больше не делает: учётной записи не заводит, отказов не
|
||||
> выдаёт, в контекст не пишет.
|
||||
|
||||
Включают слой два новых ключа настроек — предохранитель `[server] debug` и
|
||||
секция значений `[auth.test_headers]`. Прежний вспомогательный процесс уходит:
|
||||
|
||||
> **Решено** владельцем на чекпоинте: подкоманда удаляется. Назначения у неё не
|
||||
> остаётся — всё, ради чего её поднимали, делает сам сервис, — и второго способа
|
||||
> входить локально не остаётся тоже.
|
||||
|
||||
Имена заголовков служат именами ключей секции, но набор принимаемых имён
|
||||
порождают константы транспорта: дом у имён остаётся один, а ключ, не совпавший
|
||||
ни с одним из них, роняет старт.
|
||||
|
||||
## Почему
|
||||
|
||||
Владелец назвал желаемое: один бинарник, различия между запусками — в конфиге.
|
||||
Дизайн записал это целью:
|
||||
|
||||
> Локальный запуск идёт одним процессом и одной командой; **вход** — то, кем
|
||||
> назвался пришедший, — отличает тестовый прогон от боевого содержимым файла
|
||||
> настроек, и ничем больше.
|
||||
|
||||
Довод в пользу слоя перед узнаванием, а не внутри него:
|
||||
|
||||
> Отлаживается **та же** ветка кода, что работает в бою: подставленный заголовок
|
||||
> неотличим от пришедшего от Caddy к моменту, когда его читает узнавание.
|
||||
|
||||
Отвергнуты три очевидных подхода, и у каждого названа цена. **Подстановка внутри
|
||||
`TrustedHeaderIdentity`** — «узнавание получило бы второй источник значений и
|
||||
ветку, которой в бою нет. Отлаживалась бы не боевая ветка, а её отладочный
|
||||
двойник». **Произвольная карта имён заголовков в конфиге** — «опечатка
|
||||
`Remote-Usr` даёт „сервис меня не узнаёт“ без единого следа». **Вырезать
|
||||
подстановку из боевой сборки тегом сборки:**
|
||||
|
||||
> сборка образа в гейте не проверяется вовсе (`CLAUDE.md`, «Гейт»), и тег,
|
||||
> забытый в одной ступени, дал бы ровно ту тишину, которой избегает пункт 3.
|
||||
|
||||
## Почему это ADR
|
||||
|
||||
Триггер сработал дважды. **Дорогой откат:** решение заводит два имени ключа
|
||||
настроек, а имя ключа конфига `CLAUDE.md` называет необратимым; вернуться к
|
||||
вспомогательному процессу значит поднять удалённую подкоманду, убрать оба ключа
|
||||
из настроек и переписать рецепт локального запуска, разошедшийся по образцу
|
||||
конфига, `README.md`, `CLAUDE.md` и конвенции настроек. **Намеренный отказ:**
|
||||
вырезать отладочный код из боевой сборки тегом сборки — то, что делают по
|
||||
умолчанию, и отказ от этого объясняется один раз здесь, а не на каждом вопросе
|
||||
«почему подстановка вообще есть в боевом бинарнике».
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Локальный запуск идёт одним процессом и одной командой; приложение
|
||||
открывают по адресу сервиса, второго порта нет.
|
||||
- `+` Отлаживается боевая ветка узнавания: подставленный заголовок неотличим от
|
||||
пришедшего от прокси к моменту, когда его читают.
|
||||
- `+` Второго способа входить локально не остаётся, и документация перестаёт
|
||||
каждый раз говорить, какой из способов чей.
|
||||
- `+` Имена заголовков остаются с одним домом — константами транспорта; ключ, не
|
||||
совпавший ни с одним из них, роняет старт и называет принимаемые имена.
|
||||
- `−` **Местный инструмент больше не воспроизводит поломки контура.** Цена
|
||||
названа в источнике прямо: два значения `Remote-User`, заголовок с
|
||||
недоверенного адреса, цепочка `X-Forwarded-For` — всё это теперь
|
||||
воспроизводит только автотест, ставящий заголовок сам.
|
||||
- `−` В боевом бинарнике появляется код, называющий пришедшего без провайдера.
|
||||
Что его держит и чего у него нет — [ADR-2026-08-23-no-address-guard-for-debug-login](ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||||
- `−` У ключа `[server] debug` закрытый перечень следствий, и держать его
|
||||
придётся руками: новое поведение привязывается к ключу только отдельным
|
||||
решением владельца и получает своё требование спеки
|
||||
[access](../../openspec/specs/access/spec.md). Ключ с открытым перечнем
|
||||
следствий обрастает ими молча.
|
||||
- `−` Каждый новый логин имитации заводит учётную запись, а удалять их сервис не
|
||||
умеет. Локальная база ронится и пересоздаётся свободно, в бою подстановка
|
||||
выключена — но лишние записи копятся.
|
||||
+13
-5
@@ -35,6 +35,14 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-23 | [Адресного предохранителя у отладочного входа нет: держит его умолчание, а не машина](ADR-2026-08-23-no-address-guard-for-debug-login.md) | |
|
||||
| 2026-08-23 | [Заголовки входа отладочного запуска подставляет сам сервис, а не второй процесс](ADR-2026-08-23-test-headers-substituted-by-service.md) | |
|
||||
| 2026-08-22 | [Хранилищем становится SQLite с каталогом файлов, а PocketBase уходит целиком](ADR-2026-08-22-storage-without-pocketbase.md) | |
|
||||
| 2026-08-22 | [Пришедшего называет заголовок доверенного прокси, а не собственный вход OIDC](ADR-2026-08-22-login-by-trusted-header.md) | |
|
||||
| 2026-08-15 | [Node зовётся контейнером, а не ставится на машину разработчика](ADR-2026-08-15-node-in-container-not-on-machine.md) | |
|
||||
| 2026-08-15 | [Приложение живёт своим пространством адресов, а не общим с хранилищем](ADR-2026-08-15-app-namespace.md) | |
|
||||
| 2026-08-15 | [Страница архива задаётся ключом, а не номером](ADR-2026-08-15-cursor-paging.md) | |
|
||||
| 2026-08-15 | [Длительность и размер — снимок принятого колонками записи](ADR-2026-08-15-record-snapshot-columns.md) | |
|
||||
| 2026-08-15 | [Вход Telegram убран целиком, а не выключен признаком](ADR-2026-08-15-telegram-intake-removed-temporarily.md) | |
|
||||
| 2026-08-15 | [Обязательность владельца держит схема, а не приём](ADR-2026-08-15-owner-required-by-schema.md) | |
|
||||
| 2026-08-15 | [Метка убранного входа не выставляется вовсе, а не обнуляется](ADR-2026-08-15-removed-intake-has-no-metric-label.md) | |
|
||||
@@ -44,10 +52,10 @@
|
||||
| 2026-08-14 | [Учётная запись с записями не удаляется, и это осознанный тупик](ADR-2026-08-14-account-with-records-is-not-deleted.md) | |
|
||||
| 2026-08-13 | [Намерение объявляется признаком, а не выводится из ключа доступа](ADR-2026-08-13-telegram-intent-declared-not-inferred.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-13 | [Недоступность Telegram подъёму сервиса не мешает](ADR-2026-08-13-telegram-outage-does-not-block-startup.md) | устарело: вход убран [ADR-2026-08-15-telegram-intake-removed-temporarily](ADR-2026-08-15-telegram-intake-removed-temporarily.md) |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | |
|
||||
| 2026-08-12 | [Файл записи закрыт защищённым полем и отдаётся вошедшему по токену файла](ADR-2026-08-12-protected-file-behind-session.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
|
||||
| 2026-08-12 | [Сессия живёт семь суток и не продлевает саму себя](ADR-2026-08-12-session-without-refresh.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
|
||||
| 2026-08-12 | [Кого пускать в сервис, решает правило провайдера, а не сервис](ADR-2026-08-12-access-delegated-to-provider.md) | |
|
||||
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | |
|
||||
| 2026-08-12 | [Код провайдера меняется на сессию вызовом собственного адреса хранилища внутри процесса](ADR-2026-08-12-oidc-exchange-via-own-route.md) | заменено на [ADR-2026-08-22-login-by-trusted-header](ADR-2026-08-22-login-by-trusted-header.md) |
|
||||
| 2026-08-12 | [Спекой нормируется и инструмент сборки, а не только поведение сервиса](ADR-2026-08-12-spec-norms-build-toolchain.md) | устарело |
|
||||
| 2026-08-12 | [Объявленную версию Go шаг гейта читает из репозитория, а не спрашивает у инструмента](ADR-2026-08-12-version-read-from-repo-not-from-tool.md) | |
|
||||
| 2026-08-12 | [Ссылка на файл открыта знанием записи, а защищает её отсутствие имени в журнале](ADR-2026-08-12-file-link-open-but-not-logged.md) | заменено на [ADR-2026-08-12-protected-file-behind-session](ADR-2026-08-12-protected-file-behind-session.md) |
|
||||
@@ -56,8 +64,8 @@
|
||||
| 2026-08-11 | [Отказ, который решено не проверять, объявляется поимённо](ADR-2026-08-11-errcheck-check-blank.md) | |
|
||||
| 2026-08-11 | [Наружу расширение выходит только приведённым к перечню](ADR-2026-08-11-known-format-label.md) | |
|
||||
| 2026-08-11 | [Приложение пишем на Vue, а Node входит в гейт и в образ](ADR-2026-08-11-spa-on-vue.md) | |
|
||||
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | |
|
||||
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | |
|
||||
| 2026-08-11 | [Очередь остаётся своей таблицей, но коллекцией PocketBase](ADR-2026-08-11-queue-as-pocketbase-collection.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
|
||||
| 2026-08-11 | [Хранилище, файлы и вход переезжают в PocketBase](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md) | заменено на [ADR-2026-08-22-storage-without-pocketbase](ADR-2026-08-22-storage-without-pocketbase.md) |
|
||||
| 2026-08-11 | [Проверки не зовут внешних программ](ADR-2026-08-11-stub-adapters-in-tests.md) | |
|
||||
|
||||
Решения, принятые до заведения канона 2026-08-10, источника в архиве изменений
|
||||
|
||||
+227
-68
@@ -15,36 +15,57 @@
|
||||
[conventions/go-linters.md](conventions/go-linters.md).
|
||||
|
||||
- [intake](../openspec/specs/intake/spec.md) — **приём по HTTP плюс наличие
|
||||
входов**: приём и опрос за сессией, имя отправителя не доходит ни до
|
||||
входов**: приём только от узнанного, имя отправителя не доходит ни до
|
||||
хранилища, ни до журнала, метка метрики несёт только известное расширение, а
|
||||
наблюдатель видит единственный поднятый вход. Задачи
|
||||
`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) — **попытка распознавания
|
||||
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
|
||||
целиком и вложением, как из сохранённого строится структура реплик без
|
||||
повторной оплаты и почему разбор формата провайдера не доходит до конвейера.
|
||||
Задача `record-centric-model` 2026-08-14;
|
||||
- [archive](../openspec/specs/archive/spec.md) — **архив своих записей глазами
|
||||
приложения**: пространство адресов `/app/` и единая форма отказа с
|
||||
машиночитаемым кодом, пределы, которыми сервис ограничивает загрузку, и само
|
||||
чтение — страница записей ключом, карточка без текста и текст названного вида.
|
||||
Здесь же обязанность, переехавшая с убранного опроса готовности: причину
|
||||
остановки владелец записи узнаёт карточкой. Задача `json-api-for-spa`
|
||||
2026-08-15;
|
||||
- [webapp](../openspec/specs/webapp/spec.md) — **приложение в браузере**: чем
|
||||
сервис его отдаёт, каким адресом оно открывается, что делает обновление
|
||||
страницы посреди него и что человек видит, открыв его. Здесь же правило
|
||||
неизвестного пути — разметка вне корней сервиса, отказ внутри, — срок хранения
|
||||
ответов и то, что раздача пишет в журнал. Задача `spa-skeleton` 2026-08-15;
|
||||
- [access](../openspec/specs/access/spec.md) — кто пришёл в сервис и пускают ли
|
||||
его дальше: вход через внешнего провайдера OIDC, чем предъявляется сессия, что
|
||||
её прекращает и какие адреса остаются открытыми. Задача `oidc-login`
|
||||
2026-08-12. Здесь же разграничение записей по владельцу: принятая запись
|
||||
его дальше: узнавание по заголовку доверенного источника, заведение учётной
|
||||
записи первым обращением и то, какие адреса остаются открытыми. Собственный
|
||||
вход через OIDC жил здесь с 2026-08-12 по 2026-08-22 и убран задачей
|
||||
`trusted-header-login` — вместе с куками, сессией и её сроком. Здесь же разграничение записей по владельцу: принятая запись
|
||||
принадлежит тому, кто её принёс, чужая неотличима от несуществующей, а ничьей
|
||||
записи не бывает вовсе — колонка владельца пустого значения не принимает.
|
||||
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14.
|
||||
Задачи `record-ownership` и `remove-telegram-intake` 2026-08-14. Здесь же
|
||||
изъятие отладочного запуска: при включённом предохранителе `[server] debug`
|
||||
заголовки входа подставляет сам сервис значениями из `[auth.test_headers]` — с
|
||||
отказами старта, строкой журнала и закрытым перечнем следствий ключа. Задача
|
||||
`config-test-headers-login` 2026-08-23; решения —
|
||||
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md)
|
||||
и [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||||
|
||||
Поведение узла, которого нет в перечне выше, по-прежнему живёт только в коде.
|
||||
Задача, которая его трогает, дописывает спеку своей capability.
|
||||
@@ -53,26 +74,90 @@
|
||||
|
||||
- **Один процесс.** 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), «Брошенная задача возвращается
|
||||
в работу»; здесь это принцип письма шага, а не описание поведения.
|
||||
- **Ядро зависит от интерфейсов.** `internal/service` знает только
|
||||
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в
|
||||
`main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||
- **Подставной собеседник в боевом бинарнике объявлен своим ключом.** Дом ему —
|
||||
код или оснастка; в боевом бинарнике он появляется только отдельным решением
|
||||
владельца и только под ключом, названным своим предметом: имитацию заголовков
|
||||
входа объявляет секция `[auth.test_headers]`, подмену распознавания — правка
|
||||
кода (`internal/adapter/recognizer/memory.go`). Ключ, названный общим словом,
|
||||
обрастает следствиями молча, и выключить его перестаёт означать «сервис ведёт
|
||||
себя как в бою». Предохранитель `[server] debug` вторым именем собеседнику при
|
||||
этом не служит и правилу не противоречит: собой он не называет ничего, а держит
|
||||
**закрытый** перечень следствий, и перечень этот ведёт спека
|
||||
[access](../openspec/specs/access/spec.md), «Предохранитель отладки включает
|
||||
только подстановку заголовков». Новое следствие вешается на ключ только новым
|
||||
требованием той же спеки.
|
||||
- **Чистая архитектура.** Зависимости направлены внутрь, к домену: внутренний
|
||||
слой не знает внешнего никогда. `internal/service` знает только
|
||||
`internal/contract`; ffmpeg, Yandex и хранилище подставляются в точке входа
|
||||
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
|
||||
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
|
||||
они же держат обратные направления: транспорты не знают друг о друге, адаптер
|
||||
не знает ни ядра, ни транспортов.
|
||||
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http`
|
||||
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть
|
||||
роутер этого хранилища, а не наш сервер поверх него. Правила на это
|
||||
направление нет намеренно.
|
||||
не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
|
||||
разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
|
||||
предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
|
||||
и правило на это направление заведено впервые.
|
||||
|
||||
## Слои и модель домена
|
||||
|
||||
**Подход — чистая архитектура.** Зависимость идёт только внутрь: домен не знает
|
||||
ни хранилища, ни транспорта, а знание о внешнем мире живёт интерфейсом в портах
|
||||
и реализацией в инфраструктуре.
|
||||
|
||||
| Слой | Дом | Что живёт | Чего не знает |
|
||||
| --- | --- | --- | --- |
|
||||
| Домен | `internal/entity` | сущности, объекты-значения, доменные события, инварианты значениями | ничего, кроме стандартной библиотеки и единой точки времени `internal/clock` |
|
||||
| Порты | `internal/contract` | интерфейсы репозиториев и внешних служб, типизированные ошибки | реализаций |
|
||||
| Прикладной слой | `internal/service` | шаги конвейера: порядок, повтор, приговор | адаптеров и входов |
|
||||
| Инфраструктура | `internal/adapter` | репозитории, ffmpeg, Yandex, шаги схемы | ядра и входов |
|
||||
| Входы | `internal/controller` | HTTP и пул воркеров | друг друга |
|
||||
| Сборка | `cmd/transcriber` | подстановка реализаций в порты, подъём сервера и пула | — |
|
||||
|
||||
Направления держат тесты-сканеры `internal/archrules` — все, кроме чистоты
|
||||
самого домена. **Её не держит ничто**: правила смотрят ядро, входы и адаптеры, а
|
||||
импорт внешней библиотеки в `internal/entity` сегодня пройдёт молча.
|
||||
|
||||
**Модель домена ведётся тактическими шаблонами DDD.** Шаблон называется здесь
|
||||
вместе со своим сегодняшним предметом — перечень растёт вместе с моделью:
|
||||
|
||||
- **Сущность** — `entity.AudioRecord`: у неё идентичность и поведение
|
||||
(`MoveToState`, `Halt`, `Resume`, `Postpone`), а не набор полей при сервисе;
|
||||
- **корень агрегата** — она же: файлы, тексты, структура, попытки распознавания и
|
||||
журнал событий принадлежат записи и живут её идентификатором, а правит агрегат
|
||||
держатель захвата;
|
||||
- **объект-значение** — `entity.Stage` со своими сроками, `entity.StuckLimits`,
|
||||
`entity.Replica`, `entity.RecognitionResult`: сравниваются по значению и своей
|
||||
идентичности не имеют;
|
||||
- **доменное событие** — `entity.RecordEvent`: что случилось с записью, чьей
|
||||
рукой и чем кончилось;
|
||||
- **репозиторий** — интерфейсы `internal/contract`, реализации под
|
||||
`internal/adapter/repo`;
|
||||
- **служба домена** — правило, не принадлежащее одной сущности, живёт функцией
|
||||
пакета домена (`entity.WorkingStages`, `entity.SanitizeOriginalFilename`);
|
||||
- **фабрика** — `entity.NewInProgressResult` и соседи: значение приходит
|
||||
согласованным, а не заполняется полями снаружи.
|
||||
|
||||
**Анемичной модели не заводим.** Новое поведение записи ищет дом сначала в
|
||||
домене; прикладной слой назначает порядок шагов, а не правила. Признак нарушения
|
||||
наблюдаем: правило о записи, записанное в `internal/service` условием над её
|
||||
полями, принадлежит `internal/entity`.
|
||||
|
||||
Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
|
||||
снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
|
||||
маршруты вернули транспорту независимость от инфраструктуры, а узнавание
|
||||
пришедшего приходит ему интерфейсом `contract.UserRepository`.
|
||||
|
||||
## Компоненты
|
||||
|
||||
@@ -82,14 +167,17 @@
|
||||
|
||||
| Компонент | Где | Что делает |
|
||||
| --- | --- | --- |
|
||||
| HTTP API | `internal/controller/http` | Приём файла и опрос статуса задачи |
|
||||
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, подстановка заголовков входа отладочного запуска, узнавание, требование учётной записи. Условия, при которых звено подстановки встаёт в цепочку, нормирует [access](../openspec/specs/access/spec.md), «Отладочный запуск называет пришедшего настройками» |
|
||||
| Воркеры | `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` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
|
||||
|
||||
Цепочка рубежей — `uploaded` → `normalized` → `submitted` → `transcribed` →
|
||||
`done`; рубеж называет достигнутое, а не предстоящее, и нормирует его
|
||||
@@ -102,12 +190,28 @@
|
||||
## Внешние границы и форматы
|
||||
|
||||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||||
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
||||
`UsePathStyle`. Ключ объекта — имя файла записи, то есть её идентификатор с
|
||||
расширением; идентификаторы строит `internal/ident` и они ULID, а не UUID.
|
||||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||||
`deferred-general`, авторизация заголовком `Api-Key`. Распознавание
|
||||
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
|
||||
через `operation.api.cloud.yandex.net:443`, текст читается потоком.
|
||||
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
|
||||
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
|
||||
нужен. База и файлы записей лежат под одним каталогом данных.
|
||||
- **`github.com/BurntSushi/toml`.** Формат единственного источника настроек.
|
||||
Негодный TOML останавливает старт; незнакомый ключ разбор не судит и молча
|
||||
отбрасывает — наблюдение и чем оно проверено, в
|
||||
[research/toml-unknown-keys.md](research/toml-unknown-keys.md).
|
||||
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
|
||||
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
|
||||
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
|
||||
берём сами.
|
||||
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
|
||||
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
|
||||
Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
|
||||
сетезависимый адрес набора проверок; все такие перечислены в
|
||||
[CLAUDE.md](../CLAUDE.md), «Гейт».
|
||||
|
||||
## Эксплуатация
|
||||
|
||||
@@ -119,43 +223,47 @@
|
||||
целиком. Оставшиеся ключи, которых новый образ ждёт, в конфиге уже есть.
|
||||
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
|
||||
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
|
||||
сервис поднимает молча.
|
||||
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об
|
||||
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только
|
||||
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит,
|
||||
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после
|
||||
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном
|
||||
двух бинарей на одном каталоге данных.
|
||||
сервис поднимает молча. Чем это обеспечено и как проверено —
|
||||
[research/toml-unknown-keys.md](research/toml-unknown-keys.md); тем же
|
||||
свойством безопасно и обратное направление: прежний образ поднимается на
|
||||
конфиге с ключами, которых он ещё не знает.
|
||||
- **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
|
||||
сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
|
||||
другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
|
||||
таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
|
||||
вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
|
||||
схемы нет и не планируется.
|
||||
|
||||
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с
|
||||
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа;
|
||||
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до
|
||||
выкладки `record-centric-model` откат образа работает, после — нет.
|
||||
Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
|
||||
выкладки `record-centric-model` откат работал, после перестал, а с уходом
|
||||
встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
|
||||
не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
|
||||
потому, что риск сегодня чем-то грозит.
|
||||
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
|
||||
медленно» читается вместе с тем, что таймаута нет ни у одного обращения
|
||||
наружу — [database.md](database.md), «Настройки с числовым значением»:
|
||||
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline -->
|
||||
<!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
|
||||
|
||||
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; опрос готовности отдаёт рубеж `done` без поля текста |
|
||||
| Yandex SpeechKit | Шаг возвращает ошибку, запись остаётся на повтор | Захват держится час, запись не двигается; по истечении предела простоя она останавливается с причиной «застряла», не теряя идентификатора операции | Операция вечно `in progress`, повтор каждые 5 секунд — до предела простоя в сутки | Пустой текст — запись доходит до конечного рубежа без расшифровки, и в журнале стоит запись «может стать проблемой» с идентификатором записи; карточка записи отдаёт рубеж `done` с пустым перечнем доступных видов текста |
|
||||
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||
| База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
|
||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||
|
||||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — опросом готовности:
|
||||
остановленная запись отдаёт признак остановки. Владелец — по метрике
|
||||
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
|
||||
остановленная запись отдаёт признак остановки и её причину. Владелец — по метрике
|
||||
`transcriber_worker_job_count` с меткой `error="true"`, и метка `stage`
|
||||
называет рубеж, с которого запись взята: с появлением пула одинаковых воркеров
|
||||
имя потока перестало что-либо значить, а разрез по шагу — единственное, чем
|
||||
«падает приведение» отличается от «падает распознавание». Плюс логи
|
||||
контейнера. Отдельного оповещения нет.
|
||||
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
|
||||
на смену рубежа, на остановку и на снятие остановки; читает его человек в
|
||||
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет.
|
||||
на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
|
||||
него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
|
||||
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
|
||||
вхолостую с паузой из
|
||||
[database.md](database.md), «Настройки с числовым значением».
|
||||
@@ -165,7 +273,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` — таблица, а не привязка к воркеру |
|
||||
@@ -177,11 +289,23 @@
|
||||
| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера |
|
||||
| Метрики | `internal/metrics`, префикс имени `transcriber_` |
|
||||
| Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` |
|
||||
| Отображение доменной ошибки в ответ | `internal/controller/http.mapDomainError` — код, машиночитаемый код отказа и сообщение человеку; ветвь по умолчанию определена, новая ветвь заводится добавлением сюда. Отказы, рождённые слоями библиотеки (предел тела, ограничитель частоты, неизвестный путь), к той же форме приводит слой `OneErrorForm`, стоящий снаружи всех прочих |
|
||||
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
|
||||
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
|
||||
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
|
||||
| Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository` — `internal/controller/http.TrustedHeaderIdentity` |
|
||||
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
|
||||
| Имена заголовков входа | `internal/controller/http.IdentityHeaderNames` вместе с константами рядом — тройка `Remote-*` перечисляется отсюда, а не по месту. она же порождает набор имён, принимаемых секцией `[auth.test_headers]`; что делает старт с ключом вне набора, нормирует [access](../openspec/specs/access/spec.md), «Настройка, открывающая вход всем, роняет старт» |
|
||||
| Сверка адреса пира с перечнем доверенных | `internal/controller/http`, `identity.go` — `peerAddress` и `isTrusted`. Зовут их узнавание, подстановка заголовков отладочного запуска и ограничитель частоты. Свой сверщик разошёлся бы с общим молча — разбор разворачивает IPv4 в оболочке IPv6, и разница пришлась бы ровно на те адреса, ради которых он заводится |
|
||||
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
|
||||
|
||||
Единых точек, которых **нет** и которые ожидались бы: идентификаторы
|
||||
генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в
|
||||
код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло
|
||||
2026-08-13: его читает `internal/clock`, и запрет держит линтер.
|
||||
Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
|
||||
Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
|
||||
запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
|
||||
`json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
|
||||
базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
|
||||
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
|
||||
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
|
||||
|
||||
## Деплой
|
||||
|
||||
@@ -190,31 +314,62 @@
|
||||
образ едет на сервер через `docker save`/`load`. Выкладку целиком запускает человек командой
|
||||
`inv pl -- transcriber` из `pet-project-server`.
|
||||
|
||||
Сборка двухступенчатая, финальный слой — alpine с `ca-certificates` и `ffmpeg`,
|
||||
процесс работает под непривилегированным пользователем `transcriber`.
|
||||
Сборка трёхступенчатая: приложение, бинарник, рабочий слой. Приложение
|
||||
собирается первым — вшивание требует готового каталога, — а в рабочий слой Node
|
||||
не попадает. Финальный слой — alpine с `ca-certificates` и `ffmpeg`, процесс
|
||||
работает под непривилегированным пользователем `transcriber` — в образе он
|
||||
назван числом, `USER 1000:1000`, а не именем: имя разрешает в идентификатор сам
|
||||
образ, и хост, которому надо понять владельца файлов в смонтированном каталоге,
|
||||
разрешить его не может. Числа те же, что при заведении пользователя.
|
||||
|
||||
Ступень бинарника собирает **одну** точку входа — `./cmd/transcriber`, а не весь
|
||||
пакет: рядом в `cmd/` живёт `devtools`, оснастка разработчика, и в образе ей
|
||||
делать нечего.
|
||||
|
||||
Оснастка лежит **одним** пакетом с подкомандами, а не пакетом на инструмент, и
|
||||
это счёт, а не вкус: каждый отдельный пакет стоит четырёх мест — строка сборки
|
||||
здесь, «Деплой» в этом файле, «Команды» в памятке, `README`, — и забытая строка
|
||||
сборки тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
|
||||
четыре места однажды, сколько бы подкоманд в нём ни завелось.
|
||||
|
||||
Ступень приложения стоит на образе с glibc, а не на alpine, и решает это не вес:
|
||||
у musl запрос имени идёт `A` и `AAAA` разом и ждёт **оба** ответа, поэтому
|
||||
DNS-сервер, молчащий на `AAAA`, оставляет установщика пакетов без адреса при
|
||||
живом `A`. Установщик уходит в повторы с нарастающей паузой на каждом пакете, и
|
||||
сборка не краснеет, а **висит** — исход хуже красного. Слои этой ступени в
|
||||
рабочий слой не едут, поэтому её вес остаётся ценой одной сборки.
|
||||
|
||||
**По весу финальный образ от ступени приложения не растёт вовсе:** она отдаёт
|
||||
следующей только собранное, а сама в рабочий слой не копируется. Вшитое
|
||||
приложение прибавляет к бинарнику 86 072 байта. Время сборки образа не
|
||||
замерялось и замеряться не будет — решение владельца от 2026-08-15.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- **Учётные записи.** Вход через OIDC решён и развёрнут 2026-08-12: провайдер —
|
||||
Authelia, ответ провайдера обрабатывает PocketBase, а не наш код
|
||||
([ADR](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)), сессия
|
||||
живёт кукой `transcriber_session` и сама себя не продлевает. Норма —
|
||||
[access](../openspec/specs/access/spec.md), решения —
|
||||
[ADR-2026-08-12-session-without-refresh](adr/ADR-2026-08-12-session-without-refresh.md)
|
||||
и [ADR-2026-08-12-oidc-exchange-via-own-route](adr/ADR-2026-08-12-oidc-exchange-via-own-route.md).
|
||||
- **Учётные записи.** Кто пришёл, сервис узнаёт заголовком, который ставит
|
||||
обратный прокси, сходив к Authelia; учётная запись заводится первым обращением
|
||||
и находится по логину у провайдера. Задача `trusted-header-login` 2026-08-22.
|
||||
Собственного входа, куки и срока сессии у сервиса не осталось — отзыв доступа
|
||||
судит провайдер на каждом запросе, а не однажды выданное значение. Норма —
|
||||
[access](../openspec/specs/access/spec.md), решение —
|
||||
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||
**Не решено одно:** как связать чат Telegram с учётной записью — от этого
|
||||
зависит возвращение убранного входа.
|
||||
Панель администратора при этом Authelia не закрывает: у неё свой пароль
|
||||
суперпользователя.
|
||||
- **Приложение.** Экранов нет вовсе, есть только API. Решено делать SPA,
|
||||
Второго периметра на порту сервиса при этом не осталось: панель администратора
|
||||
ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
|
||||
больше нечего.
|
||||
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
|
||||
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
|
||||
списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
|
||||
Решено делать SPA,
|
||||
устанавливаемое на телефон, а фреймворком взят Vue 3 с роутером пятой версии и
|
||||
сборкой Vite — 2026-08-11,
|
||||
[ADR](adr/ADR-2026-08-11-spa-on-vue.md), сравнение кандидатов в
|
||||
[research/spa-framework.md](research/spa-framework.md). Тем же решением Node
|
||||
входит в гейт и слоем в сборку образа. Пишет это `spa-skeleton`; во что
|
||||
обходится слой Node в образе, не замерялось. Не решено, брать ли готовый набор
|
||||
компонентов.
|
||||
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом.
|
||||
входит в гейт и слоем в сборку образа; как именно он зовётся — решением
|
||||
[ADR](adr/ADR-2026-08-15-node-in-container-not-on-machine.md) 2026-08-15.
|
||||
Не решено, брать ли готовый набор компонентов.
|
||||
- **Уведомления.** Пользователь веба узнаёт о готовности только опросом карточки.
|
||||
Доставку решено брать внешнюю — apprise как отправитель, ntfy как канал; Web
|
||||
Push с VAPID отвергнут. Появляется внешняя зависимость, которой сегодня нет, и
|
||||
текст расшифровки начинает уходить на сторону — сдвиг периметра
|
||||
@@ -224,7 +379,8 @@
|
||||
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
|
||||
записи живёт в хранилище»; откуда взято число —
|
||||
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
|
||||
записка не узнала».
|
||||
записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
|
||||
она осталась только этим числом.
|
||||
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart
|
||||
задан числом в [database.md](database.md), «Настройки с числовым значением»;
|
||||
обрыв начинает загрузку заново.
|
||||
@@ -239,8 +395,8 @@
|
||||
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
|
||||
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
|
||||
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
|
||||
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его
|
||||
или нет, тоже не решено.
|
||||
всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
|
||||
ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
|
||||
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
|
||||
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
|
||||
сервис определяет содержимое сам, то ли часть записей теряется на этом.
|
||||
@@ -255,12 +411,15 @@
|
||||
паузы, а не замер
|
||||
([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). Не
|
||||
решено, отдельный это шаг конвейера или продолжение шага распознавания.
|
||||
|
||||
+59
-16
@@ -5,20 +5,22 @@
|
||||
|
||||
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
|
||||
Главные: комментариями снабжена половина полей; единого места проверки на старте
|
||||
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в `main.go`, а пустые
|
||||
ключи `[yandex]` ловит конструктор распознавателя.
|
||||
нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
|
||||
входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
|
||||
|
||||
**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml`
|
||||
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
|
||||
приезжают из TOML»; `godotenv` в `main.go` по-прежнему загружает `.env`, но кладёт
|
||||
его в окружение процесса, а не в настройки приложения.
|
||||
приезжают из TOML»: наш рабочий код окружение не читает вовсе — читателя `.env`
|
||||
в `cmd/transcriber` сняли 2026-08-23 вместе с зависимостью. Окружение остаётся
|
||||
у границы SDK: `aws-sdk-go-v2` в `internal/adapter/recognizer/yandex/s3.go`
|
||||
зовёт `config.LoadDefaultConfig`, а тот читает `AWS_PROFILE`, `AWS_CA_BUNDLE`,
|
||||
`AWS_ENDPOINT_URL` и `AWS_ENDPOINT_URL_S3` и смотрит `~/.aws/config`.
|
||||
|
||||
## Принципы
|
||||
|
||||
- **Конфигурация — только TOML.** Переменные окружения для конфигурации **не
|
||||
используем**: окружение наследуется дочерними процессами и видно через
|
||||
`/proc/<pid>/environ` — для секретов это слабее файла под `0600`.
|
||||
*Расхождение:* `main.go` зовёт `godotenv.Load()` и молча продолжает без файла.
|
||||
- Грузим **один раз при старте** в одну типизированную структуру `Config`
|
||||
(под-структуры по секциям). Дальше по коду читаем только её — чтения файла в
|
||||
прикладном коде нет, только загрузчик `internal/config`.
|
||||
@@ -57,10 +59,26 @@ force_shutdown_timeout = <N> # ждать остановки ворке
|
||||
|
||||
Секретные поля оставляем пустыми — значение приходит из выкладки (см. «Секреты»).
|
||||
|
||||
*Расхождение:* адреса провайдера в секции `[auth]` образца заполнены примерами
|
||||
вида `https://auth.example.com/...`, а не оставлены пустыми: пустой адрес не
|
||||
говорит, какой формы значение здесь ждут. Пустым оставлен только
|
||||
`client_secret` — он и есть секрет.
|
||||
*Расхождение:* перечень доверенных адресов в секции `[auth]` образца заполнен
|
||||
примером — подсетью docker, — а не оставлен пустым: пустое значение не говорит,
|
||||
какой формы значение здесь ждут, а сервис с пустым перечнем не поднимается вовсе.
|
||||
Секретов в этой секции больше нет: они ушли 2026-08-22 вместе с собственным
|
||||
входом.
|
||||
|
||||
Там же, комментарием под секцией, стоит рецепт локального входа **одним связным
|
||||
блоком**, а не тремя комментариями по месту: правки связаны между собой, и
|
||||
применённая порознь любая из них роняет старт либо оставляет сервис никого не
|
||||
узнающим. Рабочей строкой в образце стоит боевое значение —
|
||||
перечень с адресом прокси и `debug = false`, — а секция имитации закомментирована
|
||||
целиком: образец описывает боевую выкладку, а локальный вход — способ до неё
|
||||
дойти, и два рабочих значения в одном файле читались бы как выбор без указания,
|
||||
какое из них чьё.
|
||||
|
||||
*Расхождение:* петлевые адреса в рецепте названы **парой** — `127.0.0.1` и
|
||||
`::1`, — а не одним значением, хотя правило секции требует от образца только
|
||||
формы значения. Причина в цене: браузер разрешает `localhost` в IPv6 не реже,
|
||||
чем в IPv4, и перечень без `::1` даёт неузнанный запрос там, где человек ждёт
|
||||
входа.
|
||||
|
||||
## Поля по дискриминатору `type`
|
||||
|
||||
@@ -89,7 +107,8 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
|
||||
- Секретные поля transcriber: `yandex.speech_kit_api_key`,
|
||||
`yandex.object_storage_access_key_id`,
|
||||
`yandex.object_storage_secret_access_key`, `auth.client_secret`.
|
||||
`yandex.object_storage_secret_access_key`. Секрет клиента OIDC отсюда ушёл
|
||||
2026-08-22 вместе с собственным входом.
|
||||
- Отрендеренный `config.toml` (с секретами) **не коммитится**; права `0600`,
|
||||
владелец — пользователь процесса (`1000:1000`).
|
||||
- В `config.example.toml` секретные поля — пустые строки.
|
||||
@@ -110,6 +129,16 @@ Ansible из `pet-project-server`). Приложение просто читае
|
||||
текст остаётся как есть — иначе за разборчивость отказа платили бы там, где
|
||||
платить не за что.
|
||||
|
||||
**Файл, способный нести секрет, называется в `.gitignore` и в
|
||||
`.dockerignore`.** Путей наружу у такого файла два, и закрывает
|
||||
их разное: git держит `.gitignore`, а контекст сборки образа — `.dockerignore`,
|
||||
потому что docker `.gitignore` не читает. Сегодня в обоих названы `config.toml`
|
||||
и `.env`. Правило записано прозой и держится чтением: сверка двух списков стала
|
||||
бы проверкой над проверкой, а такие проект не заводит
|
||||
([../../CLAUDE.md](../../CLAUDE.md), «Запреты»). До 2026-08-23 парность не
|
||||
называл ни один документ, и прогон, снимавший мёртвого читателя `.env`, снял
|
||||
строку с одной стороны — вернуло её ревью.
|
||||
|
||||
## Проверка и остановка на старте
|
||||
|
||||
Конфиг проверяем **на старте, до приёма трафика**. Негодный конфиг — лог `ERROR`
|
||||
@@ -129,12 +158,26 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
|
||||
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
|
||||
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
|
||||
|
||||
Секция `[auth]` — первая, у которой проверка своя и стоит на старте:
|
||||
`AuthConfig.Validate()` зовётся из `main.go` сразу после загрузки и роняет
|
||||
процесс с перечнем незаполненных ключей. Причина в цене умолчания: поднявшись с
|
||||
молча выключенным входом, сервис остался бы открытым наружу, а узнать об этом
|
||||
было бы неоткуда. Сообщение называет **имена ключей**, а не значения — значение
|
||||
`client_secret` в журнал попасть не должно.
|
||||
**Проверка, охватывающая две секции разом, живёт методом на корневой `Config`.**
|
||||
Такая сегодня одна — `ValidateTestHeaders`: она судит `[server] debug` против
|
||||
`[auth.test_headers]`, и ни в `Validate()` секции сервера, ни в `Validate()`
|
||||
секции входа не помещается — секция начала бы знать о чужой секции. Зовётся она
|
||||
из `cmd/transcriber` рядом с остальными. Имена принимаемых заголовков приходят
|
||||
ей **доводом**, а не читаются из пакета настроек: дом у них один — константы
|
||||
транспорта, — а `internal/config` транспорта не знает и знать не должен, иначе
|
||||
`cmd/devtools`, которому нужен один разбор конфига, линковал бы всю поверхность
|
||||
HTTP.
|
||||
|
||||
Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
|
||||
на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
|
||||
и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
|
||||
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
|
||||
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
|
||||
которой не управляют. Причина в цене умолчания: поднявшись с
|
||||
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
|
||||
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
|
||||
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
|
||||
силе для прочих секций, где секреты есть.
|
||||
|
||||
## Структура в коде
|
||||
|
||||
|
||||
@@ -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`:
|
||||
сравнение по неуникальному значению делает порядок обработки
|
||||
невоспроизводимым.
|
||||
|
||||
+47
-17
@@ -98,20 +98,43 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- **отображение доменной ошибки в статус и сообщение** — единой точкой для
|
||||
HTTP и веба:
|
||||
|
||||
| Доменная ошибка | Статус | Сообщение |
|
||||
| --- | --- | --- |
|
||||
| задача не найдена | 404 | «задача не найдена» |
|
||||
| файл не приложен, формат не распознан | 400 | «некорректный ввод» |
|
||||
| задача ещё выполняется, действие сейчас недопустимо | 409 | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | «внутренняя ошибка» |
|
||||
| Доменная ошибка | Статус | `error_code` | Сообщение |
|
||||
| --- | --- | --- | --- |
|
||||
| пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
|
||||
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
|
||||
| файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
|
||||
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
|
||||
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
|
||||
| текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
|
||||
| прочее | 500 | `internal` | «внутренняя ошибка» |
|
||||
|
||||
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
|
||||
случаем: им был владелец панели, предъявивший собственный токен хранилища.
|
||||
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
|
||||
учётную запись заводит само.
|
||||
|
||||
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
|
||||
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
|
||||
логирующая граница спишет его в `ERROR` вместо `DEBUG`.
|
||||
|
||||
*Расхождение:* такой точки нет. `internal/controller/http/transcribe.go`
|
||||
отвечает 404 на **любую** ошибку `GetByID`, включая сбой базы, и 500 на
|
||||
любую ошибку заведения задачи.
|
||||
**Тело отказа несёт два поля — `error_code` и `message`.** Кода HTTP не
|
||||
хватает: «файл негоден», «поля записи нет» и «неизвестный вид» — все три
|
||||
`400`, а приложению надо решать, предлагать ли повтор. Разбор русской фразы
|
||||
был бы единственным оставшимся путём. Норму держит спека `archive`.
|
||||
|
||||
Точка живёт в `internal/controller/http.mapDomainError` и названа в
|
||||
[architecture.md](../architecture.md), «Единые точки проекта». Прежнее
|
||||
расхождение — «такой точки нет, обработчик решает сам» — закрыто задачей
|
||||
`json-api-for-spa` 2026-08-15.
|
||||
|
||||
**Часть отказов рождается не в обработчике** — предел тела, ограничитель
|
||||
частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
|
||||
файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
|
||||
перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
|
||||
отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
|
||||
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
|
||||
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
|
||||
неоткуда.
|
||||
|
||||
### Разовый ответ и сохранённая диагностика
|
||||
|
||||
@@ -119,7 +142,7 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
|
||||
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
|
||||
полная ошибка остаётся в логах по идентификатору задачи.
|
||||
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это
|
||||
- **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
|
||||
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
|
||||
и полезен. Но:
|
||||
- **секреты запрещены** — токены, ключи, пароли, заголовок
|
||||
@@ -130,8 +153,8 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
числом рядом**: без этого непонятно, насколько сокращать.
|
||||
|
||||
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
|
||||
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без
|
||||
машинного текста — эту часть правила держит спека `intake`.
|
||||
Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
|
||||
машинного текста — эту часть правила держит спека `archive`.
|
||||
|
||||
## panic
|
||||
|
||||
@@ -140,13 +163,20 @@ transcriber — **приложение, а не библиотека**: внеш
|
||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
|
||||
это значения `error`.
|
||||
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
|
||||
ронял процесс. В transcriber его вешает роутер хранилища сам
|
||||
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId`
|
||||
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс
|
||||
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в
|
||||
шаге конвейера роняет процесс целиком.
|
||||
ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
|
||||
обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
|
||||
владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
|
||||
чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
|
||||
роняет процесс целиком.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
- Сбор независимых ошибок (проверка конфига — все проблемы разом) —
|
||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||
|
||||
*Расхождение:* проверку конфига пункт называет поимённо, а ни одна из них так не
|
||||
устроена: `errors.Join` в `internal/config` не зовётся нигде, и всякая проверка
|
||||
возвращается на первом несовпадении. Заметило ревью задачи
|
||||
`config-test-headers-login` 2026-08-23 — тем же прогоном, каким добавили
|
||||
`ValidateTestHeaders`, ведущую себя так же. Человек, заполняющий конфиг
|
||||
впервые, чинит одну ошибку за прогон.
|
||||
|
||||
@@ -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). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
|
||||
|
||||
### Отмена и внешний собеседник
|
||||
@@ -130,12 +132,15 @@
|
||||
| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit |
|
||||
| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit |
|
||||
| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) |
|
||||
| Форматирование и статический анализ кода приложения | `web/biome.json` → Biome, зовётся шагом `front` командой `npm run check`. Разбирает и однофайловые компоненты; правила — набор `recommended` плюс своя форма (одинарные кавычки, точка с запятой по необходимости) |
|
||||
| Типы разметки и кода приложения | `vue-tsc`, и он входит в **команду сборки**, а не стоит отдельным шагом: несобираемое приложение и непроверенные типы — один отказ |
|
||||
| Поведение экранов приложения | `Taskfile.yml` → шаг `front`, юнит-тесты Vue (`npm run test`). Без них требование «приложение показывает вошедшего» не проверял бы никто, а набор проверок оставался бы зелёным на сломанном экране |
|
||||
|
||||
### Хранилище, документы, секреты, зависимости
|
||||
|
||||
| Правило | Где механизировано |
|
||||
| --- | --- |
|
||||
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `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` |
|
||||
@@ -185,11 +190,9 @@
|
||||
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
|
||||
молчит на всём каталоге, и на подозрении база задаётся руками
|
||||
(`task migrations BASE=<rev>`);
|
||||
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
|
||||
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
|
||||
есть роутер этого хранилища. Изъятие названо в
|
||||
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
|
||||
нет.
|
||||
- чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
|
||||
библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
|
||||
[../architecture.md](../architecture.md), «Слои и модель домена».
|
||||
|
||||
Отдельно названы **правила, чей подъём отклонён**:
|
||||
|
||||
|
||||
+25
-12
@@ -31,7 +31,13 @@ OpenSpec.
|
||||
{"time":"2026-08-10T11:23:45.123456Z","level":"INFO","msg":"record accepted","capability":"intake","record_id":"…","source":"api","duration_seconds":137}
|
||||
```
|
||||
|
||||
*Расхождение:* `main.go` ставит `slog.NewTextHandler(os.Stdout, …)`.
|
||||
*Расхождение:* текстовый обработчик ставит `cmd/transcriber` —
|
||||
`slog.NewTextHandler(os.Stdout, …)`.
|
||||
|
||||
*Изъятие:* оснастка разработчика `cmd/devtools` печатает не через `slog`, а
|
||||
stdlib-логом в поток ошибок. Это выбор, а не долг: её вывод читает человек в
|
||||
терминале, в сбор он не едет, а текст подсказки по командам `slog`-ом
|
||||
выглядел бы хуже, чем есть.
|
||||
|
||||
## Сообщение
|
||||
|
||||
@@ -78,9 +84,14 @@ OpenSpec.
|
||||
- `slog` не разделяет CRITICAL и FATAL — сбой на старте логируем `ERROR` и
|
||||
завершаем процесс с ненулевым кодом.
|
||||
|
||||
*Расхождение:* уровень зашит константой в `main.go`, `DEBUG` включить нечем.
|
||||
*Расхождение:* уровень зашит константой в `cmd/transcriber`, `DEBUG` включить нечем.
|
||||
Пустой прогон воркера не логируется вовсе — и это правилу не противоречит.
|
||||
|
||||
*Изъятие:* строка о подставленных заголовках входа адресована разработчику, а
|
||||
идёт на `INFO` — уровень и его довод нормирует спека
|
||||
[access](../../openspec/specs/access/spec.md), «Отладочный запуск виден в
|
||||
журнале».
|
||||
|
||||
## Время
|
||||
|
||||
- Поле — `time` (ключ `slog` по умолчанию).
|
||||
@@ -94,15 +105,18 @@ OpenSpec.
|
||||
|
||||
- Доменные поля — плоский `snake_case`.
|
||||
- Системные домены — точечная иерархия (по образцу OpenTelemetry): `http.*`,
|
||||
`ext.*`.
|
||||
`ext.*`, `webapp.*`.
|
||||
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
||||
|
||||
| Когда добавляем | Поля |
|
||||
| --- | --- |
|
||||
| на входящий 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.route` стоит `<приложение>` |
|
||||
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
|
||||
|
||||
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
|
||||
|
||||
@@ -196,7 +210,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 получается дублирующийся ключ, и строгий
|
||||
потребитель молча оставит одно из значений. Правило проверяется чтением,
|
||||
@@ -205,13 +219,12 @@ Object Storage и опрос операции не логируются ника
|
||||
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
|
||||
`INFO` они не пишутся.
|
||||
|
||||
Расхождения здесь больше нет: слой журналирования запросов свой,
|
||||
`main.go`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics`
|
||||
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе.
|
||||
Расхождения здесь больше нет: слой журналирования запросов свой —
|
||||
`internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
|
||||
то есть при боевом `INFO` не пишутся вовсе.
|
||||
|
||||
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден
|
||||
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера,
|
||||
по которому разбирают отказы, эта таблица не попадает.
|
||||
**Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
|
||||
вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
|
||||
|
||||
## Безопасность: что не логируем
|
||||
|
||||
@@ -248,7 +261,7 @@ Object Storage и опрос операции не логируются ника
|
||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||
расширением. В журнал оно идёт **собственным полем** строки приёма — это
|
||||
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
|
||||
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе
|
||||
«Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
|
||||
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
|
||||
там расширение приводится к перечню известных форматов. Остаток описан в
|
||||
[../security.md](../security.md).
|
||||
|
||||
+48
-15
@@ -10,17 +10,19 @@
|
||||
описывала htmx с прямым запретом на шаг сборки и реактивные фреймворки; она снята
|
||||
целиком вместе со сменой решения на SPA 2026-08-10.
|
||||
|
||||
**Кода приложения ещё нет.** Правила ниже выведены из выбора и из замера на
|
||||
пробном экране, а не из написанного кода: первым их применяет и проверяет
|
||||
`spa-skeleton`. Место, где правило разойдётся с тем, что окажется удобным, —
|
||||
повод править эту запись, а не обходить её молча.
|
||||
Правила ниже применены каркасом приложения (`spa-skeleton`, 2026-08-15): до него
|
||||
они были выведены из выбора и из замера на пробном экране, а не из написанного
|
||||
кода. Место, где правило разойдётся с тем, что окажется удобным, — повод править
|
||||
эту запись, а не обходить её молча.
|
||||
|
||||
Логирование запросов — [logging.md](logging.md). Трансляция доменных ошибок
|
||||
наружу — [errors.md](errors.md).
|
||||
|
||||
**Механизировано:** типы разметки и кода проверяет `vue-tsc`, и он входит в
|
||||
команду сборки, а не стоит отдельным шагом. Правил линтера для кода приложения
|
||||
пока нет.
|
||||
команду сборки, а не стоит отдельным шагом. Форматирование и статический анализ
|
||||
держит **Biome**, поведение экранов — **юнит-тесты Vue**; оба шага входят в набор
|
||||
проверок наравне со сборкой. Инструменты зовутся контейнером, а не из `PATH`:
|
||||
требованием к машине разработчика остаётся docker, а не установленный Node.
|
||||
|
||||
## Что решено про само приложение
|
||||
|
||||
@@ -51,7 +53,14 @@
|
||||
делать то же самое.
|
||||
- **Собранная статика неизменяема и адресуется хешем в имени.** Имена придумывает
|
||||
Vite, руками их не задаём: от этого зависит обновление установленного
|
||||
приложения.
|
||||
приложения. Сервис на это правило опирается, но проверить его не может — имён
|
||||
он не выбирает, — поэтому дом правила здесь, а не в спеке: долгий срок
|
||||
хранения он ставит **по каталогу** сборщика, и файл, положенный туда без
|
||||
отпечатка в имени, останется в хранилище браузера навсегда.
|
||||
- **Зависимости ставятся из файла замка командой, которая его не правит.** Иначе
|
||||
набор проверок пачкает рабочее дерево, обновление зависимости приезжает в
|
||||
коммит без чьего-либо решения, а собранное в наборе проверок перестаёт
|
||||
совпадать с собранным в образе.
|
||||
- **Шаг сборки входит в `task gate` и в сборку образа.** Красная сборка статики
|
||||
роняет гейт наравне с `go build`.
|
||||
|
||||
@@ -61,11 +70,26 @@
|
||||
включаем: сборочная надстройка роутера пятой версии стоит 34 пакета в
|
||||
установке и на нашем числе маршрутов не окупается
|
||||
([research/spa-framework.md](../research/spa-framework.md), «Vue»). Сколько
|
||||
экранов и какие — не здесь: состав нормирует спека приложения, а до неё его
|
||||
держит [spa-skeleton](../../tasks/items/spa-skeleton.md).
|
||||
экранов и какие — не здесь: состав нормирует спека
|
||||
[webapp](../../openspec/specs/webapp/spec.md).
|
||||
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
|
||||
к серверу: неизвестный путь **вне** `/api/` отдаёт `index.html`, а не `404`;
|
||||
пути внутри `/api/` в приложение не проваливаются никогда.
|
||||
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
|
||||
`404`; путь внутри корня в приложение не проваливается никогда. Корень
|
||||
сегодня **один** — `/app/` у приложения, — плюс `/health` и `/metrics`
|
||||
отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
|
||||
ушёл с собственным входом, два других — со встроенным хранилищем и его
|
||||
панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
|
||||
из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
|
||||
соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
|
||||
смены хранилища не изменились ни одним полем. Перечень корней сервису не
|
||||
описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
|
||||
он разошёлся бы с ними молча.
|
||||
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
|
||||
которому не нашлось файла, отвечает `404`. Правило — вторая половина
|
||||
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
|
||||
подменить их разметкой значит ответить `200` на то, чего нет. Браузер отвергнет
|
||||
такой ответ по типу содержимого, человек увидит пустой экран, а в кодах
|
||||
ответов сервиса не останется ничего.
|
||||
- **Экран не знает, как он открыт.** Данные экран берёт по своему адресу, а не
|
||||
получает от предыдущего: приложение открывают по ссылке и обновляют страницу
|
||||
посередине.
|
||||
@@ -83,15 +107,17 @@
|
||||
- **Обёртка — единственное место, где читается код ответа.** Она же превращает
|
||||
ошибку контракта в доменную ошибку приложения; экран получает готовый текст, а
|
||||
не `Response`.
|
||||
- **Сессия живёт кукой `transcriber_session`**, и приложение её не читает: кука
|
||||
`HttpOnly`, браузер шлёт её сам, а вошедшего экран узнаёт по ответу API. Норма
|
||||
— [access](../../openspec/specs/access/spec.md).
|
||||
- **Сессии у сервиса нет вовсе**, и приложение не хранит ничего: кто пришёл,
|
||||
называет заголовок обратного прокси, а приложение узнаёт его ответом API.
|
||||
Кук сервис не ставит — это свойство сторожится проверкой. Норма —
|
||||
[access](../../openspec/specs/access/spec.md), решение —
|
||||
[ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md).
|
||||
|
||||
## Показ ошибок и состояний
|
||||
|
||||
- **Текст ошибки приходит с сервера и показывается как есть.** Своих текстов под
|
||||
коды ответа приложение не сочиняет: единая форма ошибки — обязанность API
|
||||
([json-api-for-spa](../../tasks/items/json-api-for-spa.md)), и второй словарь
|
||||
(спека [archive](../../openspec/specs/archive/spec.md)), и второй словарь
|
||||
на клиенте разошёлся бы с первым.
|
||||
- **Отсутствие связи — состояние, а не ошибка.** Сорванный запрос показывается
|
||||
строкой «связи нет», а не пустым экраном и не сообщением браузера.
|
||||
@@ -102,6 +128,13 @@
|
||||
|
||||
## Что не решено
|
||||
|
||||
- **Инструмент статического анализа проверен наполовину.** Biome взят решением
|
||||
владельца 2026-08-15 и разбирает однофайловые компоненты; замены он потребует,
|
||||
если перестанет их держать. Тогда это отдельное решение, а не подстановка по
|
||||
ходу.
|
||||
- **Проверка типов держится на пятой линии TypeScript.** С седьмой `vue-tsc`
|
||||
не работает: новый компилятор не отдаёт точку входа, которую тот зовёт.
|
||||
Проверено прогоном 2026-08-15.
|
||||
- **Набор компонентов и стили.** Своя разметка или готовый набор — не решено, а
|
||||
готовый способен удвоить собранный файл
|
||||
([research/spa-framework.md](../research/spa-framework.md), «Чего разведка не
|
||||
|
||||
+301
-174
@@ -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 остались только в **именах файлов**: имя, под которым запись ложится в
|
||||
хранилище, задаёт сервис, и это `<uuid><расширение>`.
|
||||
**Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
|
||||
Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
|
||||
миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
|
||||
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
|
||||
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
|
||||
считается несуществующей записью и до базы не доходит.
|
||||
|
||||
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки
|
||||
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе
|
||||
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
|
||||
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
|
||||
постоянную ложь молча.
|
||||
Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
|
||||
файла внутри него — `<ULID><расширение>`.
|
||||
|
||||
Того, что единой точки генерации идентификатора и времени нет, здесь не
|
||||
повторяем: перечень единых точек и их отсутствий держит
|
||||
[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,31 +121,65 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
| Поле | Тип | Что |
|
||||
| --- | --- | --- |
|
||||
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище |
|
||||
| `owner` | relation → `users` | Владелец записи; пустого значения не принимает |
|
||||
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
|
||||
| `id` | TEXT PK | ULID |
|
||||
| `owner_id` | TEXT → `users(id)` | Владелец записи; пустого значения не принимает |
|
||||
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
|
||||
| `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 | Время |
|
||||
|
||||
Индекс один — по паре «рубеж и признак остановки»: выборка захвата идёт по ним,
|
||||
паузе и сроку протухания.
|
||||
Индексов два. `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: без своего индекса
|
||||
страница сканировала таблицу целиком и досортировывала результат во временном
|
||||
дереве, а рост архива с 5 тысяч строк до 200 тысяч растил время одной страницы
|
||||
владельца в двадцать-тридцать раз — при неизменных сорока его собственных
|
||||
записях. Цена росла с **чужими** записями, потому что сервис объявлен архивом и
|
||||
хранит их бессрочно.
|
||||
|
||||
**Имя файла и заголовок — разные колонки.** Заголовок несёт название, которое
|
||||
дал человек либо посчитала языковая модель; имя файла — то, по чему человек
|
||||
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
|
||||
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
|
||||
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
|
||||
файла на диске и в журнал оно по-прежнему не идёт.
|
||||
|
||||
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними
|
||||
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
|
||||
приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
|
||||
меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
|
||||
«что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
|
||||
а список читается без содержимого. Решение владельца от 2026-08-15.
|
||||
|
||||
**«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
|
||||
величины ставит приём и ставит всегда — запись с непрочитанными метаданными
|
||||
отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
|
||||
значение, которое схема теперь допустить может, завело бы третий смысл, которого
|
||||
никто не читает.
|
||||
|
||||
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
|
||||
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
|
||||
@@ -97,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` в этой же схеме зовут формат
|
||||
@@ -120,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 | Время |
|
||||
|
||||
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
|
||||
сохранённого ответа изменится раньше, чем архив пересчитают.
|
||||
@@ -133,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)` без каскада, а соблюдение внешних ключей включено на каждом
|
||||
соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
|
||||
его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
|
||||
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
|
||||
тупик до задачи про удаление записи.
|
||||
|
||||
## Представление данных
|
||||
|
||||
@@ -226,52 +324,62 @@ capability, и третий смысл развёл бы одно слово п
|
||||
|
||||
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
|
||||
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
|
||||
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той
|
||||
же строки и читалась при каждом опросе очереди.
|
||||
- **Аудио лежит в раскладке хранилища:**
|
||||
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт
|
||||
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает,
|
||||
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни
|
||||
файлы, ни объекты в Object Storage не удаляются после завершения задачи:
|
||||
каталог и бакет растут неограниченно.
|
||||
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым шагом `202608120001`, а правило просмотра коллекции
|
||||
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном
|
||||
файла, который берут по сессии. Прежнее решение — «право прочитать запись даёт
|
||||
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла
|
||||
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки.
|
||||
- **Коллекция `users`** заводится самой библиотекой, а шаг `202608120001` её
|
||||
сужает: создание записи разрешено только контексту обмена OIDC
|
||||
(`@request.context = "oauth2"`), вход по паролю и одноразовый код выключены.
|
||||
Без этого сужения закрытие API обходится двумя запросами — завести себе
|
||||
запись и войти паролем. Продление сессии закрыто слоем в приложении, а не
|
||||
настройкой коллекции: библиотека выдаёт сессию продлеваемой всегда.
|
||||
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
|
||||
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
|
||||
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
|
||||
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
|
||||
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
|
||||
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
|
||||
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
|
||||
его не может.
|
||||
колонки шаг читает отдельным чтением.
|
||||
- **Файлы записи лежат подкаталогом на запись:**
|
||||
`data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
|
||||
и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
|
||||
убирается целиком одним движением; плоский каталог, где копии различаются
|
||||
приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
|
||||
отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
|
||||
объекты в 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), «Инварианты»; сверку держат правила
|
||||
`internal/archrules`.
|
||||
норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
|
||||
запросе правки, поэтому между проверкой и записью не остаётся окна.
|
||||
- **Колонки записи отображаются по имени**: именованные параметры запроса и место
|
||||
назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
|
||||
длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
|
||||
который компилируется молча и кладёт идентификатор файла в колонку текста.
|
||||
Перечень мест, где правится колонка, и серьёзность правила — инвариант
|
||||
«Колонки записи правятся в трёх местах» в [CLAUDE.md](../CLAUDE.md),
|
||||
«Инварианты»; сверку держат правила `internal/archrules`.
|
||||
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
|
||||
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
|
||||
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
|
||||
по инварианту проекта не пишется в журнал и не считается в метрику.
|
||||
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а
|
||||
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают
|
||||
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки
|
||||
в Object Storage: отказ SDK несёт полный URL объекта.
|
||||
- **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
|
||||
запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
|
||||
часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
|
||||
полный URL объекта.
|
||||
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
|
||||
Отменять там нечего: операции местные и короткие, а единственное ожидание —
|
||||
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
|
||||
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
|
||||
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
|
||||
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
|
||||
распознавания.
|
||||
|
||||
## Настройки с числовым значением
|
||||
|
||||
@@ -284,23 +392,45 @@ 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` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
|
||||
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
|
||||
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
|
||||
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
|
||||
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
|
||||
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
|
||||
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
|
||||
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
|
||||
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
|
||||
| Задержка перед первой проверкой операции | 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` | расчётный потолок в шесть часов с запасом на видео |
|
||||
| Срок жизни сессии | нормирует [access](../openspec/specs/access/spec.md) | `pbrepo.SessionDuration`, ставится при подъёме | решение владельца 2026-08-12; умолчание библиотеки никем не выбрано, и спека прямо запрещает его применять |
|
||||
| Потолок времени на вход у провайдера | 10 минут | `controller/http/auth.go` | дольше носитель состояния не нужен |
|
||||
| Таймаут обмена кода у провайдера | 15 секунд | там же | молчащий провайдер иначе держит обработчик возврата открытым |
|
||||
| Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
|
||||
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
|
||||
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
|
||||
|
||||
**Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
|
||||
когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
|
||||
счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
|
||||
и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
|
||||
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
|
||||
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
|
||||
ограничивает. Как читается цепочка — спека
|
||||
[archive](../openspec/specs/archive/spec.md); здесь только числа бюджета.
|
||||
|
||||
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
|
||||
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
|
||||
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
|
||||
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
|
||||
потолок времени на вход у провайдера и таймаут обмена кода.
|
||||
|
||||
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
|
||||
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
|
||||
@@ -311,15 +441,12 @@ capability, и третий смысл развёл бы одно слово п
|
||||
остановка «застряла» наступает только после него. Мягкая остановка сюда не
|
||||
подпадает: она снимает захват сама.
|
||||
|
||||
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у
|
||||
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля
|
||||
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело
|
||||
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее
|
||||
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по
|
||||
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной
|
||||
нагрузке объявлена вне модели угроз.
|
||||
**Потолок размера назван числом там, где иначе действует умолчание** — у тела
|
||||
запроса приёма, и назван дважды: объявленная длина судится заранее, а
|
||||
необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
|
||||
величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
|
||||
часов записи по медленному каналу переживают любой фиксированный, а стойкость к
|
||||
целенаправленной нагрузке объявлена вне модели угроз.
|
||||
|
||||
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер
|
||||
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения
|
||||
файлов и объектов нет вовсе. Таймаутов у
|
||||
обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||
Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
|
||||
Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
|
||||
|
||||
+36
-22
@@ -21,7 +21,7 @@
|
||||
| --- | --- |
|
||||
| Владелец сервиса | Загрузить диктофонную запись или видео из семейного архива с телефона и получить текст. Видеть, кто сколько загрузил и во что это обошлось |
|
||||
| Приглашённый пользователь | Войти в приложение через свою учётную запись, загрузить запись, забрать текст, вернуться к ней через месяц. Приложение ставится на телефон; каждый видит только свои записи |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня почти не работает: приём и опрос закрыты сессией OIDC, а своего токена у программы нет — годится только чужая сессия, снятая из браузера и предъявленная кукой либо заголовком `Authorization`. Токен приносит `api-tokens` |
|
||||
| Внешняя программа | Отдать файл по HTTP, представившись своим токеном, и опросить готовность. Сегодня не работает вовсе: домен целиком стоит за обратным прокси, и запрос программы отбивает он, не доходя до сервиса. Токен и правило прокси мимо входа приносит `api-tokens` |
|
||||
|
||||
**Вход у сервиса один — HTTP API**, и приложение строится поверх него. До
|
||||
2026-08-11 основным входом был Telegram-бот. 2026-08-11 основным объявили
|
||||
@@ -61,10 +61,20 @@ Telegram.
|
||||
- **Собственные модели.** Не обучаем и не держим у себя ни модель распознавания,
|
||||
ни языковую модель: и речь, и выводы из текста считает внешний сервис.
|
||||
- **Управление учётными записями.** Пользователей заводит и проверяет внешний
|
||||
провайдер, свою регистрацию и свои пароли не делаем. Одно исключение появилось
|
||||
2026-08-11 вместе с решением про PocketBase: в панель администратора владелец
|
||||
входит своим паролем, потому что подпустить к ней внешнего провайдера
|
||||
PocketBase не даёт.
|
||||
провайдер, свою регистрацию и свои пароли не делаем. Своя строка учётной
|
||||
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
|
||||
имя, названное провайдером, — заводит строку при первом обращении под новым
|
||||
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
|
||||
сервис не решает никогда. Панель администратора со своим паролем владельца жила
|
||||
здесь с 2026-08-11 по 2026-08-22 и ушла вместе со встроенным хранилищем.
|
||||
*Изъятие одно:* при включённом предохранителе `[server] debug`, выключенном по
|
||||
умолчанию, сервис подставляет запросу те заголовки входа, которые в бою даёт
|
||||
обратный прокси. Своего входа, регистрации и проверки допуска он от этого не
|
||||
заводит: подставленное имя проходит то же узнавание, что и пришедшее. Кого
|
||||
пускать, провайдер решает во всяком прогоне без изъятия; в самом изъятии его не
|
||||
спрашивают вовсе — сервис называет пришедшего сам. Тем изъятие и держится
|
||||
выключенным умолчанием, а границу его держит спека
|
||||
[access](../openspec/specs/access/spec.md).
|
||||
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
|
||||
обрабатываем.
|
||||
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
|
||||
@@ -82,7 +92,9 @@ Telegram.
|
||||
|
||||
## Типовые сценарии
|
||||
|
||||
Первые два — основные, и сегодня не работает ни один: приложения нет.
|
||||
Первые два — основные, и сегодня не работает ни один. Приложение с 2026-08-15
|
||||
есть, но экранов у него пока нет: оно открывается и показывает вошедшего, а
|
||||
загрузку и список заводят `upload-and-status-screen` и `records-list-screen`.
|
||||
|
||||
1. **Семейный архив.** Человек открывает приложение на телефоне, выбирает до
|
||||
десяти записей разом — диктофонные дорожки и видео, — и закрывает его.
|
||||
@@ -92,16 +104,17 @@ Telegram.
|
||||
2. **Возвращение к записи.** Через месяц человек открывает список, находит
|
||||
запись по заголовку или теме и читает вычитанный текст, а при нужде — сырую
|
||||
расшифровку.
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /api/audio` со своим токеном,
|
||||
получает идентификатор задачи и опрашивает `GET /api/status/:id`, пока не
|
||||
увидит `done` и текст. Сегодня доступно только предъявившему сессию OIDC:
|
||||
анонимный запрос обоими адресами отклоняется. Своего входа у программы нет —
|
||||
его заводит `api-tokens`. Записи при этом разграничены: программа с чужой
|
||||
сессией видит только записи того, чью сессию предъявила.
|
||||
3. **Загрузка по HTTP.** Программа шлёт `POST /app/audiorecords` со своим
|
||||
токеном, получает идентификатор записи и читает её карточку
|
||||
`GET /app/audiorecords/{id}`, пока не увидит `done`; текст забирает отдельным
|
||||
адресом `GET /app/audiorecords/{id}/text`. Сегодня доступно только тому, кого
|
||||
назвал доверенный источник: неузнанный запрос всеми адресами отклоняется.
|
||||
Своего способа представиться у программы нет — его заводит `api-tokens`.
|
||||
Записи при этом разграничены: видны только записи того, чьим именем пришли.
|
||||
4. **Отказ на середине.** Конвертация или распознавание не удались — запись
|
||||
получает признак остановки с причиной, и опрос готовности отдаёт этот признак
|
||||
тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт: доставка
|
||||
ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
|
||||
получает признак остановки с причиной, и карточка записи отдаёт признак и
|
||||
причину тому, кто её загрузил. Сообщения о неудаче сервис никому не шлёт:
|
||||
доставка ушла вместе с ботом, а уведомления заводит задача `ntfy-delivery`.
|
||||
|
||||
## Референсы
|
||||
|
||||
@@ -111,10 +124,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 своим провайдером OIDC, но источником их не становится: заводит и
|
||||
проверяет людей по-прежнему 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).
|
||||
|
||||
@@ -20,8 +20,16 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
|
||||
|
||||
## Записи
|
||||
|
||||
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
|
||||
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
|
||||
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
|
||||
сказано.
|
||||
|
||||
| Дата | Запись | О чём |
|
||||
| --- | --- | --- |
|
||||
| 2026-08-23 | [Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина](toml-unknown-keys.md) | `err == nil` на опечатке в имени секции, потерянное только в `MetaData.Undecoded()`, безопасное направление отката в BurntSushi/toml v1.5.0 |
|
||||
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
|
||||
| 2026-08-15 | [Раздача приложения: что делают за нас библиотека и сборщик](webapp-serving.md) | Раскодированный путь у маршрутизатора, второй журнал у PocketBase, нулевое время у вшитого файла, зависание установщика без сети |
|
||||
| 2026-08-13 | [Разбор TOML: какое семейство отказов несёт значения из файла](toml-decode-errors.md) | Значения только в `ParseError.Message`, врущее поле `Line`, отказ значением в BurntSushi/toml v1.5.0 |
|
||||
| 2026-08-12 | [PocketBase: умолчания, которые ломают штатный сценарий](pocketbase-defaults.md) | Потолок файла 5 МиБ, тело 32 МиБ, таймаут чтения, суффикс имени, хук правки |
|
||||
| 2026-08-11 | [gRPC-клиент SpeechKit: когда закрытие вообще может отказать](grpc-client-close.md) | Ленивое соединение и два исхода `Close` в grpc v1.74.2 |
|
||||
|
||||
@@ -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) отличаются предметом: та мерила, **что даёт
|
||||
панель**, эта — **что библиотека делает молча**, если её не переубедить.
|
||||
|
||||
@@ -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`: что панель показывает и
|
||||
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
|
||||
держать перевод хранилища в планах.
|
||||
@@ -169,6 +175,20 @@ pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайн
|
||||
**Куки библиотека не читает вовсе** — сессию берёт только заголовком
|
||||
`Authorization` (`apis/middlewares.go`, `getAuthTokenFromRequest`).
|
||||
|
||||
**Учётную запись обмен ищет двумя способами подряд, а связь с провайдером
|
||||
уникальна.** Сперва — по неизменяемому признаку провайдера, а не найдя — по
|
||||
адресу почты (`apis/record_auth_with_oauth2.go`, ветка `case authUser.Email !=
|
||||
""` → `FindAuthRecordByEmail`). Найденной записи он пытается добавить связь, а на
|
||||
ней стоит уникальный индекс
|
||||
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)`. Отсюда
|
||||
исход, обратный ожидаемому: два разных признака провайдера с **одной** почтой не
|
||||
сливаются в одного владельца молча — второй вход отвергается, обмен отдаёт `400`,
|
||||
сервис — `401` со строкой `Failed to exchange provider code`, а настоящая причина
|
||||
остаётся в журнале хранилища строкой `failed to save linked rel: … Value must be
|
||||
unique`. Дописано 2026-08-15 задачей про заглушку OIDC; получено прогоном против
|
||||
временного каталога — чтение исходников давало ту же цепочку, но противоположную
|
||||
развязку.
|
||||
|
||||
**Журнал запросов пишет строку запроса целиком.** `activityLogger` на корневом
|
||||
роутере кладёт `RequestURI` полем `url` в таблицу `_logs`, ретеншен по умолчанию
|
||||
`MaxDays: 5`. Значит всё, что пришло параметром адреса, оседает там на пять
|
||||
|
||||
@@ -25,8 +25,8 @@ Nuxt, Next — не рассматривали: конвенция
|
||||
правил, и в сборке он у всех троих совпал до байта (883 Б), что и подтверждает
|
||||
одинаковость экрана.
|
||||
- **Четыре маршрута** — тот же экран плюс три заглушки и переходы между ними:
|
||||
столько экранов заводит задача
|
||||
[spa-skeleton](../../tasks/items/spa-skeleton.md). Роутеры
|
||||
столько экранов предполагала задача `spa-skeleton`, сделанная 2026-08-15.
|
||||
Роутеры
|
||||
`svelte-spa-router` 5.1.1, `vue-router` 5.2.0 и 4.6.4, `react-router` 8.3.0.
|
||||
- **Размеры** — `stat -c%s` и `gzip -9c | wc -c` по файлам `dist/`. Числа Vite в
|
||||
своём выводе печатает по другому уровню сжатия, поэтому в таблицах ниже стоят
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Хранилище: PocketBase против голого SQLite с каталогом файлов
|
||||
|
||||
Отвечает на вопрос владельца от 2026-08-22: не окажется ли голый SQLite с
|
||||
каталогом аудиозаписей гибче встроенной PocketBase. Записи в каталоге задач у
|
||||
вопроса нет — он поднят по ходу работы, и разведка идёт от него, а не от
|
||||
постановки.
|
||||
|
||||
Мерилось **дерево репозитория**, а не внешний сервис: вопрос о том, что
|
||||
библиотека уже держит в этом коде и чем за это плачено. Замеров на живом потоке
|
||||
нет.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Дата — 2026-08-22, коммит `a8fb479`, дерево чисто. Всё считано в самом
|
||||
репозитории, боевые данные не участвовали.
|
||||
|
||||
- **Пакеты в сборке:** `go list -deps ./... | wc -l` → **584**; из них с
|
||||
`pocketbase` в пути — `go list -deps ./... | grep -c pocketbase` → **40**.
|
||||
- **Кто тянет тяжёлый модуль:** `go mod why -m <модуль>` по семи модулям.
|
||||
- **Объём кода:** `find <каталог> -name '*.go' [! -name '*_test.go'] | xargs wc -l`.
|
||||
- **Протечка библиотеки за адаптер:** `grep -l "pocketbase/" internal/controller/http/*.go`.
|
||||
- **Узлы для варианта с монтированием** — чтением кэша модулей:
|
||||
`router.Router.BuildMux()` отдаёт `http.Handler`
|
||||
(`tools/router/router.go:61`), а `apis.Serve` присваивает `e.Server.Handler`
|
||||
(`apis/serve.go:223`). Прототипа на этих двух узлах не собирал — проверено
|
||||
только их существование.
|
||||
|
||||
## PocketBase здесь — фреймворк приложения, а не хранилище
|
||||
|
||||
Разрез «хранилище против хранилища» вопроса не покрывает: библиотека держит
|
||||
шесть ролей сразу, и только две из них про хранение.
|
||||
|
||||
| Роль | Где | Чем заменяется |
|
||||
| --- | --- | --- |
|
||||
| SQLite без CGO, пул записи одним соединением | `pbrepo.New` | `modernc.org/sqlite` напрямую, свои WAL, `busy_timeout` и единственный писатель |
|
||||
| Схема и шаги миграций | `internal/adapter/repo/pocketbase/migrations`, 1039 строк | свой раннер либо `goose`, который тут уже был |
|
||||
| Файлы записей на диске и отдача `/api/files/…` по токену | `file_repo.go`, `FileTokenPath` | каталог и свой обработчик отдачи |
|
||||
| Маршрутизатор, цепочка слоёв с приоритетами, ограничитель частоты | `internal/controller/http` целиком | `net/http` и счётчик по ключу |
|
||||
| Учётная запись значением `e.Auth` | `identity.go` | строка своей таблицы |
|
||||
| Панель `/_/` | покупалась ради неё | ничем |
|
||||
|
||||
**Библиотека вышла за адаптер.** Из пяти не-тестовых файлов
|
||||
`internal/controller/http` её импортируют **все пять**, из шести тестовых —
|
||||
**все шесть**: 1497 строк кода контроллера и 3075 строк его проверок написаны
|
||||
на `*core.RequestEvent`. Адаптер хранилища — ещё 1787 строк без шагов схемы.
|
||||
|
||||
## Три довода прежнего решения: один умер 2026-08-22
|
||||
|
||||
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](../adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)
|
||||
покупал библиотеку за три вещи разом, и отвергал половинчатые пути тем, что
|
||||
порознь ни одна перевода не оправдывает.
|
||||
|
||||
1. **Правка записей в панели** — работает. Единственная роль, которой сегодня
|
||||
нет замены.
|
||||
2. **Файлы видны в панели, и копия накрывает их вместе с базой** — работает; тот
|
||||
же ADR оговаривает, что копии сервер делает своими средствами.
|
||||
3. **Вход через её провайдер OIDC** — **отпал**. Цитата, которой ADR отверг
|
||||
вариант «взять библиотеку только хранилищем»:
|
||||
|
||||
> Панель показывает свою коллекцию пользователей и ничего больше. Отсюда
|
||||
> следствие для целевого входа: **пользователи Authelia в панели не появятся,
|
||||
> если вход делает само приложение**.
|
||||
|
||||
Вход делает само приложение с 2026-08-22
|
||||
([ADR-2026-08-22-login-by-trusted-header](../adr/ADR-2026-08-22-login-by-trusted-header.md)):
|
||||
пришедшего называет заголовок прокси, а учётную запись заводит наш
|
||||
`EnsureUser`. Пользователи в коллекции есть **потому, что их пишет наш код**,
|
||||
а не провайдер библиотеки. Довод, которым отвергнут отвергнутый вариант,
|
||||
перестал быть верным — и вместе с ним отпало основание держать вход в
|
||||
библиотеке.
|
||||
|
||||
## Что оплачено и не работает
|
||||
|
||||
- **Захват записи идёт сырым запросом мимо записей коллекции**
|
||||
(`record_repo.go`, `FindAndAcquire`): `app.DB().NewQuery` с `UPDATE … RETURNING`.
|
||||
На самом горячем месте абстракция не помогает, а её ограничения действуют —
|
||||
хуки коллекции не срабатывают, время изменения проставляет наш запрос, времена
|
||||
сравниваются строками побайтово.
|
||||
- **Налог соседства двух периметров в одном процессе и на одном порту.**
|
||||
Появился секрет, которого не было, — пароль суперпользователя. Панель
|
||||
опубликована в интернет, и её барьер обходится подменой знака: `/%5f/`
|
||||
попадает в ту же группу, что `/_/`, а правило прокси написано на литерал
|
||||
([../security.md](../security.md)); дефект не закрыт. Узнавание нельзя
|
||||
повесить на всю поверхность хранилища, иначе узнанный перепишет себе
|
||||
`provider_login` и заберёт чужой архив — область слоя сужена, и причина стоит
|
||||
абзацем в `identity.go`.
|
||||
- **Пространство `/api/` нельзя закрыть на прокси**, потому что за файлами
|
||||
ходит туда браузер пользователя. Своя отдача файла снимает это ограничение:
|
||||
закрыть можно всё пространство хранилища разом.
|
||||
|
||||
## Чем платит уход
|
||||
|
||||
- **Панель теряется целиком.** Правка записи, возврат остановленной в работу,
|
||||
просмотр очереди фильтром, прослушивание файла — всё это сегодня живёт только
|
||||
там.
|
||||
- **Пишем сами** шаги схемы, отдачу файла, ограничитель частоты и настройку
|
||||
базы. Последняя — не формальность: у `modernc.org/sqlite` запись идёт
|
||||
единственным соединением, и библиотека держит это за нас двумя пулами.
|
||||
- **Раскладка каталога данных** названа необратимой в `../../CLAUDE.md`.
|
||||
На стройке цена нулевая: на сервере пусто, переносить нечего.
|
||||
|
||||
## Что уходит из сборки, а что остаётся
|
||||
|
||||
`go mod why -m` показал, что шесть модулей достижимы **только** через
|
||||
PocketBase: `disintegration/imaging` (через `tools/filesystem`),
|
||||
`domodwyer/mailyak/v3` и `golang-jwt/jwt/v5` (через `core`),
|
||||
`ganigeorgiev/fexpr` (через `apis`), `spf13/cobra` (набор команд),
|
||||
`go-sql-driver/mysql` (через `pocketbase/dbx`). `modernc.org/sqlite` остаётся:
|
||||
он нужен и без библиотеки, и требование обходиться без CGO с ним сохраняется.
|
||||
|
||||
## Варианты и что выбрано
|
||||
|
||||
| Вариант | Что делает | Чем платит |
|
||||
| --- | --- | --- |
|
||||
| Оставить как есть | ноль работы | долг растёт с каждой задачей на `RequestEvent` |
|
||||
| **Уйти целиком** | SQLite напрямую, каталог файлов, свои шаги схемы и маршруты | панель теряется сразу, работа одной порцией |
|
||||
| Уйти в два шага | сначала снять с периметра HTTP, панель оставить; потом с хранилища | панель жива в промежутке, работа та же, но двумя порциями |
|
||||
| Локализовать протечку | библиотека не выходит за `internal/adapter/repo` | решение откладывается, панель остаётся |
|
||||
|
||||
**Решением владельца от 2026-08-22 взят уход целиком, и панель не заменяется
|
||||
ничем**: пока стройка не кончилась, остановленную запись возвращают в работу
|
||||
запросом к базе. Решение записано в
|
||||
[ADR-2026-08-22-storage-without-pocketbase](../adr/ADR-2026-08-22-storage-without-pocketbase.md).
|
||||
|
||||
Обстоятельство, которое решило дело: на сервере данных нет и сервис остановлен,
|
||||
поэтому смена стоит только кода. Дешевле она не станет никогда — каталог
|
||||
`internal/controller/http` прирастает кодом на чужих типах с каждой задачей.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Разбор TOML: незнакомый ключ и незнакомая секция не отказ, а тишина
|
||||
|
||||
Отвечает на вопрос, возникший по ходу задачи `config-test-headers-login`: что
|
||||
делает декодер настроек с ключом и секцией, которых структура не знает, и виден
|
||||
ли этот случай хоть чем-нибудь. Наблюдение понадобилось потому, что ревью нашло
|
||||
опечатку в имени новой секции `[auth.test_headers]`, проходящую молча, и без
|
||||
разреза нельзя было сказать, где кончается предмет задачи и начинается свойство
|
||||
самой библиотеки.
|
||||
|
||||
Соседняя записка о той же библиотеке — [toml-decode-errors.md](toml-decode-errors.md)
|
||||
— разбирает семейства **отказов**; здесь предмет обратный: случай, отказа не
|
||||
дающий.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Прогонами на зависимости, зафиксированной в `go.mod`:
|
||||
`github.com/BurntSushi/toml` версии **v1.5.0**. Оба уровня снял триаж ревью
|
||||
2026-08-23, отчёт —
|
||||
[triage-2026-08-23.md](../../openspec/changes/archive/2026-08-23-config-test-headers-login/review/triage-2026-08-23.md),
|
||||
находка 2 и факт, подтверждённый разбором прохода `operations`. Временные файлы
|
||||
прогонов удалены, бинарник поднимался в каталог вне репозитория, не в `data/`.
|
||||
|
||||
- **Модульный.** Вход
|
||||
`[server]\ndebug = true\n[auth]\n[auth.test_headrs]\n"Remote-User" = "dev"` —
|
||||
опечатка в имени секции.
|
||||
- **Сквозной.** Настоящий бинарник на конфиге с той же опечаткой, порт 18099,
|
||||
каталог данных вне репозитория; проба `curl /app/me`.
|
||||
|
||||
## Что выяснилось
|
||||
|
||||
- **Незнакомая секция и незнакомый ключ отказа не дают: `decode err=<nil>`.**
|
||||
Разбор проходит целиком, поля структуры остаются нулевыми, и отличить «в файле
|
||||
этого нет» от «в файле это написано с опечаткой» по результату разбора нельзя.
|
||||
В прогоне: `Server.Debug=true len(TestHeaders)=0`.
|
||||
- **Потерянное называет только `MetaData.Undecoded()`.** Он возвращает перечень
|
||||
путей, которых структура не знала: `[auth.test_headrs
|
||||
auth.test_headrs.Remote-User]`. Значение это в проекте не читает никто — ни
|
||||
загрузка настроек, ни проверки старта.
|
||||
- **Контроль показывает, что дело в уровне, а не в разборе вообще.** Ту же
|
||||
опечатку **внутри** известной секции (`Remote-Usr` вместо `Remote-User`)
|
||||
ловит проверка старта — `auth: секция [auth.test_headers] называет
|
||||
заголовок, которого сервис не читает: Remote-Usr`, — потому что судит её код
|
||||
проекта, а не библиотека. Ошибка в имени самой секции до этого кода не
|
||||
доходит.
|
||||
- **Сквозной прогон следа не оставляет вовсе.** Бинарник поднимается без
|
||||
предупреждения, `curl /app/me` отвечает `401`, а в журнале стоит только
|
||||
`INFO "Incoming request" … http.status_code=401`.
|
||||
- **Отсюда направление отката бинарника безопасно.** Прежний образ, получивший
|
||||
конфиг с ключами, которых его структура ещё не знает, эти ключи игнорирует и
|
||||
поднимается. Свойство держится ровно на тишине выше: перечень
|
||||
`MetaData.Undecoded()` никто не судит.
|
||||
|
||||
## Что из этого следует для кода
|
||||
|
||||
Свойство сегодня используется, а не терпится: правило выкладки «конфиг после
|
||||
образа» опирается именно на него, и его дом — [../architecture.md](../architecture.md),
|
||||
«Эксплуатация». Здесь записано, чем свойство обеспечено и как проверено, а не
|
||||
надо ли его менять.
|
||||
|
||||
Отсюда же цена любой будущей проверки `MetaData.Undecoded()`: непонятый ключ,
|
||||
роняющий старт, закрывает опечатки во всех секциях разом — и тем же движением
|
||||
снимает безопасность отката, потому что прежний образ перестанет поднимать
|
||||
конфиг новее себя. Разменивать одно на другое — отдельное решение владельца,
|
||||
а не попутная правка.
|
||||
|
||||
**Наблюдение привязано к версии.** Версия, начавшая судить незнакомые ключи
|
||||
сама, сменит оба следствия разом — молчаливую опечатку и безопасный откат.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Раздача приложения: что делают за нас библиотека и сборщик
|
||||
|
||||
Отвечает на вопросы, возникшие по ходу задачи `spa-skeleton`, — какие свойства
|
||||
раздачи приходят не из нашего кода, а из стандартной библиотеки, из PocketBase и
|
||||
из инструментов приложения. Наблюдения понадобились потому, что ревью нашло три
|
||||
места, где записанное намерение расходилось с тем, что на деле делает чужой код.
|
||||
|
||||
## Как снималось
|
||||
|
||||
Прогонами на живом бинарнике (свой конфиг с выдуманными ключами, свой каталог
|
||||
данных вне репозитория) и чтением исходников зависимостей, зафиксированных в
|
||||
`go.mod`: `github.com/pocketbase/pocketbase` версии **v0.39.10** и стандартной
|
||||
библиотеки Go. Отдельно — прогоны установщика и сборщика приложения в контейнере.
|
||||
Числа ниже сняты 2026-08-15 на этом прогоне, а не взяты из чужих записок.
|
||||
|
||||
## Что выяснилось
|
||||
|
||||
- **Маршрутизатор стандартной библиотеки сравнивает сегменты пути после
|
||||
раскодирования.** Поэтому `/%5f/` попадает туда же, куда `/_/`, а `/%68ealth`
|
||||
— туда же, куда `/health`: ответы совпадают байт в байт. Исходная форма
|
||||
остаётся в `URL.RawPath`, и решение, принимаемое **вне** сервиса по сырому пути
|
||||
— правилом обратного прокси, — такой формы не видит. Цена записана в
|
||||
[security.md](../security.md), «Периметр»: барьер перед панелью владельца
|
||||
обходится подменой одного знака.
|
||||
|
||||
- **PocketBase пишет каждый запрос в свою таблицу журнала**, а не только в вывод
|
||||
контейнера: слой `activityLogger` подключён ко всем маршрутам и кладёт путь
|
||||
целиком
|
||||
(до 3000 знаков), адрес отправителя, источник перехода и клиент. Умолчания —
|
||||
хранить пять суток, адрес записывать. Готовая раздача статики
|
||||
(`apis.Static`) первой же строкой ставит признак «успех не записывать»; своя
|
||||
раздача этого признака не наследует, и его надо ставить руками. Отсюда правило
|
||||
в [review.md](../review.md): журналов **два**, и говорить надо про оба.
|
||||
|
||||
- **Вшитая файловая система не несёт времени правки.** `embed.FS` отдаёт нулевое
|
||||
время у любого файла, поэтому отдача файла стандартной библиотекой никогда не
|
||||
отвечает подтверждением «не менялось» — всякая проверка приходит полным телом.
|
||||
Заголовок, обещающий дешёвую проверку, без метки ответа обещает то, чего код не
|
||||
делает.
|
||||
|
||||
- **Сборщик приложения чистит выходной каталог перед каждой сборкой.** Метка,
|
||||
положенная рядом с собранным ради того, чтобы каталог существовал в git,
|
||||
уезжает первым же прогоном. Живёт она только этажом выше выходного каталога.
|
||||
|
||||
- **Проверка типов однофайловых компонентов не работает с седьмой линией
|
||||
TypeScript.** `vue-tsc` версии 3.3.10 зовёт у компилятора точку входа, которой
|
||||
новый компилятор не отдаёт, и сборка падает на этапе проверки типов. Рабочая
|
||||
пара — пятая линия TypeScript.
|
||||
|
||||
- **Установщик пакетов без сети не отказывает, а виснет.** Он уходит в повторы с
|
||||
нарастающей паузой **на каждом пакете**, и набор проверок вместо кода отказа
|
||||
просто стоит. Пределы у отдельных обращений положения не спасают: их сумма и
|
||||
даёт зависание. Помогает короткое обращение-проба перед установкой.
|
||||
|
||||
- **Вес приложения в бинарнике равен весу собранного.** Замер: две сборки, с
|
||||
собранным приложением и с пустым каталогом, разница — 86 072 байта, то есть
|
||||
ровно `index.html` плюс единственный ресурс. Собранное приложение на четыре
|
||||
экрана в разведке `spa-framework` весило того же порядка.
|
||||
|
||||
## Чего эта записка не узнала
|
||||
|
||||
- **Во что ступень сборки обходится образу по времени.** Прогон до конца не
|
||||
доходит: из контейнеров этой машины нет исходящей сети при рабочем разрешении
|
||||
имён. По весу вопрос закрыт иначе — ступень в рабочий слой не копируется, и
|
||||
финальный образ от неё не растёт вовсе.
|
||||
- **Как поведёт себя раздача под настоящим потоком.** Ограничителя частоты на
|
||||
корневом маршруте нет, а профиля нагрузки у проекта нет тоже.
|
||||
- **Что делает настоящий браузер** с этими заголовками: проверено кодами ответов
|
||||
и заголовками, а не браузером.
|
||||
+274
-52
@@ -2,10 +2,11 @@
|
||||
|
||||
## Как настроен конвейер
|
||||
|
||||
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/` — под именем
|
||||
`triage.md` либо `report.md`: имя менялось по ходу, и оба встречаются. Самый
|
||||
ранний — `fix-http-handler-tests` 2026-08-11, самый поздний —
|
||||
`start-without-telegram-token` 2026-08-13.
|
||||
Артефакты прогонов лежат в `openspec/changes/archive/<id>/review/`; имя файла
|
||||
менялось по ходу — `triage.md`, `report.md`, `design-review.md`,
|
||||
`code-review.md`, `triage-<дата>.md`. Самый ранний — `fix-http-handler-tests`
|
||||
2026-08-11; самый поздний здесь не называется: строка протухала бы с каждым
|
||||
прогоном, и смотреть его надо в самом архиве.
|
||||
|
||||
Конвейер прогонялся и на работе, шедшей без своего изменения openspec; артефакта
|
||||
в архиве у таких прогонов нет, и урожай их виден только записями журнала ниже.
|
||||
@@ -31,6 +32,13 @@
|
||||
квота на три темы. Механизации нет — потолок объявляет сам проход, и заставить его нечем;
|
||||
остаётся сверка триажа.
|
||||
|
||||
Пробел повторился на прогоне `config-test-headers-login` 2026-08-23, и это уже
|
||||
не единичный случай: строки о потолке не дал ни один из шести проходов, а
|
||||
заметил это снова только триаж. Состав прогона при этом был самым широким из
|
||||
тогда доступных, — и разница с прошлым разом ровно в числе проходов,
|
||||
промолчавших одинаково. Читать пробел надо как границу покрытия каждого прогона,
|
||||
а не как свойство одного из них.
|
||||
|
||||
Что уже проверяет машина и о чём поэтому спрашивать не нужно — конвенция
|
||||
[conventions/go-linters.md](conventions/go-linters.md). Вопросы ниже — то, чего
|
||||
машина не проверяет; свойства, которые обязан проверять тест, — в «Типовых
|
||||
@@ -58,6 +66,26 @@
|
||||
- переводит доменную ошибку в свой ответ, а не отдаёт сырой текст;
|
||||
- закрывает то, что открыл, на всех ветках выхода.
|
||||
|
||||
**Раздача собранного приложения и шаг его сборки** (`controller/http/webapp.go`,
|
||||
шаг `front`):
|
||||
|
||||
- путь, принадлежащий корню сервиса, разметку не отдаёт никогда, а перечень
|
||||
корней порождает регистрацию маршрутов, а не описывает её;
|
||||
- несовпавший ресурс под каталогом сборщика отвечает `404`, а не разметкой с
|
||||
кодом `200`;
|
||||
- раздача ставит долгий неотзываемый срок хранения **только** файлу из каталога
|
||||
сборщика: отозвать его у браузера сервису нечем;
|
||||
- отсутствие сборки громкое — код ответа, страница и строка журнала; «сборки
|
||||
нет» отличается от «файла нет»;
|
||||
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
|
||||
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
|
||||
**отказывает, а не висит**;
|
||||
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
|
||||
Журнал у сервиса с 2026-08-22 **один** — свой, в вывод контейнера: второй
|
||||
ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
|
||||
адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
|
||||
пишется дословно ни под каким корнем, включая корень приложения.
|
||||
|
||||
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
|
||||
|
||||
- имеет таймаут и не виснет, когда внешний сервис не отвечает;
|
||||
@@ -66,17 +94,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`):
|
||||
@@ -99,6 +130,18 @@
|
||||
|
||||
### Типовые ложноположительные
|
||||
|
||||
- **«Хранилище молча сливает две учётные записи с одной почтой в одного
|
||||
владельца».** Для версии v0.39.10 неверно, и неверна именно развязка. Первая
|
||||
половина цепочки настоящая: обмен ищет запись по признаку провайдера, а не
|
||||
найдя — по адресу почты, и приходит к чужой записи. Но повесить на неё второй
|
||||
признак он не может — уникальный индекс
|
||||
`idx_externalAuths_record_provider (collectionRef, recordRef, provider)` связь
|
||||
отвергает, обмен отдаёт `400`, а сервис — `401` со строкой
|
||||
`Failed to exchange provider code`. Отказ **громкий**, тихого слияния владельцев
|
||||
не происходит, и ложно-зелёной проверки разграничения такой дефект не даёт.
|
||||
Проверено прогоном 2026-08-15 (задача про заглушку OIDC); найдено чтением
|
||||
исходников библиотеки, опровергнуто запуском — то есть цена гипотезы, добытой
|
||||
без прогона, здесь и измерена.
|
||||
- **«Воркер глотает ошибку `NoopJobError`».** Не дефект: этот тип означает «задач
|
||||
в этом состоянии нет», и `internal/controller/worker/worker.go` намеренно не
|
||||
логирует его и не считает в метрику. Норма записана требованием
|
||||
@@ -139,6 +182,21 @@
|
||||
|
||||
Форма: `<тема>: <вопрос> (<откуда>)`.
|
||||
|
||||
- `operations`: не завёл ли инструмент разработчика второй дом тому, что уже есть
|
||||
в проверках. Прецедент: подставных провайдера OIDC в репозитории было два —
|
||||
`cmd/oidcstub` и `fakeProvider` в проверках входа, — с теми же адресами и той
|
||||
же посылкой, и они уже разошлись в мелочи (`token_type` «bearer» против
|
||||
«Bearer»). Оба ушли 2026-08-22 вместе с протоколом; на их месте встал
|
||||
`cmd/devtools proxy`, а 2026-08-23 задачей `config-test-headers-login` убран и
|
||||
он: заголовки входа подставляет сам сервис под предохранителем
|
||||
`[server] debug`. Проверки ставят заголовок сами и подставного собеседника не
|
||||
держат вовсе. Тем же вопросом судится подставной распознаватель. **Пробел
|
||||
закрыт той же задачей:** норма о подставных собеседниках записана в
|
||||
[architecture.md](architecture.md), «Принципы» — пункт «Подставной собеседник
|
||||
в боевом бинарнике объявлен своим ключом»; здесь она не пересказывается.
|
||||
Вопрос при этом остаётся вопросом:
|
||||
норма называет, где собеседнику жить, а не сколько домов у него уже завелось
|
||||
(ревью задачи про заглушку OIDC, 2026-08-15).
|
||||
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||||
@@ -172,6 +230,18 @@
|
||||
- `conventions`: новая колонка правится в обоих местах репозитория, а новый
|
||||
рубеж — одним дескриптором
|
||||
(CLAUDE.md, «Инварианты»).
|
||||
- `autotests`: судит ли проверка формы ответа по **настоящему запросу**, а не по
|
||||
прямому вызову отображателя ошибки. Вызов напрямую формой ответа не является и
|
||||
остаётся зелёным, когда отказ рождается слоем ниже обработчика (запись журнала
|
||||
2026-08-15 про единую форму отказа).
|
||||
- `operations`: есть ли у новой выборки свой индекс. Единственный индекс записи
|
||||
заведён под захват воркера — по рубежу и признаку остановки, — и выборке,
|
||||
сужаемой владельцем, он не помогает ничем: замер 2026-08-15 показал полное
|
||||
сканирование таблицы и рост времени страницы вместе с **чужими** записями.
|
||||
- `security`: не схлопнулись ли внутрипроцессные запросы в один счётчик
|
||||
ограничителя частоты. Запрос, собранный руками, приходит без адреса, а
|
||||
вырожденное значение библиотека отдаёт не пустой строкой, и её собственный
|
||||
страж «пустой ключ пропускаем» такое значение не ловит (запись 2026-08-15).
|
||||
- `autotests`: покрыт ли изменённый шаг конвейера хоть одним **проходящим**
|
||||
тестом. Что уже закрыто проверками, видно по журналу дефектов ниже и по
|
||||
[conventions/go-linters.md](conventions/go-linters.md), «Механизировано»;
|
||||
@@ -194,43 +264,57 @@
|
||||
- `architecture`: не зовётся ли на каждый запрос то, что меняет состояние
|
||||
приложения, — сборка роутера хранилища оказалась именно такой.
|
||||
|
||||
### Триггеры метки
|
||||
### Когда звать глубокое ревью
|
||||
|
||||
Проектная конкретизация правила выбора метки. Умолчание — `medium`.
|
||||
Проектная конкретизация признаков, по которым зовут `av-dev:code-deep-review`.
|
||||
Что за признаками следует и каким составом идёт прогон, решает сам скилл ревью;
|
||||
здесь только места этого проекта.
|
||||
|
||||
**Крупное здесь** (поднимает до `large`, ось объёма):
|
||||
**Смотрим целиком** (область кода, а не дифф задачи):
|
||||
|
||||
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
|
||||
колонку разом;
|
||||
- замена хранилища или переход на PocketBase — любой её кусок;
|
||||
- смена модели очереди: захват, повторы и воркеры разом;
|
||||
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
|
||||
- изменение, убирающее или возвращающее вход приёма целиком.
|
||||
- **вход и разграничение доступа** — звенья `internal/controller/http`
|
||||
(узнавание, требование учётной записи, ограничитель частоты, подстановка
|
||||
заголовков отладочного запуска) вместе со спекой
|
||||
[access](../openspec/specs/access/spec.md). Возвращаются сюда чаще, чем куда бы
|
||||
то ни было ещё, и журнал дефектов ниже это показывает: закрытая поверхность, в
|
||||
которую не мог войти никто; проверка, читавшая живую карту заголовков вместо
|
||||
ответа; анонимный запрос, навсегда замедлявший запись; путь анонима, уехавший в
|
||||
журнал; ключ бюджета ограничителя, который выбирал тот, кого ограничивают. С
|
||||
2026-08-22 барьер держит заголовок от прокси, изъятие у него одно — отладочный
|
||||
запуск ([security.md](security.md), «Периметр»), — а настоящей Authelia в
|
||||
прогоне нет (см. «Недоступно проверке»);
|
||||
- **пакет хранилища** `internal/adapter/repo/sqlite` — здесь живёт инвариант
|
||||
«Колонки записи правятся в трёх местах» ([CLAUDE.md](../CLAUDE.md),
|
||||
«Инварианты», major): места, цена забытого и то, чем держится сверка, названы
|
||||
там. Смотрится целиком потому, что компилятор не видит ни одного из мест;
|
||||
- **конвейер расшифровки** `internal/service` вместе с дескриптором рубежа
|
||||
`internal/entity/stage.go` — здесь живёт инвариант «Рубеж объявляется одним
|
||||
дескриптором» ([CLAUDE.md](../CLAUDE.md), «Инварианты», major), и цена
|
||||
забытого рубежа названа там. Сюда же дефекты о потере уже полученного: пустой
|
||||
второй ответ распознавателя, стиравший сохранённую расшифровку, и остановка
|
||||
сервиса, хоронившая конвертируемую запись;
|
||||
- **распознаватель** `internal/adapter/recognizer/yandex` — единственное место,
|
||||
чья ошибка стоит денег ([CLAUDE.md](../CLAUDE.md), «Запреты», «Yandex Cloud за
|
||||
деньги»). Живым прогоном оно не проверяется вовсе (см. «Недоступно
|
||||
проверке»), и разбор остаётся единственным способом судить о нём.
|
||||
|
||||
**Незнакомое здесь** (поднимает до `large`, ось формы решения):
|
||||
**Необратимое здесь.** Перечень необратимого один и лежит в
|
||||
[CLAUDE.md](../CLAUDE.md), «Работа»; здесь — только места кода, которых его
|
||||
пункты касаются, и правило прохода: находка в таком месте уходит человеку
|
||||
развилкой, а не чинится молча.
|
||||
|
||||
- вход через OIDC и разграничение доступа: как связать чат Telegram с учётной
|
||||
записью, до начала работы назвать нельзя;
|
||||
- всё, что делается на выбранном фреймворке впервые: правила
|
||||
[conventions/web-ui.md](conventions/web-ui.md) выведены из выбора и из замера
|
||||
на пробном экране, а не из написанного кода, и первая же задача проверяет их
|
||||
собой — форма решения нащупывается по ходу;
|
||||
- установка на телефон: service worker перехватывает запросы, и что он кэширует,
|
||||
до работы назвать нельзя;
|
||||
- работа с записями в несколько часов: потолки внешних сервисов не замерены,
|
||||
форма решения зависит от замера;
|
||||
- приём дорожки из видео и форматов, которых `ffmpeg` не берёт текущей командой;
|
||||
- всё, что требует записи в `research/` прежде, чем начать.
|
||||
- применённый шаг схемы — `internal/adapter/repo/sqlite/migrations`;
|
||||
- формат файла на диске и раскладка каталога данных —
|
||||
`internal/adapter/repo/sqlite`, `store.go`;
|
||||
- публичный контракт HTTP API — `internal/controller/http`;
|
||||
- имя ключа конфига — `internal/config`;
|
||||
- действие с боевыми данными и с Yandex Cloud, ротация секрета —
|
||||
`internal/adapter/recognizer/yandex`.
|
||||
|
||||
**Мелкое здесь** (опускает до `small`):
|
||||
|
||||
- новая метрика в `internal/metrics`;
|
||||
- правка `config.example.toml` и умолчаний `defaultConfig()` без нового поля;
|
||||
- правка документов канона.
|
||||
|
||||
Помни отрицательный тест: миграция, формат файла на диске, публичный контракт
|
||||
API и имя не откатываются обратной правкой после мерджа — какими бы маленькими
|
||||
ни были, они не `small`.
|
||||
Строка, уже ушедшая в журнал контейнера или меткой метрики, из перечня не
|
||||
берётся: её необратимость записана инвариантами о секрете и о содержимом записи
|
||||
([CLAUDE.md](../CLAUDE.md), «Инварианты», оба critical), и правка кода помогает
|
||||
там только следующей записи.
|
||||
|
||||
### Недоступно проверке
|
||||
|
||||
@@ -242,13 +326,15 @@ API и имя не откатываются обратной правкой по
|
||||
день, и утверждения о росте остаются условиями, а не замерами;
|
||||
- `security`: стойкость `ffmpeg` к вредоносному входу — разбор чужого формата
|
||||
отдан внешней программе, и она вне нашей границы;
|
||||
- `security`: поведение настоящей Authelia и её правило на нашего клиента.
|
||||
Провайдера в прогоне нет, подменяет его свой сервер; кто допущен — настройка
|
||||
выкладки вне репозитория, и по коду её не проверить
|
||||
- `security`: поведение настоящей Authelia и правило обратного прокси на домен
|
||||
сервиса. Ни того ни другого в прогоне нет, а с 2026-08-22 от прокси зависит
|
||||
**весь** барьер: он обязан заголовки `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`: поведение браузера с куками — применение `SameSite`, приём
|
||||
`Set-Cookie` при переходе с чужого сайта. Браузера в прогоне нет, и находки
|
||||
этого рода остаются гипотезами.
|
||||
- `security`: поведение браузера с куками. Своих кук сервис не ставит с
|
||||
2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
|
||||
панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
|
||||
как возврат недоступного проверке, а не как обычная работа.
|
||||
|
||||
**Перестали проверять сознательно:**
|
||||
|
||||
@@ -260,7 +346,8 @@ API и имя не откатываются обратной правкой по
|
||||
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
|
||||
можно: он встаёт своим единственным входом на выдуманных непустых ключах
|
||||
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
|
||||
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче.
|
||||
осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
|
||||
предметов осмотра нет с 2026-08-22.
|
||||
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
|
||||
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
|
||||
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
|
||||
@@ -269,8 +356,16 @@ API и имя не откатываются обратной правкой по
|
||||
**Остаток**: за настоящие SpeechKit и Object Storage живой прогон по-прежнему
|
||||
не отвечает — ключи Yandex в прогоне выдуманные, а распознавание подменяют в
|
||||
коде. Проверить живьём можно подъём, отказ старта, маршруты, метрики и
|
||||
остановку; нельзя — расшифровку и заливку. Вход через живого провайдера OIDC
|
||||
тоже недоступен: сессию в прогоне выдать нечем.
|
||||
остановку; нельзя — расшифровку и заливку.
|
||||
|
||||
**Вход живой прогон теперь проверяет целиком, и это сдвиг 2026-08-22.** Прежде
|
||||
сессию в прогоне выдать было нечем; теперь заголовок ставит сам сервис по
|
||||
секции `[auth.test_headers]` под предохранителем `[server] debug` — прежде
|
||||
`cmd/devtools proxy`, убранный 2026-08-23, — и живьём проверяются узнавание,
|
||||
заведение учётной записи первым обращением, отказ с недоверенного адреса и
|
||||
отказ старта на пустом перечне.
|
||||
Настоящая Authelia по-прежнему недоступна — её правило на домен живёт в
|
||||
контуре (см. «Не проверит ни один проход»).
|
||||
|
||||
## Журнал дефектов
|
||||
|
||||
@@ -280,6 +375,133 @@ 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 для локального входа;
|
||||
доккоммент пакета, подсказка флага `-sub` и проза `config.example.toml`
|
||||
- **Симптом:** рецепт «второй вошедший получается сменой `-sub`» записан в трёх
|
||||
местах и в короткой форме не работал вовсе. Заглушка отдавала обоим `sub` одну
|
||||
и ту же почту умолчанием, вход отвечал `401`, а причина оставалась строкой в
|
||||
журнале хранилища
|
||||
- **Причина:** обмен ищет учётную запись сперва по признаку провайдера, а не
|
||||
найдя — по адресу почты. Второй `sub` при общей почте приходил к первой записи,
|
||||
а признак провайдера на записи уникален — `idx_externalAuths_record_provider` —
|
||||
и связь отвергалась. Умолчание почты стояло своим значением вместо выведенного
|
||||
из `sub`
|
||||
- **Почему не поймали раньше:** рецепт проверяли **длинной** формой, где почта
|
||||
задана флагом явно. Короткую не гонял никто, хотя записана она первой и берут
|
||||
читатели именно её
|
||||
- **Что меняем:** проверять ту форму рецепта, которая записана **короче всех**.
|
||||
Оракул — прогон именно её: два входа подряд разными `-sub` без прочих флагов,
|
||||
затем счёт записей в коллекции пользователей. Само умолчание почты теперь
|
||||
выводится из `-sub`
|
||||
|
||||
## 2026-08-15 — своя раздача статики потеряла отказ от записи успеха [пойман ревью]
|
||||
|
||||
- **Где:** `internal/controller/http/webapp.go`, регистрация корневого маршрута;
|
||||
задача `spa-skeleton`
|
||||
- **Симптом:** каждый успешный ответ разметкой и ресурсом клал в журнал
|
||||
хранилища выбранный анонимом путь вместе с его адресом и держал строку пять
|
||||
суток. При этом строка `docs/review.md`, добавленная той же задачей,
|
||||
утверждала, что путь анонима в журнал не идёт
|
||||
- **Причина:** готовая раздача статики библиотеки первой же строкой ставит
|
||||
признак «успех не записывать». Своя написана мимо неё — и не зря, подстановка
|
||||
разметки у готовой не отличает отсутствующий ресурс от неизвестного пути, — но
|
||||
признак при переписывании не перенесён.
|
||||
Журналов у сервиса два, а сделанная защита закрыла один
|
||||
- **Почему не поймали раньше:** свойство было записано **утверждением**, а
|
||||
проверялось только против журнала контейнера. Второй журнал живёт в базе, и ни
|
||||
один тест туда не смотрел
|
||||
- **Что меняем:** утверждение о журнале называет оба журнала поимённо. Оракул —
|
||||
чтение таблицы журнала после прогона: три успешных запроса не оставляют строк,
|
||||
два отказа оставляют
|
||||
|
||||
## 2026-08-15 — единая форма отказа не покрывала то, что рождается не в обработчике [пойман ревью]
|
||||
|
||||
- **Где:** `internal/controller/http/errors.go`, слой `OneErrorForm`; задача
|
||||
`json-api-for-spa`
|
||||
- **Симптом:** три отказа под корнем приложения — превышение
|
||||
потолка тела, ограничитель частоты и неизвестный путь — уходили телом
|
||||
библиотеки, без машиночитаемого кода и без предела числом. То есть форм отказа
|
||||
на адресах приложения было две, а не одна, — ровно то, ради чего задача и
|
||||
заводилась
|
||||
- **Причина:** отображение доменной ошибки заведено верно, но покрывает лишь то,
|
||||
что вернул **обработчик**. Предел тела и ограничитель частоты рождают отказ
|
||||
слоями ниже, а «ничего не совпало» — вовсе маршрутом корневой группы, к
|
||||
которому слои нашей группы не привязаны. Комментарий у слоя при этом перечислял
|
||||
все три случая как закрытые
|
||||
- **Почему не поймали раньше:** оракулом служил комментарий, а не прогон.
|
||||
Приёмочный тест звал отображатель **напрямую** ошибкой, которую сам же и
|
||||
сочинил, — запроса он не слал и потому оставался зелёным независимо от того,
|
||||
что происходит при настоящем HTTP-запросе. Ветвь `too_large` при этом не имела ни одного
|
||||
производителя в рабочем коде
|
||||
- **Что меняем:** проверка, стерегущая форму ответа, обязана слать **настоящий
|
||||
запрос**; вызов отображателя напрямую формой ответа не является. Добавлено
|
||||
вопросом в раздел ниже
|
||||
|
||||
## 2026-08-15 — пустой второй ответ распознавателя стирал сохранённую расшифровку [пойман ревью]
|
||||
|
||||
- **Где:** `internal/adapter/repo/pocketbase/text_repo.go`, `TextRepository.Put`
|
||||
|
||||
+239
-115
@@ -3,15 +3,32 @@
|
||||
## Периметр
|
||||
|
||||
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||
обратный прокси, а приём записи, опрос готовности и файл записи требуют входа
|
||||
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты
|
||||
без входа только проба здоровья и метрики. Находки строятся против этого —
|
||||
сегодняшнего — периметра.
|
||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
|
||||
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
|
||||
`trusted-header-login`, называет она его **заголовком, который ставит обратный
|
||||
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
|
||||
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
|
||||
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
|
||||
**каждый** запрос, и отзыв доступа действует со следующего.
|
||||
|
||||
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
|
||||
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
|
||||
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
|
||||
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
|
||||
открытость не касается — всякий адрес под корнем приложения узнанного
|
||||
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||
|
||||
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
|
||||
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
|
||||
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
|
||||
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
|
||||
браузеру сервису нечем.
|
||||
|
||||
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||||
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||||
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
|
||||
задачей `record-ownership`: и опрос готовности, и файл записи сужены владельцем
|
||||
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
|
||||
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
|
||||
доступа — страницы расхода для владельца сервиса.
|
||||
|
||||
@@ -26,29 +43,103 @@ 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`, встроенное хранилище убрано целиком:
|
||||
панели не существует, второго периметра на порту сервиса не осталось, и правилу
|
||||
прокси нечего закрывать.
|
||||
|
||||
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
|
||||
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
|
||||
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
|
||||
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
|
||||
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
|
||||
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
|
||||
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
|
||||
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
|
||||
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
|
||||
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
|
||||
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
|
||||
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
|
||||
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
|
||||
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
|
||||
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
|
||||
|
||||
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||||
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||||
секрет в настройки коллекции пользователей, и чтение файла базы становилось
|
||||
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
|
||||
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
|
||||
|
||||
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
|
||||
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
|
||||
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
|
||||
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
|
||||
и аноним войдёт под любым именем. Часть этой беды сервис закрывает сам:
|
||||
запрос с двумя значениями `Remote-User` не узнаёт никого. **Закрыт при этом
|
||||
только логин.** `Remote-Name` и `Remote-Email` берутся первым значением, то есть
|
||||
присланным анонимом, и адрес почты, занятый им, закрепляется за чужой учётной
|
||||
записью навсегда: колонка уникальна, а найденную запись узнавание не
|
||||
переписывает. Прогон ревью 2026-08-23 построил этот путь и прогнал его;
|
||||
правило решено распространить на всю тройку отдельной задачей. Остальное
|
||||
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
|
||||
репозитория `pet-project-server`, и **требование к нему такое — заголовки
|
||||
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||||
человек.
|
||||
|
||||
**Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23**
|
||||
задачей `config-test-headers-login`. При включённом предохранителе
|
||||
`[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из
|
||||
секции `[auth.test_headers]`: на машине разработчика прокси нет, а браузер
|
||||
заголовков не ставит. Узнавание при этом остаётся тем же и подставленного
|
||||
заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует
|
||||
изъятие спека [access](../openspec/specs/access/spec.md), решение о подстановке
|
||||
самим сервисом —
|
||||
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md).
|
||||
|
||||
Держится оно тремя вещами, и других нет: умолчание предохранителя —
|
||||
«выключено»; заполненная имитация при выключенном предохранителе роняет старт с
|
||||
именем ключа; боевой конфиг рендерится шаблоном Ansible, а не копируется с
|
||||
машины разработчика. Подставленный заголовок проходит тот же барьер доверенного
|
||||
адреса, что и пришедший, и судит адрес та же функция — но барьером отладочному
|
||||
входу это не служит: перечень доверенных адресов включению предохранителя не
|
||||
мешает.
|
||||
|
||||
**Боевая поломка машиной не исключена, и это названо прямо.** Сервис, поднятый в
|
||||
бою с включённым предохранителем и заполненной имитацией, поднимется на любом
|
||||
перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл
|
||||
через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного
|
||||
предохранителя у изъятия нет: требование петлевого перечня рассматривалось и
|
||||
снято — [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||||
|
||||
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
|
||||
Как именно читается цепочка, нормирует спека
|
||||
[archive](../openspec/specs/archive/spec.md), «Адреса приложения живут своим
|
||||
пространством». Отсюда периметровое следствие: прокси, дописывающий
|
||||
`X-Forwarded-For` к присланному, этим правилом покрыт, и требования
|
||||
«перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`.
|
||||
Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого
|
||||
соединения.
|
||||
|
||||
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||||
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||||
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
|
||||
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
|
||||
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
|
||||
именно поэтому.
|
||||
|
||||
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
|
||||
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
|
||||
после ухода прежнего владельца, и тогда новый человек при первом же обращении
|
||||
попадает в **существующую** запись и получает весь её архив — самое
|
||||
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
|
||||
может: неизменяемого признака заголовок не приносит. Не допускать
|
||||
переиспользования — работа провайдера, и это принятая цена, записанная в
|
||||
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
|
||||
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
|
||||
нечем ни слить, ни убрать.
|
||||
|
||||
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||||
/api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не
|
||||
ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги
|
||||
на распознавание столько, сколько захочет.
|
||||
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
|
||||
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
|
||||
распознавание столько, сколько захочет: ограничитель частоты под корнем
|
||||
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
|
||||
по-прежнему нет — её заводит `per-user-size-quota`.
|
||||
|
||||
## Недоверенный вход
|
||||
|
||||
@@ -56,8 +147,11 @@ Telegram — связи чата с учётной записью сервис
|
||||
|
||||
| Вход | Канал | Кто может слать |
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела |
|
||||
| Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи |
|
||||
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения. Слать тройку может ещё и сам сервис — при включённом предохранителе `[server] debug`, значением из настроек; изъятие целиком описано в «Периметре» выше |
|
||||
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
|
||||
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
|
||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||
|
||||
@@ -67,7 +161,6 @@ Telegram — связи чата с учётной записью сервис
|
||||
| Вход | Канал | Кто может слать | Чья задача |
|
||||
| --- | --- | --- | --- |
|
||||
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
|
||||
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
|
||||
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
|
||||
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
|
||||
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
|
||||
@@ -81,8 +174,8 @@ Telegram — связи чата с учётной записью сервис
|
||||
|
||||
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||||
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только по опросу
|
||||
готовности и в панели владельца.
|
||||
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
|
||||
приложения.
|
||||
|
||||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||||
|
||||
@@ -102,44 +195,40 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||||
путь не предполагался.
|
||||
|
||||
- **Путь на диске** выбирает хранилище:
|
||||
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** —
|
||||
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
|
||||
отправителя, не применяется: имя отправителя в хранилище не попадает.
|
||||
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
|
||||
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
|
||||
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
|
||||
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||||
каталогов, но это единственное, что стоит между входом и именем файла.
|
||||
каталогов, но это единственное, что стоит между входом и именем файла. Длина
|
||||
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
|
||||
роняет заведение временного файла.
|
||||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||||
расширением. Бакет один на все записи, префикса по пользователю нет. С
|
||||
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
|
||||
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
|
||||
распознавания.
|
||||
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым
|
||||
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя
|
||||
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не
|
||||
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции
|
||||
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и
|
||||
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с
|
||||
чужим и со своим токеном файла ссылка отвечает «не найдено».
|
||||
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла
|
||||
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь
|
||||
можно только с коротким токеном файла, который выдаётся по сессии, и запрос
|
||||
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет —
|
||||
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет
|
||||
остаётся: **имя файла в хранилище в журнал не пишется**
|
||||
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
|
||||
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
|
||||
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное,
|
||||
что защищает `GET /api/status/:id`.
|
||||
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
|
||||
`/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).
|
||||
|
||||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||||
|
||||
@@ -154,34 +243,49 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
|
||||
([database.md](database.md), «Настройки с числовым значением»).
|
||||
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
||||
бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
|
||||
доходит до сервиса, — не значил бы ничего.
|
||||
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
||||
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
||||
предъявить ту же сессию.
|
||||
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
|
||||
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
|
||||
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
|
||||
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
|
||||
здесь «сессия → токен файла → ссылка».
|
||||
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
|
||||
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
|
||||
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||||
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||||
отзыв доступа у Authelia действует со следующего обращения.
|
||||
Собственных токенов сервис не принимает вовсе: значения, предъявленного
|
||||
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
|
||||
значение било заголовок — им работал владелец панели; панели нет, и правило
|
||||
приоритета осталось бы правилом без предмета.
|
||||
**Область узнавания — корень приложения**, и выводится она из объявленного
|
||||
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
|
||||
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
|
||||
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
|
||||
базе, а первый такой запрос с новым именем — записи в неё.
|
||||
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||||
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||||
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
|
||||
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
|
||||
библиотечным правилом, не осталось.
|
||||
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
|
||||
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
|
||||
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
|
||||
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
|
||||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||
2026-08-12.
|
||||
- **Заведение учётной записи** — только входом у провайдера. Собственное
|
||||
создание записи, вход по паролю, одноразовый код и восстановление доступа
|
||||
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
|
||||
без этого закрытия вход обходился бы двумя запросами.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
|
||||
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
|
||||
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
|
||||
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
|
||||
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
|
||||
больше нечего, и адресов этих не существует.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||||
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||||
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||
эти адреса не несут.
|
||||
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
|
||||
частоты на них нет: правило заведено под корень приложения, а раздача стоит
|
||||
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
|
||||
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
|
||||
кто-то, — узнанному и неузнанному отдаётся одно и то же.
|
||||
|
||||
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||||
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||||
@@ -192,9 +296,9 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
| Механизм | Что даёт | Чья задача |
|
||||
| --- | --- | --- |
|
||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
||||
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
|
||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
|
||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
|
||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||
|
||||
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
||||
@@ -202,32 +306,47 @@ 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.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
|
||||
приложение от чужого имени.
|
||||
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
|
||||
из базы вместе с собственным входом.
|
||||
|
||||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||||
|
||||
**Строки `.env` в `.gitignore` и в `.dockerignore` стоят без читателя, и снимать
|
||||
их поэтому нельзя.** Читателя сняли 2026-08-23 вместе с зависимостью
|
||||
`godotenv`: наш рабочий код окружение не читает, и файл, положенный рядом с
|
||||
бинарником, ничего не меняет. Барьеры остались против другого — против того,
|
||||
чтобы секрет завёлся в этом файле руками и уехал из него теми же двумя путями,
|
||||
какими уехал бы из конфига: в git и в контекст сборки образа. Второй барьер
|
||||
нужен отдельно от первого: `Dockerfile` копирует корень целиком (`COPY . .`), а
|
||||
`.gitignore` docker не читает — чужой `.env` лёг бы слоем образа. Инвариант,
|
||||
ради которого барьеры стоят, — «Секрет не покидает конфиг» из
|
||||
[../CLAUDE.md](../CLAUDE.md).
|
||||
|
||||
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
|
||||
секрета, которого сегодня в проекте нет вовсе:
|
||||
|
||||
@@ -243,14 +362,10 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
||||
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
||||
открыта только владельцу.
|
||||
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех
|
||||
пользователей разом, то есть стоит вровень с самым чувствительным из списка
|
||||
выше. Второй секрет после токенов пользователей, который лежит **не в
|
||||
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец
|
||||
по приглашению, которое сервис печатает в журнал при первом запуске. У
|
||||
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
|
||||
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
|
||||
читателю навсегда.
|
||||
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
|
||||
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
|
||||
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
|
||||
заводить нечего.
|
||||
|
||||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||||
@@ -273,7 +388,7 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
||||
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
||||
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
||||
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная
|
||||
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
|
||||
по старому значению, покажет пустоту, и это не поломка.
|
||||
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
||||
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||||
@@ -305,6 +420,16 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
|
||||
Ansible — не наша граница.
|
||||
- **Машина разработчика и то, что он на ней поднимает.** На место контура встаёт
|
||||
сам сервис: при включённом предохранителе `[server] debug` он подставляет
|
||||
заголовки входа значениями из конфига. Прежде эту роль играли отдельные
|
||||
процессы — `cmd/oidcstub` с 2026-08-15 по 2026-08-22 и подкоманда
|
||||
`cmd/devtools proxy` с 2026-08-22 по 2026-08-23; ни того, ни другой в
|
||||
репозитории больше нет. Периметра выкладки отладочный запуск не касается,
|
||||
пока предохранитель выключен, а выключен он по умолчанию; кто включил его у
|
||||
себя в чужой сети, отвечает за это сам. В оснастке `cmd/devtools` осталась
|
||||
одна подкоманда — `resume`, — и в образ она не едет: ступень собирает
|
||||
`./cmd/transcriber` поимённо.
|
||||
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
||||
доверяем полностью.
|
||||
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
||||
@@ -332,14 +457,13 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
|
||||
строки потребления текста не содержат.
|
||||
|
||||
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.**
|
||||
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных
|
||||
выше («Что чувствительнее чего»), связи приложений с записью обязательны и
|
||||
каскада не имеют, поэтому удаление самой
|
||||
строки записи отвергается хранилищем, а удаление её файлов проходит молча.
|
||||
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный
|
||||
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера
|
||||
файлом на диске. Порядок, которым запись убирается на самом деле: сперва
|
||||
строки приложений — журнал событий, попытка распознавания вместе с её
|
||||
вложением, структура, тексты, — потом сама запись, потом её файлы. До
|
||||
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
|
||||
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
|
||||
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
|
||||
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
|
||||
сперва строки приложений — журнал событий, попытка распознавания, структура,
|
||||
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
|
||||
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
|
||||
кто убрал только файлы, стирает аудио и **оставляет полный текст речи** —
|
||||
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
|
||||
`delete-record` это единственный способ, и он ручной целиком.
|
||||
|
||||
@@ -11,18 +11,16 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
|
||||
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/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
|
||||
@@ -39,42 +37,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/pocketbase/ozzo-validation/v4 v4.3.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
|
||||
)
|
||||
|
||||
@@ -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,97 @@ 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 +140,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 +154,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 +164,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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,55 +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")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "ничей файл в хранилище не ложится")
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"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) {
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -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, "признак нового захвата отличается от прежнего")
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package pocketbase
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// ProviderName — имя провайдера у коллекции пользователей. Библиотека знает его
|
||||
// как обобщённый OIDC и по нему же ищет настройку при обмене кода.
|
||||
const ProviderName = "oidc"
|
||||
|
||||
// SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано
|
||||
// решением владельца от 2026-08-12; умолчание библиотеки в пять суток не
|
||||
// применяется, потому что оно никем не выбрано.
|
||||
//
|
||||
// Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками
|
||||
// провайдера, и потому живёт здесь, а не в каталоге шагов. Причина та же:
|
||||
// применённый шаг не переписывается, и число, положенное туда, разошлось бы со
|
||||
// сроком жизни куки при первой же правке — браузер получил бы новый срок, а
|
||||
// хранилище продолжило выдавать прежний.
|
||||
const SessionDuration = 7 * 24 * 60 * 60
|
||||
|
||||
// ProviderSettings — то, что приезжает из конфига и приводится к настройкам
|
||||
// коллекции.
|
||||
type ProviderSettings struct {
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
UserInfoURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// ApplyProviderSettings приводит настройки провайдера у коллекции пользователей
|
||||
// к значениям конфига.
|
||||
//
|
||||
// Делается это при каждом подъёме, а не однажды шагом схемы, и причина в
|
||||
// инварианте: применённый шаг не переписывается. Секрет, положенный шагом, не
|
||||
// пережил бы ротации — смена значения в конфиге до хранилища не доехала бы
|
||||
// вовсе, и вход сломался бы после смены ключа, а починить это можно было бы
|
||||
// только руками в панели.
|
||||
//
|
||||
// Секрет здесь не логируется и в текст ошибки не попадает: сообщение называет
|
||||
// имя коллекции, а не значения.
|
||||
func ApplyProviderSettings(app core.App, settings ProviderSettings) error {
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find users collection: %w", err)
|
||||
}
|
||||
|
||||
// Срок жизни сессии живёт здесь, а не в шаге схемы: применённый шаг не
|
||||
// переписывается, и правка числа не доехала бы до хранилища, разойдясь со
|
||||
// сроком жизни куки.
|
||||
users.AuthToken.Duration = SessionDuration
|
||||
|
||||
users.OAuth2.Enabled = true
|
||||
users.OAuth2.Providers = []core.OAuth2ProviderConfig{{
|
||||
Name: ProviderName,
|
||||
ClientId: settings.ClientID,
|
||||
ClientSecret: settings.ClientSecret,
|
||||
AuthURL: settings.AuthURL,
|
||||
TokenURL: settings.TokenURL,
|
||||
UserInfoURL: settings.UserInfoURL,
|
||||
DisplayName: "Authelia",
|
||||
}}
|
||||
|
||||
if err := app.Save(users); err != nil {
|
||||
return fmt.Errorf("failed to apply provider settings to users collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,119 +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))
|
||||
}
|
||||
|
||||
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")),
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,110 +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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)`,
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// defaultListLimit — умолчание, к которому приводится непозитивный предел.
|
||||
// Значение своё, а не занятое у транспорта: адаптер о транспорте не знает.
|
||||
const defaultListLimit = 30
|
||||
|
||||
// List отдаёт страницу записей владельца, новыми сверху.
|
||||
//
|
||||
// **Страница берётся ключом, а не смещением.** Приём пишет в голову той же
|
||||
// ленты, которую читает список, и человек, загрузивший запись и листающий свой
|
||||
// архив, — штатный сценарий. Смещение сдвинуло бы окно на единицу: последний
|
||||
// элемент первой страницы пришёл бы вторым разом первым элементом второй, а один
|
||||
// элемент между ними не пришёл бы никогда — и оба раза молча.
|
||||
//
|
||||
// Ключ полный: пара «время заведения и идентификатор». Одного времени мало — у
|
||||
// записей, принятых одним запросом, оно совпадает, и порядок между ними иначе не
|
||||
// определён вовсе.
|
||||
//
|
||||
// Ни расшифровки, ни структуры реплик выборка не читает: обе лежат порознь от
|
||||
// записи ровно затем, чтобы список их не тянул. Длительность и размер берутся
|
||||
// колонками самой записи.
|
||||
func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.RecordPage, error) {
|
||||
// Пустой владелец не совпадает ни с одной записью. Правило записано со
|
||||
// стороны спрашивающего: обязательность, которую держит одна лишь схема,
|
||||
// пустую строку пропустила бы.
|
||||
if q.OwnerID == "" {
|
||||
return &contract.RecordPage{Items: []*entity.AudioRecord{}}, nil
|
||||
}
|
||||
|
||||
// Непозитивный предел приводится к умолчанию, а не роняет процесс: ниже
|
||||
// стоит обращение по индексу `q.Limit-1`, и нулевой предел дал бы индекс −1.
|
||||
// Сегодня отсекает его обработчик, но метод — часть интерфейса, и второй
|
||||
// вызывающий с забытым полем структуры получил бы панику, а восстановления у
|
||||
// воркеров нет вовсе.
|
||||
if q.Limit <= 0 {
|
||||
q.Limit = defaultListLimit
|
||||
}
|
||||
|
||||
conditions := []string{"owner_id = ?"}
|
||||
args := []any{q.OwnerID}
|
||||
|
||||
state, stateArgs, err := stateCondition(q.Filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state != "" {
|
||||
conditions = append(conditions, state)
|
||||
args = append(args, stateArgs...)
|
||||
}
|
||||
|
||||
total, err := repo.countRecords(conditions, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pageConditions := conditions
|
||||
pageArgs := args
|
||||
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
|
||||
// — строго меньше по идентификатору.
|
||||
if q.Cursor != nil {
|
||||
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), "")
|
||||
|
||||
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
|
||||
// следующая страница» без второго запроса и без вычислений по общему числу,
|
||||
// которое к этому моменту могло измениться.
|
||||
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(items) > q.Limit {
|
||||
last := items[q.Limit-1]
|
||||
page.NextCursor = &contract.RecordCursor{
|
||||
CreatedAt: last.CreatedAt,
|
||||
ID: last.Id,
|
||||
}
|
||||
items = items[:q.Limit]
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// stateCondition переводит состояние отбора в условие запроса.
|
||||
//
|
||||
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
|
||||
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
|
||||
// молча поменял бы состав всех трёх состояний.
|
||||
func stateCondition(filter *entity.ListFilter) (string, []any, error) {
|
||||
if filter == nil {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
switch *filter {
|
||||
case entity.ListFilterHalted:
|
||||
return "halted_at IS NOT NULL", nil, nil
|
||||
case entity.ListFilterWorking:
|
||||
condition, args := stateIn(entity.WorkingStages())
|
||||
return "halted_at IS NULL AND " + condition, args, nil
|
||||
case entity.ListFilterDone:
|
||||
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)
|
||||
}
|
||||
|
||||
func stateIn(stages []entity.Stage) (string, []any) {
|
||||
names := entity.StageNames(stages)
|
||||
args := make([]any, 0, len(names))
|
||||
placeholders := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
args = append(args, name)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
return "state IN (" + strings.Join(placeholders, ", ") + ")", args
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
placeholders := make([]string, 0, len(ids))
|
||||
args := []any{ownerID}
|
||||
for _, id := range ids {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+206
-42
@@ -28,7 +28,7 @@ const (
|
||||
)
|
||||
|
||||
// Ядро — `internal/service`: оно знает только интерфейсы `internal/contract`, а
|
||||
// ffmpeg, Yandex и хранилище подставляются в `main.go`
|
||||
// ffmpeg, Yandex и хранилище подставляются в точке входа `cmd/transcriber`
|
||||
// (docs/architecture.md, «Принципы»).
|
||||
const core = "internal/service"
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestЯдроНеЗнаетОбАдаптерах(t *testing.T) {
|
||||
if strings.HasPrefix(imp, adapterPrefix) {
|
||||
t.Errorf(
|
||||
"%s импортирует адаптер %s: ядро зависит от интерфейсов "+
|
||||
"internal/contract, а реализацию подставляет main.go",
|
||||
"internal/contract, а реализацию подставляет cmd/transcriber",
|
||||
core, imp,
|
||||
)
|
||||
}
|
||||
@@ -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 пишется отображением записи, но ни один шаг схемы её не "+
|
||||
@@ -260,6 +306,39 @@ func TestУКаждогоРабочегоРубежаЕстьШаг(t *testing.T
|
||||
|
||||
// Обратное направление того же правила: шаг, написанный под рубеж, которого в
|
||||
// дескрипторе нет, недостижим — захват такую запись не выдаст никогда.
|
||||
// Отбор списка — очередной потребитель словаря рубежей, и перечислять их у него
|
||||
// строкой запроса нельзя: рубеж, добавленный конвейером, молча поменял бы состав
|
||||
// всех трёх состояний отбора, а заметить это было бы нечем.
|
||||
//
|
||||
// Правило смотрит, что выборка списка берёт рубежи у дескриптора, а не пишет их
|
||||
// литералом. Инвариант проекта «Рубеж объявляется одним дескриптором» компилятор
|
||||
// не проверяет — проверяет оно.
|
||||
func TestОтборСпискаБерётРубежиУДескриптора(t *testing.T) {
|
||||
const listFile = repoPkg + "/record_list.go"
|
||||
|
||||
body := readFile(t, listFile)
|
||||
|
||||
for _, value := range declaredStateValues(t) {
|
||||
if strings.Contains(body, `"`+value+`"`) {
|
||||
t.Errorf(
|
||||
"отбор списка называет рубеж %q строкой: рубеж, добавленный "+
|
||||
"дескриптором, молча не попадёт ни в одно состояние отбора",
|
||||
value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _, fn := range []string{"entity.WorkingStages()", "entity.TerminalStages()"} {
|
||||
if !strings.Contains(body, fn) {
|
||||
t.Errorf(
|
||||
"отбор списка не зовёт %s: перечень рубежей обязан приходить из "+
|
||||
"дескриптора, а не собираться по месту",
|
||||
fn,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestШагиОбъявленыРубежамиДескриптора(t *testing.T) {
|
||||
body := funcBody(t, serviceFile, "func (s *TranscribeService) stepFor(")
|
||||
declared := map[string]bool{}
|
||||
@@ -279,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 {
|
||||
@@ -294,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
|
||||
}
|
||||
@@ -363,6 +480,25 @@ func stageDescriptor(t *testing.T) (all []string, working []string) {
|
||||
}
|
||||
|
||||
// declaredStates — константы рубежей, объявленные доменом.
|
||||
// declaredStateValues — **значения** рубежей, а не имена их констант: правило
|
||||
// отбора ищет строковый литерал в чужом файле, и сравнивать его надо со
|
||||
// значением.
|
||||
//
|
||||
// declaredStates рядом отдаёт имена констант — им пользуются правила, читающие
|
||||
// код, а не строки.
|
||||
func declaredStateValues(t *testing.T) []string {
|
||||
t.Helper()
|
||||
out := []string{}
|
||||
re := regexp.MustCompile(`(?m)^\tState\w+\s*=\s*"([^"]+)"`)
|
||||
for _, m := range re.FindAllStringSubmatch(readFile(t, stateFile), -1) {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("в %s не объявлено ни одного рубежа: правило потеряло предмет", stateFile)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func declaredStates(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
out := map[string]bool{}
|
||||
@@ -395,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)
|
||||
@@ -405,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") {
|
||||
@@ -415,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))
|
||||
|
||||
+117
-58
@@ -3,9 +3,8 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -63,12 +62,50 @@ type ServerConfig struct {
|
||||
Port int `toml:"port"`
|
||||
ShutdownTimeout int `toml:"shutdown_timeout"`
|
||||
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
|
||||
// Debug — предохранитель отладочного запуска. Умолчание — «выключено»:
|
||||
// отсутствие ключа читается как боевой прогон, а не как отладочный.
|
||||
//
|
||||
// Означает он одно: прогон идёт на машине разработчика, и сервису позволено
|
||||
// подставить то, что в бою даёт окружение. Сегодня подставляется ровно одна
|
||||
// вещь — заголовки входа, — и перечень следствий закрыт: уровня журнала,
|
||||
// текстов внутренних отказов, ограничителя частоты, подмены распознавателя и
|
||||
// проверок старта признак не касается. Новое следствие вешается на него
|
||||
// только отдельной нормой спеки `access`.
|
||||
Debug bool `toml:"debug"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -81,64 +118,79 @@ type YandexConfig struct {
|
||||
ObjStorageEndpoint string `toml:"object_storage_endpoint"`
|
||||
}
|
||||
|
||||
// AuthConfig — вход через внешнего провайдера OIDC. Адреса, идентификатор
|
||||
// клиента и секрет приезжают сюда и приводятся к настройкам коллекции
|
||||
// пользователей при каждом подъёме: применённый шаг схемы не переписывается, и
|
||||
// секрет, положенный однажды шагом, не пережил бы ротации.
|
||||
// AuthConfig — кому сервис верит на входе.
|
||||
//
|
||||
// Своего входа у сервиса нет: кто пришёл, называет обратный прокси заголовком,
|
||||
// сходив к провайдеру. Секция поэтому свелась к одному ключу — перечню адресов,
|
||||
// чьему заголовку верить. Ни адресов провайдера, ни идентификатора клиента, ни
|
||||
// его секрета здесь больше нет: обменивать код не на что, и секрет ушёл из
|
||||
// конфига вместе с протоколом.
|
||||
type AuthConfig struct {
|
||||
AuthURL string `toml:"auth_url"`
|
||||
TokenURL string `toml:"token_url"`
|
||||
UserInfoURL string `toml:"user_info_url"`
|
||||
ClientID string `toml:"client_id"`
|
||||
ClientSecret string `toml:"client_secret"`
|
||||
// RedirectURL — адрес возврата, тот же, что записан клиенту у провайдера.
|
||||
RedirectURL string `toml:"redirect_url"`
|
||||
// SecureCookie — признак `Secure` у куки сессии. Умолчание «включено»;
|
||||
// выключается только для локального запуска по `http://localhost`, где
|
||||
// браузер такую куку не сохранит.
|
||||
SecureCookie bool `toml:"secure_cookie"`
|
||||
// TrustedProxies — адреса и подсети, чьему заголовку верят. Значение
|
||||
// сверяется с адресом самого соединения, а не с пересылаемым заголовком:
|
||||
// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
|
||||
// той же строкой, которой он обходится, не барьер вовсе.
|
||||
TrustedProxies []string `toml:"trusted_proxies"`
|
||||
|
||||
// TestHeaders — имитация заголовков, которые в бою ставит обратный прокси.
|
||||
// Ключ — имя заголовка, значение — то, чем сервис назовёт пришедшего сам.
|
||||
//
|
||||
// Работает только при включённом предохранителе `[server] debug`, и
|
||||
// заполненная секция без него роняет старт: состояние «имитация есть,
|
||||
// предохранителя нет» не читается никак, а обе его прочтения — поломка.
|
||||
//
|
||||
// Умолчание — пустая секция. Непустой она считается по наличию ключа, каким
|
||||
// бы ни было его значение: `Remote-User = ""` — заполненная имитация, а не
|
||||
// отсутствие её.
|
||||
TestHeaders map[string]string `toml:"test_headers"`
|
||||
}
|
||||
|
||||
// Validate проверяет, что вход настроен целиком и что адреса — адреса. Пустое
|
||||
// или негодное поле роняет старт: с молча выключенным входом сервис поднялся бы
|
||||
// открытым наружу, а узнать об этом было бы неоткуда.
|
||||
// TrustedNetworks разбирает перечень доверенных адресов.
|
||||
//
|
||||
// Форма адреса проверяется здесь, а не только хранилищем, потому что хранилище
|
||||
// отвергает негодный адрес позже — из хука подъёма, до регистрации пробы
|
||||
// здоровья и метрик. Тогда владелец не получает даже кода состояния: сервис
|
||||
// молча падает целиком, вместе с ботом и воркерами.
|
||||
// Одиночный адрес принимается наравне с подсетью и превращается в подсеть на
|
||||
// один адрес: писать `/32` руками значит помнить разрядность, а перечень читает
|
||||
// человек.
|
||||
func (c AuthConfig) TrustedNetworks() ([]netip.Prefix, error) {
|
||||
networks := make([]netip.Prefix, 0, len(c.TrustedProxies))
|
||||
|
||||
for _, raw := range c.TrustedProxies {
|
||||
value := strings.TrimSpace(raw)
|
||||
|
||||
if prefix, err := netip.ParsePrefix(value); err == nil {
|
||||
networks = append(networks, prefix.Masked())
|
||||
continue
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: %s не читается как адрес или подсеть: %q", trustedProxiesKey, value)
|
||||
}
|
||||
networks = append(networks, netip.PrefixFrom(addr, addr.BitLen()))
|
||||
}
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// trustedProxiesKey — имя ключа в отказах старта. Литерал один на файл: два
|
||||
// разошлись бы молча, и владелец искал бы в конфиге ключ, которого там нет.
|
||||
const trustedProxiesKey = "trusted_proxies"
|
||||
|
||||
// Validate проверяет, что сервису есть кому верить.
|
||||
//
|
||||
// Пустой перечень роняет старт. Он значит «не верить никому», то есть сервис,
|
||||
// поднявшийся никого не узнающим, — и молчать об этом старт не вправе: узнать о
|
||||
// такой поломке было бы неоткуда, все адреса приложения просто отвечали бы
|
||||
// отказом.
|
||||
//
|
||||
// Нечитаемая строка роняет старт по той же причине: перечень с опечаткой
|
||||
// проверяется только тем, что кто-то не смог войти.
|
||||
func (c AuthConfig) Validate() error {
|
||||
values := map[string]string{
|
||||
"auth_url": c.AuthURL,
|
||||
"token_url": c.TokenURL,
|
||||
"user_info_url": c.UserInfoURL,
|
||||
"client_id": c.ClientID,
|
||||
"client_secret": c.ClientSecret,
|
||||
"redirect_url": c.RedirectURL,
|
||||
if len(c.TrustedProxies) == 0 {
|
||||
return fmt.Errorf("auth: не заполнен ключ %s: сервису некому верить, и узнать он никого не сможет", trustedProxiesKey)
|
||||
}
|
||||
|
||||
missing := make([]string, 0, len(values))
|
||||
for name, value := range values {
|
||||
if value == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
// Названы имена ключей, а не значения: значение `client_secret` в
|
||||
// сообщение об ошибке попасть не должно, оно уедет в журнал.
|
||||
return fmt.Errorf("auth: не заполнены ключи: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
malformed := make([]string, 0, 4)
|
||||
for _, name := range []string{"auth_url", "token_url", "user_info_url", "redirect_url"} {
|
||||
parsed, err := url.Parse(values[name])
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
malformed = append(malformed, name)
|
||||
}
|
||||
}
|
||||
if len(malformed) > 0 {
|
||||
return fmt.Errorf("auth: ключи не похожи на адрес: %s", strings.Join(malformed, ", "))
|
||||
if _, err := c.TrustedNetworks(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -154,6 +206,13 @@ func defaultConfig() *Config {
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
DataDir: "data",
|
||||
// Пять секунд ожидания и четыре читающих соединения: числа выведены
|
||||
// из числа воркеров по умолчанию, а не из замера. Смысл ожидания —
|
||||
// пережить чужую запись, а не чужую работу: пишет сервис короткими
|
||||
// операциями, и очередь из трёх воркеров укладывается в него с
|
||||
// запасом.
|
||||
BusyTimeoutMs: 5000,
|
||||
ReadConnections: 4,
|
||||
},
|
||||
Pipeline: PipelineConfig{
|
||||
Workers: 3,
|
||||
@@ -173,9 +232,9 @@ func defaultConfig() *Config {
|
||||
ObjStorageRegion: "ru-central1",
|
||||
ObjStorageEndpoint: "https://storage.yandexcloud.net/",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
SecureCookie: true,
|
||||
},
|
||||
// Умолчания у перечня доверенных адресов нет намеренно: подставленное
|
||||
// значение соврало бы ровно там, где по нему решают, кого пускать.
|
||||
Auth: AuthConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,97 +8,65 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
|
||||
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
|
||||
// исполнялась ни разу.
|
||||
// Проверка перечня доверенных адресов — единственная страховка от того, чтобы
|
||||
// сервис поднялся никого не узнающим. Узнать о такой поломке было бы неоткуда:
|
||||
// все адреса приложения просто отвечали бы отказом.
|
||||
|
||||
func validAuthConfig() AuthConfig {
|
||||
return AuthConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "secret-value",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
}
|
||||
return AuthConfig{TrustedProxies: []string{"172.16.0.0/12", "127.0.0.1"}}
|
||||
}
|
||||
|
||||
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
|
||||
if err := validAuthConfig().Validate(); err != nil {
|
||||
t.Fatalf("заполненный конфиг отвергнут: %v", err)
|
||||
t.Fatalf("заполненный перечень отвергнут: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
|
||||
cases := map[string]func(*AuthConfig){
|
||||
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
|
||||
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
|
||||
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
|
||||
"client_id": func(c *AuthConfig) { c.ClientID = "" },
|
||||
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
|
||||
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
|
||||
}
|
||||
|
||||
for key, clear := range cases {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
clear(&cfg)
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
|
||||
}
|
||||
if !strings.Contains(err.Error(), key) {
|
||||
t.Fatalf("имя ключа %s не названо: %v", key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
|
||||
// и значения секрета в нём быть не должно — только имя ключа.
|
||||
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
cfg.ClientSecret = "super-secret-value"
|
||||
cfg.AuthURL = ""
|
||||
|
||||
err := cfg.Validate()
|
||||
// Пустой перечень значит «не верить никому»: сервис поднялся бы, никого не
|
||||
// узнавая, и молчать об этом старт не вправе.
|
||||
func TestAuthConfigValidateRejectsEmptyList(t *testing.T) {
|
||||
err := AuthConfig{}.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("отказа нет")
|
||||
t.Fatal("пустой перечень принят: сервис поднялся бы никого не узнающим")
|
||||
}
|
||||
if strings.Contains(err.Error(), "super-secret-value") {
|
||||
t.Fatalf("значение секрета попало в текст отказа: %v", err)
|
||||
if !strings.Contains(err.Error(), "trusted_proxies") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
|
||||
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
|
||||
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
|
||||
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"без схемы": "auth.example.com/api/oidc/authorization",
|
||||
"пробел спереди": " https://auth.example.com/authorize",
|
||||
"чужая схема": "ftp://auth.example.com/authorize",
|
||||
"пустой хост": "https:///authorize",
|
||||
"не адрес вовсе": "todo: заполнить",
|
||||
}
|
||||
|
||||
for name, value := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := validAuthConfig()
|
||||
cfg.AuthURL = value
|
||||
// Нечитаемая строка роняет старт: перечень с опечаткой проверяется только тем,
|
||||
// что кто-то не смог войти.
|
||||
func TestAuthConfigValidateRejectsMalformedEntry(t *testing.T) {
|
||||
for _, value := range []string{"", "not-an-address", "10.0.0.0/99", "10.0.0.256"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
cfg := AuthConfig{TrustedProxies: []string{value}}
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("негодный адрес %q пропущен", value)
|
||||
t.Fatalf("строка %q принята как адрес", value)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "auth_url") {
|
||||
if !strings.Contains(err.Error(), "trusted_proxies") {
|
||||
t.Fatalf("имя ключа не названо: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Одиночный адрес принимается наравне с подсетью и становится подсетью на один
|
||||
// адрес: писать разрядность руками значит её помнить, а перечень читает человек.
|
||||
func TestTrustedNetworksAcceptsBareAddress(t *testing.T) {
|
||||
networks, err := AuthConfig{TrustedProxies: []string{"10.1.2.3"}}.TrustedNetworks()
|
||||
if err != nil {
|
||||
t.Fatalf("одиночный адрес отвергнут: %v", err)
|
||||
}
|
||||
if len(networks) != 1 {
|
||||
t.Fatalf("подсетей %d, ожидалась одна", len(networks))
|
||||
}
|
||||
if !networks[0].IsSingleIP() {
|
||||
t.Fatalf("одиночный адрес стал подсетью шире одного адреса: %s", networks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -248,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/textproto"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Имена ключей в отказах старта. Литералы по одному на пакет: два разошлись бы
|
||||
// молча, и владелец искал бы в конфиге ключ, которого там нет.
|
||||
const (
|
||||
debugKey = "[server] debug"
|
||||
testHeadersKey = "[auth.test_headers]"
|
||||
)
|
||||
|
||||
// HeaderSubstitution отдаёт **что** сервис подставит запросу вместо заголовков
|
||||
// обратного прокси — и этим же перечнем отвечает на «подставляет ли»: пустой
|
||||
// перечень значит «не подставляет».
|
||||
//
|
||||
// Признака вторым значением нет намеренно. Предикат, посчитанный дважды —
|
||||
// булевым ответом здесь и непустотой карты у потребителя, — разошёлся бы молча,
|
||||
// и сервис либо подставлял бы молча, либо молча не подставлял. Представление
|
||||
// одно: непустота карты, и судят её все одинаково — проверка старта, строка
|
||||
// журнала при старте и слой подстановки.
|
||||
//
|
||||
// Ключи возвращаемой карты приведены к каноническому виду имени заголовка: в
|
||||
// HTTP имя нечувствительно к регистру, а ключ TOML чувствителен.
|
||||
//
|
||||
// Перечень пуст и тогда, когда предохранитель включён, а имитация пуста: сам по
|
||||
// себе признак ничего не включает.
|
||||
func (c *Config) HeaderSubstitution() map[string]string {
|
||||
if !c.Server.Debug || len(c.Auth.TestHeaders) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := make(map[string]string, len(c.Auth.TestHeaders))
|
||||
for name, value := range c.Auth.TestHeaders {
|
||||
headers[textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name))] = value
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// ValidateTestHeaders судит настройки отладочного входа на старте, до приёма
|
||||
// трафика: настройка, отданная на честность выкладки, проверяется только тем,
|
||||
// что чужой архив уже уехал не тому.
|
||||
//
|
||||
// Имена заголовков приходят доводом, а не читаются отсюда: дом у них один —
|
||||
// константы транспорта, — а пакет настроек транспорта не знает и знать не
|
||||
// должен. Обратное ребро сделало бы `cmd/devtools`, которому нужен один разбор
|
||||
// конфига, линкующим всю поверхность HTTP.
|
||||
//
|
||||
// Отказов четыре, и каждый закрывает своё «не читается никак»:
|
||||
//
|
||||
// - имитация заполнена, предохранитель выключен. Либо человек забыл включить
|
||||
// предохранитель и будет искать поломку везде, кроме одного ключа, либо
|
||||
// забыл убрать имитацию из боевого файла — и тогда до открытого архива
|
||||
// остаётся одно слово;
|
||||
// - два ключа секции дают одно каноническое имя заголовка. `Remote-User` и
|
||||
// `remote-user` для TOML — два ключа, для HTTP — одно имя, и одно из двух
|
||||
// значений потерялось бы молча;
|
||||
// - имитация называет имя, которого сервис не читает. Опечатка `Remote-Usr`
|
||||
// иначе кончается сервисом, который никого не узнаёт, без единого следа;
|
||||
// - имитация непуста, а годного логина в ней нет. Секция с одним
|
||||
// `Remote-Email` подняла бы сервис, который подставит почту, удалит логин и
|
||||
// не узнает никого.
|
||||
//
|
||||
// Значений отказы не называют: логин — ключ к чужому архиву, и запрет печатать
|
||||
// его действует на подставленное значение наравне с пришедшим.
|
||||
func (c *Config) ValidateTestHeaders(accepted []string, loginHeader string) error {
|
||||
headers := c.HeaderSubstitution()
|
||||
if len(headers) == 0 {
|
||||
if len(c.Auth.TestHeaders) > 0 {
|
||||
return fmt.Errorf(
|
||||
"auth: секция %s заполнена, а предохранитель %s выключен: "+
|
||||
"либо включите предохранитель, либо уберите имитацию",
|
||||
testHeadersKey, debugKey,
|
||||
)
|
||||
}
|
||||
|
||||
// Включённый предохранитель при пустой имитации законен: сам по себе он
|
||||
// ничего не включает.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Два ключа, различающиеся только регистром, дали бы одно имя заголовка и
|
||||
// одно значение — второе потерялось бы молча.
|
||||
if len(headers) != len(c.Auth.TestHeaders) {
|
||||
return fmt.Errorf(
|
||||
"auth: в секции %s два ключа называют один заголовок: "+
|
||||
"имя заголовка нечувствительно к регистру, и одно из значений потерялось бы молча",
|
||||
testHeadersKey,
|
||||
)
|
||||
}
|
||||
|
||||
known := make(map[string]bool, len(accepted))
|
||||
for _, name := range accepted {
|
||||
known[textproto.CanonicalMIMEHeaderKey(name)] = true
|
||||
}
|
||||
|
||||
unknown := make([]string, 0, len(headers))
|
||||
for name := range headers {
|
||||
if !known[name] {
|
||||
unknown = append(unknown, name)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
// Порядок перебора карты свой у каждого прогона, а отказ старта читает
|
||||
// человек: без сортировки один и тот же конфиг давал бы разный текст.
|
||||
sort.Strings(unknown)
|
||||
return fmt.Errorf(
|
||||
"auth: секция %s называет заголовок, которого сервис не читает: %s; принимаются %s",
|
||||
testHeadersKey, strings.Join(unknown, ", "), strings.Join(accepted, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
login, named := headers[textproto.CanonicalMIMEHeaderKey(loginHeader)]
|
||||
if !named {
|
||||
return fmt.Errorf(
|
||||
"auth: секция %s не называет ключа %s: сервис подставил бы всё прочее и не узнал бы никого",
|
||||
testHeadersKey, loginHeader,
|
||||
)
|
||||
}
|
||||
if _, ok := entity.AcceptProviderLogin(login); !ok {
|
||||
return fmt.Errorf(
|
||||
"auth: значение ключа %s в секции %s не годится в логин: "+
|
||||
"пустое, из одних пробельных знаков, длиннее %d знаков либо с управляющими знаками",
|
||||
loginHeader, testHeadersKey, entity.MaxProviderLoginLength,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
)
|
||||
|
||||
// Проверки этого файла судят требование «Настройка, открывающая вход всем,
|
||||
// роняет старт». Предмет у них один: сервис, поднявшийся на настройках, при
|
||||
// которых отладочный вход становится открытым входом либо не работает вовсе.
|
||||
//
|
||||
// Имена заголовков берутся у транспорта, а не выписываются здесь: дом у них
|
||||
// один, и проверка со своим списком зеленела бы на разошедшемся коде.
|
||||
|
||||
func acceptedHeaderNames() []string { return httpcontroller.IdentityHeaderNames() }
|
||||
|
||||
// validateTestHeaders зовёт проверку так же, как её зовёт точка входа.
|
||||
func validateTestHeaders(cfg *Config) error {
|
||||
return cfg.ValidateTestHeaders(acceptedHeaderNames(), httpcontroller.LoginHeader)
|
||||
}
|
||||
|
||||
// debugConfig собирает настройки отладочного запуска: предохранитель и имитация.
|
||||
func debugConfig(debug bool, headers map[string]string) *Config {
|
||||
cfg := defaultConfig()
|
||||
cfg.Server.Debug = debug
|
||||
cfg.Auth.TrustedProxies = []string{"127.0.0.1"}
|
||||
cfg.Auth.TestHeaders = headers
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Первый отказ: имитация заполнена, предохранителя нет. Состояние не читается
|
||||
// никак — либо человек забыл включить предохранитель, либо забыл убрать
|
||||
// имитацию из боевого файла, и до открытого архива остаётся одно слово.
|
||||
func TestTestHeadersWithoutDebugFailsStartup(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(false, map[string]string{
|
||||
httpcontroller.LoginHeader: "local",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("имитация без предохранителя принята: сервис поднялся бы никого не узнающим")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[server] debug") {
|
||||
t.Fatalf("имя ключа предохранителя не названо, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустое значение — заполненная имитация, а не отсутствие её: человек, написавший
|
||||
// ключ, имитацию завёл, и пустое значение у него вторая поломка, а не первая.
|
||||
func TestEmptyLoginValueCountsAsFilledSection(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(false, map[string]string{
|
||||
httpcontroller.LoginHeader: "",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("секция с пустым значением сочтена пустой")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[server] debug") {
|
||||
t.Fatalf("имя ключа предохранителя не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Второй отказ: имя, которого сервис не читает. Опечатка иначе кончается
|
||||
// сервисом, который никого не узнаёт, без единого следа.
|
||||
func TestUnknownHeaderNameFailsStartup(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(true, map[string]string{
|
||||
httpcontroller.LoginHeader: "local",
|
||||
"Remote-Usr": "local",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("неизвестное имя заголовка принято")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, "Remote-Usr") {
|
||||
t.Fatalf("неизвестное имя не названо: %v", err)
|
||||
}
|
||||
for _, name := range acceptedHeaderNames() {
|
||||
if !strings.Contains(message, name) {
|
||||
t.Fatalf("принимаемое имя %s не названо, чинить нечего: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Третий отказ, первая его половина: ключа логина нет вовсе. Такая секция
|
||||
// подняла бы сервис, который подставит почту, удалит логин и не узнает никого.
|
||||
func TestSectionWithoutLoginKeyFailsStartup(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(true, map[string]string{
|
||||
httpcontroller.EmailHeader: "local@example.com",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("имитация без ключа логина принята")
|
||||
}
|
||||
if !strings.Contains(err.Error(), httpcontroller.LoginHeader) {
|
||||
t.Fatalf("имя недостающего ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Третий отказ, вторая половина: логин судится **тем же** приёмом, каким
|
||||
// узнавание судит пришедшее значение. Иначе сервис поднимается, ставит
|
||||
// заголовок, получает отказ приёма и отвечает неузнанным на всё.
|
||||
func TestUnacceptableLoginValueFailsStartup(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"пустое": "",
|
||||
"пробельное": " ",
|
||||
"сверх предела": strings.Repeat("x", 300),
|
||||
"с управляющим знаком": "loc\x00al",
|
||||
}
|
||||
|
||||
for name, login := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(true, map[string]string{
|
||||
httpcontroller.LoginHeader: login,
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("негодное значение логина принято")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, httpcontroller.LoginHeader) {
|
||||
t.Fatalf("имя ключа не названо, чинить нечего: %v", err)
|
||||
}
|
||||
// Значение в отказ не идёт: логин — ключ к чужому архиву.
|
||||
if login != "" && strings.Contains(message, login) {
|
||||
t.Fatalf("значение логина уехало в отказ старта: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Два ключа, различающиеся регистром, назвали бы один заголовок: имя заголовка
|
||||
// нечувствительно к регистру, а ключ TOML чувствителен, и одно из значений
|
||||
// потерялось бы молча.
|
||||
func TestDuplicateHeaderKeyFailsStartup(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(true, map[string]string{
|
||||
"Remote-User": "one",
|
||||
"remote-user": "two",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("два ключа на один заголовок приняты: одно значение потерялось бы молча")
|
||||
}
|
||||
}
|
||||
|
||||
// Законный случай: предохранитель включён, имитация пуста. Сам по себе признак
|
||||
// ничего не включает, и перечень доверенных адресов ему не судья.
|
||||
func TestDebugWithoutTestHeadersStarts(t *testing.T) {
|
||||
cfg := debugConfig(true, nil)
|
||||
cfg.Auth.TrustedProxies = []string{"172.20.0.0/24"}
|
||||
|
||||
if err := validateTestHeaders(cfg); err != nil {
|
||||
t.Fatalf("включённый предохранитель при пустой имитации уронил старт: %v", err)
|
||||
}
|
||||
if len(cfg.HeaderSubstitution()) > 0 {
|
||||
t.Fatal("пустая имитация включила подстановку")
|
||||
}
|
||||
}
|
||||
|
||||
// Законный случай главный: конфиг сегодняшнего дня, не называющий ни одного
|
||||
// нового ключа, ведёт себя ровно как вёл.
|
||||
func TestConfigWithoutNewKeysStartsUnchanged(t *testing.T) {
|
||||
path := writeConfig(t, "[auth]\ntrusted_proxies = [\"172.20.0.0/24\"]\n"+validConfigBody)
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("конфиг без новых ключей не прочитан: %v", err)
|
||||
}
|
||||
if cfg.Server.Debug {
|
||||
t.Fatal("отсутствие ключа предохранителя прочитано как «включено»")
|
||||
}
|
||||
if len(cfg.Auth.TestHeaders) != 0 {
|
||||
t.Fatalf("отсутствие секции имитации прочитано как заполненная: %v", cfg.Auth.TestHeaders)
|
||||
}
|
||||
if err := validateTestHeaders(cfg); err != nil {
|
||||
t.Fatalf("конфиг без новых ключей уронил старт: %v", err)
|
||||
}
|
||||
if cfg.HeaderSubstitution() != nil {
|
||||
t.Fatal("конфиг без новых ключей включил подстановку")
|
||||
}
|
||||
}
|
||||
|
||||
// Негодное значение предохранителя роняет старт разбором, а не читается как
|
||||
// «включено»: ошибка разбора не вправе открывать вход.
|
||||
func TestMalformedDebugValueFailsLoad(t *testing.T) {
|
||||
path := writeConfig(t, "[server]\ndebug = \"yes\"\n"+validConfigBody)
|
||||
|
||||
if _, err := LoadConfig(path); err == nil {
|
||||
t.Fatal("строка вместо булева значения принята")
|
||||
}
|
||||
}
|
||||
|
||||
// Ключи имитации приезжают из файла, а на выходе приведены к каноническому виду
|
||||
// имени заголовка: в HTTP имя нечувствительно к регистру, а ключ TOML — нет.
|
||||
func TestHeaderSubstitutionReadsSectionAndCanonicalizes(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[server]
|
||||
debug = true
|
||||
|
||||
[auth]
|
||||
trusted_proxies = ["127.0.0.1"]
|
||||
|
||||
[auth.test_headers]
|
||||
remote-user = "local"
|
||||
REMOTE-EMAIL = "local@example.com"
|
||||
`+validConfigBody)
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("конфиг с имитацией не прочитан: %v", err)
|
||||
}
|
||||
if err := validateTestHeaders(cfg); err != nil {
|
||||
t.Fatalf("годная имитация уронила старт: %v", err)
|
||||
}
|
||||
|
||||
headers := cfg.HeaderSubstitution()
|
||||
if len(headers) == 0 {
|
||||
t.Fatal("заполненная имитация при включённом предохранителе не включила подстановку")
|
||||
}
|
||||
if headers[httpcontroller.LoginHeader] != "local" {
|
||||
t.Fatalf("логин не приведён к каноническому имени заголовка: %v", headers)
|
||||
}
|
||||
if headers[httpcontroller.EmailHeader] != "local@example.com" {
|
||||
t.Fatalf("адрес почты не приведён к каноническому имени заголовка: %v", headers)
|
||||
}
|
||||
if _, named := headers[httpcontroller.NameHeader]; named {
|
||||
t.Fatalf("не названный секцией заголовок появился в перечне: %v", headers)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,67 @@ import (
|
||||
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
|
||||
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
|
||||
// него не кладётся никогда.
|
||||
//
|
||||
// Достижимого случая у него нет: узнавание заводит учётную запись само, и
|
||||
// предъявителя без неё под корнем приложения не бывает. Отдельной ветви ответа
|
||||
// он поэтому не получает — ветвь по умолчанию читает его как аварию сервиса,
|
||||
// каковой он и был бы.
|
||||
var ErrOwnerRequired = errors.New("owner is required to accept a record")
|
||||
|
||||
// ErrRecordUnreadable — присланную запись не удалось прочитать: источник
|
||||
// метаданных не разобрал её содержимое. Причина отказа — сама запись, а не сбой
|
||||
// сервиса, и код ответа обязан называть причину, а не место.
|
||||
//
|
||||
// Заводится sentinel'ом, а не остаётся голой ошибкой источника метаданных:
|
||||
// ветвь по умолчанию отдала бы `500`, и «файл негоден» читалось бы как «сломался
|
||||
// сервер». Своих данных отказу нести нечего — имя файла в него не кладётся
|
||||
// никогда.
|
||||
var ErrRecordUnreadable = errors.New("uploaded record cannot be read")
|
||||
|
||||
// ErrRecordTooLarge — присланная запись длиннее потолка размера. Самый частый
|
||||
// отказ у человека на мобильной сети, и прежде он уходил телом ограничителя тела
|
||||
// — мимо единой формы отказа.
|
||||
var ErrRecordTooLarge = errors.New("uploaded record exceeds size limit")
|
||||
|
||||
// ErrTextNotReady — текста запрошенного вида у записи ещё нет. Состояние, а не
|
||||
// отсутствие: запись есть и принадлежит спрашивающему, просто конвейер до этого
|
||||
// вида не дошёл. Отвечать на это тем же, чем отвечает чужая запись, нельзя —
|
||||
// человек увидел бы «не найдено» на своей записи, загруженной минуту назад.
|
||||
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
|
||||
// тем, что негодна **просьба**, а не присланная запись.
|
||||
var ErrBadRequest = errors.New("request input is not valid")
|
||||
|
||||
// ErrUnauthorized — сессии нет вовсе. Первая строка таблицы отображения, и без
|
||||
// собственного признака она собиралась бы руками мимо единой точки: правка формы
|
||||
// тела не доехала бы до неё, и два места разошлись бы молча.
|
||||
var ErrUnauthorized = errors.New("session is required")
|
||||
|
||||
// ErrNotFound — под корнем приложения такого адреса нет. Отличается от
|
||||
// JobNotFoundError тем, что не найдена **просьба**, а не запись: тело у ответа
|
||||
// то же, но повод другой, и смешивать их в одном признаке значило бы называть
|
||||
// отсутствующий адрес отсутствующей записью.
|
||||
var ErrNotFound = errors.New("address not found")
|
||||
|
||||
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
|
||||
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а негодный
|
||||
// ввод, и звать по нему учётную запись не надо.
|
||||
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
|
||||
|
||||
type JobNotFoundError struct {
|
||||
State string
|
||||
Message string
|
||||
|
||||
@@ -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 — то, что отдаёт захват: идентификатор записи и признак
|
||||
@@ -71,8 +77,45 @@ type AcquiredRecord struct {
|
||||
Holder string
|
||||
}
|
||||
|
||||
// RecordCursor — положение в ленте записей, заданное **полным** ключом
|
||||
// сортировки. Одного времени мало: у записей, принятых одним запросом, оно
|
||||
// совпадает, и порядок между ними иначе не определён.
|
||||
//
|
||||
// Время лежит здесь значением времени, а не строкой: вид, каким оно уходит в
|
||||
// запрос, принадлежит хранилищу — сравнение там побайтово, и вид, собранный
|
||||
// транспортом, разошёлся бы с колонкой молча, обратив условие в постоянную ложь.
|
||||
type RecordCursor struct {
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
}
|
||||
|
||||
// RecordQuery — что спрашивают у ленты записей.
|
||||
type RecordQuery struct {
|
||||
// OwnerID обязателен: пустой не совпадает ни с одной записью.
|
||||
OwnerID string
|
||||
// Filter — состояние записи. Пустой значит «все».
|
||||
Filter *entity.ListFilter
|
||||
// Cursor — положение, с которого продолжать. Пустой значит «сначала».
|
||||
Cursor *RecordCursor
|
||||
Limit int
|
||||
}
|
||||
|
||||
// RecordPage — страница ленты. Ключ следующей страницы пуст, когда страница
|
||||
// последняя.
|
||||
type RecordPage struct {
|
||||
Items []*entity.AudioRecord
|
||||
NextCursor *RecordCursor
|
||||
TotalItems int
|
||||
}
|
||||
|
||||
type AudioRecordRepository interface {
|
||||
Create(record *entity.AudioRecord) error
|
||||
// List отдаёт страницу записей владельца, новыми сверху, не читая ни
|
||||
// расшифровки, ни структуры реплик.
|
||||
List(q RecordQuery) (*RecordPage, error)
|
||||
// ResolveTopicNames разрешает темы названиями одним запросом на страницу и
|
||||
// сужает их владельцем: словарь тем свой у каждого человека.
|
||||
ResolveTopicNames(ownerID string, ids []string) (map[string]string, error)
|
||||
// Save сохраняет запись, захват которой держит holder. Захват, доставшийся
|
||||
// за время работы другому, даёт LostAcquisitionError и запись не проводит.
|
||||
// Пустой holder снимает эту условность и в конвейере не употребляется: все
|
||||
@@ -121,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 отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда
|
||||
@@ -133,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/`, принадлежавшего встроенному
|
||||
// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
|
||||
// Соседство, ради которого корень был выбран, кончилось вместе с соседом.
|
||||
const AppRoot = "/app"
|
||||
|
||||
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
|
||||
// без прокрутки в два экрана; потолок — против того, чтобы попросить весь архив
|
||||
// одним запросом и тем обойти постраничность её же параметром.
|
||||
const (
|
||||
DefaultPageLimit = 30
|
||||
MaxPageLimit = 100
|
||||
)
|
||||
|
||||
// pollBudgetShare — какую долю бюджета ограничителя занимает опрос карточки.
|
||||
//
|
||||
// Доля, а не весь бюджет: опрос идёт не один. В ту же секунду человек листает
|
||||
// список, открывает карточку соседней записи и грузит новую, а бюджет
|
||||
// ограничителя один на все адреса приложения и считается по адресу
|
||||
// спрашивающего, а не по учётной записи — двое за одним домашним адресом делят
|
||||
// его пополам.
|
||||
const pollBudgetShare = 8
|
||||
|
||||
// PollIntervalMs — частота, с которой приложению разрешено опрашивать карточку.
|
||||
//
|
||||
// Выводится из настройки ограничителя частоты под корнем приложения, а не
|
||||
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
|
||||
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
|
||||
// которого сервис сам же ему обещал избежать.
|
||||
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
|
||||
}
|
||||
|
||||
func NewAppHandler(
|
||||
recordRepo contract.AudioRecordRepository,
|
||||
textRepo contract.TextRepository,
|
||||
structureRepo contract.StructureRepository,
|
||||
fileRepo contract.FileRepository,
|
||||
trsService *service.TranscribeService,
|
||||
logger *slog.Logger,
|
||||
) *AppHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &AppHandler{
|
||||
recordRepo: recordRepo,
|
||||
textRepo: textRepo,
|
||||
structureRepo: structureRepo,
|
||||
fileRepo: fileRepo,
|
||||
trsService: trsService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RecordView — карточка записи и элемент страницы: **одна форма**. Две формы
|
||||
// одной вещи разошлись бы молча, и экран, написанный по одной, ломался бы о
|
||||
// другую.
|
||||
//
|
||||
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
|
||||
// Причина остановки — значение из закрытого перечня, и она не он: без причины
|
||||
// признак остановки не говорит человеку, чего ждать.
|
||||
//
|
||||
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
|
||||
// выводится из рубежа записи, который карточка несёт и так. Второе поле
|
||||
// повторяло бы рубеж и разошлось бы с ним молча.
|
||||
type RecordView struct {
|
||||
ID string `json:"id"`
|
||||
Title *string `json:"title"`
|
||||
OriginalFilename *string `json:"original_filename"`
|
||||
Brief *string `json:"brief"`
|
||||
Topics []string `json:"topics"`
|
||||
State string `json:"state"`
|
||||
Halted bool `json:"halted"`
|
||||
HaltReason *string `json:"halt_reason"`
|
||||
DurationMs *int64 `json:"duration_ms"`
|
||||
SizeBytes *int64 `json:"size_bytes"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// AvailableViews — перечень доступных видов текста, а не признак «текст
|
||||
// есть». Видов больше одного, и шаг завершения пишет их несколькими
|
||||
// операциями: состояние «сплошной текст есть, реплик ещё нет» достижимо. Один
|
||||
// признак отправил бы приложение за репликами, которых нет, и исход стал бы
|
||||
// функцией того, где прервался шаг. Пустой перечень значит «текста ещё нет».
|
||||
//
|
||||
// У элемента страницы поле опущено: страница видов не читает.
|
||||
AvailableViews *[]string `json:"available_views,omitempty"`
|
||||
}
|
||||
|
||||
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
|
||||
type IntakeItem struct {
|
||||
RecordView
|
||||
Duplicate bool `json:"duplicate"`
|
||||
}
|
||||
|
||||
type PageView struct {
|
||||
Items []RecordView `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
TotalItems int `json:"total_items"`
|
||||
}
|
||||
|
||||
type MeView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ConfigView struct {
|
||||
MaxRecordSizeBytes int64 `json:"max_record_size_bytes"`
|
||||
MaxPageSize int `json:"max_page_size"`
|
||||
PollIntervalMs int64 `json:"poll_interval_ms"`
|
||||
KnownExtensions []string `json:"known_extensions"`
|
||||
MaxTopicsPerRecord int `json:"max_topics_per_record"`
|
||||
}
|
||||
|
||||
type TextView struct {
|
||||
View string `json:"view"`
|
||||
Contents string `json:"contents,omitempty"`
|
||||
Replicas []ReplicaView `json:"replicas,omitempty"`
|
||||
}
|
||||
|
||||
type ReplicaView struct {
|
||||
StartMs int64 `json:"start_ms"`
|
||||
EndMs int64 `json:"end_ms"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Routes — адреса приложения одним обработчиком.
|
||||
//
|
||||
// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
|
||||
// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
|
||||
// адресного пространства. Так область их действия выводится из объявленного
|
||||
// пространства, а не перечисляется вторым списком.
|
||||
//
|
||||
// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
|
||||
// на неверный метод ушёл бы его формой тела, а форма отказа под корнем
|
||||
// приложения одна.
|
||||
func (h *AppHandler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
mux.HandleFunc(pattern, h.handlerOf(pattern))
|
||||
}
|
||||
|
||||
// Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
|
||||
// же: он принадлежит корню приложения, адресом приложения не является и
|
||||
// потому отвечает как неизвестный путь под ним.
|
||||
//
|
||||
// Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его
|
||||
// перенаправлением на `/app/`, а перенаправления норма не заказывала. В
|
||||
// закрытый перечень образцов они не входят: под них подходит **всё**, что
|
||||
// накрыто корнем, а значит путь под ними выбирает спрашивающий.
|
||||
mux.HandleFunc(AppRoot+"/", h.notFound)
|
||||
mux.HandleFunc(AppRoot, h.notFound)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// Образцы адресов приложения. Перечень закрытый и **единственный**: из него
|
||||
// вешаются обработчики, и из него же берётся значение `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)
|
||||
|
||||
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
||||
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
||||
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
||||
// наружу наравне с журналом.
|
||||
writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name})
|
||||
}
|
||||
|
||||
func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) {
|
||||
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
|
||||
// Приложение, знающее предел своей константой, расходится с сервером молча —
|
||||
// до первого отказа на записи, которую человек уже успел отправить.
|
||||
writeJSON(w, http.StatusOK, ConfigView{
|
||||
MaxRecordSizeBytes: entity.MaxRecordSize,
|
||||
MaxPageSize: MaxPageLimit,
|
||||
PollIntervalMs: PollIntervalMs,
|
||||
KnownExtensions: metrics.PublicFormats(),
|
||||
MaxTopicsPerRecord: entity.MaxTopicsPerRecord,
|
||||
})
|
||||
}
|
||||
|
||||
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 {
|
||||
// Предел тела ловит запись на чтении. Не различив этот отказ и
|
||||
// отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о
|
||||
// записи, которую он приложил и которая просто больше потолка.
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
fail(w, contract.ErrRecordTooLarge)
|
||||
return
|
||||
}
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
h.logger.Error("Failed to close uploaded file", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
||||
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
||||
// загруженную запись от одного обрыва соединения, а забрать результат он
|
||||
// может и позже — карточкой записи.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
// Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец,
|
||||
// пришедший полем запроса, дал бы всякому узнанному право завести запись на
|
||||
// чужое имя.
|
||||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID)
|
||||
if err != nil {
|
||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
|
||||
// делает это одним местом — по причине отказа, а не по месту.
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
|
||||
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
|
||||
writeJSON(w, http.StatusCreated, []IntakeItem{{
|
||||
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
|
||||
// отсутствие поля и пустой перечень приложение не различит.
|
||||
RecordView: h.viewOf(record, nil, &[]string{}),
|
||||
}})
|
||||
}
|
||||
|
||||
func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
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 {
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
||||
return
|
||||
}
|
||||
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
|
||||
// сервис отдаёт, но просьба сама по себе не негодна.
|
||||
q.Limit = min(limit, MaxPageLimit)
|
||||
}
|
||||
|
||||
if raw := r.URL.Query().Get("filter"); raw != "" {
|
||||
filter, ok := entity.ParseListFilter(raw)
|
||||
if !ok {
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
||||
return
|
||||
}
|
||||
q.Filter = &filter
|
||||
}
|
||||
|
||||
if raw := r.URL.Query().Get("cursor"); raw != "" {
|
||||
cursor, err := decodeCursor(raw)
|
||||
if err != nil {
|
||||
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
|
||||
// архив, листающийся по кругу, и ни строки в журнале.
|
||||
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", account.ID)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
names, err := h.topicNames(account.ID, page.Items)
|
||||
if err != nil {
|
||||
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}
|
||||
for _, record := range page.Items {
|
||||
// Страница видов текста не читает: перечень доступных видов есть только у
|
||||
// карточки, и опущенное поле честнее пустого — пустое читалось бы как
|
||||
// «текста нет».
|
||||
view.Items = append(view.Items, h.viewOf(record, names, nil))
|
||||
}
|
||||
if page.NextCursor != nil {
|
||||
encoded := encodeCursor(page.NextCursor)
|
||||
view.NextCursor = &encoded
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
record, err := h.readOwn(r, account.ID)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
views := h.availableViews(record)
|
||||
writeJSON(w, http.StatusOK, h.viewOf(record, names, &views))
|
||||
}
|
||||
|
||||
func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
view := r.URL.Query().Get("view")
|
||||
if !entity.IsKnownTextView(view) {
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.readOwn(r, account.ID)
|
||||
if err != nil {
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if view == entity.TextViewReplicas {
|
||||
h.replicasOf(w, record)
|
||||
return
|
||||
}
|
||||
h.plainTextOf(w, record, view)
|
||||
}
|
||||
|
||||
// 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, 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)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if text.Contents == "" {
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents})
|
||||
}
|
||||
|
||||
func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) {
|
||||
if record.StructureID == nil {
|
||||
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)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if len(structure.Replicas) == 0 {
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
replicas := make([]ReplicaView, 0, len(structure.Replicas))
|
||||
for _, replica := range structure.Replicas {
|
||||
replicas = append(replicas, ReplicaView{
|
||||
StartMs: replica.StartMs,
|
||||
EndMs: replica.EndMs,
|
||||
Text: replica.Text,
|
||||
})
|
||||
}
|
||||
|
||||
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{}
|
||||
for _, record := range records {
|
||||
for _, id := range record.TopicIDs {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return h.recordRepo.ResolveTopicNames(ownerID, ids)
|
||||
}
|
||||
|
||||
func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string, views *[]string) RecordView {
|
||||
topics := make([]string, 0, len(record.TopicIDs))
|
||||
for _, id := range record.TopicIDs {
|
||||
if name, ok := names[id]; ok {
|
||||
topics = append(topics, name)
|
||||
}
|
||||
}
|
||||
|
||||
return RecordView{
|
||||
ID: record.Id,
|
||||
Title: record.Title,
|
||||
OriginalFilename: record.OriginalFilename,
|
||||
Brief: record.Brief,
|
||||
Topics: topics,
|
||||
State: record.State,
|
||||
Halted: record.IsHalted(),
|
||||
HaltReason: record.HaltReason,
|
||||
DurationMs: record.DurationMs,
|
||||
SizeBytes: record.SizeBytes,
|
||||
CreatedAt: record.CreatedAt.Format(time.RFC3339),
|
||||
AvailableViews: views,
|
||||
}
|
||||
}
|
||||
|
||||
// availableViews — какие виды текста у записи есть **сейчас**.
|
||||
//
|
||||
// Перечень, а не признак: состояние «сплошной текст есть, реплик ещё нет»
|
||||
// достижимо, потому что шаг завершения пишет их несколькими операциями.
|
||||
//
|
||||
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
|
||||
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
|
||||
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
|
||||
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно.
|
||||
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
||||
views := []string{}
|
||||
|
||||
if h.hasText(record.TranscriptTextID) {
|
||||
views = append(views, entity.TextViewTranscript)
|
||||
}
|
||||
if h.hasText(record.LiteraryTextID) {
|
||||
views = append(views, entity.TextViewLiterary)
|
||||
}
|
||||
if h.hasReplicas(record.StructureID) {
|
||||
views = append(views, entity.TextViewReplicas)
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
|
||||
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
|
||||
// из-за неё карточку хуже, чем недосказать.
|
||||
func (h *AppHandler) hasText(textID *string) bool {
|
||||
if textID == nil {
|
||||
return false
|
||||
}
|
||||
text, err := h.textRepo.GetByID(*textID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read text while listing views", "error", err)
|
||||
return false
|
||||
}
|
||||
return text.Contents != ""
|
||||
}
|
||||
|
||||
func (h *AppHandler) hasReplicas(structureID *string) bool {
|
||||
if structureID == nil {
|
||||
return false
|
||||
}
|
||||
structure, err := h.structureRepo.GetByID(*structureID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read structure while listing views", "error", err)
|
||||
return false
|
||||
}
|
||||
return len(structure.Replicas) > 0
|
||||
}
|
||||
|
||||
// encodeCursor и decodeCursor прячут пару «время заведения и идентификатор» за
|
||||
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
|
||||
// составлять ключ руками значило бы завязаться на порядок сортировки.
|
||||
//
|
||||
// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
|
||||
// телом.
|
||||
func encodeCursor(c *contract.RecordCursor) string {
|
||||
return base64.RawURLEncoding.EncodeToString(
|
||||
[]byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID),
|
||||
)
|
||||
}
|
||||
|
||||
func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cursor is not decodable: %w", err)
|
||||
}
|
||||
|
||||
createdAt, id, ok := strings.Cut(string(decoded), "|")
|
||||
if !ok {
|
||||
return nil, errors.New("malformed cursor")
|
||||
}
|
||||
|
||||
// Обе половины ключа разбираются, а не берутся строкой: время уходит в
|
||||
// запрос сравнением, а идентификатор — точным совпадением, и негодная
|
||||
// половина дала бы человеку либо пустой архив при непустом счётчике, либо
|
||||
// ленту с начала.
|
||||
parsed, err := time.Parse(time.RFC3339, createdAt)
|
||||
if err != nil {
|
||||
return nil, errors.New("cursor carries no readable time")
|
||||
}
|
||||
|
||||
recordID, valid := ident.Parse(id)
|
||||
if !valid {
|
||||
return nil, errors.New("cursor carries no readable record key")
|
||||
}
|
||||
|
||||
return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionCookieName — имя куки сессии. Имя нормативно: его смена молча
|
||||
// выкидывает всех вошедших.
|
||||
SessionCookieName = "transcriber_session"
|
||||
// stateCookieName — носитель состояния и проверочного кода PKCE. Живёт
|
||||
// один вход и убирается на возврате, каким бы тот ни был.
|
||||
stateCookieName = "transcriber_login"
|
||||
|
||||
// stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель
|
||||
// не нужен, а вечный носитель надолго фиксирует состояние.
|
||||
stateCookieMaxAge = 10 * 60
|
||||
|
||||
// exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий
|
||||
// провайдер держит обработчик возврата открытым неограниченно долго, и «медленный»
|
||||
// становится неотличим от «отказал».
|
||||
exchangeTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// AuthHandler ведёт вход, возврат от провайдера и выход.
|
||||
//
|
||||
// Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11.
|
||||
// Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом,
|
||||
// поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же
|
||||
// роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные
|
||||
// записи заводит хранилище и они видны в панели.
|
||||
type AuthHandler struct {
|
||||
app core.App
|
||||
logger *slog.Logger
|
||||
authURL string
|
||||
redirectURL string
|
||||
clientID string
|
||||
secureCookie bool
|
||||
|
||||
// storageMux — роутер хранилища, через который идёт обмен кода. Собирается
|
||||
// один раз: сборка вешает обработчики на само приложение и без
|
||||
// идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним.
|
||||
// Собранный на каждый вход, он копил бы их без предела — и копил бы по
|
||||
// запросу анонима, потому что обмен исполняется раньше обращения к
|
||||
// провайдеру.
|
||||
storageMux http.Handler
|
||||
storageMuxOnce sync.Once
|
||||
storageMuxErr error
|
||||
}
|
||||
|
||||
type AuthHandlerConfig struct {
|
||||
AuthURL string
|
||||
RedirectURL string
|
||||
ClientID string
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &AuthHandler{
|
||||
app: app,
|
||||
logger: logger,
|
||||
authURL: cfg.AuthURL,
|
||||
redirectURL: cfg.RedirectURL,
|
||||
clientID: cfg.ClientID,
|
||||
secureCookie: cfg.SecureCookie,
|
||||
}
|
||||
}
|
||||
|
||||
// Register вешает адреса входа вне пространства `/api`: оно поделено с
|
||||
// собственными адресами хранилища.
|
||||
func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// Продление сессии закрывается на всём роутере: адрес приносит хранилище
|
||||
// своим, и перехватить его можно только слоем.
|
||||
r.Bind(BlockSessionRefresh())
|
||||
|
||||
r.GET("/auth/login", h.Login)
|
||||
r.GET("/auth/callback", h.Callback)
|
||||
// Выход берёт POST намеренно: по GET его срабатывание уносится переходом по
|
||||
// чужой ссылке.
|
||||
//
|
||||
// Слой предъявления нужен и здесь: без него выход не знает, чью сессию
|
||||
// обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое
|
||||
// значение годным. Требования сессии при этом нет: выход без неё убирает
|
||||
// куку и молчит.
|
||||
r.POST("/auth/logout", h.Logout).Bind(SessionFromCookie())
|
||||
}
|
||||
|
||||
// Login уводит человека к провайдеру, запомнив состояние и проверочный код
|
||||
// PKCE у браузера.
|
||||
func (h *AuthHandler) Login(e *core.RequestEvent) error {
|
||||
state := security.RandomString(32)
|
||||
verifier := security.RandomString(43)
|
||||
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: state + ":" + verifier,
|
||||
Path: "/",
|
||||
MaxAge: stateCookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("response_type", "code")
|
||||
query.Set("client_id", h.clientID)
|
||||
query.Set("redirect_uri", h.redirectURL)
|
||||
query.Set("scope", "openid profile email")
|
||||
query.Set("state", state)
|
||||
query.Set("code_challenge", security.S256Challenge(verifier))
|
||||
query.Set("code_challenge_method", "S256")
|
||||
|
||||
separator := "?"
|
||||
if strings.Contains(h.authURL, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode())
|
||||
}
|
||||
|
||||
// Callback принимает возврат от провайдера, сверяет состояние и меняет код на
|
||||
// сессию средствами хранилища.
|
||||
func (h *AuthHandler) Callback(e *core.RequestEvent) error {
|
||||
// Носитель убирается всегда — и на успехе, и на отказе, — и убирается
|
||||
// **до** записи ответа. Отложенная уборка не работает вовсе: заголовки
|
||||
// фиксируются в момент, когда ответ начинают писать, и позднейшая правка их
|
||||
// карты до браузера не доезжает. Состояние одноразовое ровно этим: пока
|
||||
// носитель жив, переигранный возврат проходит сверку.
|
||||
h.clearStateCookie(e)
|
||||
|
||||
query := e.Request.URL.Query()
|
||||
|
||||
// Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка
|
||||
// сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе
|
||||
// обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит».
|
||||
if providerError := query.Get("error"); providerError != "" {
|
||||
h.logger.Debug("Login rejected by provider",
|
||||
"reason", knownProviderError(providerError), "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
state, verifier, err := h.readStateCookie(e)
|
||||
if err != nil {
|
||||
h.logger.Debug("Login state is missing or malformed",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
if query.Get("state") != state {
|
||||
h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
code := query.Get("code")
|
||||
if code == "" {
|
||||
h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
token, err := h.exchange(e.Request.Context(), code, verifier)
|
||||
if err != nil {
|
||||
// Отказ обмена — уже про сервис и его связь с провайдером, поэтому
|
||||
// уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель
|
||||
// входа.
|
||||
h.logger.Error("Failed to exchange provider code",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"})
|
||||
}
|
||||
|
||||
h.setSessionCookie(e, token)
|
||||
|
||||
return e.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
|
||||
// Logout обесценивает выданные учётной записи сессии и убирает куку.
|
||||
//
|
||||
// Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке
|
||||
// выход, разошедшийся с одновременным входом, оставил бы годную сессию, а
|
||||
// человек был бы уверен, что вышел.
|
||||
func (h *AuthHandler) Logout(e *core.RequestEvent) error {
|
||||
if e.Auth != nil {
|
||||
// Ключ токенов обновляется у свежей записи: между чтением и записью
|
||||
// могла пройти чужая правка, и полное сохранение устаревшей записи
|
||||
// затёрло бы её.
|
||||
record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to load account for logout", "error", err, "transport", "http")
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
|
||||
}
|
||||
|
||||
record.RefreshTokenKey()
|
||||
if err := h.app.Save(record); err != nil {
|
||||
h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http")
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"})
|
||||
}
|
||||
}
|
||||
|
||||
h.clearSessionCookie(e)
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не
|
||||
// идёт: роутер поднимается тот же, что обслуживает внешние запросы.
|
||||
func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, exchangeTimeout)
|
||||
defer cancel()
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"provider": pbrepo.ProviderName,
|
||||
"code": code,
|
||||
"codeVerifier": verifier,
|
||||
"redirectURL": h.redirectURL,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build exchange request: %w", err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
"/api/collections/users/auth-with-oauth2",
|
||||
strings.NewReader(string(body)),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build exchange request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler, err := h.storageHandler()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
// Тело ответа наружу не выносится: в нём приезжает описание отказа
|
||||
// провайдера, а оно принадлежит журналу, а не человеку.
|
||||
return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
return "", fmt.Errorf("failed to read exchange response: %w", err)
|
||||
}
|
||||
if response.Token == "" {
|
||||
return "", errors.New("exchange response carries no session")
|
||||
}
|
||||
|
||||
return response.Token, nil
|
||||
}
|
||||
|
||||
// knownProviderError приводит причину отказа к перечню известных.
|
||||
//
|
||||
// Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без
|
||||
// приведения аноним пишет в журнал что угодно и сколько угодно — предел один,
|
||||
// размер заголовков. Журнал же единственное место, где наблюдаются инварианты о
|
||||
// молчаливой потере задачи, и вытеснять его чужим текстом нельзя.
|
||||
//
|
||||
// Приём тот же, каким расширение записи приводится к перечню форматов.
|
||||
func knownProviderError(value string) string {
|
||||
switch value {
|
||||
case "access_denied", "invalid_request", "invalid_scope", "server_error",
|
||||
"temporarily_unavailable", "unauthorized_client", "unsupported_response_type",
|
||||
"interaction_required", "login_required", "consent_required":
|
||||
return value
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
|
||||
// storageHandler собирает роутер хранилища один раз и отдаёт его всем
|
||||
// последующим обменам.
|
||||
func (h *AuthHandler) storageHandler() (http.Handler, error) {
|
||||
h.storageMuxOnce.Do(func() {
|
||||
router, err := apis.NewRouter(h.app)
|
||||
if err != nil {
|
||||
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
|
||||
return
|
||||
}
|
||||
mux, err := router.BuildMux()
|
||||
if err != nil {
|
||||
h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err)
|
||||
return
|
||||
}
|
||||
h.storageMux = mux
|
||||
})
|
||||
|
||||
return h.storageMux, h.storageMuxErr
|
||||
}
|
||||
|
||||
func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) {
|
||||
cookie, err := e.Request.Cookie(stateCookieName)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("login state cookie is missing: %w", err)
|
||||
}
|
||||
|
||||
state, verifier, found := strings.Cut(cookie.Value, ":")
|
||||
if !found || state == "" || verifier == "" {
|
||||
return "", "", errors.New("login state cookie is malformed")
|
||||
}
|
||||
|
||||
return state, verifier, nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: pbrepo.SessionDuration,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: stateCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: h.secureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
@@ -1,29 +1,27 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
|
||||
// предъявляется сессия, что её прекращает и какие адреса остаются открытыми.
|
||||
// Проверки этого файла судят допуск: кого пускают к адресам приложения, чем
|
||||
// называется пришедший, кому верят и какие адреса остаются открытыми.
|
||||
|
||||
// TestApiRequiresSession — первый критерий приёмки. Запрос без сессии получает
|
||||
// отказ и ничего не заводит, а проба здоровья и метрики остаются открытыми.
|
||||
func TestApiRequiresSession(t *testing.T) {
|
||||
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
|
||||
// отказ и ничего не заводит.
|
||||
func TestApiRequiresIdentity(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
t.Run("приём записи без сессии", func(t *testing.T) {
|
||||
t.Run("приём записи неузнанным", func(t *testing.T) {
|
||||
req := createMultipartRequest(t, "test.mp3", []byte("audio"))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -32,366 +30,268 @@ func TestApiRequiresSession(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, "/api/status/anything", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.mux.ServeHTTP(w, req)
|
||||
t.Run("карточка записи неузнанному", func(t *testing.T) {
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
// TestUnknownJobIsIndistinguishableWithoutSession: по кодам ответа без сессии не
|
||||
// перебирается список заведённых задач.
|
||||
func TestUnknownJobIsIndistinguishableWithoutSession(t *testing.T) {
|
||||
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
|
||||
// не перебирается список заведённых записей.
|
||||
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
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, "/api/status/"+jobs[0].Id, nil))
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/api/status/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())
|
||||
}
|
||||
|
||||
// TestOpenEndpointsStayOpen — вторая сторона границы: проба здоровья и метрики
|
||||
// сессии не требуют. Маршруты вешает `main`, поэтому здесь собирается такой же
|
||||
// роутер с теми же двумя адресами.
|
||||
func TestOpenEndpointsStayOpen(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.GET("/health", func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.GET("/metrics", func(e *core.RequestEvent) error {
|
||||
return e.String(http.StatusOK, "# metrics")
|
||||
})
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, path := range []string{"/health", "/metrics"} {
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
assert.Equal(t, http.StatusOK, w.Code, "адрес %s обязан отвечать без сессии", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionSurvivesRestart — второй критерий приёмки. Подпись сессии считается
|
||||
// от секрета коллекции и ключа записи, оба лежат в базе, поэтому выкладка
|
||||
// вошедших не выкидывает.
|
||||
func TestSessionSurvivesRestart(t *testing.T) {
|
||||
// TestFirstRequestCreatesAccountAndSecondReuses — учётная запись заводится
|
||||
// первым обращением и находится вторым, а строка её в базе одна.
|
||||
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := httptest.NewRecorder()
|
||||
env.serve(before, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||
require.Equal(t, http.StatusCreated, before.Code)
|
||||
const login = "newcomer"
|
||||
|
||||
// Сервер пересоздаётся на том же хранилище — то же, что перезапуск процесса
|
||||
// поверх прежнего каталога данных.
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
env.handler.Register(r)
|
||||
before := countAccounts(t, env)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
first := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
second := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
|
||||
// Прежняя кука прошла проверку: до обработчика дошло, и он ответил про
|
||||
// ненайденную задачу, а не про отсутствующую сессию.
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Equal(t, before+1, countAccounts(t, env),
|
||||
"второе обращение завело вторую запись: архив разъехался бы между ними")
|
||||
assert.Equal(t, first.Body.String(), second.Body.String(),
|
||||
"второе обращение попало в другую учётную запись")
|
||||
}
|
||||
|
||||
// TestLogoutClosesAccess — третий критерий приёмки. Выход обесценивает выданные
|
||||
// сессии, а не только убирает куку.
|
||||
func TestLogoutClosesAccess(t *testing.T) {
|
||||
// TestUntrustedPeerIsNotIdentified — тот же заголовок с недоверенного адреса
|
||||
// даёт отказ, а не вход под названным именем.
|
||||
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
before := countAccounts(t, env)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
env.handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
logoutResponse := httptest.NewRecorder()
|
||||
mux.ServeHTTP(logoutResponse, logout)
|
||||
require.Equal(t, http.StatusOK, logoutResponse.Code)
|
||||
|
||||
// Куку выход убирает.
|
||||
assert.Contains(t, logoutResponse.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
|
||||
// И прежнее значение больше не открывает доступ — этого уборка куки сама по
|
||||
// себе не даёт: унесённое значение работало бы до истечения срока.
|
||||
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
afterResponse := httptest.NewRecorder()
|
||||
mux.ServeHTTP(afterResponse, after)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
|
||||
}
|
||||
|
||||
// TestLogoutWhenAccountIsGone: учётной записи, которой предъявлена сессия, уже
|
||||
// нет — выход отвечает отказом и не делает вид, что закрыл доступ.
|
||||
func TestLogoutWhenAccountIsGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Сессия выдана, а запись удалена — так выглядит гонка выхода с удалением
|
||||
// учётной записи в панели.
|
||||
require.NoError(t, env.app.Delete(env.account))
|
||||
|
||||
logout := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
|
||||
logout.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, logout)
|
||||
|
||||
// Записи нет — проверка сессии её не находит, и до обесценивания дело не
|
||||
// доходит: выход отвечает успехом, убрав куку. Доступа при этом всё равно
|
||||
// не осталось, потому что не осталось учётной записи.
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
|
||||
after := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||
after.AddCookie(&http.Cookie{Name: SessionCookieName, Value: env.session})
|
||||
afterResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(afterResponse, after)
|
||||
assert.Equal(t, http.StatusUnauthorized, afterResponse.Code)
|
||||
}
|
||||
|
||||
// TestLogoutWithoutSession: выход без сессии убирает куку и молчит.
|
||||
func TestLogoutWithoutSession(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/auth/logout", nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), SessionCookieName+"=;")
|
||||
}
|
||||
|
||||
// TestLoginRedirectsToProvider: вход уводит к провайдеру и запоминает состояние
|
||||
// у браузера.
|
||||
func TestLoginRedirectsToProvider(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
location := w.Result().Header.Get("Location")
|
||||
assert.Contains(t, location, "https://auth.example.com/api/oidc/authorization")
|
||||
assert.Contains(t, location, "code_challenge_method=S256")
|
||||
assert.Contains(t, location, "client_id=transcriber")
|
||||
|
||||
// Носитель состояния несёт те же признаки защиты, что и кука сессии.
|
||||
stateCookie := w.Result().Header.Get("Set-Cookie")
|
||||
assert.Contains(t, stateCookie, stateCookieName)
|
||||
assert.Contains(t, stateCookie, "HttpOnly")
|
||||
assert.Contains(t, stateCookie, "Secure")
|
||||
assert.Contains(t, stateCookie, "SameSite=Lax")
|
||||
}
|
||||
|
||||
// TestCallbackRejectsForeignState — возврат с невыданным состоянием сессии не
|
||||
// открывает и учётной записи не заводит.
|
||||
func TestCallbackRejectsForeignState(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
authHandler := NewAuthHandler(env.app, AuthHandlerConfig{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
authHandler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
accountsBefore, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cookie *http.Cookie
|
||||
query string
|
||||
}{
|
||||
{
|
||||
name: "состояния не выдавали вовсе",
|
||||
cookie: nil,
|
||||
query: "?code=whatever&state=foreign",
|
||||
},
|
||||
{
|
||||
name: "состояние не совпало с выданным",
|
||||
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
|
||||
query: "?code=whatever&state=foreign",
|
||||
},
|
||||
{
|
||||
name: "провайдер вернул отказ",
|
||||
cookie: &http.Cookie{Name: stateCookieName, Value: "issued:verifier"},
|
||||
query: "?error=access_denied&state=issued",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback"+tc.query, nil)
|
||||
if tc.cookie != nil {
|
||||
req.AddCookie(tc.cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
accountsAfter, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, accountsAfter, len(accountsBefore))
|
||||
|
||||
// Носитель убирается и на отказном возврате: иначе состояние
|
||||
// осталось бы годным для новой попытки.
|
||||
assert.Contains(t, w.Result().Header.Get("Set-Cookie"), stateCookieName+"=;")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaderBeatsCookie: предъявленный заголовок побеждает куку.
|
||||
func TestHeaderBeatsCookie(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "totally-invalid-session"})
|
||||
req.Header.Set("Authorization", env.session)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, "intruder")
|
||||
req.RemoteAddr = untrustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
// Прошёл заголовок: иначе негодная кука дала бы отказ.
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"заголовок с недоверенного адреса завёл учётную запись")
|
||||
}
|
||||
|
||||
// TestSelfServiceAccountsAreClosed — то, ради чего задача вообще имеет смысл.
|
||||
// Пока создание записи и вход по паролю открыты, закрытие приёма обходится
|
||||
// двумя запросами.
|
||||
func TestSelfServiceAccountsAreClosed(t *testing.T) {
|
||||
// TestStorageAddressSpaceIsGone — **критерий приёмки**: пространства хранилища
|
||||
// не существует.
|
||||
//
|
||||
// Прежде под корнем `/api/` жила собственная поверхность встроенного хранилища:
|
||||
// собственные входы, перечисление коллекции пользователей, правка своей строки —
|
||||
// то есть путь захвата чужого имени. Хранилище ушло целиком, и адресов этих нет:
|
||||
// они отвечают тем же, чем отвечает всякий путь вне корней сервиса.
|
||||
//
|
||||
// Проверка судит **и то, что ответ прежний, и то, что ничего не произошло**:
|
||||
// число учётных записей после обхода то же самое.
|
||||
func TestStorageAddressSpaceIsGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
t.Run("завести учётную запись самому нельзя", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
before := countAccounts(t, env)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
// Эталон: путь вне корней сервиса, за которым не стояло ничего никогда.
|
||||
reference := env.get("/nothing-was-ever-here")
|
||||
require.Equal(t, http.StatusOK, reference.Code)
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, w.Code)
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
})
|
||||
cases := map[string]string{
|
||||
"перечисление учётных записей": "/api/collections/users/records",
|
||||
"вход по паролю": "/api/collections/users/auth-with-password",
|
||||
"обмен кода у провайдера": "/api/collections/users/auth-with-oauth2",
|
||||
"выдача токена файла": "/api/files/token",
|
||||
"адрес панели": "/_/",
|
||||
"адрес панели знаком кода": "/%5f/",
|
||||
}
|
||||
|
||||
t.Run("вход паролем недоступен", func(t *testing.T) {
|
||||
body := strings.NewReader(`{"identity":"person@example.com","password":"whatever"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/auth-with-password", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for name, path := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
w := env.getOwn(path)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
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 выдал значение доступа", path)
|
||||
})
|
||||
}
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
})
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"обход прежнего пространства хранилища изменил число учётных записей")
|
||||
}
|
||||
|
||||
// TestAccessGrantingValuesAreNotLogged — четвёртый критерий приёмки, расширенный
|
||||
// ревью дизайна: не печатается ничто, что даёт доступ.
|
||||
// TestAccountKeyHasNoEditAddress: ключ учётной записи не правится ничем, кроме
|
||||
// заведения самим сервисом.
|
||||
//
|
||||
// Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
|
||||
// его будет нечем. Держится это тем, что адреса правки учётной записи у сервиса
|
||||
// нет вовсе — своих экранов профиля он не заводит, а поверхности хранилища,
|
||||
// правившей запись библиотечным правилом, не осталось.
|
||||
func TestAccountKeyHasNoEditAddress(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
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))
|
||||
|
||||
// Метод правки до раздачи приложения не доходит: она открывает страницу
|
||||
// только на `GET` и `HEAD`.
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
|
||||
assert.Equal(t, env.login, accountLogin(t, env, env.account.ID),
|
||||
"ключ учётной записи переписан снаружи")
|
||||
}
|
||||
|
||||
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
|
||||
// ничего не заводит.
|
||||
//
|
||||
// Пустое значение здесь не крайний случай, а штатное поведение прокси: там, где
|
||||
// он никого не назвал, заголовок приходит пустым. Без этой проверки все
|
||||
// неназванные собрались бы в одну учётную запись с общим архивом.
|
||||
func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
cases := map[string]string{
|
||||
"пустое значение": "",
|
||||
"одни пробелы": " ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
|
||||
}
|
||||
|
||||
for name, value := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, value)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env), "вырожденное значение завело учётную запись")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoLoginHeadersIdentifyNobody: запрос с двумя значениями заголовка не
|
||||
// узнаёт никого.
|
||||
//
|
||||
// Прокси, настроенный **добавлять** заголовок вместо замены, оставляет рядом со
|
||||
// своим значением присланное анонимом. Умолчание «берём первое» отдало бы вход
|
||||
// анониму, а «берём последнее» зависело бы от порядка, которым распоряжается не
|
||||
// сервис.
|
||||
func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Add(LoginHeader, "intruder")
|
||||
req.Header.Add(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, before, countAccounts(t, env))
|
||||
}
|
||||
|
||||
// TestPresentedValueIsNotAccepted: предъявленного значения сервис не признаёт.
|
||||
//
|
||||
// Собственных токенов у него не существует — ни выдаваемых, ни принимаемых, — и
|
||||
// пришедшим считается названный заголовком. Прежде такое значение било заголовок:
|
||||
// им пользовался владелец панели, а панели больше нет.
|
||||
func TestPresentedValueIsNotAccepted(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
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))
|
||||
|
||||
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: проба здоровья и метрики открыты неузнанному,
|
||||
// а заголовок на них учётной записи не заводит.
|
||||
//
|
||||
// Вторая половина важнее первой: узнавание сужено до области приложения именно
|
||||
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
|
||||
// такой запрос с новым именем — записи в неё.
|
||||
func TestOpenAddressesDoNotIdentify(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} {
|
||||
anonymous := env.get(path)
|
||||
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
|
||||
|
||||
withHeader := env.getAs("passerby", path)
|
||||
assert.Equal(t, http.StatusOK, withHeader.Code,
|
||||
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
|
||||
}
|
||||
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"обращение к открытому адресу завело учётную запись")
|
||||
}
|
||||
|
||||
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
|
||||
//
|
||||
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
|
||||
// суток, отозванный у провайдера человек работал до его истечения.
|
||||
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := env.getOwn("/app/me")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
|
||||
}
|
||||
|
||||
// TestIdentityValuesAreNotLogged — не печатается ничто, что даёт доступ.
|
||||
//
|
||||
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
|
||||
// другим ключом, поиск по ключу не разбудил бы.
|
||||
func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
|
||||
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
|
||||
// довольно назваться, чтобы стать этим человеком.
|
||||
func TestIdentityValuesAreNotLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
@@ -401,139 +301,155 @@ func TestAccessGrantingValuesAreNotLogged(t *testing.T) {
|
||||
journal := env.journal.String()
|
||||
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
|
||||
|
||||
assert.NotContains(t, journal, env.session,
|
||||
"значение сессии в журнале: строка стала бы ключом к чужому доступу")
|
||||
assert.NotContains(t, journal, env.account.Email(),
|
||||
assert.NotContains(t, journal, env.login,
|
||||
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
|
||||
assert.NotContains(t, journal, "person@example.com",
|
||||
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
|
||||
}
|
||||
|
||||
// TestProviderSecretIsNotLogged: секрет клиента не появляется в журнале при
|
||||
// приведении настроек провайдера к конфигу.
|
||||
func TestProviderSecretIsNotLogged(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
const secret = "super-secret-client-value"
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: secret,
|
||||
}))
|
||||
|
||||
// Настройка доехала до хранилища — иначе проверка отсутствия секрета в
|
||||
// журнале прошла бы на невыполненной работе.
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, secret, provider.ClientSecret)
|
||||
}
|
||||
|
||||
// TestProviderSecretRotationReachesStorage: смена секрета в конфиге доезжает до
|
||||
// хранилища. Положенный однажды шагом схемы, он бы не доехал — применённый шаг
|
||||
// не переписывается.
|
||||
func TestProviderSecretRotationReachesStorage(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
settings := pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "first-secret",
|
||||
}
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
|
||||
|
||||
settings.ClientSecret = "rotated-secret"
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, settings))
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
provider, found := users.OAuth2.GetProviderConfig(pbrepo.ProviderName)
|
||||
require.True(t, found)
|
||||
assert.Equal(t, "rotated-secret", provider.ClientSecret)
|
||||
}
|
||||
|
||||
// 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,
|
||||
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
|
||||
}
|
||||
|
||||
// TestRecordFileNeedsSession: ссылка на файл записи без сессии отказывает, а
|
||||
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
|
||||
// ссылке.
|
||||
func TestRecordFileNeedsSession(t *testing.T) {
|
||||
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
|
||||
// виден **адресом пира**, а не значением заголовка.
|
||||
func TestUntrustedPeerIsLogged(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)
|
||||
const intruder = "intruder-login-value"
|
||||
|
||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set(LoginHeader, intruder)
|
||||
req.RemoteAddr = untrustedPeer
|
||||
|
||||
names := files[0].GetStringSlice("file")
|
||||
require.Len(t, names, 1)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
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")
|
||||
|
||||
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
|
||||
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))
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "203.0.113.9", "адреса пира в журнале нет: поломку не отличить")
|
||||
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestSessionLifetimeIsAssigned: срок жизни сессии назначен нами, а не достался
|
||||
// умолчанием библиотеки в пять суток.
|
||||
// countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение
|
||||
// по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу
|
||||
// обработчика они неразличимы.
|
||||
func countAccounts(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
|
||||
return countRows(t, env, "users")
|
||||
}
|
||||
|
||||
// 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 TestSessionLifetimeIsAssigned(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
|
||||
// ограничителя, он работал бы на запросах, которые тот уже отверг: бюджет
|
||||
// выбирается, следующие запросы получают отказ — и заводят учётные записи.
|
||||
// Убрать их потом нечем: учётная запись с записями не удаляется.
|
||||
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration,
|
||||
"шаг схемы назначил срок сам — тогда правка числа до хранилища не доедет")
|
||||
before := countAccounts(t, env)
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
||||
TokenURL: "https://auth.example.com/api/oidc/token",
|
||||
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "local-test-secret",
|
||||
}))
|
||||
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
|
||||
// потолок раньше, чем начнутся новые имена.
|
||||
for range appRateMaxRequests + 5 {
|
||||
env.getOwn("/app/me")
|
||||
}
|
||||
|
||||
users, err = app.FindCollectionByNameOrId("users")
|
||||
rejected := 0
|
||||
for i := range 20 {
|
||||
w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me")
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
rejected++
|
||||
}
|
||||
}
|
||||
|
||||
require.Positive(t, rejected, "ограничитель не сработал — проверке не на чем сработать")
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"отвергнутый ограничителем запрос завёл учётную запись: узнавание стоит раньше ограничителя")
|
||||
}
|
||||
|
||||
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
|
||||
func TestAccountCreationIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const login = "brand-new-person"
|
||||
|
||||
w := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "Account created from login header", "заведение не оставило строки")
|
||||
assert.NotContains(t, journal, login, "значение заголовка уехало в журнал")
|
||||
|
||||
// Второе обращение новой строки не прибавляет: заводится запись однажды.
|
||||
before := strings.Count(journal, "Account created from login header")
|
||||
|
||||
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"),
|
||||
"повторное обращение отчиталось заведением")
|
||||
}
|
||||
|
||||
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
|
||||
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Add(LoginHeader, "intruder")
|
||||
req.Header.Add(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "level=WARN", "поломка контура записана уровнем, невидимым в бою")
|
||||
assert.Contains(t, journal, "more than one login header")
|
||||
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestStorageFailureOnIdentityIsServiceFailure — отказ базы на пути узнавания
|
||||
// кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
||||
//
|
||||
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя то,
|
||||
// что сломано не у него.
|
||||
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
// Колонка ключа переименовывается: выборка по ней перестаёт работать — так
|
||||
// же, как она перестанет работать при отказе базы.
|
||||
_, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(pbrepo.SessionDuration), users.AuthToken.Duration)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
// TestFormerAuthRootServesMarkup — прежние адреса входа отдают разметку.
|
||||
//
|
||||
// Корень `/auth` снят из перечня адресного пространства, и путь под ним стал
|
||||
// обычным путём вне корней. Проверка сторожит именно это: вернувшийся корень
|
||||
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
|
||||
// поведение.
|
||||
func TestFormerAuthRootServesMarkup(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
res := env.get("/auth/login")
|
||||
|
||||
assert.Equal(t, http.StatusOK, res.Code)
|
||||
assert.Contains(t, res.Body.String(), "приложение")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
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"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// Пределы объявляются тем же значением, которое сервис применяет, а не его
|
||||
// копией. Приложение, знающее предел своей константой, расходится с сервером
|
||||
// молча — до первого отказа на записи, которую человек уже успел отправить.
|
||||
func TestConfig_LimitsAreTheAppliedOnes(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var config ConfigView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config))
|
||||
|
||||
// Потолок размера — то же число, которым ограничено тело запроса приёма и
|
||||
// которым сервис отвергает запись.
|
||||
assert.Equal(t, entity.MaxRecordSize, config.MaxRecordSizeBytes)
|
||||
assert.Equal(t, MaxPageLimit, config.MaxPageSize)
|
||||
assert.Equal(t, entity.MaxTopicsPerRecord, config.MaxTopicsPerRecord)
|
||||
assert.Equal(t, PollIntervalMs, config.PollIntervalMs)
|
||||
|
||||
// Перечень расширений — тот же, что сужает метку метрики, за вычетом
|
||||
// собственного умолчания сервиса: `audio` не формат, и подсказкой человеку
|
||||
// выходить не должно.
|
||||
assert.Equal(t, metrics.PublicFormats(), config.KnownExtensions)
|
||||
assert.NotContains(t, config.KnownExtensions, "audio",
|
||||
"умолчание сервиса форматом не является")
|
||||
assert.Contains(t, config.KnownExtensions, "mp3")
|
||||
}
|
||||
|
||||
// Кто вошёл — приложение узнаёт ответом: кука недоступна скриптам страницы, и
|
||||
// прочитать из неё имя оно не может вовсе. Адрес почты при этом наружу не идёт.
|
||||
func TestMe_CarriesAccountWithoutEmail(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/me", http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var me MeView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))
|
||||
|
||||
assert.Equal(t, env.account.ID, me.ID)
|
||||
assert.NotContains(t, w.Body.String(), "person@example.com",
|
||||
"адрес почты принадлежит человеку, а не сервису")
|
||||
}
|
||||
|
||||
// Без сессии заведённая запись неотличима от неизвестной: иначе по разнице
|
||||
// ответов перебирается список заведённых записей.
|
||||
func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
existing := httptest.NewRecorder()
|
||||
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/"+unknownRecordID, http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||
require.Equal(t, http.StatusUnauthorized, unknown.Code)
|
||||
assert.Equal(t, existing.Body.String(), unknown.Body.String(),
|
||||
"тело одно: по разнице ответов иначе перебирается список записей")
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(existing.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeUnauthorized, body.Code)
|
||||
}
|
||||
|
||||
// Имя файла отправителя доходит до своей колонки, а колонка заголовка остаётся
|
||||
// пустой: приём заголовков не сочиняет, а посчитанное языковой моделью название
|
||||
// легло бы поверх имени, если бы они делили одну колонку.
|
||||
func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, createMultipartRequest(t, "разговор.mp3", []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
assert.Equal(t, "разговор.mp3", *record.OriginalFilename)
|
||||
assert.Nil(t, record.Title, "колонка заголовка у принятой записи пуста")
|
||||
|
||||
// Длительность и размер — снимок принятого, взятый приёмом.
|
||||
require.NotNil(t, record.DurationMs)
|
||||
assert.Equal(t, int64(42_000), *record.DurationMs)
|
||||
require.NotNil(t, record.SizeBytes)
|
||||
assert.Positive(t, *record.SizeBytes)
|
||||
}
|
||||
|
||||
// Имя длиннее предела доходит до записи обрезанным.
|
||||
//
|
||||
// Управляющие знаки этой проверкой не судятся, и причина внешняя: имя с ними
|
||||
// ломает разбор multipart раньше нашего кода — заголовок части становится
|
||||
// негодным, и запрос до обработчика не доезжает вовсе. Уборку знаков поэтому
|
||||
// судит проверка домена рядом, где живёт само правило.
|
||||
func TestIntake_LongFilenameIsTrimmed(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
long := strings.Repeat("я", entity.MaxOriginalFilenameLen+50) + ".mp3"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, createMultipartRequest(t, long, []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
|
||||
stored := *record.OriginalFilename
|
||||
assert.Len(t, []rune(stored), entity.MaxOriginalFilenameLen,
|
||||
"имя обрезано по пределу, и режется оно по знакам, а не по байтам")
|
||||
}
|
||||
|
||||
// Отказ по превышению потолка размера проходит через единую форму: прежде он
|
||||
// уходил телом ограничителя тела и читался как «сломался сервер».
|
||||
func TestIntake_TooLargeGoesThroughOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
status, body := mapDomainError(errTooLargeForTest())
|
||||
|
||||
assert.Equal(t, http.StatusRequestEntityTooLarge, status)
|
||||
assert.Equal(t, CodeTooLarge, body.Code)
|
||||
require.NotNil(t, body.Limit, "предел уходит человеку числом")
|
||||
assert.Equal(t, entity.MaxRecordSize, *body.Limit)
|
||||
assert.NotEmpty(t, body.Message)
|
||||
|
||||
// И тот же предел объявлен адресом пределов — одним числом, а не двумя.
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/config", http.NoBody))
|
||||
var config ConfigView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &config))
|
||||
assert.Equal(t, *body.Limit, config.MaxRecordSizeBytes)
|
||||
}
|
||||
|
||||
// Форма тела отказа одна на всех ветвях: код разбирает программа, сообщение
|
||||
// читает человек, сырого текста ошибки нет нигде.
|
||||
func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
req func() *http.Request
|
||||
code string
|
||||
}{
|
||||
{
|
||||
name: "записи нет",
|
||||
req: func() *http.Request {
|
||||
return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody)
|
||||
},
|
||||
code: CodeNotFound,
|
||||
},
|
||||
{
|
||||
name: "негодный ввод",
|
||||
req: func() *http.Request {
|
||||
return httptest.NewRequest("GET", "/app/audiorecords?limit=0", http.NoBody)
|
||||
},
|
||||
code: CodeBadRequest,
|
||||
},
|
||||
{
|
||||
name: "негодная запись",
|
||||
req: func() *http.Request {
|
||||
return createMultipartRequestWithField(t, "wrong-field", "sample.mp3", []byte("данные"))
|
||||
},
|
||||
code: CodeBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, tc.req())
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
|
||||
assert.Equal(t, tc.code, body.Code, "код машиночитаем и из закрытого перечня")
|
||||
assert.NotEmpty(t, body.Message, "рядом с кодом стоит фраза для человека")
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
assert.Contains(t, raw, "error_code")
|
||||
assert.Contains(t, raw, "message")
|
||||
assert.NotContains(t, raw, "error", "прежнее поле ушло вместе с прежним контрактом")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ограничитель частоты покрывает адреса приложения и **не трогает** адресов
|
||||
// наблюдения: правило своё, и настроено оно на корень приложения.
|
||||
func TestRateLimitCoversAppRootOnly(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
var refused *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
refused = env.getOwn("/app/me")
|
||||
if refused.Code == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Equal(t, http.StatusTooManyRequests, refused.Code,
|
||||
"бюджет под корнем приложения не исчерпался — проверке не на чем сработать")
|
||||
|
||||
// Проба здоровья тем же бюджетом не ограничена: она лежит вне корня
|
||||
// приложения, а слои одеты на корень.
|
||||
assert.Equal(t, http.StatusOK, env.get(HealthPath).Code,
|
||||
"ограничитель приложения закрыл наблюдение за сервисом")
|
||||
}
|
||||
|
||||
// Два клиентских адреса через один доверенный прокси расходуют **разные**
|
||||
// бюджеты, а заголовок пересылки с недоверенного адреса на ключ бюджета не
|
||||
// влияет.
|
||||
//
|
||||
// Обе половины закрывают свою поломку: бюджет, посчитанный по пиру, становится
|
||||
// общим на весь сервис, а вера заголовку без сверки пира отдаёт обход
|
||||
// ограничителя ровно тому, кого он ограничивает.
|
||||
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.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), nil, 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, "после паники поверхность перестала отвечать")
|
||||
}
|
||||
|
||||
// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст
|
||||
// в нём тоже: без этой проверки ветвь ни разу не исполнялась бы, а приложение не
|
||||
// предложило бы открыть готовый текст.
|
||||
func TestAvailableViewsCoverEveryKind(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
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 := env.handler.structureRepo
|
||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
record.LiteraryTextID = &literary.Id
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
record.StructureID = &structure.Id
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var card RecordView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||
|
||||
require.NotNil(t, card.AvailableViews, "поле обязано быть на проводе")
|
||||
assert.ElementsMatch(t,
|
||||
[]string{entity.TextViewTranscript, entity.TextViewLiterary, entity.TextViewReplicas},
|
||||
*card.AvailableViews,
|
||||
"каждый готовый вид назван перечнем")
|
||||
|
||||
// И каждый названный вид действительно отдаётся своим адресом.
|
||||
for _, view := range *card.AvailableViews {
|
||||
got := textOf(t, env, record.Id, view)
|
||||
assert.Equal(t, http.StatusOK, got.Code, "вид %q обещан перечнем и обязан отдаваться", view)
|
||||
}
|
||||
}
|
||||
|
||||
// errTooLargeForTest — отказ по превышению потолка, каким его строит приём.
|
||||
func errTooLargeForTest() error {
|
||||
return contract.ErrRecordTooLarge
|
||||
}
|
||||
|
||||
// Отказ по превышению потолка размера проходит **настоящим путём**, а не вызовом
|
||||
// отображателя. Прежде проверка звала `mapDomainError` самодельной ошибкой и была
|
||||
// зелёной независимо от того, что происходит на проводе: предел тела срабатывает
|
||||
// слоем, до обработчика запрос не доходит, и отказ уходил телом библиотеки.
|
||||
func TestTooLargeOnTheRealPath(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("данные"))
|
||||
req.ContentLength = entity.MaxRecordSize + 1
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, req)
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeTooLarge, body.Code, "код машиночитаем")
|
||||
require.NotNil(t, body.Limit, "предел уходит человеку числом")
|
||||
assert.Equal(t, entity.MaxRecordSize, *body.Limit)
|
||||
assert.Equal(t, 0, countJobs(t, env), "записи не заводится")
|
||||
}
|
||||
|
||||
// Отказ ограничителя частоты тоже идёт единой формой: он рождается слоем ниже
|
||||
// обработчика, и без перевода приложение получило бы тело библиотеки на самом
|
||||
// частом отказе после превышения размера.
|
||||
func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
var last *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
last = httptest.NewRecorder()
|
||||
env.serve(last, httptest.NewRequest("GET", "/app/me", http.NoBody))
|
||||
if last.Code == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, last.Code, "ограничитель сработал")
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(last.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeTooManyRequests, body.Code)
|
||||
assert.NotEmpty(t, body.Message)
|
||||
}
|
||||
|
||||
// Объявленная частота опроса умещается в бюджет ограничителя с запасом: прежде
|
||||
// она равнялась всему бюджету, и любой соседний запрос в ту же секунду выводил
|
||||
// приложение за потолок — отказ, которого сервис сам же обещал избежать.
|
||||
func TestPollIntervalLeavesBudgetHeadroom(t *testing.T) {
|
||||
pollsPerWindow := int64(appRateWindowSec) * 1000 / PollIntervalMs
|
||||
|
||||
assert.Less(t, pollsPerWindow, int64(appRateMaxRequests),
|
||||
"опрос с объявленной частотой не выбирает бюджет целиком")
|
||||
assert.Positive(t, pollsPerWindow, "и при этом опрашивать вообще можно")
|
||||
}
|
||||
|
||||
// Длинное расширение из имени отправителя не роняет приём в «внутреннюю ошибку»:
|
||||
// хвост после последней точки задаёт отправитель, и без потолка имя `x.` с
|
||||
// четырьмястами знаками валит заведение временного файла.
|
||||
func TestIntake_AbsurdExtensionDoesNotBecomeInternalError(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, createMultipartRequest(t, "x."+strings.Repeat("a", 400), []byte("данные")))
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code,
|
||||
"запись принята: абсурдное расширение заменено собственным умолчанием")
|
||||
assert.NotContains(t, env.journal.String(), "file name too long")
|
||||
}
|
||||
|
||||
// Неизвестный путь и неверный метод под корнем приложения тоже идут единой
|
||||
// формой. Слой формы их не покрывает: отказ «ничего не совпало» рождается
|
||||
// маршрутом корневой группы, к которому слои нашей группы не привязаны, — и без
|
||||
// своего перехвата форм отказа под корнем было бы две.
|
||||
func TestUnknownAddressUnderAppRootUsesOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{name: "неизвестный путь", method: "GET", path: "/app/nosuchendpoint"},
|
||||
{name: "неверный метод у списка", method: "DELETE", path: "/app/audiorecords"},
|
||||
{name: "неверный метод у пределов", method: "POST", path: "/app/config"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest(tc.method, tc.path, http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeNotFound, body.Code, "код машиночитаем, а не тело библиотеки")
|
||||
assert.NotEmpty(t, body.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ссылка на текст без содержимого видом не считается. Иначе карточка обещала бы
|
||||
// вид, а адрес текста отвечал бы «ещё не готов» вечно: приложение опрашивало бы
|
||||
// его без конца, а человек видел бы завершённую запись, из которой текст
|
||||
// «вот-вот появится». Пустой ответ распознавания — состояние штатное.
|
||||
func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := env.handler.textRepo
|
||||
empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
record.TranscriptTextID = &empty.Id
|
||||
record.MoveToState(entity.StateDone)
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var card RecordView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &card))
|
||||
|
||||
require.NotNil(t, card.AvailableViews)
|
||||
assert.Empty(t, *card.AvailableViews,
|
||||
"ссылка есть, содержимого нет — вид доступным не считается")
|
||||
|
||||
// И адрес текста отвечает тем же: состоянием, а не обещанием.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код
|
||||
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
||||
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
||||
//
|
||||
// Кода `forbidden` в перечне больше нет: его единственным случаем был владелец
|
||||
// панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у
|
||||
// сервиса не осталось, а узнавание по заголовку учётную запись заводит само —
|
||||
// предъявителя без неё не бывает.
|
||||
const (
|
||||
CodeUnauthorized = "unauthorized"
|
||||
CodeNotFound = "not_found"
|
||||
CodeBadRequest = "bad_request"
|
||||
CodeTooLarge = "too_large"
|
||||
CodeNotReady = "not_ready"
|
||||
CodeTooManyRequests = "too_many_requests"
|
||||
CodeInternal = "internal"
|
||||
)
|
||||
|
||||
// ErrorBody — единая форма тела отказа на всех адресах приложения.
|
||||
//
|
||||
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
||||
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
||||
// внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в
|
||||
// журнале владельца сервиса.
|
||||
//
|
||||
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
||||
// числом, а не пересказать его словами.
|
||||
type ErrorBody struct {
|
||||
Code string `json:"error_code"`
|
||||
Message string `json:"message"`
|
||||
Limit *int64 `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
||||
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
||||
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
||||
// ошибка» на негодный файл.
|
||||
//
|
||||
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
||||
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
||||
// `internal`, и владелец сервиса видит в журнале аварию там, где её нет.
|
||||
func mapDomainError(err error) (int, ErrorBody) {
|
||||
switch {
|
||||
case errors.Is(err, contract.ErrBadRequest):
|
||||
// Причина у всех негодных вводов одна, а сказать человеку надо разное:
|
||||
// «размер страницы отрицательный» и «неизвестный вид текста» ведут к
|
||||
// разным действиям. Свой текст приезжает обёрткой; его нет — говорим
|
||||
// общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы,
|
||||
// а не библиотека.
|
||||
message := "Запрос составлен неверно"
|
||||
var owned *messagedError
|
||||
if errors.As(err, &owned) {
|
||||
message = owned.message
|
||||
}
|
||||
return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message}
|
||||
|
||||
case errors.Is(err, contract.ErrRecordUnreadable):
|
||||
return http.StatusBadRequest, ErrorBody{
|
||||
Code: CodeBadRequest,
|
||||
Message: "Не удалось прочитать запись: формат не распознан или файл повреждён",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrRecordTooLarge):
|
||||
limit := entity.MaxRecordSize
|
||||
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||||
Code: CodeTooLarge,
|
||||
Message: "Запись больше допустимого размера",
|
||||
Limit: &limit,
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrTextNotReady):
|
||||
return http.StatusConflict, ErrorBody{
|
||||
Code: CodeNotReady,
|
||||
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
|
||||
if errors.As(err, &owned) {
|
||||
message = owned.message
|
||||
}
|
||||
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
|
||||
|
||||
case errors.Is(err, contract.ErrUnauthorized):
|
||||
// Не «требуется вход»: своего входа у сервиса нет, и уводить человека
|
||||
// некуда. Сообщение называет то, что произошло на самом деле, — сервис
|
||||
// не узнал пришедшего, — и приложение показывает его как есть, своего
|
||||
// словаря текстов под коды ответа не заводя.
|
||||
return http.StatusUnauthorized, ErrorBody{
|
||||
Code: CodeUnauthorized,
|
||||
Message: "Сервис вас не узнал",
|
||||
}
|
||||
}
|
||||
|
||||
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||||
// ответов иначе перебирается список заведённых записей.
|
||||
var notFound *contract.JobNotFoundError
|
||||
if errors.As(err, ¬Found) {
|
||||
return http.StatusNotFound, ErrorBody{
|
||||
Code: CodeNotFound,
|
||||
Message: "Запись не найдена",
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError, ErrorBody{
|
||||
Code: CodeInternal,
|
||||
Message: "Внутренняя ошибка сервиса",
|
||||
}
|
||||
}
|
||||
|
||||
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
||||
// уходит наружу с адресов приложения.
|
||||
//
|
||||
// Отказы, рождающиеся **не в обработчике** — предел тела, ограничитель частоты,
|
||||
// неизвестный путь под корнем приложения, — приходят сюда же: слои сервиса
|
||||
// написаны нами и отвечают своей доменной ошибкой, а не телом библиотеки. Второй
|
||||
// формы тела на адресах приложения не существует.
|
||||
func fail(w http.ResponseWriter, err error) {
|
||||
status, body := mapDomainError(err)
|
||||
writeJSON(w, status, body)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
||||
// всех одна, а сказать человеку надо разное.
|
||||
func errWithMessage(base error, message string) error {
|
||||
return &messagedError{base: base, message: message}
|
||||
}
|
||||
|
||||
type messagedError struct {
|
||||
base error
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *messagedError) Error() string { return e.message }
|
||||
func (e *messagedError) Unwrap() error { return e.base }
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// Заголовки, которыми обратный прокси называет пришедшего.
|
||||
//
|
||||
// Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка,
|
||||
// которая сама ставит и сама читает своё имя, этого не замечает. Контур уже
|
||||
// пишет эти имена соседним сервисам, и настройкой они не делаются: второе место,
|
||||
// где их можно написать неверно, выгоды не даёт.
|
||||
const (
|
||||
LoginHeader = "Remote-User"
|
||||
NameHeader = "Remote-Name"
|
||||
EmailHeader = "Remote-Email"
|
||||
)
|
||||
|
||||
// IdentityHeaderNames отдаёт имена заголовков входа целиком, в одном порядке.
|
||||
//
|
||||
// Перечисление тройки живёт здесь и только здесь. Всякий, кому нужен её состав —
|
||||
// слой подстановки, проверка старта, строка журнала, — берёт его отсюда: второй
|
||||
// список разошёлся бы с первым молча, а имена нормативны.
|
||||
func IdentityHeaderNames() []string {
|
||||
return []string{LoginHeader, NameHeader, EmailHeader}
|
||||
}
|
||||
|
||||
// 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 узнаёт пришедшего по заголовку доверенного источника.
|
||||
//
|
||||
// # Область
|
||||
//
|
||||
// Слой вешается на цепочку корня приложения и только на неё. Область поэтому
|
||||
// выводится из объявленного адресного пространства сервиса, а не перечисляется
|
||||
// вторым списком: корень, переехавший в перечне, уносит слой с собой.
|
||||
//
|
||||
// Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не
|
||||
// срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе
|
||||
// запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с
|
||||
// новым именем — записи в неё.
|
||||
//
|
||||
// # Чего слой не делает
|
||||
//
|
||||
// Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием
|
||||
// учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а
|
||||
// не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там,
|
||||
// где легла база.
|
||||
func TrustedHeaderIdentity(
|
||||
users contract.UserRepository,
|
||||
trusted []netip.Prefix,
|
||||
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) {
|
||||
// Более одного значения — не выбор, а отказ. Прокси, настроенный
|
||||
// добавлять заголовок вместо замены, оставляет рядом со своим
|
||||
// значением присланное анонимом, и умолчание «берём первое» отдало
|
||||
// бы вход анониму.
|
||||
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", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
} else {
|
||||
// Заголовка нет вовсе. В этом контуре это значит, что прокси
|
||||
// никого не назвал; строка отладочная, потому что случай
|
||||
// штатный — так выглядит и человек, которому провайдер
|
||||
// отказал.
|
||||
logger.Debug("Request carries no login header",
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
peer, ok := peerAddress(r.RemoteAddr)
|
||||
if !ok || !isTrusted(trusted, peer) {
|
||||
// Уровень предупреждающий, а не отладочный, и это решение о
|
||||
// цене. Заголовок с недоверенного адреса в этом контуре — не
|
||||
// рутина: контейнер портов наружу не публикует, снаружи всё
|
||||
// приходит прокси, то есть с доверенного адреса. Значит либо
|
||||
// перечень задан неверно, либо кто-то оказался внутри сети — и
|
||||
// то и другое владелец обязан увидеть.
|
||||
//
|
||||
// Значение заголовка при этом в журнал не идёт: оно целиком
|
||||
// задаётся тем, кто шлёт запрос. Адрес пира идёт — по нему
|
||||
// видно, чья это поломка: своя (перечень) или контура (прокси
|
||||
// заголовка не ставит).
|
||||
logger.Warn("Login header came from an untrusted peer",
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
account, created, err := users.EnsureUser(contract.Identity{
|
||||
Login: values[0],
|
||||
Name: r.Header.Get(NameHeader),
|
||||
Email: r.Header.Get(EmailHeader),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, contract.ErrLoginNotAcceptable) {
|
||||
// Негодный логин — это негодный ввод, а не поломка сервиса:
|
||||
// пустой заголовок прокси шлёт штатно там, где никого не
|
||||
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
|
||||
// неузнанным.
|
||||
logger.Debug("Login header value is not acceptable",
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error("Failed to resolve account by login header",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if created {
|
||||
// Заведение учётной записи — событие, и владелец обязан его
|
||||
// видеть: иначе «никто не заходил» неотличимо от «завелось
|
||||
// двадцать», а прокси, пропустивший чужой заголовок, не
|
||||
// оставляет следа вовсе. Убрать заведённую запись потом нечем —
|
||||
// учётная запись с записями не удаляется.
|
||||
//
|
||||
// Значение заголовка в строку не идёт: им довольно назваться,
|
||||
// чтобы стать этим человеком. Идут адрес пира и идентификатор
|
||||
// записи — оба выданы не спрашивающим.
|
||||
logger.Info("Account created from login header",
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"account_id", account.ID,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, withAccount(r, account))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireUser — слой предъявления адресов приложения.
|
||||
//
|
||||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||
// уложенный файл — чего сервис не умеет вовсе.
|
||||
//
|
||||
// Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит
|
||||
// учётную запись само, и предъявителя без неё не бывает.
|
||||
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
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// peerAddress достаёт адрес того, кто открыл соединение.
|
||||
//
|
||||
// Берётся именно он, а не пересылаемый заголовок: значением пересылаемого
|
||||
// распоряжается тот, кто шлёт запрос, и барьер, подделываемый той же строкой,
|
||||
// которой он обходится, не барьер вовсе.
|
||||
func peerAddress(remoteAddr string) (netip.Addr, bool) {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
// Адрес без порта — законная форма у некоторых слушателей.
|
||||
host = remoteAddr
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
// Адрес IPv4, приехавший в оболочке IPv6, сверяется с перечнем как IPv4:
|
||||
// иначе `127.0.0.1` в перечне не совпал бы с `::ffff:127.0.0.1` у пира.
|
||||
return addr.Unmap(), true
|
||||
}
|
||||
|
||||
func isTrusted(trusted []netip.Prefix, peer netip.Addr) bool {
|
||||
for _, network := range trusted {
|
||||
if network.Contains(peer) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
"строка журнала растёт вместе с длиной запрошенного пути")
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// pageOf спрашивает страницу записей от имени вошедшего.
|
||||
func pageOf(t *testing.T, env *testEnv, query string) PageView {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords"+query, http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code, "тело: %s", w.Body.String())
|
||||
|
||||
var page PageView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &page))
|
||||
return page
|
||||
}
|
||||
|
||||
// acceptRecords заводит несколько записей приёмом — тем же путём, каким они
|
||||
// появляются в проде.
|
||||
func acceptRecords(t *testing.T, env *testEnv, n int) {
|
||||
t.Helper()
|
||||
|
||||
for i := range n {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, createMultipartRequest(t, fmt.Sprintf("запись-%d.mp3", i), []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Страница отдаётся новыми сверху и несёт общее число записей.
|
||||
func TestList_NewestFirst(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 3)
|
||||
|
||||
page := pageOf(t, env, "?limit=2")
|
||||
|
||||
require.Len(t, page.Items, 2)
|
||||
assert.Equal(t, 3, page.TotalItems, "общее число не зависит от размера страницы")
|
||||
assert.Equal(t, "запись-2.mp3", *page.Items[0].OriginalFilename, "первой стоит заведённая последней")
|
||||
require.NotNil(t, page.NextCursor, "есть что читать дальше")
|
||||
}
|
||||
|
||||
// Запись, заведённая между двумя страницами, окна не сдвигает: ключ задаёт
|
||||
// положение, а не смещение. Со смещением один элемент пришёл бы дважды, а другой
|
||||
// не пришёл бы никогда — и оба раза молча.
|
||||
func TestList_RecordAcceptedBetweenPagesDoesNotShiftWindow(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 4)
|
||||
|
||||
first := pageOf(t, env, "?limit=2")
|
||||
require.Len(t, first.Items, 2)
|
||||
require.NotNil(t, first.NextCursor)
|
||||
|
||||
// Человек загружает ещё одну запись, не закрыв список, — штатный сценарий
|
||||
// экрана загрузки.
|
||||
acceptRecords(t, env, 1)
|
||||
|
||||
second := pageOf(t, env, "?limit=2&cursor="+*first.NextCursor)
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, item := range first.Items {
|
||||
seen[item.ID] = true
|
||||
}
|
||||
for _, item := range second.Items {
|
||||
assert.False(t, seen[item.ID], "элемент первой страницы не приходит вторым разом")
|
||||
seen[item.ID] = true
|
||||
}
|
||||
|
||||
// Ни одна из четырёх исходных записей не потеряна: дочитываем до конца.
|
||||
cursor := second.NextCursor
|
||||
for cursor != nil {
|
||||
page := pageOf(t, env, "?limit=2&cursor="+*cursor)
|
||||
for _, item := range page.Items {
|
||||
seen[item.ID] = true
|
||||
}
|
||||
cursor = page.NextCursor
|
||||
}
|
||||
assert.Len(t, seen, 4, "все четыре исходные записи дочитаны, ни одна не пропущена")
|
||||
|
||||
// Пятая, заведённая уже после начала листания, стоит **выше** окна и потому
|
||||
// движением вперёд не приходит — это и есть искомое свойство ключа. Человек
|
||||
// видит её, перечитав первую страницу.
|
||||
fresh := pageOf(t, env, "?limit=2")
|
||||
assert.Equal(t, 5, fresh.TotalItems)
|
||||
assert.Equal(t, "запись-0.mp3", *fresh.Items[0].OriginalFilename,
|
||||
"свежая запись видна сверху при перечитывании")
|
||||
}
|
||||
|
||||
// Записи с одинаковым временем заведения идут в устойчивом порядке: ключ
|
||||
// сортировки полный, а одного времени мало — у записей, принятых одним запросом,
|
||||
// оно совпадает.
|
||||
func TestList_EqualCreatedAtKeepsStableOrder(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 4)
|
||||
|
||||
first := pageOf(t, env, "")
|
||||
second := pageOf(t, env, "")
|
||||
|
||||
require.Len(t, first.Items, 4)
|
||||
for i := range first.Items {
|
||||
assert.Equal(t, first.Items[i].ID, second.Items[i].ID,
|
||||
"порядок не меняется от прогона к прогону")
|
||||
}
|
||||
}
|
||||
|
||||
// Размер страницы сверх потолка усекается, а негодный отвергается: человек
|
||||
// попросил больше, чем сервис отдаёт, но просьба сама по себе не негодна.
|
||||
func TestList_PageSizeCeilingAndBadValue(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 2)
|
||||
|
||||
page := pageOf(t, env, fmt.Sprintf("?limit=%d", MaxPageLimit+500))
|
||||
assert.LessOrEqual(t, len(page.Items), MaxPageLimit)
|
||||
|
||||
for _, bad := range []string{"0", "-3", "много"} {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?limit="+bad, http.NoBody))
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code, "размер %q негоден", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// Ключ, который сервис не может прочитать, даёт отказ, а не первую страницу:
|
||||
// молчаливая отдача первой дала бы человеку архив, листающийся по кругу.
|
||||
//
|
||||
// Негодность у ключа двух родов, и обе ветви разбора судятся здесь: строка,
|
||||
// которая не декодируется вовсе, и строка, которая декодируется — то есть
|
||||
// подделывается легко, — но не несёт пары «время и идентификатор».
|
||||
func TestList_MalformedCursorIsRejected(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cursor string
|
||||
}{
|
||||
{
|
||||
name: "не декодируется вовсе",
|
||||
cursor: "мусор",
|
||||
},
|
||||
{
|
||||
name: "декодируется, но разделителя нет",
|
||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("без-разделителя")),
|
||||
},
|
||||
{
|
||||
name: "декодируется, но времени нет",
|
||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("|только-идентификатор")),
|
||||
},
|
||||
{
|
||||
name: "декодируется, но идентификатора нет",
|
||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15T10:00:00Z|")),
|
||||
},
|
||||
{
|
||||
name: "пара пуста целиком",
|
||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("|")),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?cursor="+tc.cursor, http.NoBody))
|
||||
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 TestTopicsResolveToNames(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 1)
|
||||
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 1)
|
||||
|
||||
// Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его
|
||||
// задача языковой модели, которой ещё нет.
|
||||
topicID := newTopic(t, env, env.account.ID, "семейный архив")
|
||||
attachTopic(t, env, all.Items[0].ID, topicID)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(all.Items[0].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
page := pageOf(t, env, "")
|
||||
require.Len(t, page.Items, 1)
|
||||
assert.Equal(t, []string{"семейный архив"}, page.Items[0].Topics,
|
||||
"страница отдаёт название темы, а не её идентификатор")
|
||||
|
||||
card := httptest.NewRecorder()
|
||||
env.serve(card, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
require.Equal(t, http.StatusOK, card.Code)
|
||||
|
||||
var view RecordView
|
||||
require.NoError(t, json.Unmarshal(card.Body.Bytes(), &view))
|
||||
assert.Equal(t, []string{"семейный архив"}, view.Topics)
|
||||
}
|
||||
|
||||
// Отбор различает три состояния, и остановленная запись приходит ровно в одном
|
||||
// из них. Надвое она выпала бы из обеих половин — исчезла бы из списка при любом
|
||||
// значении, хотя ради неё список и открывают.
|
||||
func TestList_ThreeStatesEachRecordOnce(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 3)
|
||||
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 3)
|
||||
|
||||
repo := env.handler.recordRepo
|
||||
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
done.MoveToState(entity.StateDone)
|
||||
require.NoError(t, repo.Save(done, ""))
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, filter := range []string{"working", "halted", "done"} {
|
||||
page := pageOf(t, env, "?filter="+filter)
|
||||
for _, item := range page.Items {
|
||||
counts[item.ID]++
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, counts, 3, "все три записи видны отбором")
|
||||
for id, seen := range counts {
|
||||
assert.Equal(t, 1, seen, "запись %s приходит ровно в одном состоянии", id)
|
||||
}
|
||||
|
||||
// И остановленная приходит с причиной: без неё признак не говорит человеку,
|
||||
// чего ждать.
|
||||
haltedPage := pageOf(t, env, "?filter=halted")
|
||||
require.Len(t, haltedPage.Items, 1)
|
||||
require.NotNil(t, haltedPage.Items[0].HaltReason)
|
||||
assert.Equal(t, entity.HaltReasonStuck, *haltedPage.Items[0].HaltReason)
|
||||
}
|
||||
|
||||
// Неизвестное состояние отбора — негодный ввод, а не пустая выборка.
|
||||
func TestList_UnknownFilterIsRejected(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords?filter=неизвестно", http.NoBody))
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
// Чужих записей в странице нет.
|
||||
func TestList_ShowsOnlyOwnRecords(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 2)
|
||||
|
||||
newSecondAccount(t, env)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
|
||||
asUser(req, "stranger")
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var page PageView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &page))
|
||||
assert.Empty(t, page.Items, "чужие записи в страницу не попадают")
|
||||
assert.Equal(t, 0, page.TotalItems)
|
||||
}
|
||||
|
||||
// Список не тянет расшифровку: она лежит порознь от записи ровно затем, чтобы
|
||||
// чтение страницы её не читало.
|
||||
func TestList_DoesNotReadTranscript(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 1)
|
||||
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 1)
|
||||
|
||||
repo := env.handler.recordRepo
|
||||
record, err := repo.GetByID(all.Items[0].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР"
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker)
|
||||
require.NoError(t, err)
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
require.NoError(t, repo.Save(record, ""))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
assert.NotContains(t, w.Body.String(), marker,
|
||||
"текст расшифровки в страницу не попадает")
|
||||
}
|
||||
|
||||
// Карточка и элемент страницы — одна форма: две формы одной вещи разошлись бы
|
||||
// молча, и экран, написанный по одной, ломался бы о другую.
|
||||
func TestCardAndPageItemShareOneShape(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, createMultipartRequest(t, "запись.mp3", []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var intake []map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &intake))
|
||||
require.Len(t, intake, 1)
|
||||
|
||||
item := httptest.NewRecorder()
|
||||
env.serve(item, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody))
|
||||
var rawPage struct {
|
||||
Items []map[string]json.RawMessage `json:"items"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(item.Body.Bytes(), &rawPage))
|
||||
require.Len(t, rawPage.Items, 1)
|
||||
|
||||
id := strings.Trim(string(intake[0]["id"]), `"`)
|
||||
card := httptest.NewRecorder()
|
||||
env.serve(card, httptest.NewRequest("GET", "/app/audiorecords/"+id, http.NoBody))
|
||||
var rawCard map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(card.Body.Bytes(), &rawCard))
|
||||
|
||||
// Карточка = элемент страницы плюс перечень доступных видов.
|
||||
assert.Equal(t, fieldNames(rawPage.Items[0]), fieldNames(rawCard, "available_views"),
|
||||
"карточка отличается от элемента страницы ровно перечнем видов")
|
||||
|
||||
// Элемент ответа приёма = карточка плюс признак повтора. Перечень видов есть
|
||||
// у обоих: у свежей записи он пуст, но на проводе присутствует — отсутствие
|
||||
// поля и пустой перечень приложение не различит.
|
||||
assert.Equal(t, fieldNames(rawCard), fieldNames(intake[0], "duplicate"),
|
||||
"элемент ответа приёма отличается от карточки ровно признаком повтора")
|
||||
assert.Contains(t, intake[0], "available_views",
|
||||
"перечень видов есть и в ответе приёма, пустым")
|
||||
}
|
||||
|
||||
// fieldNames отдаёт отсортированные имена полей за вычетом названных.
|
||||
func fieldNames(raw map[string]json.RawMessage, except ...string) []string {
|
||||
skip := map[string]bool{}
|
||||
for _, name := range except {
|
||||
skip[name] = true
|
||||
}
|
||||
|
||||
out := []string{}
|
||||
for name := range raw {
|
||||
if !skip[name] {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sortStrings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortStrings(v []string) {
|
||||
for i := 1; i < len(v); i++ {
|
||||
for j := i; j > 0 && v[j] < v[j-1]; j-- {
|
||||
v[j], v[j-1] = v[j-1], v[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Разрешение тем сужено владельцем: словарь тем свой у каждого человека — пара
|
||||
// «владелец и название» уникальна, — и без сужения название чужой темы приехало
|
||||
// бы в ответ, как только темы начнёт писать языковая модель.
|
||||
func TestForeignTopicDoesNotResolve(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
acceptRecords(t, env, 1)
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 1)
|
||||
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
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))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
assert.NotContains(t, w.Body.String(), "ЧУЖАЯ-ТЕМА-МАРКЕР",
|
||||
"название чужой темы наружу не выходит")
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Проверки этого файла проходят вход целиком — от увода к провайдеру до куки
|
||||
// сессии. Без них сердцевина изменения не исполнялась ни разу: прочие проверки
|
||||
// заводят учётную запись прямым сохранением и останавливаются раньше обмена.
|
||||
|
||||
// fakeProvider — подставной провайдер OIDC. Отдаёт токен и сведения о человеке,
|
||||
// считая обращения: по счётчику видно, дошло ли до сети вообще.
|
||||
type fakeProvider struct {
|
||||
server *httptest.Server
|
||||
tokenHits int
|
||||
failToken bool
|
||||
subject string
|
||||
emailValue string
|
||||
}
|
||||
|
||||
func newFakeProvider(t *testing.T) *fakeProvider {
|
||||
t.Helper()
|
||||
|
||||
provider := &fakeProvider{subject: "person-sub-1", emailValue: "person@example.com"}
|
||||
|
||||
write := func(w http.ResponseWriter, body string) {
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Errorf("подставной провайдер не ответил: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
provider.tokenHits++
|
||||
if provider.failToken {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
write(w, `{"error":"invalid_grant"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
write(w, `{"access_token":"provider-access-token","token_type":"bearer","expires_in":3600}`)
|
||||
})
|
||||
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
write(w, `{"sub":"`+provider.subject+`","email":"`+provider.emailValue+`","name":"Person","email_verified":true}`)
|
||||
})
|
||||
|
||||
provider.server = httptest.NewServer(mux)
|
||||
t.Cleanup(provider.server.Close)
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
// loginEnv — окружение проверки входа: хранилище с настроенным подставным
|
||||
// провайдером и собранный роутер со всеми слоями.
|
||||
type loginEnv struct {
|
||||
app core.App
|
||||
mux http.Handler
|
||||
handler *AuthHandler
|
||||
provider *fakeProvider
|
||||
}
|
||||
|
||||
func setupLoginEnv(t *testing.T) *loginEnv {
|
||||
t.Helper()
|
||||
|
||||
app := newTestStorage(t)
|
||||
provider := newFakeProvider(t)
|
||||
|
||||
require.NoError(t, pbrepo.ApplyProviderSettings(app, pbrepo.ProviderSettings{
|
||||
AuthURL: provider.server.URL + "/authorize",
|
||||
TokenURL: provider.server.URL + "/token",
|
||||
UserInfoURL: provider.server.URL + "/userinfo",
|
||||
ClientID: "transcriber",
|
||||
ClientSecret: "local-test-secret",
|
||||
}))
|
||||
|
||||
handler := NewAuthHandler(app, AuthHandlerConfig{
|
||||
AuthURL: provider.server.URL + "/authorize",
|
||||
RedirectURL: "https://transcriber.example.com/auth/callback",
|
||||
ClientID: "transcriber",
|
||||
SecureCookie: true,
|
||||
}, nil)
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &loginEnv{app: app, mux: mux, handler: handler, provider: provider}
|
||||
}
|
||||
|
||||
// startLogin проходит первый шаг входа и отдаёт носитель состояния вместе с
|
||||
// выданным состоянием — тем, что сервис ждёт обратно.
|
||||
func (e *loginEnv) startLogin(t *testing.T) (cookie *http.Cookie, state string) {
|
||||
t.Helper()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == stateCookieName {
|
||||
cookie = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, cookie, "носитель состояния не поставлен")
|
||||
|
||||
location, err := url.Parse(w.Result().Header.Get("Location"))
|
||||
require.NoError(t, err)
|
||||
state = location.Query().Get("state")
|
||||
require.NotEmpty(t, state)
|
||||
|
||||
return cookie, state
|
||||
}
|
||||
|
||||
// TestLoginCreatesAccountAndSession — вход целиком: человека заводят по слову
|
||||
// провайдера, и он получает сессию.
|
||||
//
|
||||
// Без этой проверки закрытое создание записи в коллекции пользователей выглядит
|
||||
// работающим: прочие проверки заводят запись мимо входа.
|
||||
func TestLoginCreatesAccountAndSession(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
before, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, before, "учётных записей быть не должно: шаг схемы их не заводит")
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusFound, w.Code, "вход не прошёл: тело %s", w.Body.String())
|
||||
assert.Equal(t, 1, env.provider.tokenHits, "обмен до провайдера не дошёл")
|
||||
|
||||
after, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, after, 1, "учётная запись не заведена — войти не может никто")
|
||||
|
||||
var session *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookieName {
|
||||
session = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, session, "кука сессии не поставлена")
|
||||
|
||||
// Признаки куки нормативны: их потеря делает сессию доступной скриптам либо
|
||||
// уносит её по незашифрованному соединению.
|
||||
assert.True(t, session.HttpOnly)
|
||||
assert.True(t, session.Secure)
|
||||
assert.Equal(t, http.SameSiteLaxMode, session.SameSite)
|
||||
assert.Equal(t, pbrepo.SessionDuration, session.MaxAge)
|
||||
assert.NotEmpty(t, session.Value)
|
||||
|
||||
// Носитель состояния убран — и убран так, что это видно готовому ответу, а
|
||||
// не только живой карте заголовков.
|
||||
var cleared bool
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == stateCookieName && c.MaxAge < 0 {
|
||||
cleared = true
|
||||
}
|
||||
}
|
||||
assert.True(t, cleared, "носитель состояния пережил возврат")
|
||||
|
||||
// Выданная сессия открывает доступ к закрытым адресам.
|
||||
check := httptest.NewRequest(http.MethodGet, "/api/status/nosuchjobid", nil)
|
||||
check.AddCookie(session)
|
||||
checkResponse := httptest.NewRecorder()
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
NewTranscribeHandler(pbrepo.NewAudioRecordRepository(env.app), pbrepo.NewTextRepository(env.app), nil, nil).Register(r)
|
||||
checkMux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
checkMux.ServeHTTP(checkResponse, check)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, checkResponse.Code,
|
||||
"сессия не открыла доступ: получен %d", checkResponse.Code)
|
||||
}
|
||||
|
||||
// TestSelfServiceRegistrationStaysClosed: правило создания пускает обмен и не
|
||||
// пускает постороннего.
|
||||
func TestSelfServiceRegistrationStaysClosed(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
body := strings.NewReader(`{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/collections/users/records", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
||||
"посторонний завёл себе учётную запись: %s", w.Body.String())
|
||||
|
||||
accounts, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, accounts)
|
||||
}
|
||||
|
||||
// TestCallbackDoesNotLeakHooks: обмен не копит обработчики приложения.
|
||||
//
|
||||
// Сборка роутера хранилища вешает обработчики на само приложение и без
|
||||
// идентификатора, поэтому повторная не заменяет прежние. Собранный на каждый
|
||||
// вход, роутер копил бы их без предела — и копил бы по запросу анонима, потому
|
||||
// что обмен исполняется раньше обращения к провайдеру.
|
||||
func TestCallbackDoesNotLeakHooks(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
count := func() int {
|
||||
hook := reflect.ValueOf(env.app.OnModelAfterCreateSuccess()).Elem().FieldByName("handlers")
|
||||
return hook.Len()
|
||||
}
|
||||
|
||||
// Первый вход собирает роутер — с него и считаем.
|
||||
cookie, state := env.startLogin(t)
|
||||
first := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
|
||||
first.AddCookie(cookie)
|
||||
env.mux.ServeHTTP(httptest.NewRecorder(), first)
|
||||
|
||||
before := count()
|
||||
|
||||
for range 20 {
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=c&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
env.mux.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
assert.Equal(t, before, count(),
|
||||
"обработчики копятся: 20 входов добавили %d", count()-before)
|
||||
}
|
||||
|
||||
// TestSessionRefreshIsClosed: сессия не продлевает саму себя.
|
||||
//
|
||||
// При живом продлении срок её жизни ничего не значит, а вместе с ним перестаёт
|
||||
// работать единственный канал, которым отзыв доступа у провайдера доходит до
|
||||
// сервиса.
|
||||
func TestSessionRefreshIsClosed(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusFound, w.Code)
|
||||
|
||||
var session *http.Cookie
|
||||
for _, c := range w.Result().Cookies() {
|
||||
if c.Name == SessionCookieName {
|
||||
session = c
|
||||
}
|
||||
}
|
||||
require.NotNil(t, session)
|
||||
|
||||
refresh := httptest.NewRequest(http.MethodPost, RefreshPath, nil)
|
||||
refresh.Header.Set("Authorization", session.Value)
|
||||
refreshResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(refreshResponse, refresh)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, refreshResponse.Code,
|
||||
"сессия продлилась: %s", refreshResponse.Body.String())
|
||||
|
||||
// И нового значения в ответе нет — продлевать нечем.
|
||||
assert.NotContains(t, refreshResponse.Body.String(), `"token"`)
|
||||
}
|
||||
|
||||
// TestCallbackRejectsProviderFailure: отказ обмена не открывает сессию.
|
||||
func TestCallbackRejectsProviderFailure(t *testing.T) {
|
||||
env := setupLoginEnv(t)
|
||||
env.provider.failToken = true
|
||||
|
||||
cookie, state := env.startLogin(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/auth/callback?code=provider-code&state="+state, nil)
|
||||
req.AddCookie(cookie)
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
for _, c := range w.Result().Cookies() {
|
||||
assert.NotEqual(t, SessionCookieName, c.Name, "сессия открыта на отказе обмена")
|
||||
}
|
||||
|
||||
accounts, err := env.app.FindAllRecords("users")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, accounts)
|
||||
}
|
||||
|
||||
// TestRecordFileNeedsSessionAndToken: файл записи отдаётся вошедшему и не
|
||||
// отдаётся анониму.
|
||||
func TestRecordFileNeedsSessionAndToken(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.GreaterOrEqual(t, anonymous.Code, http.StatusBadRequest)
|
||||
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||
|
||||
// Вошедший берёт короткоживущий токен файла и проходит по ссылке с ним:
|
||||
// защищённый файл судится этим токеном, а не сессионной кукой.
|
||||
tokenRequest := httptest.NewRequest(http.MethodPost, "/api/files/token", nil)
|
||||
tokenRequest.Header.Set("Authorization", env.session)
|
||||
tokenResponse := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(tokenResponse, tokenRequest)
|
||||
require.Equal(t, http.StatusOK, tokenResponse.Code, "токен файла не выдан: %s", tokenResponse.Body.String())
|
||||
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(tokenResponse.Body.Bytes(), &payload))
|
||||
require.NotEmpty(t, payload.Token)
|
||||
|
||||
withToken := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(withToken, httptest.NewRequest(http.MethodGet, link+"?token="+payload.Token, nil))
|
||||
|
||||
assert.Equal(t, http.StatusOK, withToken.Code,
|
||||
"вошедший не получил файл: %d", withToken.Code)
|
||||
assert.Contains(t, withToken.Body.String(), "audio content")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user