хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ version = 4 # версия раскладки; обратной совмест
[docs] [docs]
# каталог миграций: по нему docs.py сверяет схему с database.md # каталог миграций: по нему docs.py сверяет схему с database.md
migrations = "internal/adapter/repo/pocketbase/migrations" migrations = "internal/adapter/repo/sqlite/migrations"
[tasks] [tasks]
# каталог задач от корня репозитория; имена частей — умолчания скрипта # каталог задач от корня репозитория; имена частей — умолчания скрипта
+3 -8
View File
@@ -20,14 +20,9 @@ transcriber
# Go workspace file # Go workspace file
go.work go.work
# Database files # Каталог данных: файл базы, её журнал упреждающей записи, замок наката схемы и
data/transcriber.db # подкаталоги с файлами записей. Раскладку задаёт сервис.
data/transcriber.db-shm data/
data/transcriber.db-wal
# Uploaded files
data/files/*
!data/files/.gitkeep
# IDE files # IDE files
.vscode/ .vscode/
+13
View File
@@ -155,6 +155,19 @@ linters:
- (*os.File).Close - (*os.File).Close
- (io.ReadCloser).Close - (io.ReadCloser).Close
- os.Remove - os.Remove
# Закрытие выборки отложенным вызовом: строки к этому моменту прочитаны,
# а их отказ уже спрошен у `rows.Err()` — отдельного смысла у отказа
# закрытия нет.
- (*database/sql.Rows).Close
# Откат транзакции отложенным вызовом. Успешно завершённая транзакция
# отвечает на него «уже закончена», и проверка этого отказа означала бы
# разбор штатного исхода.
- (*database/sql.Tx).Rollback
# Запись тела ответа. Отказ здесь значит оборванное соединение, и
# сказать о нём некому: код ответа уже ушёл, а строка о каждом закрытом
# браузере наполняла бы журнал ничем.
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write
exclusions: exclusions:
rules: rules:
+41 -24
View File
@@ -11,10 +11,11 @@
Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, — Сервис расшифровки аудио в текст. Принимает запись одним входом — HTTP API, —
конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex конвертирует её `ffmpeg` в ogg, отдаёт на отложенное распознавание Yandex
SpeechKit и отдаёт текст тому, кто запись загрузил, по опросу готовности. SpeechKit и отдаёт текст тому, кто запись загрузил, карточкой записи.
Состояние записей, метаданные и сами файлы лежат во встроенной PocketBase, и она Состояние записей и метаданные лежат в SQLite, файлы записей — своим каталогом
же даёт владельцу панель администратора. Вход Telegram убран 2026-08-14 — рядом с базой. Панели администратора у сервиса нет: встроенное хранилище,
временно, до задачи, которая свяжет чат с учётной записью. дававшее её, убрано 2026-08-22. Вход Telegram убран 2026-08-14 — временно, до
задачи, которая свяжет чат с учётной записью.
Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст Чего **не** делает: сам речь не распознаёт и своих моделей не держит, текст
руками не правит и в форматы документов не экспортирует, учётных записей не руками не правит и в форматы документов не экспортирует, учётных записей не
@@ -25,8 +26,10 @@ SpeechKit и отдаёт текст тому, кто запись загруз
## Стек ## Стек
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), SQLite через
панель администратора, — `aws-sdk-go-v2` для Object `modernc.org/sqlite` — база, — шаги схемы библиотекой `pressly/goose/v3`,
маршруты и слои на `net/http`, файлы записей своим каталогом,
`aws-sdk-go-v2` для Object
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3 Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Приложение — Vue 3
с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют с роутером пятой версии и сборкой Vite; собранное вшито в бинарник, проверяют
его Biome и юнит-тесты Vue. Сборка — его Biome и юнит-тесты Vue. Сборка —
@@ -60,7 +63,8 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в Изъятия у инварианта нет. Оно было — секрет клиента OIDC жил ещё и в
настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с настройках коллекции пользователей хранилища, — и снято 2026-08-22 вместе с
самим секретом: вход переехал на доверенный заголовок, обменивать код стало не самим секретом: вход переехал на доверенный заголовок, обменивать код стало не
на что. Чтение файла базы больше не равносильно чтению секрета. на что. Чтение файла базы больше не равносильно чтению секрета. Секретов в
базе не осталось вовсе: пароль владельца от панели ушёл вместе с панелью.
- **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла - **Содержимое записи остаётся приватным.** Текст расшифровки, имя файла
пользователя и его сообщение в лог не пишутся — только длина и пользователя и его сообщение в лог не пишутся — только длина и
идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера. идентификаторы. Нарушение необратимо: строки уже уехали в журнал контейнера.
@@ -85,20 +89,31 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
логируется, не считается в метрику и не поднимает уровень. Нарушение даёт логируется, не считается в метрику и не поднимает уровень. Нарушение даёт
запись раз в секунду на каждый воркер. **major** запись раз в секунду на каждый воркер. **major**
- **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым - **Миграция, уехавшая на сервер, не переписывается.** Изменение — только новым
файлом шага. Необратимо: хранилище считает применённое по имени файла. файлом шага. Необратимо: учёт применённого ведёт сама база.
**critical** **critical**
- **Имя файла в хранилище задаёт сервис, а в журнал не идёт.** Умолчание *Снятие было, разовое:* 2026-08-22 решением владельца весь каталог шагов
PocketBase строит имя из имени, данного отправителем, — оно не применяется. встроенного хранилища удалён и заменён одним шагом начальной схемы. Причина —
Само имя — последняя часть ссылки `/api/files/...`, поэтому в журнал пишется стройка: на сервере данных нет, сервис остановлен, выкладка идёт с чистого
листа, а новая база ведёт учёт применённого своей таблицей, которой отметки
прежнего каталога не годятся вовсе. Граница названа: снятие кончилось этим
изменением, и шаг начальной схемы подпадает под инвариант как всякий прежний.
- **Имя файла на диске задаёт сервис, а в журнал не идёт.** Ни имя файла, ни имя
подкаталога записи не строятся из имени, данного отправителем: подкаталог зовётся
идентификатором записи, файл — идентификатором с расширением. В журнал пишется
расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой расширение, а не имя: строка журнала иначе стала бы бессрочным ключом к чужой
записи. **critical** записи. **critical**
- **Колонки записи правятся в двух местах** пакета хранилища — - **Колонки записи правятся в трёх местах** пакета хранилища —
`applyOwnedByPipeline` вместе с `applyToRecord` и `recordToAudioRecord`, — `writeOwnedByPipeline` вместе с `writeRecord`, `readRecordColumns` и
плюс шаг схемы. Компилятор не видит ни одного: колонка, забытая в одном из `rowToAudioRecord`, — плюс шаг схемы. Компилятор не видит ни одного: колонка,
них, теряется молча — запись сохранится без поля либо приедет с нулевым. забытая в одном из них, теряется молча — запись сохранится без поля, приедет с
нулевым либо доедет до сущности пустой, и ближайшее сохранение запишет этот
ноль поверх сохранённого.
Мест было четыре, пока захват перечислял колонки поимённо; теперь он Мест было четыре, пока захват перечислял колонки поимённо; теперь он
возвращает идентификатор и признак своего захвата, и перечень перестал расти возвращает идентификатор и признак своего захвата, и перечень перестал расти
с моделью. Сверку держат правила `internal/archrules`. **major** с моделью. Отображение при этом идёт **по имени колонки**: именованные
параметры запроса и место назначения, найденное по имени, — позиционный список
дал бы сдвиг на одно поле, который компилируется молча. Сверку держат правила
`internal/archrules`. **major**
- **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него - **Рубеж объявляется одним дескриптором** — `internal/entity/stage.go`. Из него
выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя; выводятся выбор шага, отбор захвата, срок протухания захвата и предел простоя;
перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в перечислять рубежи порознь в каждом потребителе нельзя. Рубеж, забытый в
@@ -108,15 +123,16 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
- **Результат пишет только держатель захвата, и держатель узнаётся значением.** - **Результат пишет только держатель захвата, и держатель узнаётся значением.**
Признак захвата уникален для каждого захвата, и запись результата условна по Признак захвата уникален для каждого захвата, и запись результата условна по
нему, а не по занятости записи. Шаг, чей захват за время работы достался нему, а не по занятости записи. Шаг, чей захват за время работы достался
другому — по протуханию срока или после того, как человек снял признак другому — по протуханию срока или после того, как человек вернул запись в
остановки в панели, — завершается без записи результата. Условие работу подкомандой оснастки, — завершается без записи результата. Условие
по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись по непустоте признака пропустило бы обоих: два воркера писали бы в одну запись
по очереди, портя её результат. **major** по очереди, портя её результат. **major**
- **У записи есть владелец, и колонка пустого значения не принимает.** Ничья - **У записи есть владелец, и колонка пустого значения не принимает.** Ничья
запись не заводится ничем — ни приёмом, ни конвейером, ни рукой в панели, — и запись не заводится ничем — ни приёмом, ни конвейером, ни запросом к базе, — и
держит это схема хранилища, а не договорённость. Пока обязательность жила в держит это схема, а не договорённость: колонка объявлена внешним ключом на
одном приёме, ничью запись заводили в панели, она уходила в конвейер, стоила учётную запись и обязательна. Пока обязательность жила в одном приёме, ничью
денег на распознавание и не доставалась потом никому. Правило со стороны запись заводили руками мимо него, она уходила в конвейер, стоила денег на
распознавание и не доставалась потом никому. Правило со стороны
спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной спрашивающего при этом остаётся: пустой владелец не совпадает ни с одной
записью, потому что схема запрещает **заводить** ничью, а это правило — записью, потому что схема запрещает **заводить** ничью, а это правило —
**спрашивать** ничьим именем. **major** **спрашивать** ничьим именем. **major**
@@ -137,6 +153,7 @@ gofmt -l .
golangci-lint run golangci-lint run
go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml go run ./cmd/transcriber -c config.toml # флаг -c или --config, по умолчанию config.toml
go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально go run ./cmd/devtools proxy # подставной прокси: ставит заголовок входа локально
go run ./cmd/devtools resume -c config.toml <id> # вернуть остановленную запись в работу
task front # приложение: зависимости, Biome, юнит-тесты, сборка task front # приложение: зависимости, Biome, юнит-тесты, сборка
task image # docker-образ; тег и раскладка — docs/architecture.md task image # docker-образ; тег и раскладка — docs/architecture.md
task gate # весь набор проверок разом task gate # весь набор проверок разом
@@ -247,8 +264,8 @@ Node на машину **не ставится**: шаг сборки прило
## Запреты ## Запреты
- **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и - **Боевой каталог данных не трогать.** `data/` на сервере целиком: под ним и
база (`data/data.db`), и записи живых людей база (`data/transcriber.db`), и записи живых людей
(`data/storage/<коллекция>/<запись>/`). На стройке под ним пусто и сервис (`data/records/<запись>/`). На стройке под ним пусто и сервис
остановлен — запрет от этого не снимается: каталог принадлежит серверу, и остановлен — запрет от этого не снимается: каталог принадлежит серверу, и
выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой, выкладка с чистого листа наполнит его снова. Локальный каталог данных — свой,
его ронять и пересоздавать можно свободно. его ронять и пересоздавать можно свободно.
+34 -26
View File
@@ -8,16 +8,17 @@
- Конвертация в ogg через ffmpeg - Конвертация в ogg через ffmpeg
- Распознавание речи через Yandex SpeechKit - Распознавание речи через Yandex SpeechKit
- Отслеживание статуса задач расшифровки - Отслеживание статуса задач расшифровки
- Встроенная PocketBase для метаданных, файлов и панели владельца; метрики Prometheus - Своё хранилище: SQLite для метаданных и каталог файлов записей рядом с ним; метрики Prometheus
## Технологии ## Технологии
- **Язык**: Go 1.26, CGO не нужен - **Язык**: Go 1.26, CGO не нужен
- **Веб-фреймворк**: gin-gonic/gin - **HTTP**: стандартная библиотека, `net/http`
- **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3) - **Распознавание**: Yandex SpeechKit + Yandex Object Storage (S3)
- **Конвертация**: ffmpeg - **Конвертация**: ffmpeg
- **Хранилище, файлы и панель**: встроенная PocketBase - **База данных**: SQLite через modernc.org/sqlite, CGO не нужен
- **База данных**: SQLite внутри PocketBase (через modernc.org/sqlite, CGO не нужен) - **Шаги схемы**: pressly/goose/v3, библиотекой — накат при старте
- **Файлы записей**: свой каталог, подкаталог на запись
- **Метрики**: prometheus/client_golang - **Метрики**: prometheus/client_golang
## Установка и запуск ## Установка и запуск
@@ -75,13 +76,13 @@ inv pl -- transcriber
Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём Адреса приложения живут под корнем `/app`: `POST /app/audiorecords` — приём
записи, `GET /app/audiorecords` — страница своих записей, записи, `GET /app/audiorecords` — страница своих записей,
`GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` — `GET /app/audiorecords/{id}` — карточка, `GET /app/audiorecords/{id}/text` —
текст названного вида, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, текст названного вида, `GET /app/audiorecords/{id}/file` — файл записи названной
которые сервис объявляет приложению. Отдельными адресами стоят `GET /metrics` — копии, `GET /app/me` — кто пришёл, `GET /app/config` — пределы, которые сервис
метрики Prometheus с префиксом `transcriber_` — и `GET /health` — проверка объявляет приложению. Отдельными адресами стоят `GET /metrics` — метрики
живости. Своего входа у сервиса нет: кто пришёл, называет заголовок обратного Prometheus с префиксом `transcriber_` — и `GET /health` — проверка живости.
прокси ([access](openspec/specs/access/spec.md)). Сверх этого тем же портом Своего входа у сервиса нет: кто пришёл, называет заголовок обратного прокси
отдаётся собственная поверхность встроенного хранилища и панель `/_/` — ([access](openspec/specs/access/spec.md)). Больше на этом порту не отвечает
[docs/security.md](docs/security.md), «Из чего строятся пути и ключи». ничего: всякий прочий путь получает разметку приложения.
Контракт приёма и опроса нормативен и живёт в Контракт приёма и опроса нормативен и живёт в
[openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и [openspec/specs/intake/spec.md](openspec/specs/intake/spec.md): поля запроса и
@@ -91,7 +92,7 @@ inv pl -- transcriber
## Состояния задач ## Состояния задач
Перечень состояний, переходы между ними и число воркеров — Перечень состояний, переходы между ними и число воркеров —
[docs/database.md](docs/database.md), разделы «Коллекции» и «Представление [docs/database.md](docs/database.md), разделы «Таблицы» и «Представление
данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md). данных»; как сложен конвейер целиком — [docs/architecture.md](docs/architecture.md).
## Структура проекта ## Структура проекта
@@ -100,9 +101,10 @@ inv pl -- transcriber
transcriber/ transcriber/
├── cmd/ ├── cmd/
│ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск │ ├── transcriber/ # Точка входа сервиса: конфиг, миграции, сборка зависимостей, запуск
│ └── devtools/ # Оснастка разработчика: подставной прокси для локального входа │ └── devtools/ # Оснастка разработчика: подставной прокси и возврат записи в работу
├── internal/ ├── internal/
│ ├── entity/ # Модели: задача, файл, результат распознавания │ ├── entity/ # Модели: запись, файл, результат распознавания
│ ├── ident/ # Выдача и разбор идентификаторов строк (ULID)
│ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок │ ├── contract/ # Интерфейсы адаптеров и репозиториев, типы ошибок
│ ├── config/ # Разбор config.toml │ ├── config/ # Разбор config.toml
│ ├── metrics/ # Метрики Prometheus │ ├── metrics/ # Метрики Prometheus
@@ -114,26 +116,32 @@ transcriber/
│ ├── converter/ffmpeg/ # Конвертация аудио │ ├── converter/ffmpeg/ # Конвертация аудио
│ ├── metaviewer/ffmpeg/ # Длительность аудио │ ├── metaviewer/ffmpeg/ # Длительность аудио
│ ├── recognizer/yandex/ # SpeechKit + Object Storage │ ├── recognizer/yandex/ # SpeechKit + Object Storage
│ └── repo/pocketbase/ # Репозитории, схема коллекций, правила панели │ └── repo/sqlite/ # Репозитории, подключение к базе, шаги схемы, каталог файлов
└── data/ # Каталог данных: база и файлы записей вместе └── data/ # Каталог данных: база и файлы записей вместе
├── data.db # База хранилища (создаётся автоматически) ├── transcriber.db # База (создаётся автоматически)
── storage/ # Файлы записей в раскладке хранилища ── 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 Схему двигают шаги `pressly/goose/v3`
`internal/adapter/repo/pocketbase/migrations`, файл на шаг. Непринятые шаги `internal/adapter/repo/sqlite/migrations`, файл на шаг, версия шага — число в
накатываются при подъёме хранилища, прежде чем стартуют воркеры и сервер. начале имени файла. Непринятые шаги накатываются при старте, прежде чем поднимутся
Применённый шаг не переписывается: изменение — только новым файлом шага. входы и стартуют воркеры; отказ шага роняет старт. Применённый шаг не
переписывается: изменение — только новым файлом шага.
Проверки перед коммитом — одной командой: Проверки перед коммитом — одной командой:
+6 -2
View File
@@ -7,8 +7,9 @@
// тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти // тихо кладёт инструмент разработчика в боевой образ. Один пакет платит эти
// четыре места **однажды**, сколько бы подкоманд в нём ни завелось. // четыре места **однажды**, сколько бы подкоманд в нём ни завелось.
// //
// Сегодня подкоманда одна — `proxy`. Заведение владельца панели придёт // Подкоманд две: `proxy` — подставной обратный прокси, `resume` — возврат
// подкомандой `admin` вместе с задачей о запуске одной командой. // остановленной записи в работу. Вторая встала на место панели владельца:
// панели у сервиса больше нет, а экраны правки записи приносят отдельные задачи.
// //
// Имена заголовков берутся **константами транспорта**, а не литералами: они // Имена заголовков берутся **константами транспорта**, а не литералами: они
// нормативны, и второй список разошёлся бы с первым молча — локальный вход // нормативны, и второй список разошёлся бы с первым молча — локальный вход
@@ -43,6 +44,8 @@ func main() {
switch os.Args[1] { switch os.Args[1] {
case "proxy": case "proxy":
runProxy(os.Args[2:]) runProxy(os.Args[2:])
case "resume":
runResume(os.Args[2:])
default: default:
fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1]) fmt.Fprintf(os.Stderr, "неизвестная подкоманда: %s\n\n", os.Args[1])
usage() usage()
@@ -55,6 +58,7 @@ func usage() {
Подкоманды: Подкоманды:
proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису proxy подставной обратный прокси: ставит заголовок и шлёт запрос сервису
resume вернуть остановленную запись в работу
`) `)
} }
+100
View File
@@ -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)
}
-92
View File
@@ -1,92 +0,0 @@
package main
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
)
// Перечень адресного пространства для проверок журнала. Обработчиков он здесь
// не вешает: журналу нужны только границы, а не то, что стоит за ними.
func journalMounts() []httpcontroller.Mount {
return []httpcontroller.Mount{
{Path: httpcontroller.StorageRoot},
{Path: httpcontroller.PanelRoot},
{Path: httpcontroller.AppRoot},
{Path: httpcontroller.HealthPath, Exact: true},
{Path: httpcontroller.MetricsPath, Exact: true},
}
}
// Имя файла в хранилище в журнал не идёт: оно последняя часть ссылки
// `/api/files/...`, и строка журнала вместе с идентификатором записи собрала бы
// ссылку целиком. Инвариант проекта, critical.
func TestJournalRouteHidesStoredFileName(t *testing.T) {
cases := []struct {
name string
path string
want string
}{
{
name: "ссылка на файл теряет имя",
path: "/api/files/files/abc123def456ghi/9f1c-3b2a.mp3",
want: "/api/files/files/abc123def456ghi/<имя>",
},
{
name: "маршрут остаётся различимым",
path: "/api/files/files/abc123def456ghi/запись.ogg",
want: "/api/files/files/abc123def456ghi/<имя>",
},
{
name: "прочие пути не трогаются",
path: "/app/audiorecords/abc123def456ghi",
want: "/app/audiorecords/abc123def456ghi",
},
{
name: "приём не трогается",
path: "/app/audiorecords",
want: "/app/audiorecords",
},
{
name: "сам префикс без имени не портится",
path: "/api/files/",
want: "/api/files/",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, journalRoute(c.path, journalMounts()))
})
}
}
// Отдельно и прямо: имени в готовой строке нет. Проверка судит результат, а не
// устройство — переписанная реализация обязана остаться зелёной.
func TestJournalRouteDropsNameEntirely(t *testing.T) {
const stored = "0f7b8dd3-d1cc-424c.mp3"
route := journalRoute("/api/files/files/rec0000000000000/"+stored, journalMounts())
assert.NotContains(t, route, stored, "имя файла в хранилище не доезжает до журнала")
assert.Contains(t, route, "rec0000000000000", "идентификатор записи остаётся: по нему прослеживается путь")
}
// Путь, не принадлежащий сервису, уходит приложению и в журнал дословно не
// идёт: множеством его значений распоряжается спрашивающий.
func TestJournalRouteHidesWebappPath(t *testing.T) {
cases := []string{
"/",
"/records/abc123def456ghi",
"/" + strings.Repeat("a", 1024),
}
for _, path := range cases {
route := journalRoute(path, journalMounts())
assert.Equal(t, webappRoute, route)
}
}
+79 -182
View File
@@ -9,29 +9,31 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
"github.com/joho/godotenv"
"github.com/prometheus/client_golang/prometheus/promhttp"
ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg" ffmpegconv "git.vakhrushev.me/av/transcriber/internal/adapter/converter/ffmpeg"
ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg" ffmpegmv "git.vakhrushev.me/av/transcriber/internal/adapter/metaviewer/ffmpeg"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer/yandex"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/config" "git.vakhrushev.me/av/transcriber/internal/config"
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http" httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
"git.vakhrushev.me/av/transcriber/internal/controller/worker" "git.vakhrushev.me/av/transcriber/internal/controller/worker"
"git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/metrics"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
"git.vakhrushev.me/av/transcriber/web" "git.vakhrushev.me/av/transcriber/web"
"github.com/joho/godotenv"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus/promhttp"
"git.vakhrushev.me/av/transcriber/internal/clock"
) )
// main держит одну обязанность: отказ подъёма пишется **одной** строкой и
// кончается ненулевым кодом выхода.
//
// Работа вынесена в run, чтобы уборка шла отложенными вызовами: `os.Exit`
// посреди подъёма оставил бы за собой открытые пулы базы и незакрытого клиента
// распознавания.
func main() { func main() {
// Создаем структурированный логгер // Создаем структурированный логгер
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
@@ -39,34 +41,36 @@ func main() {
})) }))
slog.SetDefault(logger) 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 // Parse command line flags
configPath := flag.String("c", "config.toml", "Path to config file") configPath := flag.String("c", "config.toml", "Path to config file")
flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)") flag.StringVar(configPath, "config", "config.toml", "Path to config file (alias for -c)")
flag.Parse() flag.Parse()
// Load configuration
cfg, err := config.LoadConfig(*configPath) cfg, err := config.LoadConfig(*configPath)
if err != nil { if err != nil {
logger.Error("Unable to load configuration", "config_path", *configPath, "error", err) return fmt.Errorf("unable to load configuration from %s: %w", *configPath, err)
os.Exit(1)
} else {
logger.Info("Configuration loaded successfully", "config_path", *configPath)
} }
logger.Info("Configuration loaded successfully", "config_path", *configPath)
// Пустой перечень доверенных адресов роняет старт: он значит «не верить // Пустой перечень доверенных адресов роняет старт: он значит «не верить
// никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об // никому», то есть сервис, поднявшийся никого не узнающим, — и узнать об
// этом было бы неоткуда. // этом было бы неоткуда.
if err := cfg.Auth.Validate(); err != nil { if err := cfg.Auth.Validate(); err != nil {
logger.Error("Unable to start without trusted proxies", "error", err) return err
os.Exit(1)
} }
// Перечень разобран один раз, при старте: разбирать строки на каждом запросе // Перечень разобран один раз, при старте: разбирать строки на каждом запросе
// значило бы платить за настройку, которая не меняется. // значило бы платить за настройку, которая не меняется.
trustedNetworks, err := cfg.Auth.TrustedNetworks() trustedNetworks, err := cfg.Auth.TrustedNetworks()
if err != nil { if err != nil {
logger.Error("Unable to read trusted proxies", "error", err) return err
os.Exit(1)
} }
// Перечень называется строкой журнала: сервис, никого не узнающий из-за // Перечень называется строкой журнала: сервис, никого не узнающий из-за
// неверного перечня, иначе неотличим от сервиса, до которого заголовок не // неверного перечня, иначе неотличим от сервиса, до которого заголовок не
@@ -77,8 +81,10 @@ func main() {
// отрицательное число и нулевой предел простоя — опечатка, и подниматься с // отрицательное число и нулевой предел простоя — опечатка, и подниматься с
// ней значит остановить всякую запись первым же захватом. // ней значит остановить всякую запись первым же захватом.
if err := cfg.Pipeline.Validate(); err != nil { if err := cfg.Pipeline.Validate(); err != nil {
logger.Error("Unable to start with incorrect pipeline settings", "error", err) return err
os.Exit(1) }
if err := cfg.Storage.Validate(); err != nil {
return err
} }
// Загружаем переменные окружения из .env файла // Загружаем переменные окружения из .env файла
@@ -86,32 +92,43 @@ func main() {
logger.Warn("Warning: .env file not found, using system environment variables") logger.Warn("Warning: .env file not found, using system environment variables")
} }
// Хранилище поднимается библиотекой, а не её набором команд: разбор флагов db, err := sqliterepo.Open(cfg.Storage.DataDir, sqliterepo.Settings{
// и мягкая остановка остаются нашими. Схему накатывает Serve — он гоняет BusyTimeoutMs: cfg.Storage.BusyTimeoutMs,
// непринятые шаги прежде, чем поднять сервер. ReadConnections: cfg.Storage.ReadConnections,
storage, err := pbrepo.New(cfg.Storage.DataDir) })
if err != nil { if err != nil {
logger.Error("Failed to open storage", "error", err) return err
os.Exit(1)
} }
defer func() { defer func() {
if err := storage.ResetBootstrapState(); err != nil { if err := db.Close(); err != nil {
logger.Error("Failed to close storage", "error", err) logger.Error("Failed to close the database", "error", err)
} }
}() }()
pbrepo.BindPanelRules(storage) // Создаем контекст для graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Создаем репозитории // Схема накатывается **до** подъёма входов и до старта воркеров, а её отказ
recordRepo := pbrepo.NewAudioRecordRepository(storage) // роняет старт: сервис, поднявшийся на неприведённой схеме, отвечает отказом
// на каждый запрос и на каждый прогон воркера — вместо одной строки о
// причине их становятся сотни.
if err := sqliterepo.Migrate(ctx, db, cfg.Storage.DataDir, logger); err != nil {
return err
}
store := sqliterepo.NewStore(cfg.Storage.DataDir)
recordRepo := sqliterepo.NewAudioRecordRepository(db)
fileRepo := sqliterepo.NewFileRepository(db, store)
repos := service.Repositories{ repos := service.Repositories{
Records: recordRepo, Records: recordRepo,
Files: pbrepo.NewFileRepository(storage), Files: fileRepo,
Texts: pbrepo.NewTextRepository(storage), Texts: sqliterepo.NewTextRepository(db),
Structures: pbrepo.NewStructureRepository(storage), Structures: sqliterepo.NewStructureRepository(db),
Recognitions: pbrepo.NewRecognitionRepository(storage), Recognitions: sqliterepo.NewRecognitionRepository(db, store),
Events: pbrepo.NewRecordEventRepository(storage), Events: sqliterepo.NewRecordEventRepository(db),
} }
users := sqliterepo.NewUserRepository(db)
// Создаем адаптеры // Создаем адаптеры
metaviewer := ffmpegmv.NewFfmpegMetaViewer() metaviewer := ffmpegmv.NewFfmpegMetaViewer()
@@ -127,8 +144,7 @@ func main() {
FolderID: cfg.Yandex.FolderID, FolderID: cfg.Yandex.FolderID,
}) })
if err != nil { if err != nil {
logger.Error("failed to create audio recognizer", "error", err) return fmt.Errorf("failed to create audio recognizer: %w", err)
os.Exit(1)
} }
// Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт // Отдавать отказ закрытия некому — процесс заканчивается, — поэтому он идёт
// в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь // в журнал владельца. Что он означает: gRPC-клиент отдаёт здесь отказ лишь
@@ -140,7 +156,6 @@ func main() {
} }
}() }()
// Создаем сервисы
transcribeService := service.NewTranscribeService( transcribeService := service.NewTranscribeService(
repos, repos,
metaviewer, metaviewer,
@@ -150,10 +165,6 @@ func main() {
logger, logger,
) )
// Создаем контекст для graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Создаем WaitGroup для ожидания завершения всех воркеров // Создаем WaitGroup для ожидания завершения всех воркеров
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -166,113 +177,41 @@ func main() {
pool.Start(ctx) pool.Start(ctx)
}() }()
// Вход у сервиса один — приём по HTTP, — и метка ставится только ему. Метки // Вход у сервиса один — приём по HTTP, — и метка ставится только ему.
// убранного входа Telegram здесь нет намеренно: ноль читался бы как поломка,
// а признак существует ради того дня, когда входов снова станет больше.
metrics.IntakeUpGauge.WithLabelValues("http").Set(1) metrics.IntakeUpGauge.WithLabelValues("http").Set(1)
// Наши маршруты живут на роутере хранилища: панель отдаётся тем же портом, appHandler := httpcontroller.NewAppHandler(
// и второму серверу на нём взяться неоткуда. recordRepo, repos.Texts, repos.Structures, fileRepo, transcribeService, logger,
appHandler := httpcontroller.NewAppHandler(recordRepo, repos.Texts, repos.Structures, transcribeService, logger) )
// Адресное пространство сервиса объявлено одним перечнем, и он порождает // Адресное пространство сервиса объявлено одним перечнем, и он порождает
// регистрацию, а не описывает её: корень, заведённый мимо перечня, не // регистрацию, а не описывает её: корень, заведённый мимо перечня, не
// получит обработчика вовсе. Отсюда же уровень журнала для адресов // получит обработчика вовсе. Отсюда же уровень журнала для адресов
// наблюдения и правило неизвестного пути у раздачи приложения. // наблюдения, правило неизвестного пути у раздачи приложения и область
mounts := httpcontroller.ServiceMounts(appHandler, promhttp.Handler()) // действия узнавания.
mounts := httpcontroller.ServiceMounts(
httpcontroller.AppChain(appHandler.Routes(), users, trustedNetworks, logger),
promhttp.Handler(),
)
dist, appBuilt := web.Dist() dist, appBuilt := web.Dist()
webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, mounts, logger) webappHandler := httpcontroller.NewWebappHandler(dist, appBuilt, logger)
// Сервер приезжает каналом, а не общей переменной: хук исполняется в srv := &http.Server{
// горутине сервера, а читает его горутина остановки, и связи «произошло Addr: fmt.Sprintf(":%d", cfg.Server.Port),
// раньше» между ними иначе нет. Handler: httpcontroller.BuildHandler(mounts, webappHandler, logger),
srvCh := make(chan *http.Server, 1)
storage.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Шесть часов записи по медленному каналу переживают любой фиксированный // Шесть часов записи по медленному каналу переживают любой фиксированный
// таймаут чтения, а умолчание хранилища — пять минут. Стойкость к // таймаут чтения. Стойкость к целенаправленной нагрузке объявлена вне
// целенаправленной нагрузке объявлена вне модели угроз проекта. // модели угроз проекта.
se.Server.ReadTimeout = 0 ReadTimeout: 0,
srvCh <- se.Server
// Журнал входящих запросов вернулся своим слоем: вместе с gin ушёл
// `sloggin`, а хранилище пишет запросы в свою таблицу, которой в
// журнале контейнера не видно. Поля — те, что просит конвенция.
se.Router.BindFunc(func(e *core.RequestEvent) error {
start := clock.Start()
err := e.Next()
level := slog.LevelInfo
if httpcontroller.IsObservationAddress(mounts, e.Request.URL.Path) {
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
level = slog.LevelDebug
} }
attrs := []any{
"http.method", e.Request.Method,
"http.route", journalRoute(e.Request.URL.Path, mounts),
"http.status_code", e.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"transport", "http",
}
// Путь, отданный приложению, в журнал не идёт — вместо него исход
// и длина: по ним видно, что происходит, а множеством значений
// самого пути распоряжается спрашивающий.
if outcome := httpcontroller.WebappOutcome(e); outcome != "" {
attrs = append(attrs,
"webapp.outcome", outcome,
"http.path_length", len(e.Request.URL.Path))
}
logger.Log(e.Request.Context(), level, "Incoming request", attrs...)
return err
})
// Слой узнавания вешается на **корневой** роутер, а не под корнем
// приложения: он накрывает ещё и адрес выдачи файлового токена, который
// принадлежит роутеру хранилища и группой не накрывается. Область его
// действия при этом выводится из перечня адресного пространства — см.
// `underIdentifiedArea`, — а не из места привязки.
se.Router.Bind(httpcontroller.TrustedHeaderIdentity(storage, mounts, trustedNetworks, logger))
// Своё правило ограничителя частоты под корень приложения. Правило
// хранилища настроено на его собственный корень и наших адресов больше не
// покрывает: вместе с переездом приложения ограничитель перестал бы
// существовать для него вовсе, и заметить это было бы нечем.
if err := httpcontroller.ApplyAppRateLimit(storage); err != nil {
return fmt.Errorf("failed to apply app rate limit: %w", err)
}
// Хранилищу называется заголовок, из которого брать адрес
// спрашивающего. Без этого счётчик ограничителя ключуется адресом пира,
// а пир теперь всегда один — прокси, и бюджет становится общим на всех.
if err := httpcontroller.ApplyTrustedProxyHeaders(storage); err != nil {
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
}
httpcontroller.RegisterServiceRoutes(se.Router, mounts)
// Раздача приложения вешается последней: она занимает корень, и всё,
// что не совпало ни с одним адресом сервиса, доходит до неё.
webappHandler.Register(se.Router)
return se.Next()
})
// Запускаем HTTP сервер в отдельной горутине
serveErr := make(chan error, 1) serveErr := make(chan error, 1)
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
logger.Info("Starting HTTP server", "port", cfg.Server.Port) logger.Info("Starting HTTP server", "port", cfg.Server.Port)
err := apis.Serve(storage, apis.ServeConfig{ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
HttpAddr: fmt.Sprintf(":%d", cfg.Server.Port),
ShowStartBanner: false,
})
if err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("HTTP server error", "error", err)
serveErr <- err serveErr <- err
} }
}() }()
@@ -285,41 +224,36 @@ func main() {
logger.Info("Press Ctrl+C to stop...") logger.Info("Press Ctrl+C to stop...")
// Ждем сигнал завершения либо отказ сервера // Ждем сигнал завершения либо отказ сервера
var startupErr error
select { select {
case <-sigChan: case <-sigChan:
logger.Info("Received shutdown signal, initiating graceful shutdown...") logger.Info("Received shutdown signal, initiating graceful shutdown...")
case <-serveErr: case err := <-serveErr:
logger.Error("HTTP server stopped unexpectedly, shutting down") logger.Error("HTTP server stopped unexpectedly, shutting down", "error", err)
startupErr = err
} }
// Создаем контекст с таймаутом для graceful shutdown HTTP сервера // Останавливаем HTTP сервер
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second) shutdownCtx, shutdownCancel := context.WithTimeout(
context.Background(), time.Duration(cfg.Server.ShutdownTimeout)*time.Second)
defer shutdownCancel() defer shutdownCancel()
// Останавливаем HTTP сервер
select {
case srv := <-srvCh:
logger.Info("Shutting down HTTP server...") logger.Info("Shutting down HTTP server...")
if err := srv.Shutdown(shutdownCtx); err != nil { if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server forced to shutdown", "error", err) logger.Error("HTTP server forced to shutdown", "error", err)
} else { } else {
logger.Info("HTTP server stopped gracefully") logger.Info("HTTP server stopped gracefully")
} }
default:
logger.Info("HTTP server was not started, nothing to shut down")
}
// Отменяем контекст для остановки воркеров // Отменяем контекст для остановки воркеров
cancel() cancel()
// Создаем канал для уведомления о завершении всех воркеров
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
wg.Wait() wg.Wait()
close(done) close(done)
}() }()
// Ждем завершения всех воркеров или таймаута
select { select {
case <-done: case <-done:
logger.Info("All workers stopped gracefully") logger.Info("All workers stopped gracefully")
@@ -328,43 +262,6 @@ func main() {
} }
logger.Info("Transcriber service stopped") logger.Info("Transcriber service stopped")
}
// filesPathPrefix — начало пути, которым хранилище отдаёт файл записи. Последний return startupErr
// сегмент такого пути и есть имя файла в хранилище.
const filesPathPrefix = "/api/files/"
// webappRoute — чем в журнале обозначается всякий путь, отданный приложению.
const webappRoute = "<приложение>"
// journalRoute готовит путь запроса к записи в журнал.
//
// Инвариант проекта запрещает имени файла в хранилище попадать в журнал: имя —
// последняя часть ссылки `/api/files/...`, и строка журнала вместе с
// идентификатором записи собирала бы ссылку целиком. Слой журнала пишет путь
// всякого запроса, поэтому имя срезается здесь — иначе оно уезжало бы в
// собранные логи при каждом скачивании записи.
//
// Срезается только имя: маршрут остаётся различимым, и наблюдаемость от этого не
// теряется.
//
// Путь, не принадлежащий сервису, в журнал не идёт вовсе. До появления раздачи
// приложения такой путь ловил отказ маршрутизатора, а теперь получает разметку
// с кодом `200`: множеством его значений распоряжается спрашивающий, и
// дословная строка сделала бы журнал местом, куда аноним пишет свой текст.
func journalRoute(path string, mounts []httpcontroller.Mount) string {
if !httpcontroller.IsServiceAddress(mounts, path) {
return webappRoute
}
if !strings.HasPrefix(path, filesPathPrefix) {
return path
}
cut := strings.LastIndex(path, "/")
if cut < len(filesPathPrefix) {
return path
}
return path[:cut+1] + "<имя>"
} }
+24 -2
View File
@@ -4,11 +4,33 @@ port = 8080
shutdown_timeout = 5 shutdown_timeout = 5
force_shutdown_timeout = 20 force_shutdown_timeout = 20
# Storage configuration # Хранилище: каталог данных и числа его базы.
# Единственный каталог данных: под ним лежат и база, и файлы записей. #
# Каталог единственный: под ним лежат и файл базы, и подкаталог с файлами
# записей. Двух путей у хранилища не бывает.
[storage] [storage]
data_dir = "data" data_dir = "data"
# Сколько ждать занятую базу, миллисекунды. Положительное число.
#
# База принимает **одного** писателя: драйвер пишет единственным соединением, и
# несколько воркеров, пришедших писать разом, встают в очередь. Это число —
# сколько ждущий готов простоять, прежде чем получить отказ «база занята».
# Крутят его при таком отказе под несколькими воркерами; ноль означает «отказать
# сразу» и потому не принимается.
busy_timeout_ms = 5000
# Сколько соединений держит читающий пул. Положительное число.
#
# Чтение идёт отдельно от записи: в журнале упреждающей записи читатели не
# мешают писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
# Число выводят из числа воркеров плюс запас под запросы приложения.
#
# Пишущее соединение при этом всегда одно и настройкой не делается: второе
# означало бы отказы по занятости на записи результата шага, то есть после
# оплаченной работы.
read_connections = 4
# Конвейер расшифровки. # Конвейер расшифровки.
[pipeline] [pipeline]
# Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к # Число рабочих потоков. Специализации у них нет: каждый берёт любую пригодную к
@@ -13,6 +13,25 @@ PocketBase уходит из проекта целиком: состояние
ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к ничем**: пока идёт стройка, остановленную запись возвращает в работу запрос к
базе. базе.
**Два решения абзаца выше сменились при разметке изменения**, и заменившее
названо здесь.
Шаги схемы двигает библиотека `github.com/pressly/goose/v3`, а не свой раннер.
Инструмент выбрал владелец 2026-08-22: библиотека уже была в этом проекте и ушла
вместе с PocketBase, а из трёх норм, которые накат обязан выполнять, две
выполняет сама.
Остановленную запись возвращает в работу подкоманда `cmd/devtools resume`, а не
запрос к базе руками. Возврат сбрасывает не одно поле записи и пишет событие
журнала с происхождением `entity.EventOriginHuman`; рука за клавиатурой не делает
ни того, ни другого. Последствие ниже — «возврат остановленной в работу […]
делает запрос к базе руками» — читается этой сменой.
Доводы обоих решений записаны в
[design.md](../../openspec/changes/archive/2026-08-23-storage-without-pocketbase/design.md),
разделы «Шаги схемы двигает `goose`, а не свой раннер» и «Панель не заменяется
ничем, а возврат в работу делает подкоманда оснастки».
Запись заменяет три: Запись заменяет три:
[ADR-2026-08-11-pocketbase-storage-with-admin-panel](ADR-2026-08-11-pocketbase-storage-with-admin-panel.md), [ADR-2026-08-11-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-11-queue-as-pocketbase-collection](ADR-2026-08-11-queue-as-pocketbase-collection.md) и
+71 -59
View File
@@ -21,18 +21,20 @@
`http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11, `http-handler-tests-never-green` и `no-user-filename-in-log` 2026-08-11,
`pocketbase-storage` и `oidc-login` 2026-08-12, `pocketbase-storage` и `oidc-login` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake` `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) — пустой прогон воркера, захват - [pipeline](../openspec/specs/pipeline/spec.md) — пустой прогон воркера, захват
задачи и срок его протухания, число попыток, остановка признаком, пауза перед задачи и срок его протухания, число попыток, остановка признаком, пауза перед
повтором и молчание конвейера наружу: задачи повтором и молчание конвейера наружу: задачи
`errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12, `errors-as-instead-of-typecast` 2026-08-11, `pocketbase-storage` 2026-08-12,
`local-run-without-telegram-token` 2026-08-13 и `remove-telegram-intake` `local-run-without-telegram-token` 2026-08-13, `remove-telegram-intake`
2026-08-14. Переходы состояний и отмена 2026-08-14 и `storage-without-pocketbase` 2026-08-22. Переходы состояний и отмена
контекста посреди шага остаются контекста посреди шага остаются
долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки; долгом; что именно не описано, перечисляет раздел `Purpose` самой спеки;
- [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные - [storage](../openspec/specs/storage/spec.md) — где живут запись, её метаданные
и её файл, как файл отдаётся и что видит владелец: задача `pocketbase-storage` и её файл, как файл отдаётся и что видит владелец: задачи `pocketbase-storage`
2026-08-12; 2026-08-12 и `storage-without-pocketbase` 2026-08-22. Последняя убрала
встроенное хранилище целиком: база стала своей, файлы — своим каталогом,
панель владельца исчезла и не заменена ничем;
- [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания - [recognition](../openspec/specs/recognition/spec.md) — **попытка распознавания
у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется у внешнего провайдера**: что о ней хранится, почему сырой ответ сохраняется
целиком и вложением, как из сохранённого строится структура реплик без целиком и вложением, как из сохранённого строится структура реплик без
@@ -66,13 +68,15 @@
- **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и - **Один процесс.** HTTP-сервер и фоновые воркеры живут в одном бинарнике и
делят одну базу. Отдельного воркер-процесса нет намеренно. делят одну базу. Отдельного воркер-процесса нет намеренно.
- **Очередь таблицей.** Состояние задачи лежит коллекцией хранилища; неделимость - **Очередь таблицей.** Состояние задачи лежит таблицей базы; неделимость
захвата и порядок выборки нормирует захвата и порядок выборки нормирует
[pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим». [pipeline](../openspec/specs/pipeline/spec.md), «Захват задачи неделим».
Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца, Внешний брокер не заводим: нагрузка — единицы записей в день (оценка владельца,
не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11, не замер). Готовую библиотеку очереди тоже не заводим — решено 2026-08-11,
[ADR](adr/ADR-2026-08-11-queue-as-pocketbase-collection.md), сравнение [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), «Брошенная задача возвращается [pipeline](../openspec/specs/pipeline/spec.md), «Брошенная задача возвращается
@@ -83,11 +87,10 @@
`cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель `cmd/transcriber`. Слои, их дома и словарь модели — раздел «Слои и модель
домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и домена» ниже. Правило механизировано тестами-сканерами `internal/archrules`, и
они же держат обратные направления: транспорты не знают друг о друге, адаптер они же держат обратные направления: транспорты не знают друг о друге, адаптер
не знает ни ядра, ни транспортов. не знает ни ядра, ни транспортов, транспорт не знает адаптеров. Изъятие,
*Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http` разрешавшее транспорту знать адаптер хранилища, снято 2026-08-22 вместе с
импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть предметом: HTTP-поверхность была роутером встроенного хранилища, а стала своей,
роутер этого хранилища, а не наш сервер поверх него. Правила на это и правило на это направление заведено впервые.
направление нет намеренно.
## Слои и модель домена ## Слои и модель домена
@@ -133,10 +136,10 @@
наблюдаем: правило о записи, записанное в `internal/service` условием над её наблюдаем: правило о записи, записанное в `internal/service` условием над её
полями, принадлежит `internal/entity`. полями, принадлежит `internal/entity`.
Место, где подход нарушен сегодня, названо изъятием в «Принципах»: транспорт Изъятий у подхода сегодня нет: последнее — транспорт знал адаптер хранилища —
знает адаптер хранилища, потому что HTTP-поверхность и есть роутер этого снято задачей `storage-without-pocketbase` 2026-08-22. Своя отдача файла и свои
хранилища. Изъятие снимает задача `storage-without-pocketbase` — своя отдача маршруты вернули транспорту независимость от инфраструктуры, а узнавание
файла и свои маршруты возвращают транспорту независимость от инфраструктуры. пришедшего приходит ему интерфейсом `contract.UserRepository`.
## Компоненты ## Компоненты
@@ -146,14 +149,15 @@
| Компонент | Где | Что делает | | Компонент | Где | Что делает |
| --- | --- | --- | | --- | --- | --- |
| HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/`: приём записи, страница своих записей, карточка, текст названного вида, пределы сервера и «кто вошёл» | | HTTP API | `internal/controller/http` | Адреса приложения под корнем `/app/` на `net/http`: приём записи, страница своих записей, карточка, текст названного вида, файл записи, пределы сервера и «кто вошёл». Слои — свои: журнал, восстановление после паники, ограничитель частоты, узнавание, требование учётной записи |
| Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен | | Воркеры | `internal/controller/worker` | Пул одинаковых потоков: каждый берёт любую пригодную запись и опрашивает базу. Число — настройкой, ноль законен |
| Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи | | Сервис расшифровки | `internal/service` | Конвейер: приём, приведение, отправка, опрос, завершение. Шаг выбирается по рубежу записи |
| Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности | | Конвертер и метаданные | `internal/adapter/{converter,metaviewer}/ffmpeg` | `ffmpeg` в ogg/vorbis, `ffprobe` для длительности |
| Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем | | Распознаватель | `internal/adapter/recognizer/yandex` | Заливка в Object Storage и отложенное распознавание SpeechKit; разбор ответа в реплики со временем |
| Репозитории | `internal/adapter/repo/pocketbase` | Записи, файлы, тексты, структура, попытки распознавания и журнал событий — коллекциями хранилища; захват — сырым запросом | | Репозитории | `internal/adapter/repo/sqlite` | Учётные записи, записи, файлы, тексты, структура, попытки распознавания и журнал событий — таблицами базы; захват — одним запросом с `RETURNING` по пишущему соединению |
| Шаги схемы | `internal/adapter/repo/pocketbase/migrations` | Файл на шаг, имя файла — имя шага; там же имена коллекций | | Файлы записей | `internal/adapter/repo/sqlite`, `store.go` | Подкаталог на запись под её идентификатором; укладка атомарна — временное имя рядом и переименование |
| Панель владельца | `internal/adapter/repo/pocketbase`, `panel.go` | Панель хранилища; правила правки записи нормирует [storage](../openspec/specs/storage/spec.md), «Владелец видит записи в панели» | | Шаги схемы | `internal/adapter/repo/sqlite/migrations` | Файл на шаг, версия — число в начале имени; накатывает `pressly/goose/v3` под своим замком |
| Оснастка владельца | `cmd/devtools` | Подставной прокси для местного запуска и возврат остановленной записи в работу. Панели у сервиса нет и не будет: экраны правки приносят отдельные задачи |
| Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет | | Приложение | `web/` | Vue 3, роутер пятой версии, сборка Vite. Собранное лежит в `web/embed/dist` и вшивается в бинарник; в git его нет |
| Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика | | Раздача приложения | `internal/controller/http`, `webapp.go` | Корневой маршрут: разметка вне корней сервиса, отказ внутри, срок хранения по каталогу сборщика |
@@ -174,6 +178,12 @@
асинхронное: запрос возвращает идентификатор операции, готовность опрашивается асинхронное: запрос возвращает идентификатор операции, готовность опрашивается
через `operation.api.cloud.yandex.net:443`, текст читается потоком. через `operation.api.cloud.yandex.net:443`, текст читается потоком.
- **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`. - **ffmpeg и ffprobe.** Внешние процессы, ищутся в `PATH`.
- **SQLite через `modernc.org/sqlite`.** Драйвер на чистом Go: CGO сборке не
нужен. База и файлы записей лежат под одним каталогом данных.
- **`github.com/pressly/goose/v3`.** Шаги схемы — библиотекой, а не командной
строкой: перечень шагов приходит провайдеру доводом, накат идёт при старте.
Исключающей блокировки под SQLite библиотека не даёт, и замок каталога данных
берём сами.
- **Node и его установщик пакетов.** Нужны только сборке приложения и на машину - **Node и его установщик пакетов.** Нужны только сборке приложения и на машину
не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`. не ставятся: шаг зовёт их контейнером, а образ берёт из ступени `Dockerfile`.
Требованием к машине разработчика поэтому становится docker. Реестр пакетов — Требованием к машине разработчика поэтому становится docker. Реестр пакетов —
@@ -191,34 +201,23 @@
Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого Секцию `[telegram]` и ключ `server.users_while_list` человек убирает из боевого
файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними файла после выкладки: незнакомые ключи разбор настроек не судит, и файл с ними
сервис поднимает молча. сервис поднимает молча.
- **Откат образа через шаг схемы `202608140002` не работает и не говорит об - **Откат образа на версию до 2026-08-22 не работает вовсе.** Каталог данных
этом.** Шаг удаляет прежнюю коллекцию задач, а библиотека накатывает только сменил раскладку целиком: база зовётся другим файлом, файлы записей лежат
те шаги, которые знает сам бинарь: прежний образ шагов новее не видит, другими путями, а учёт применённых шагов ведёт другая таблица. Прежний образ на
поднимается **без единой ошибки** и отвечает зелёной пробой здоровья — после таком каталоге поднимется, накатит **свои** шаги в пустое место и заведёт
чего всякое обращение к очереди отказывает «коллекции нет». Проверено прогоном вторую, чужую схему рядом. Лечится повторной выкладкой вперёд; обратного шага
двух бинарей на одном каталоге данных. схемы нет и не планируется.
Значит штатное средство владельца на инциденте — «вернём прошлый образ» — с Прежние два порога — шаги `202608140002` и `202608220001` — этим поглощены: до
этого шага делает хуже и молчит. Лечится повторной выкладкой нового образа; выкладки `record-centric-model` откат работал, после перестал, а с уходом
обратного шага схемы нет и не планируется. Порог перехода назван прямо: до встроенного хранилища перестал окончательно. Окно порога сегодня пусто: сервис
выкладки `record-centric-model` откат образа работает, после — нет. не выложен. Строка стоит здесь потому, что порог принято называть прямо, а не
- **Откат образа через шаг схемы `202608220001` обрывает вход.** Шаг закрывает потому, что риск сегодня чем-то грозит.
правила коллекции пользователей наглухо, а прежний образ заводил учётную
запись внутренним запросом обмена кода — и этот запрос закрытое правило
отвергает. Проверено прогоном прежнего кода поверх нового каталога данных:
вход отвечает `401`, в журнале «storage rejected the exchange with code 403».
Порог тот же по форме, что и у `202608140002`: до выкладки
`trusted-header-login` откат работает, после — нет, и лечится он повторной
выкладкой вперёд. Обратного шага схемы нет и не планируется.
Окно этого порога сегодня пусто: сервис не выложен, а откат уже не работает с
шага `202608140002`. Строка стоит здесь потому, что порог принято называть
прямо, а не потому, что риск сегодня чем-то грозит.
- **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает - **Внешние зависимости поимённо и чем каждая отказывает.** Столбец «отвечает
медленно» читается вместе с тем, что таймаута нет ни у одного обращения медленно» читается вместе с тем, что таймаута нет ни у одного обращения
наружу — [database.md](database.md), «Настройки с числовым значением»: наружу — [database.md](database.md), «Настройки с числовым значением»:
<!-- канон: поведение → openspec/specs/intake, pipeline --> <!-- канон: поведение → openspec/specs/intake, pipeline, storage -->
| Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор | | Зависимость | Падает | Отвечает медленно | Молчит | Отдаёт мусор |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
@@ -226,7 +225,7 @@
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — | | ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
| Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции | | Yandex Object Storage | Заливка падает, запись остаётся на рубеже `normalized` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
| ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании | | ffmpeg, ffprobe | Запись останавливается признаком с текстом «сбой конвертации файла» — рубеж при этом сохраняется, и снятие признака продолжает с него. Остановка сервиса — исход другой: процесс убивают контекстом, запись остаётся на повтор и отказа не тратит | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — | | База (файл на диске) | Старт кончается отказом с именем шага схемы либо шаг падает на каждом запросе | Ожидание занятой базы задано числом; исчерпав его, операция отказывает, и запись остаётся пригодной к повтору | — | — |
| Диск | Запись файла падает, задача не заводится | — | — | — | | Диск | Запись файла падает, задача не заводится | — | — | — |
- **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи: - **Кто заметит отказ и когда:** тот, кто загрузил запись, — карточкой записи:
@@ -237,8 +236,8 @@
«падает приведение» отличается от «падает распознавание». Плюс логи «падает приведение» отличается от «падает распознавание». Плюс логи
контейнера. Отдельного оповещения нет. контейнера. Отдельного оповещения нет.
- **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется - **Журнал событий записи** — второй канал наблюдения, `record_events`. Пишется
на смену рубежа, на остановку и на снятие остановки; читает его человек в на смену рубежа, на остановку и на возврат в работу; ни один шаг конвейера на
панели, ни один шаг конвейера на него не смотрит. Экрана у него пока нет. него не смотрит. Читается запросом к базе: ни панели, ни экрана у него нет.
- **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу - **Характер потока:** непрерывный, но разреженный. Воркеры опрашивают базу
вхолостую с паузой из вхолостую с паузой из
[database.md](database.md), «Настройки с числовым значением». [database.md](database.md), «Настройки с числовым значением».
@@ -248,7 +247,11 @@
| Что | Где | | Что | Где |
| --- | --- | | --- | --- |
| Приём аудио и заведение записи | `TranscribeService.createRecord` — единственный путь, которым запись появляется в хранилище | | Приём аудио и заведение записи | `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`, отдаёт идентификатор и признак захвата | | Захват записи воркером | `AudioRecordRepository.FindAndAcquire` — один запрос с `RETURNING`, отдаёт идентификатор и признак захвата |
| Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда | | Объявление рубежа | `internal/entity/stage.go` — выбор шага, отбор захвата, срок протухания и предел простоя выводятся отсюда |
| Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру | | Выбор шага по рубежу | `TranscribeService.stepFor` — таблица, а не привязка к воркеру |
@@ -264,13 +267,17 @@
| Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса | | Состояния отбора списка | `internal/entity.ListFilter` вместе с `WorkingStages` и `TerminalStages` — предикаты выводятся из дескриптора рубежа, а не пишутся строкой запроса |
| Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём | | Уборка имени файла отправителя | `internal/entity.SanitizeOriginalFilename` — режет по пределу и убирает управляющие знаки; зовёт её приём |
| Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания | | Адресное пространство сервиса | `internal/controller/http.ServiceMounts` — перечень корней и адресов наблюдения. Он **порождает** регистрацию наших маршрутов, а не описывает её, и из него же выводятся правило неизвестного пути, уровень журнала и область действия узнавания |
| Узнавание предъявителя | `pbrepo.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод — `internal/controller/http.TrustedHeaderIdentity` | | Узнавание предъявителя | `sqlite.UserRepository.EnsureUser` — поиск учётной записи по логину у провайдера и заведение при первом обращении. Дом правила один и лежит в хранилище, а не в транспорте: второй способ представиться (личные токены) возьмёт этот же метод, а уложенное куском в слой оно разошлось бы двумя копиями. Транспорт читает заголовок, судит адрес пира и зовёт метод интерфейсом `contract.UserRepository``internal/controller/http.TrustedHeaderIdentity` |
| Приём значения заголовка | `internal/entity.AcceptProviderLogin`, `AcceptDisplayName`, `AcceptEmail` — правило одно на все способы представиться |
| Ограничитель частоты | `internal/controller/http.RateLimit` — бюджет по адресу спрашивающего под корнем приложения; из его чисел выводится объявляемая частота опроса |
Единых точек, которых **нет** и которые ожидались бы: идентификаторы Единых точек, которых **нет** и которые ожидались бы, сегодня не осталось.
генерируются вызовом `uuid.NewString()` по месту. Время из этого перечня ушло Время ушло из перечня отсутствий 2026-08-13 — его читает `internal/clock`, и
2026-08-13: его читает `internal/clock`, и запрет держит линтер; отображение запрет держит линтер; отображение доменной ошибки — 2026-08-15 задачей
доменной ошибки — 2026-08-15 задачей `json-api-for-spa`, и до неё обработчик `json-api-for-spa`, и до неё обработчик решал сам: опрос отвечал `404` на упавшую
решал сам: опрос отвечал `404` на упавшую базу, а приём — `500` на негодный файл. базу, а приём — `500` на негодный файл; выдача идентификаторов — 2026-08-22
задачей `storage-without-pocketbase`, и до неё их выдавало встроенное хранилище
своим алфавитом, а сервис звал `uuid.NewString()` по месту.
## Деплой ## Деплой
@@ -320,8 +327,9 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
[ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md). [ADR-2026-08-22-login-by-trusted-header](adr/ADR-2026-08-22-login-by-trusted-header.md).
**Не решено одно:** как связать чат Telegram с учётной записью — от этого **Не решено одно:** как связать чат Telegram с учётной записью — от этого
зависит возвращение убранного входа. зависит возвращение убранного входа.
Панель администратора при этом Authelia не закрывает: у неё свой пароль Второго периметра на порту сервиса при этом не осталось: панель администратора
суперпользователя. ушла вместе со встроенным хранилищем 2026-08-22, и закрывать её на прокси
больше нечего.
- **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение - **Приложение.** Каркас поставлен `spa-skeleton` 2026-08-15: приложение
открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и открывается, показывает вошедшего и вшито в бинарник. Экранов загрузки и
списка нет — их делают `upload-and-status-screen` и `records-list-screen`. списка нет — их делают `upload-and-status-screen` и `records-list-screen`.
@@ -343,7 +351,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл шесть часов нормирует [storage](../openspec/specs/storage/spec.md), «Файл
записи живёт в хранилище»; откуда взято число — записи живёт в хранилище»; откуда взято число —
[research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта [research/pocketbase-defaults.md](research/pocketbase-defaults.md), «Чего эта
записка не узнала». записка не узнала». Записка описывает умолчания ушедшей библиотеки, и живой
она осталась только этим числом.
- **Приём большого файла.** Форма читается целиком, предел памяти под multipart - **Приём большого файла.** Форма читается целиком, предел памяти под multipart
задан числом в [database.md](database.md), «Настройки с числовым значением»; задан числом в [database.md](database.md), «Настройки с числовым значением»;
обрыв начинает загрузку заново. обрыв начинает загрузку заново.
@@ -358,8 +367,8 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
- **Резервные копии.** Копии делает сервер своими средствами, и приложение о них - **Резервные копии.** Копии делает сервер своими средствами, и приложение о них
ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или ничего не знает. Не решено, хватит ли копировать каталог данных файлами, или
приложению нужна команда выгрузки: база под нагрузкой копируется файлом не приложению нужна команда выгрузки: база под нагрузкой копируется файлом не
всегда целой. Своё копирование по расписанию у PocketBase есть — берём мы его всегда целой. Готового копирования по расписанию у сервиса нет вовсе: оно
или нет, тоже не решено. ушло вместе со встроенным хранилищем, и заводить своё пока не решено.
- **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а - **Формат для распознавания.** Конвертер отдаёт ogg/vorbis (`libvorbis`), а
SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли SpeechKit получает `ContainerAudio_OGG_OPUS`. Расхождение не разобрано: то ли
сервис определяет содержимое сам, то ли часть записей теряется на этом. сервис определяет содержимое сам, то ли часть записей теряется на этом.
@@ -374,12 +383,15 @@ DNS-сервер, молчащий на `AAAA`, оставляет устано
паузы, а не замер паузы, а не замер
([research/job-queue.md](research/job-queue.md), «Как снималось»), — при ([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` остаётся и развивается. Чем — дописывать - **Наблюдаемость.** `/metrics` остаётся и развивается. Чем — дописывать
счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой — счётчики через `client_golang` или перейти на OpenTelemetry с трассировкой —
решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в решает разведка `opentelemetry-fit`. Коллектор был бы процессом, которого в
выкладке сегодня нет. выкладке сегодня нет.
- **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено - **Выводы из текста.** Литературный текст, заголовок, темы и пересказ решено
считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost. считать внешним сервисом с OpenAI-совместимым интерфейсом за шлюзом bifrost.
Появляется пятая внешняя зависимость, платная, и текст расшифровки начинает Появляется ещё одна внешняя зависимость, платная, и текст расшифровки начинает
уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не уходить ещё на одну сторону — сдвиг периметра [security.md](security.md). Не
решено, отдельный это шаг конвейера или продолжение шага распознавания. решено, отдельный это шаг конвейера или продолжение шага распознавания.
+8 -5
View File
@@ -5,8 +5,8 @@
**Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту. **Взято из проекта jellybit.** Расхождения с сегодняшним кодом названы по месту.
Главные: комментариями снабжена половина полей; единого места проверки на старте Главные: комментариями снабжена половина полей; единого места проверки на старте
нет: у секций `[auth]` и `[pipeline]` свой `Validate()` в точке входа, а пустые нет: у секций `[auth]`, `[pipeline]` и `[storage]` свой `Validate()` в точке
ключи `[yandex]` ловит конструктор распознавателя. входа, а пустые ключи `[yandex]` ловит конструктор распознавателя.
**Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml` **Механизировано:** запрет `os.Getenv``forbidigo` в `.golangci.yml`
([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки ([go-linters.md](go-linters.md), «Механизировано»). Он держит правило «настройки
@@ -137,9 +137,12 @@ TOML. Пустые ключи Yandex ловятся в конструкторе
Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим Два ключа секции `[telegram]`, стоявшие здесь исключением, ушли вместе с самим
входом 2026-08-14: секции больше нет, и своей проверки у неё тоже. входом 2026-08-14: секции больше нет, и своей проверки у неё тоже.
Секция `[auth]` — первая, у которой проверка своя и стоит на старте: Секции `[auth]`, `[pipeline]` и `[storage]` проверяют себя сами, и проверка стоит
`AuthConfig.Validate()` зовётся из `cmd/transcriber` сразу после загрузки и роняет на старте: `Validate()` каждой зовётся из `cmd/transcriber` сразу после загрузки
процесс с именем незаполненного ключа. Причина в цене умолчания: поднявшись с и роняет процесс с именем незаполненного ключа. У `[storage]` это ожидание занятой
базы и число соединений читающего пула: ноль у первого отдаёт «база занята»
первому же воркеру, ноль у второго означает пул без предела — то есть настройку,
которой не управляют. Причина в цене умолчания: поднявшись с
пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об пустым перечнем доверенных адресов, сервис не узнавал бы никого, а узнать об
этом было бы неоткуда — все адреса приложения просто отвечали бы отказом. этом было бы неоткуда — все адреса приложения просто отвечали бы отказом.
Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в Сообщение называет **имя ключа**; правило «значения в отказ не идут» остаётся в
+29 -32
View File
@@ -2,11 +2,11 @@
Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md). Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md).
**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует **Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует этому
этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время целиком: ключи — ULID в нижнем регистре, выдаёт их единая точка `internal/ident`
единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило (с 2026-08-22, задача `storage-without-pocketbase`), время читает единая точка
держит линтер. Правила действуют на новый код; переписывание существующего — `internal/clock` (с 2026-08-13), и правило времени держит линтер. Расхождений у
отдельная работа, и до неё расхождение читается как долг, а не как нарушение. записи не осталось.
**Механизировано:** сверка изменённого шага схемы с **Механизировано:** сверка изменённого шага схемы с
[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой [../database.md](../database.md) (`docs.py check`), чтение времени единой точкой
@@ -17,17 +17,17 @@
## Первичные ключи — ULID, не автоинкремент ## Первичные ключи — ULID, не автоинкремент
- **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется - **PK сущности — TEXT ULID** (26 символов Crockford base32), генерируется
**приложением** в момент создания записи. **приложением** в момент создания записи. Выдача монотонна внутри одной
*Расхождение:* идентификаторы записей выдаёт хранилище — 15 знаков миллисекунды: колонка времени несёт секунды, и порядок записей одной секунды
собственного алфавита. Своей точки генерации у приложения нет, и `ORDER BY id` задаёт ключ. Порядок ленты берут парой «время заведения и ключ» — одного
хронологией не является: порядок берут по колонке времени с ключом. времени мало.
- Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология), - Почему ULID: сортируем по времени создания (`ORDER BY id` = хронология),
компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id компактен и удобен в URL и логах (без дефисов — grep и двойной клик берут id
целиком), глобально уникален между таблицами — поиск по голому id находит все целиком), глобально уникален между таблицами — поиск по голому id находит все
записи сущности в логах. записи сущности в логах.
- **Точка генерации и разбора одна**: создание — при вставке записи в - **Точка генерации и разбора одна**`internal/ident`: `New` выдаёт, `Parse`
репозитории, разбор — на входных границах. Самодельных генераторов по месту разбирает пришедшее снаружи. Самодельных генераторов по месту вызова не
вызова не заводим. заводим.
## Канонический вид — lowercase ## Канонический вид — lowercase
@@ -47,31 +47,28 @@
## Прочее ## Прочее
- Enum-поля (`state`, `source`, …) — обычный `TEXT` без `CHECK`; допустимые - Enum-поля (`state`, `halt_reason`, …) — обычный `TEXT` без `CHECK`; допустимые
значения держит код. значения держит код. Прежде часть перечней закрывала схема — правку руками вела
*Расхождение:* перечни, по которым панель владельца правит запись руками, панель владельца, и она вправе была завести значение, которого сервис не
закрыты схемой (`SelectField`), а не кодом: правка руками не должна заводить знает. Панели нет с 2026-08-22, правка идёт только нашим кодом, и закрытый
значение, которого сервис не знает. Закрыты рубеж записи, причина её перечень в схеме остался бы ценой — новое значение стоило бы нового шага — без
остановки, вид текста, источник и исход события журнала. Цена названа: новое покупателя.
значение любого из них потребует нового шага схемы, а применённый шаг не
переписывается. Прочие перечни остаются обычным `TEXT`.
- Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например - Временные метки — `TEXT` в **RFC 3339, UTC (суффикс `Z`)**, например
`2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет `2006-01-02T15:04:05Z` (секундная точность). Фиксированная ширина сохраняет
лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`). лексикографическую сортировку TEXT, то есть хронологию (`ORDER BY created_at`).
Единая точка генерации — приложение, а не умолчание в схеме: так забытая Единая точка генерации — приложение, а не умолчание в схеме: так забытая
вставка падает громко. Измерение длительности — не метка времени. вставка падает громко. Измерение длительности — не метка времени.
*Расхождение:* вид времени задаёт хранилище — `2006-01-02 15:04:05.000Z`, Умолчаний вида `CURRENT_TIMESTAMP` в схеме нет ни у одной колонки, и вид один
пробел вместо `T` и доли секунды ([../database.md](../database.md), «Время»). на все — включая те, что пишет только сам сервис: своего типа времени у SQLite
Правило RFC 3339 действует на то, что пишем мы сами мимо хранилища; вид нет, а колонка, заполненная то одним видом, то другим, молча обращает условие
хранилища не меняем — сравнение строк в сыром запросе побайтово, и срока захвата в константу.
разошедшийся вид молча обращает условие срока захвата в константу. - Миграции — шаги `pressly/goose/v3` на Go
- Миграции — шаги PocketBase на Go (`internal/adapter/repo/sqlite/migrations`, файл на шаг, версия — число в
(`internal/adapter/repo/pocketbase/migrations`, файл на шаг): коллекции и их начале имени): таблицы, их колонки и индексы заводятся кодом. При изменении
поля заводятся кодом. При изменении структуры обновляем схему структуры обновляем схему [../database.md](../database.md) тем же изменением.
[../database.md](../database.md) тем же изменением. - Время в запросе кладётся и сравнивается тем же видом, каким оно лежит в
- Время в **сыром запросе** кладётся и сравнивается тем же видом, каким колонке. Сравнение строк побайтово, и разошедшийся вид обращает условие в
хранилище пишет свои `created`/`updated`. Сравнение строк побайтово, и постоянную истину или ложь — молча.
разошедшийся вид обращает условие в постоянную истину или ложь — молча.
- Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`: - Выборка «следующей» записи с `LIMIT 1` дополняется ключом в `ORDER BY`:
сравнение по неуникальному значению делает порядок обработки сравнение по неуникальному значению делает порядок обработки
невоспроизводимым. невоспроизводимым.
+23 -16
View File
@@ -100,15 +100,19 @@ transcriber — **приложение, а не библиотека**: внеш
| Доменная ошибка | Статус | `error_code` | Сообщение | | Доменная ошибка | Статус | `error_code` | Сообщение |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| сессии нет | 401 | `unauthorized` | «требуется вход» | | пришедший не узнан | 401 | `unauthorized` | «сервис вас не узнал» |
| предъявитель узнан, учётной записи пользователя нет | 403 | `forbidden` | «у вашей сессии нет учётной записи» |
| запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» | | запись не найдена, чужая либо ничья | 404 | `not_found` | «запись не найдена» |
| файл не приложен, формат не распознан, негодное значение параметра | 400 | `bad_request` | «некорректный ввод» | | файл не приложен, формат не распознан, негодное значение параметра, негодный диапазон | 400 | `bad_request` | «некорректный ввод» |
| запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом | | запись сверх потолка размера | 413 | `too_large` | «запись больше допустимого размера», плюс предел числом |
| запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» | | запросов слишком много подряд | 429 | `too_many_requests` | «слишком много запросов подряд, попробуйте позже» |
| текста запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» | | текста или копии файла запрошенного вида ещё нет | 409 | `not_ready` | «действие недоступно в текущем состоянии» |
| прочее | 500 | `internal` | «внутренняя ошибка» | | прочее | 500 | `internal` | «внутренняя ошибка» |
Ветвь `403`/`forbidden` ушла отсюда 2026-08-22 вместе со своим единственным
случаем: им был владелец панели, предъявивший собственный токен хранилища.
Ни панели, ни токенов у сервиса не осталось, а узнавание по заголовку
учётную запись заводит само.
Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе Новую штатную ветвь отказа заводим sentinel'ом и добавляем сюда — иначе
ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а ветвь по умолчанию отдаст 500 «внутренняя ошибка» на обычный конфликт, а
логирующая граница спишет его в `ERROR` вместо `DEBUG`. логирующая граница спишет его в `ERROR` вместо `DEBUG`.
@@ -124,10 +128,13 @@ transcriber — **приложение, а не библиотека**: внеш
`json-api-for-spa` 2026-08-15. `json-api-for-spa` 2026-08-15.
**Часть отказов рождается не в обработчике** — предел тела, ограничитель **Часть отказов рождается не в обработчике** — предел тела, ограничитель
частоты, неизвестный путь под корнем приложения — и до этой точки не доходит частоты, неизвестный путь под корнем приложения, негодный диапазон в запросе
вовсе. Их приводит к той же форме слой `OneErrorForm`, стоящий снаружи всех файла — и до этой точки не доходит вовсе. С 2026-08-22 отдельного слоя
прочих. Без него формы отказа было бы две, и отказ у человека на мобильной сети перевода им не нужно: маршрутизатор и слои написаны нами, и каждый из них
приходил бы телом библиотеки. отвечает **своей доменной ошибкой** через ту же точку. Прежде их приводил к
общей форме слой `OneErrorForm`, стоявший снаружи всех прочих и переводивший
тело чужой библиотеки; библиотеки не осталось, и второй формы отказа взяться
неоткуда.
### Разовый ответ и сохранённая диагностика ### Разовый ответ и сохранённая диагностика
@@ -135,7 +142,7 @@ transcriber — **приложение, а не библиотека**: внеш
- **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт, - **Разовый ответ на действие** (тело HTTP-ответа) — строго нейтральный: отображение выше, `err.Error()` наружу не идёт,
полная ошибка остаётся в логах по идентификатору задачи. полная ошибка остаётся в логах по идентификатору задачи.
- **Сохранённая диагностика состояния** — колонка `error_text` задачи. Это - **Сохранённая диагностика состояния** — колонка `error_text` аудиозаписи. Это
**поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим **поверхность владельца**, а не пользователя: сюда сырой текст ошибки допустим
и полезен. Но: и полезен. Но:
- **секреты запрещены** — токены, ключи, пароли, заголовок - **секреты запрещены** — токены, ключи, пароли, заголовок
@@ -146,8 +153,8 @@ transcriber — **приложение, а не библиотека**: внеш
числом рядом**: без этого непонятно, насколько сокращать. числом рядом**: без этого непонятно, насколько сокращать.
*Расхождение:* `error_text` пишется целиком, без вычистки и без усечения. *Расхождение:* `error_text` пишется целиком, без вычистки и без усечения.
Наружу он при этом не выходит: опрос готовности отдаёт признак остановки без Наружу он при этом не выходит: карточка записи отдаёт причину остановки без
машинного текста — эту часть правила держит спека `intake`. машинного текста — эту часть правила держит спека `archive`.
## panic ## panic
@@ -156,11 +163,11 @@ transcriber — **приложение, а не библиотека**: внеш
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) — - Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой ввод) —
это значения `error`. это значения `error`.
- `recover` — на верхней границе обработчика, чтобы один паникующий запрос не - `recover` — на верхней границе обработчика, чтобы один паникующий запрос не
ронял процесс. В transcriber его вешает роутер хранилища сам ронял процесс. В transcriber его ставит свой слой `http.Recover`: паникующий
(`apis.panicRecover`, слой с идентификатором `DefaultPanicRecoverMiddlewareId` обработчик отдаёт `500` нашей формой тела, а строка о панике идёт в журнал
на каждом роутере PocketBase): паникующий обработчик отдаёт `500`, процесс владельца. Слой стал своим 2026-08-22 вместе с роутером — прежде его вешала
живёт. Своего слоя мы не пишем. У воркеров такой границы **нет**: паника в чужая библиотека. У воркеров такой границы **нет**: паника в шаге конвейера
шаге конвейера роняет процесс целиком. роняет процесс целиком.
## Несколько ошибок ## Несколько ошибок
+9 -9
View File
@@ -78,10 +78,11 @@
| --- | --- | | --- | --- |
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` | | Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml``errorlint` |
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules``TestОшибкаНеУзнаётсяПоТексту` | | Ошибка не узнаётся сравнением текста сообщения (`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` его не видит | | Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml``errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml``nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа | | Проверенный отказ не оборачивается в `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` | | Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml``depguard` |
### Структура и границы ### Структура и границы
@@ -90,8 +91,9 @@
| --- | --- | | --- | --- |
| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` | | Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules``TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` |
| Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` | | Транспорты (`controller/http`, `controller/worker`) не знают друг о друге | `internal/archrules``TestТранспортыНеЗнаютДругОДруге` |
| Транспорты не знают адаптеров | `internal/archrules``TestТранспортыНеЗнаютАдаптеров`. Правило заведено 2026-08-22: изъятие, разрешавшее транспорту знать адаптер хранилища, снято вместе с предметом |
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` | | Адаптер не знает ни ядра, ни транспортов | `internal/archrules``TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
| Колонки записи согласованы: что пишет отображение ↔ что читает обратное ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках. Закрывает инвариант «колонки записи правятся в двух местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций, а не в файле целиком | | Колонки записи согласованы: что пишет отображение ↔ что спрошено чтением ↔ что доезжает до сущности ↔ что заводит шаг схемы | `internal/archrules` → правила о колонках (`TestКолонкиЗаписиПишутсяИЧитаются`, `TestПрочитанныеКолонкиДоезжаютДоСущности`, `TestКолонкиЗаписиЗаведеныШагомСхемы`). Закрывает инвариант «колонки записи правятся в трёх местах» (CLAUDE.md, major), которого компилятор не держит. Имя колонки ищется в телах нужных функций, а не в файле целиком |
| Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда | | Рубежи согласованы: дескриптор ↔ таблица выбора шага, в обе стороны | `internal/archrules` → правила о рубежах. Закрывает инвариант «рубеж объявляется одним дескриптором» (CLAUDE.md, major). Рубеж без шага останавливает запись, не начав работы; шаг без рубежа недостижим — захват такую запись не выдаст никогда |
### Отмена и внешний собеседник ### Отмена и внешний собеседник
@@ -138,7 +140,7 @@
| Правило | Где механизировано | | Правило | Где механизировано |
| --- | --- | | --- | --- |
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там | | Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни база: применённое считается своей таблицей учёта. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` секции `[docs]` в `.av-dev.toml`, чтобы у факта не было второго дома. Исходы шага и их коды — [CLAUDE.md](../../CLAUDE.md), «Гейт». `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` | | Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` секции `[docs]` в `.av-dev.toml` |
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` | | Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
| Секреты в коммите | `lefthook.yml``gitleaks git --staged` | | Секреты в коммите | `lefthook.yml``gitleaks git --staged` |
@@ -188,11 +190,9 @@
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
молчит на всём каталоге, и на подозрении база задаётся руками молчит на всём каталоге, и на подозрении база задаётся руками
(`task migrations BASE=<rev>`); (`task migrations BASE=<rev>`);
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно — - чистота домена: правила смотрят ядро, входы и адаптеры, а импорт внешней
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и библиотеки в `internal/entity` сегодня пройдёт молча. Названо в
есть роутер этого хранилища. Изъятие названо в [../architecture.md](../architecture.md), «Слои и модель домена».
[../architecture.md](../architecture.md), «Принципы», и правила на это направление
нет.
Отдельно названы **правила, чей подъём отклонён**: Отдельно названы **правила, чей подъём отклонён**:
+9 -10
View File
@@ -105,12 +105,12 @@ stdlib-логом в поток ошибок. Это выбор, а не дол
| Когда добавляем | Поля | | Когда добавляем | Поля |
| --- | --- | | --- | --- |
| на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms` | | на входящий HTTP-запрос | `transport` (`http`), `http.method`, `http.route`, `http.status_code`, `duration_ms`, `http.path_length`. **Запрошенного пути в строке нет ни под каким корнем**: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. В `http.route` идёт маршрут из закрытого перечня — точный адрес наблюдения либо образец адреса приложения, — а всё прочее обозначается одним общим значением |
| на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним | | на узнавание пришедшего | `http.peer_addr` — адрес того, кто открыл соединение; плюс `account_id` на заведении учётной записи. **Значения заголовка в строке нет**: им довольно назваться, чтобы стать этим человеком, а с недоверенного адреса его пишет аноним |
| на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` | | на задачу | `capability` (значения — по именам заведённых capability в `openspec/specs/`), `record_id`, `file_id`, `source` |
| на запись об ошибке | `error` | | на запись об ошибке | `error` |
| на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | | на вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
| на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт), `http.path_length`. Самого пути в строке нет: его выбирает спрашивающий, и дословная запись сделала бы журнал местом, куда аноним пишет свой текст. Вместо пути в `http.route` стоит `<приложение>` | | на запрос, отданный приложению | `webapp.outcome` (`markup`, `asset`, `failure` — перечень закрыт). Правило о пути строкой выше, общее: вместо пути в `http.route` стоит `<приложение>` |
| на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» | | на подъёме сервиса | `webapp.build` — отпечаток вшитой сборки; им «не та сборка» отличается от «той» |
Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум. Не заводим `service.*` и `host.*` — для одного бинарника на одном хосте это шум.
@@ -205,7 +205,7 @@ Object Storage и опрос операции не логируются ника
## HTTP и проверка здоровья ## HTTP и проверка здоровья
- Входящие HTTP-запросы логируем с полями `http.method`, `http.route`, - Входящие HTTP-запросы логируем с полями `http.method`, `http.route`,
`http.status_code`, `duration_ms`, `transport`. `http.status_code`, `duration_ms`, `http.path_length`, `transport`.
- **Поле, которое уже даёт логгер с подставленным ключом, руками не - **Поле, которое уже даёт логгер с подставленным ключом, руками не
доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий доклеиваем.** Иначе в JSON получается дублирующийся ключ, и строгий
потребитель молча оставит одно из значений. Правило проверяется чтением, потребитель молча оставит одно из значений. Правило проверяется чтением,
@@ -214,13 +214,12 @@ Object Storage и опрос операции не логируются ника
периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом периодически, на `INFO` они забивают разбор шумом. В продакшене при базовом
`INFO` они не пишутся. `INFO` они не пишутся.
Расхождения здесь больше нет: слой журналирования запросов свой, Расхождения здесь больше нет: слой журналирования запросов свой
`cmd/transcriber`, хук `OnServe` — вместе с gin ушёл и `sloggin`. `/health` и `/metrics` `internal/controller/http`, `journal.go`. `/health` и `/metrics` идут на `DEBUG`,
идут на `DEBUG`, то есть при боевом `INFO` не пишутся вовсе. то есть при боевом `INFO` не пишутся вовсе.
Хранилище ведёт **свой** журнал запросов в собственной таблице, и он виден **Журнал у сервиса один.** Второй, куда встроенное хранилище клало путь целиком
владельцу в панели. Заменой потоку процесса он не служит: в журнал контейнера, вместе с адресом отправителя, ушёл вместе с самим хранилищем 2026-08-22.
по которому разбирают отказы, эта таблица не попадает.
## Безопасность: что не логируем ## Безопасность: что не логируем
@@ -257,7 +256,7 @@ Object Storage и опрос операции не логируются ника
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост (`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
расширением. В журнал оно идёт **собственным полем** строки приёма — это расширением. В журнал оно идёт **собственным полем** строки приёма — это
объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md), объявленное изъятие инварианта приватности ([CLAUDE.md](../../CLAUDE.md),
«Инварианты»); ни имени файла в хранилище, ни пути к нему в журнале нет вовсе «Инварианты»); ни имени файла на диске, ни пути к нему в журнале нет вовсе
(норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит: (норма — `openspec/specs/intake`). Наружу — в метку метрики — хвост не выходит:
там расширение приводится к перечню известных форматов. Остаток описан в там расширение приводится к перечню известных форматов. Остаток описан в
[../security.md](../security.md). [../security.md](../security.md).
+10 -9
View File
@@ -74,15 +74,16 @@
[webapp](../../openspec/specs/webapp/spec.md). [webapp](../../openspec/specs/webapp/spec.md).
- **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование - **Адреса обычные, а не после решётки** (`createWebHistory`). Отсюда требование
к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не к серверу: неизвестный путь **вне корней сервиса** отдаёт `index.html`, а не
`404`; путь внутри корня в приложение не проваливается никогда. Корней `404`; путь внутри корня в приложение не проваливается никогда. Корень
сегодня три — `/api/` у хранилища, `/app/` у приложения, `/_/` у панели, — сегодня **один**`/app/` у приложения, — плюс `/health` и `/metrics`
плюс `/health` и `/metrics` отдельными адресами. Корень `/auth/` снят отдельными адресами. Корни `/auth/`, `/api/` и `/_/` сняты 2026-08-22: первый
2026-08-22 вместе с собственным входом, и пути под ним стали обычными путями ушёл с собственным входом, два других — со встроенным хранилищем и его
вне корней. Приложение панелью, и пути под ними стали обычными путями вне корней. Приложение уехало
уехало из общего `/api/` решением владельца 2026-08-15: пространство из общего `/api/` решением владельца 2026-08-15, и корень свой сохранило:
принадлежит хранилищу, и обновление библиотеки вправе занять там имя рядом с соседа, ради которого выбирался, больше нет, а формы запросов и ответов от
нашим. Перечень корней сервису не описывают, а из него **порождают** смены хранилища не изменились ни одним полем. Перечень корней сервису не
регистрацию маршрутов: описанный порознь, он разошёлся бы с ними молча. описывают, а из него **порождают** регистрацию маршрутов: описанный порознь,
он разошёлся бы с ними молча.
- **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика, - **Несовпавший ресурс разметкой не подменяется.** Путь под каталогом сборщика,
которому не нашлось файла, отвечает `404`. Правило — вторая половина которому не нашлось файла, отвечает `404`. Правило — вторая половина
предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и предыдущего: разметка прежней сборки называет ресурсы прежней сборки, и
+276 -217
View File
@@ -1,62 +1,118 @@
# Схема хранилища # Схема хранилища
Хранилище, коллекции, правило времени и идентификаторов. База, таблицы, раскладка файлов, правило времени и идентификаторов.
Хранилище **встроенная PocketBase 0.39.10**: она держит и базу, и файлы Хранилище **своё**: база SQLite через `modernc.org/sqlite` (CGO сборке не нужен)
записей под одним каталогом данных. Ключ конфигурации — `[storage] data_dir`, и файлы записей своим каталогом рядом с ней. Ключ конфигурации один
умолчание `data`. В SQLite библиотека ходит через `modernc.org/sqlite`, поэтому `[storage] data_dir`, умолчание `data`. Встроенная PocketBase, державшая до
CGO сборке не нужен. 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 знаков собственного алфавита. **Идентификаторы** — ULID в нижнем регистре, `TEXT`, 26 знаков алфавита
Свои UUID остались только в **именах файлов**: имя, под которым запись ложится в Crockford. Выдаёт их приложение единой точкой `internal/ident`; внутри одной
хранилище, задаёт сервис, и это `<uuid><расширение>`. миллисекунды выдача монотонна, потому что колонка времени несёт секунды и
порядок записей одной секунды задаёт ключ. Идентификатор, пришедший снаружи,
разбирается на границе: разбор проверяет вид и приводит регистр, а негодный
считается несуществующей записью и до базы не доходит.
**Время** — вид хранилища: строка `2006-01-02 15:04:05.000Z` в UTC. Колонки Тем же идентификатором зовётся **подкаталог записи** в каталоге данных, а имя
`created` и `updated` проставляет само хранилище; те же поля в сыром запросе файла внутри него — `<ULID><расширение>`.
захвата кладёт наш код — **тем же видом**, потому что сравнение строк в SQLite
побайтово, и разошедшийся вид обратил бы условие срока в постоянную истину или
постоянную ложь молча.
Того, что единой точки генерации идентификатора и времени нет, здесь не **Время**`TEXT` в RFC 3339, UTC, суффикс `Z`, секундная точность:
повторяем: перечень единых точек и их отсутствий держит `2006-01-02T15:04:05Z`. Ширина записи постоянная, поэтому лексикографический
[architecture.md](architecture.md), «Единые точки проекта». порядок совпадает с хронологией. Вид один на **все** колонки времени, включая
те, что пишет только сам сервис: своего типа времени у 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` ### `files`
Одна запись на одну физическую копию. Копий у аудиозаписи ровно две: принятая и Одна строка на одну физическую копию. Копий у аудиозаписи ровно две: принятая и
приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не приведённая к рабочему формату. Копия во внешнем хранилище файлом записи не
считается — она существует только потому, что провайдер распознавания читает считается — она существует только потому, что провайдер распознавания читает
аудио по адресу, и её ключ живёт в строке попытки распознавания. аудио по адресу, и её ключ живёт в строке попытки распознавания.
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `id` | TEXT PK | ULID |
| `file` | file | Сам файл | | `owner_id` | TEXT → `users(id)` | Владелец копии; пустого значения не принимает |
| `owner` | relation → `users` | Владелец файла; пустого значения не принимает | | `record_id` | TEXT | Запись, которой копия принадлежит: имя её подкаталога |
| `location` | select | `local` или `s3` | | `file_name` | TEXT | Имя файла в этом подкаталоге; задаёт сервис |
| `object_key` | TEXT | Ключ объекта; заведён прежним шагом и новым путём не заполняется | | `size_bytes` | INTEGER | Размер копии в байтах |
| `size` | INTEGER | Размер в байтах |
| `format` | TEXT | Расширение без точки, в нижнем регистре | | `format` | TEXT | Расширение без точки, в нижнем регистре |
| `duration_ms` | INTEGER | Длительность, если её удалось прочитать | | `duration_ms` | INTEGER | Длительность, если её удалось прочитать |
| `created`, `updated` | DATETIME | Проставляет хранилище | | `created_at` | TEXT | Время |
Поле названо `location`, а не `storage`: последним словом зовут само хранилище и **Внешнего ключа на аудиозапись у `record_id` нет намеренно.** Приём заводит
capability, и третий смысл развёл бы одно слово по разным вещам. файл **до** самой записи — подкаталог назван её идентификатором, и знать его надо
раньше, — и обязательная связь отвергала бы первую же принятую запись. Владелец
при этом лежит своей колонкой, а не выводится через запись: файл переживает свою
запись, и заведённый шагом до её сохранения остаётся с владельцем и без ссылки.
### `audio_records` ### `audio_records`
@@ -65,36 +121,37 @@ capability, и третий смысл развёл бы одно слово п
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `id` | TEXT PK | Идентификатор записи, выдаёт хранилище | | `id` | TEXT PK | ULID |
| `owner` | relation`users` | Владелец записи; пустого значения не принимает | | `owner_id` | TEXT`users(id)` | Владелец записи; пустого значения не принимает |
| `source` | select | `api`, `unknown`; значение `telegram` осталось историческим — вход убран, новых записей с ним не появляется |
| `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком | | `title`, `brief` | TEXT | Заголовок и краткое описание: читаются вместе со списком |
| `original_filename` | TEXT ≤ 255 | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки | | `original_filename` | TEXT | Имя файла, данное отправителем; кладёт приём, обрезав по пределу и убрав управляющие знаки |
| `duration_ms` | INTEGER ≥ 0 | Длительность **принятого**, миллисекунды; ставит приём и всегда | | `duration_ms` | INTEGER, обязателен | Длительность **принятого**, миллисекунды; ставит приём и всегда |
| `size_bytes` | INTEGER ≥ 0 | Размер **принятого**, байты | | `size_bytes` | INTEGER, обязателен | Размер **принятого**, байты |
| `state` | select | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done`; перечень закрыт схемой | | `state` | TEXT | Рубеж: `uploaded`, `normalized`, `submitted`, `transcribed`, `done` |
| `state_entered_at` | DATETIME | Время входа в рубеж — сторож застревания | | `state_entered_at` | TEXT | Время входа в рубеж — сторож застревания |
| `halted_at` | DATETIME | Признак остановки; рубеж при ней не стирается | | `halted_at` | TEXT | Признак остановки; рубеж при ней не стирается |
| `halt_reason` | select | `step_failed`, `attempts_exhausted`, `stuck` | | `halt_reason` | TEXT | `step_failed`, `attempts_exhausted`, `stuck` |
| `error_text` | TEXT | Текст ошибки, машинный | | `error_text` | TEXT | Текст ошибки, машинный |
| `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого | | `acquisition_id` | TEXT | Признак **этого** захвата, уникальный для каждого |
| `acquire_expires_at` | DATETIME | Срок протухания захвата; приезжает с рубежом | | `acquire_expires_at` | TEXT | Срок протухания захвата; приезжает с рубежом |
| `delay_time` | DATETIME | Не брать запись раньше этого времени | | `delay_time` | TEXT | Не брать запись раньше этого времени |
| `attempts` | INTEGER ≥ 0 | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании | | `attempts` | INTEGER | Число **отказов**: растёт при захвате, обнуляется на шаге без отказа и на откладывании |
| `original_file` | relation`files` | Принятая копия | | `original_file_id` | TEXT`files(id)` | Принятая копия |
| `normalized_file` | relation`files` | Копия, приведённая к рабочему формату | | `normalized_file_id` | TEXT`files(id)` | Копия, приведённая к рабочему формату |
| `transcript_text`, `literary_text` | relation → `texts` | Тексты записи | | `transcript_text_id`, `literary_text_id` | TEXT | Тексты записи |
| `structure` | relation → `structures` | Структура реплик | | `structure_id` | TEXT | Структура реплик |
| `recognition` | relation → `recognitions` | Попытка распознавания | | `recognition_id` | TEXT | Попытка распознавания |
| `topics` | relation → `topics`, до 5 | Темы записи | | `created_at`, `updated_at` | TEXT | Время |
| `tg_chat_id` | INTEGER | Адресат ответа у записи убранного входа; кодом не читается |
| `tg_reply_message_id` | INTEGER | Ответное сообщение у неё же; кодом не читается |
| `created`, `updated` | DATETIME | Проставляет хранилище |
Индексов три. Первый — по паре «рубеж и признак остановки»: по ним, паузе и Индексов два. `idx_audio_records_acquire``(state, halted_at, created_at, id)`:
сроку протухания идёт выборка захвата. Два других завела страница списка приложения: по нему идёт отбор захвата, и по нему же он берёт запись в определённом порядке.
`(owner, created DESC, id DESC)` под страницу «новыми сверху» и `idx_audio_records_owner_page``(owner_id, created_at, id)`: под страницу
`(owner, state, halted_at)` под отбор тремя состояниями. списка, сужаемую владельцем и режущуюся полным ключом сортировки.
Оба индекса заведены **начальным шагом**, а не отложены: применённый шаг схемы не
переписывается, и добавление индекса стоило бы отдельного шага. Проверено
`EXPLAIN QUERY PLAN`: ни отбор захвата, ни страница списка не показывают полного
сканирования таблицы.
**Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает **Ведущая колонка у ленты — владелец, и потому индекс захвата ей не помогает
ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса ничем.** Замер на задаче `json-api-for-spa` 2026-08-15: без своего индекса
@@ -109,21 +166,20 @@ capability, и третий смысл развёл бы одно слово п
узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное узнаёт свою запись, пока заголовка нет. Одной колонкой на оба смысла посчитанное
название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит название затирало бы имя, и вернуть затёртое было бы неоткуда. Имя приходит
извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя извне, поэтому приём режет его по пределу и убирает управляющие знаки; в имя
файла в хранилище и в журнал оно по-прежнему не идёт. файла на диске и в журнал оно по-прежнему не идёт.
**Длительность и размер лежат и на записи, и на её файле, и равенство между ними **Длительность и размер лежат и на записи, и на её файле, и равенство между ними
не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый не поддерживается никем — намеренно.** На записи снимок **принятого**, взятый
приёмом один раз; на файле — величины нынешней копии файла. приёмом один раз; на файле — величины нынешней копии. Уточнение длительности
Уточнение длительности меняет вторые и не трогает первые: это разные вопросы — меняет вторые и не трогает первые: это разные вопросы — «что человек прислал» и
«что человек прислал» и «что лежит сейчас». Колонками записи они нужны потому, «что лежит сейчас». Колонками записи они нужны потому, что показываются в списке,
что показываются в списке, а список читается без содержимого. Решение владельца а список читается без содержимого. Решение владельца от 2026-08-15.
от 2026-08-15.
**«Неизвестно» эти колонки не выражают, и это решение владельца от 2026-08-15.** **«Неизвестно» эти колонки не выражают**, и это то же решение владельца: обе
Числовая колонка хранилища пустого значения не держит: пустое она кладёт нулём. величины ставит приём и ставит всегда — запись с непрочитанными метаданными
Платить за отличимость четвёртой колонкой-признаком или текстовым типом у чисел отвергается отказом и не заводится вовсе. Обе объявлены обязательными: пустое
не за что — обе величины ставит приём и ставит всегда, а запись с непрочитанными значение, которое схема теперь допустить может, завело бы третий смысл, которого
метаданными отвергается отказом и не заводится вовсе. никто не читает.
**Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её **Ссылки на файлы две и порознь.** Прежняя модель держала одну и переставляла её
каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем каждым шагом: у прошедшей конвейер записи она вела на копию во внешнем
@@ -131,20 +187,32 @@ capability, и третий смысл развёл бы одно слово п
**Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead` **Остановка — признак, а не рубеж.** Прежние состояния `failed` и `dead`
схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием схлопнуты в `halted_at` с причиной: обе восстанавливаются одинаково — снятием
признака, — и различие между ними перестало быть структурным. Рубеж при признака, — и различие между ними перестало быть структурным.
остановке сохраняется, поэтому запись продолжает с места остановки.
**Сторожей двое.** `attempts` ограничивает повторы внутри шага, **Сторожей двое.** `attempts` ограничивает повторы внутри шага,
`state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не `state_entered_at` — застревание. Прежде обе обязанности несло одно число, и не
справлялось ни с одной. справлялось ни с одной.
### `record_topics`
| Поле | Тип | Что |
| --- | --- | --- |
| `record_id` | TEXT → `audio_records(id)` | Запись |
| `topic_id` | TEXT → `topics(id)` | Тема |
Первичный ключ — пара целиком. Потолок в пять тем на запись держит **триггер**:
без него часовой разговор даёт два десятка тем, и словарь распухает за неделю.
Число берётся у домена — то же самое, которое сервис объявляет приложению.
### `texts` ### `texts`
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `record` | relation → `audio_records` | Чья это расшифровка | | `id` | TEXT PK | ULID |
| `kind` | select | `transcript` или `literary` | | `record_id` | TEXT → `audio_records(id)` | Чья это расшифровка |
| `contents` | editor | Сам текст | | `kind` | TEXT | `transcript` или `literary` |
| `contents` | TEXT | Сам текст |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки. Пара «запись и вид» уникальна: повтор прерванного шага не заводит второй строки.
Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат Поле зовётся `kind`, а не `format`: словом `format` в этой же схеме зовут формат
@@ -154,9 +222,11 @@ capability, и третий смысл развёл бы одно слово п
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `record` | relation → `audio_records` | Чья это структура | | `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это структура |
| `version` | INTEGER | Версия вида разбора | | `version` | INTEGER | Версия вида разбора |
| `contents` | JSON | Реплики со временем | | `contents` | TEXT | Реплики со временем, JSON |
| `created_at`, `updated_at` | TEXT | Время |
Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор Пара «запись и версия разбора» уникальна. Номер версии нужен потому, что разбор
сохранённого ответа изменится раньше, чем архив пересчитают. сохранённого ответа изменится раньше, чем архив пересчитают.
@@ -167,92 +237,86 @@ capability, и третий смысл развёл бы одно слово п
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `record` | relation → `audio_records` | Чья это попытка | | `id` | TEXT PK | ULID |
| `record_id` | TEXT → `audio_records(id)` | Чья это попытка |
| `provider`, `model` | TEXT | Кем и какой моделью считано | | `provider`, `model` | TEXT | Кем и какой моделью считано |
| `external_id` | TEXT | Идентификатор операции у провайдера | | `external_id` | TEXT | Идентификатор операции у провайдера |
| `source_uri` | TEXT | Адрес, по которому провайдер читает аудио | | `source_uri` | TEXT | Адрес, по которому провайдер читает аудио |
| `payload` | file, **защищённое** | Сырой ответ провайдера целиком | | `payload_file` | TEXT | Имя файла с сохранённым ответом провайдера |
| `started_at`, `finished_at` | DATETIME | Границы операции | | `started_at`, `finished_at` | TEXT | Границы операции |
| `created_at`, `updated_at` | TEXT | Время |
**Сырой ответ лежит вложением, а не колонкой.** Шаг опроса читает эту строку раз **Сохранённый ответ лежит третьим файлом в подкаталоге записи, а не колонкой.**
в несколько секунд, а хранилище читает запись целиком: ответ на многочасовую Шаг опроса читает эту строку раз в несколько секунд, а репозиторий читает строку
запись ехал бы в память при каждом опросе. Хранится он потому, что результат целиком: ответ на многочасовую запись, положенный колонкой, ехал бы в память при
операции у провайдера не переспрашивается. каждом опросе. Хранится он потому, что результат операции у провайдера не
переспрашивается. Копией аудио он при этом не считается — их у записи по-прежнему
Поле вложения помечено защищённым: сырой ответ — это полный текст речи, и две, — и адреса, которым его читают снаружи, у сервиса нет вовсе.
умолчание библиотеки отдавало бы его по ссылке любому, кто её знает.
### `record_events` ### `record_events`
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `record` | relation → `audio_records` | Чьё это событие | | `id` | TEXT PK | ULID |
| `origin` | select | `pipeline` или `human` | | `record_id` | TEXT → `audio_records(id)` | Чьё это событие |
| `origin` | TEXT | `pipeline` или `human` |
| `step` | TEXT | Имя шага | | `step` | TEXT | Имя шага |
| `outcome` | select | `done`, `failed`, `halted`, `resumed` | | `outcome` | TEXT | `done`, `failed`, `halted`, `resumed` |
| `outcome_text` | TEXT | Причина, если она есть | | `outcome_text` | TEXT | Причина, если она есть |
| `duration_ms` | INTEGER | Сколько шаг занял | | `duration_ms` | INTEGER | Сколько шаг занял |
| `created_at` | TEXT | Время |
Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо Колонка текста зовётся `outcome_text`, а не `error_text`: последнее имя названо
поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант поимённо инвариантом о секрете, и две колонки с этим именем сделали бы инвариант
двусмысленным. двусмысленным.
Журнал пишется на смену рубежа, на остановку и на снятие остановки — не на Журнал пишется на смену рубежа, на остановку и на возврат в работу — не на
каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить, каждое откладывание опроса. Ни один шаг конвейера его не читает, чтобы решить,
что делать дальше. что делать дальше. Происхождение `human` пишет сегодня подкоманда оснастки,
возвращающая остановленную запись в работу: другого писателя, кроме конвейера, у
журнала не осталось.
### `topics` ### `topics`
| Поле | Тип | Что | | Поле | Тип | Что |
| --- | --- | --- | | --- | --- | --- |
| `owner` | relation → `users` | Чей это словарь | | `id` | TEXT PK | ULID |
| `owner_id` | TEXT → `users(id)` | Чей это словарь |
| `name` | TEXT | Название темы | | `name` | TEXT | Название темы |
| `created_at`, `updated_at` | TEXT | Время |
Пара «владелец и название» уникальна: словарь тем свой у каждого человека. Пара «владелец и название» уникальна: словарь тем свой у каждого человека.
Коллекцией, а не набором строк в записи, потому что перечень тем нужен целиком Отдельной таблицей, а не набором строк в записи, потому что перечень тем нужен
перед каждым обращением к языковой модели. Ни один шаг сегодняшнего сервиса тем целиком перед каждым обращением к языковой модели. Ни один шаг сегодняшнего
не пишет и не читает — место заведено вперёд, чтобы задача, считающая темы, не сервиса тем не пишет и не читает — место заведено вперёд, чтобы задача,
платила вторым необратимым шагом схемы. считающая темы, не платила вторым необратимым шагом схемы.
### Чего в схеме больше нет ### Чего в схеме больше нет
Коллекция `transcribe_jobs` удалена шагом `202608140002`. Данных под ней не было: **Каталог шагов PocketBase удалён целиком, и на его месте стоит один шаг
сервис на сервере остановлен, а прежние записи удалены решением владельца начальной схемы** — `202608220002_init.go`. Это разовое снятие инварианта
2026-08-14 — переноса это изменение не делало. Оставленная пустая коллекция «применённая миграция не переписывается», решением владельца от 2026-08-22:
висела бы в панели вторым домом для понятия, которого больше нет. стадия проекта — стройка, на сервере данных нет, сервис остановлен, а новая база
ведёт учёт применённого своей таблицей, которой отметки прежнего каталога не
годятся вовсе. Снятие кончается этим шагом.
**Владелец записи** заведён шагом `202608140001` — связью с коллекцией `users` в **Колонок `location` и `source` в новой схеме нет.** Обе писались одним значением
обеих таблицах, — и шагом `202608140003` пустого значения больше не принимает. и не читались никем: в `location` уходило `local`, второго значения (`s3`) не
Прежде принимал, и цену за это платили записи входа Telegram: связи чата с писал ни один шаг; в `source` всякий приём писал `api`, а второе значение
учётной записью сервис не вёл. Вход убран 2026-08-14, ничью запись заводить стало (`telegram`) держалось ссылкой из применённого шага, а не потребителем. Шаги
некому, и обязательность переехала из приёма в схему — туда, где её держит ушли, и держать их стало нечем. Поле, у которого появится читатель, вернётся
хранилище, а не договорённость. одним новым шагом схемы.
**Колонки `tg_chat_id` и `tg_reply_message_id`** остались от убранного входа и **Колонок `tg_chat_id`, `tg_reply_message_id` и `object_key` нет по той же
кодом больше не читаются. Из схемы они не убираются: заводили их применённые причине:** их держал применённый шаг, которого больше не существует.
шаги `202608110001` и `202608140002`, а применённый шаг не переписывается.
Выборка по владельцу сужает **чтение записи**: чужая, ничья и несуществующая **Учётная запись с записями не удаляется**, и держит это схема обязательной
дают один и тот же отказ. Выборку воркера владелец не сужает — конвейер связью, а не проверка вызывающего: `audio_records`, `files` и `topics` ссылаются
обрабатывает записи всех. Тот же шаг сузил правило просмотра коллекции `files` на `users(id)` без каскада, а соблюдение внешних ключей включено на каждом
владельцем: прежнее правило пускало всякого вошедшего, и знание идентификатора соединении обоих пулов. Прежде запрет ставил слой приложения — сборка, забывшая
файловой записи равнялось праву скачать чужое аудио. его позвать, теряла защиту молча, и теряла. Адреса, которым учётную запись
удаляют, у сервиса нет вовсе; способа удалить записи тоже нет, и это осознанный
**Учётная запись с записями не удаляется.** Каскадное удаление у связи выключено, тупик до задачи про удаление записи.
но одного этого мало: при выключенном каскаде хранилище снимает ссылку и
сохраняет запись без проверок — записи остались бы, но стали бы ничьими, а ничья
запись не достаётся никому. Отказ ставит слой приложения `GuardOwnerDeletion`,
а не правило коллекции: панель ходит правами суперпользователя, и правило её не
судит. Считаются все коллекции с колонкой владельца — `audio_records`, `files` и
`topics`, — и перечень живёт одним местом: пропущенная коллекция пропускает
удаление вперёд, а наружу приезжает подсказка библиотеки про обязательную связь
вместо нашего отказа с причиной.
**Правила доступа новых коллекций пусты**, то есть перечислять и читать их может
только владелец панели. Содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища; непустое правило открыло бы перечисление коллекции впрок.
Проверено прогоном: анонимный запрос к `/api/collections/*/records` отвечает
`403`, к `/api/logs`, `/api/backups`, `/api/settings` и `/api/crons``401`.
## Представление данных ## Представление данных
@@ -260,70 +324,63 @@ capability, и третий смысл развёл бы одно слово п
- **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват - **Расшифровка лежит отдельной строкой `texts`**, а не колонкой записи. Захват
её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а её не тянет вовсе: он возвращает **идентификатор и признак своего захвата**, а
колонки шаг читает отдельным чтением. Прежде расшифровка стояла колонкой той колонки шаг читает отдельным чтением.
же строки и читалась при каждом опросе очереди. - **Файлы записи лежат подкаталогом на запись:**
- **Аудио лежит в раскладке хранилища:** `data/records/<ULID записи>/<имя>`. Внутри — принятая копия, приведённая копия
`data/storage/<коллекция>/<запись>/<имя>` рядом с файлом атрибутов. Имя задаёт и сохранённый ответ провайдера. Так копии одной записи лежат вместе, а запись
сервис — `<uuid><расширение>`; собственного суффикса хранилище не дописывает, убирается целиком одним движением; плоский каталог, где копии различаются
потому что умолчание, строящее имя из имени отправителя, не применяется. Ни приставкой в имени, обращал бы уборку в перебор по маске. Имя, данное
файлы, ни объекты в Object Storage не удаляются после завершения задачи: отправителем, не попадает ни в имя файла, ни в путь к нему. Ни файлы, ни
каталог и бакет растут неограниченно. объекты в Object Storage не удаляются после завершения записи: каталог и бакет
- **Файл отдаётся ссылкой** `/api/files/<коллекция>/<запись>/<имя>`. Поле файла растут неограниченно.
помечено защищённым шагом `202608120001`, а правило просмотра коллекции - **Укладка атомарна:** содержимое пишется во временное имя **в том же
пускает всякого вошедшего: пройти по ссылке можно только с коротким токеном подкаталоге записи** и переименовывается в рабочее только после того, как поток
файла, который берёт узнанный. Прежнее решение — «право прочитать запись даёт дочитан до конца без отказа. Строка о файле заводится **после** этого;
знание её идентификатора» — отменено задачей `oidc-login` 2026-08-12. Имя файла содержимое легло, а строка не сохранилась — уложенный файл убирается.
в хранилище **в журнал не пишется** по-прежнему: оно последняя часть ссылки. - **Файл отдаётся адресом приложения** —
- **Коллекция `users`** заводится самой библиотекой, а два наших шага её сужают. `GET /app/audiorecords/{id}/file?copy=original|normalized`, — и право пройти по
`202608120001` выключил вход по паролю и одноразовый код; `202608220001` нему даёт узнавание пришедшего и владение записью. Значений на предъявителя
довершил: снял настройки OAuth2 и **все пять правил доступа** — перечисление, сервис не выдаёт вовсе: ни короткого токена файла, ни подписанной ссылки со
чтение, создание, правку и удаление, — оставив их пустыми, что у хранилища сроком. Отзыв доступа доходит до файла сразу, а не через срок жизни выданного
означает «только владелец панели». значения. Имя файла на диске в журнал не пишется и в ответ не идёт.
- **Учётная запись заводится первым обращением** — поиск по `provider_login` и
Правку и удаление умолчание библиотеки открывало владельцу записи вставка идут одной транзакцией на пишущем соединении. Два отказа уникальности
(`id = @request.auth.id`), и до переезда входа это ничему не мешало: слой различаются повторным поиском по ключу: нашёлся — гонка двух первых обращений
предъявления жил под корнем приложения, и браузер до поверхности хранилища не одним логином, не нашёлся — занятая почта, и запись заводится без неё.
дотягивался. С узнаванием по заголовку она достижима, а ключ учётной записи - **Захват записи — один запрос `UPDATE … RETURNING`** по пишущему соединению:
лежит теперь обычной колонкой — правка своей записи была бы присвоением чужого выбор подходящей записи и пометка её захваченной идут вместе. Порядок выборки —
имени. Наш код читает и заводит запись мимо правил, панель работает по времени заведения **и по ключу**: время неуникально, и без ключа порядок
суперпользователем, своих экранов профиля сервис не заводит. обработки невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку
протухания захвата и отсутствию признака остановки; срок протухания выбирается
- **Ключ учётной записи — колонка `provider_login`** с уникальным индексом, по рубежу самой записи прямо в запросе — воркер, ещё не знающий, что вытянет,
заведена шагом `202608220001`. В ней логин человека **у провайдера** — то подставить его не может.
значение, которым его называет обратный прокси заголовком. По нему запись
ищется и по нему же заводится при первом обращении.
Почта в той же коллекции переведена в необязательную тем же шагом: провайдер
не обязан её приносить, а ключом она не служит. Уникальность почты держится
**частичным** индексом (`WHERE email != ''`), поэтому записи без почты
уживаются друг с другом; уникальность логина — обычным, поэтому двух записей с
пустым ключом схема не примет вовсе.
- **Захват записи — один запрос с `RETURNING`**, мимо записей коллекции.
`app.DB()` направляет всё, кроме выборок, в пул с единственным соединением,
поэтому захваты выстраиваются в очередь. Порядок выборки — по времени
заведения **и по ключу**: время неуникально, и без ключа порядок обработки
невоспроизводим. Отбор идёт по рубежам из дескриптора, паузе, сроку протухания
захвата и отсутствию признака остановки; срок протухания выбирается по рубежу
самой записи прямо в запросе — воркер, ещё не знающий, что вытянет, подставить
его не может.
- **Запись результата условна по признаку захвата** — инвариант «Результат пишет - **Запись результата условна по признаку захвата** — инвариант «Результат пишет
только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major); только держатель захвата» в [CLAUDE.md](../CLAUDE.md), «Инварианты» (major);
норма — [pipeline](../openspec/specs/pipeline/spec.md). Здесь названо потому, норма — [pipeline](../openspec/specs/pipeline/spec.md). Условие стоит в самом
что условие проверяется тем же запросом, что и сам захват. запросе правки, поэтому между проверкой и записью не остаётся окна.
- **Список колонок задан двумя местами** — `applyOwnedByPipeline` вместе с - **Колонки записи отображаются по имени**: именованные параметры запроса и место
`applyToRecord` и `recordToAudioRecord`, — плюс шагом схемы. Мест было четыре, назначения, найденное по имени колонки. У аудиозаписи поля одного типа идут
пока захват перечислял колонки поимённо; теперь он возвращает идентификатор, и длинным непрерывным рядом, и позиционный список дал бы сдвиг на одно поле,
перечень перестал расти с моделью. Правило правки и его серьёзность — который компилируется молча и кладёт идентификатор файла в колонку текста.
инвариант в [CLAUDE.md](../CLAUDE.md), «Инварианты»; сверку держат правила Перечень задан двумя местами — `writeOwnedByPipeline` вместе с `writeRecord` и
`readRecordColumns`, — плюс шагом схемы; правило правки и его серьёзность —
инвариант в [CLAUDE.md](../CLAUDE.md), сверку держат правила
`internal/archrules`. `internal/archrules`.
- **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`. - **Перечень рубежей объявлен одним дескриптором** — `internal/entity/stage.go`.
Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя: Из него выводятся выбор шага, отбор захвата, срок протухания и предел простоя:
рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон рубеж, забытый в отборе, не выдаётся ни одному воркеру никогда, а пустой прогон
по инварианту проекта не пишется в журнал и не считается в метрику. по инварианту проекта не пишется в журнал и не считается в метрику.
- **Отказ хранилища наружу не выходит дословно.** Он несёт ключ файла целиком, а - **Отказ базы наружу не выходит дословно.** Отказы чтения и укладки называют
ключ — последняя часть ссылки на скачивание; поэтому чтение и укладка отдают запись её идентификатором и не несут ни имени файла, ни пути к нему: имя —
свой текст с идентификатором записи, а цепочку `%w` обрывают. То же у выгрузки часть пути к чужому аудио. То же у выгрузки в Object Storage: отказ SDK несёт
в Object Storage: отказ SDK несёт полный URL объекта. полный URL объекта.
- **Обращения к базе идут с собственным контекстом**, а не с контекстом запроса.
Отменять там нечего: операции местные и короткие, а единственное ожидание —
занятая база — задано числом. За отмену платили бы дважды: шаг, прерванный
остановкой сервиса, перестал бы освобождать захват и писать причину остановки —
то есть отмена ломала бы ровно ту уборку, ради которой она и делается. Отмена,
которой сервис распоряжается по-настоящему, доходит до `ffmpeg` и до платного
распознавания.
## Настройки с числовым значением ## Настройки с числовым значением
@@ -336,41 +393,46 @@ capability, и третий смысл развёл бы одно слово п
| Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды | | Срок захвата, опрос операции | 1 час | там же | опрос идёт секунды |
| Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды | | Срок захвата, завершение | 1 час | там же | запись текста и ответ идут секунды |
| Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение | | Число воркеров конвейера | 3 | конфиг, `[pipeline] workers` | решение владельца; ноль — законное значение |
| Ожидание занятой базы | 5000 миллисекунд | конфиг, `[storage] busy_timeout_ms` | выведено из числа воркеров, а не замерено: пишет сервис короткими операциями, и очередь из трёх воркеров укладывается в него с запасом |
| Соединений в читающем пуле | 4 | конфиг, `[storage] read_connections` | число воркеров плюс запас под запросы приложения; пишущее соединение при этом всегда одно и настройкой не делается |
| Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже | | Предел простоя, своя работа | 60 минут | конфиг, `[pipeline] own_work_limit_minutes` | решение владельца 2026-08-14: сторож ловит зависание, а не долгую работу. Число **меньше** времени приведения многочасовой записи, и цена названа прямо — остановка обратима. Предел этот работает только по записи, вернувшейся в выборку: см. строку ниже |
| Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого | | Предел простоя, чужая операция | 1440 минут | конфиг, `[pipeline] foreign_work_limit_minutes` | сколько идёт распознавание долгой записи, никто не мерил: ошибаемся в сторону долгого |
| Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая | | Версия вида структуры реплик | 1 | `entity.StructureVersion` | первая |
| Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана | | Умолчание размера страницы списка | 30 | `controller/http.DefaultPageLimit` | столько помещается на экран телефона без прокрутки в два экрана |
| Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром | | Потолок размера страницы списка | 100 | `controller/http.MaxPageLimit` | против того, чтобы попросить весь архив одним запросом и тем обойти постраничность её же параметром |
| Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи | | Ограничитель частоты под `/app/` | 120 запросов за 60 секунд | `controller/http.appRateMaxRequests`, `appRateWindowSec` | сервисом пользуются единицы человек; бюджет считается по адресу спрашивающего, а не по учётной записи |
| Срок жизни неиспользуемого счётчика ограничителя | 10 минут | `controller/http.staleBudgetAge` | карта счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда спрашивающий кладёт по строке на каждый свой адрес |
**Адрес спрашивающего берётся из `X-Forwarded-For`, и это назначается кодом при
подъёме** — `controller/http.ApplyTrustedProxyHeaders`. Без этого хранилище
ключует счётчик адресом пира, а пир с переездом входа на заголовок всегда один и
тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и восемь
одновременно открытых карточек выбирают его целиком. Требование к контуру,
которое отсюда следует, записано в [security.md](security.md), «Периметр»:
`X-Forwarded-For` прокси обязан перезаписывать, а не дописывать.
| Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет | | Доля бюджета под опрос карточки | 1/8 | `controller/http.pollBudgetShare` | опрос идёт не один: в ту же секунду приложение листает список и грузит новую запись. Из этой доли **выводится** объявляемая частота опроса, и своей константы у неё нет |
| Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт | | Потолок длины имени файла отправителя | 255 знаков | `entity.MaxOriginalFilenameLen` | предел длины имени в распространённых файловых системах: длиннее системный диалог выбора файла не даёт |
| Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла | | Потолок длины расширения | 32 знака | `service/transcribe.go`, `maxExtLen` | сторож от патологии, а не перечень: расширения известных форматов укладываются в пять знаков, а `x.` с четырьмястами знаками роняет заведение временного файла |
| Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем | | Потолок тем на запись | 5 | `entity.MaxTopicsPerRecord` | решение владельца: без него часовой разговор даёт два десятка тем |
| Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново | | Срок хранения ресурса приложения | 1 год | `controller/http.assetMaxAgeSeconds` | имена ресурсов несут отпечаток содержимого, поэтому ответ устареть не может; срок ставится только файлам из каталога сборщика, всё прочее браузер спрашивает заново |
| Потолок сохранённого ответа провайдера | 256 МиБ | шаг `202608140002` | ответ многословнее расшифровки: несёт альтернативы, время каждого слова и разбор говорящих |
| Потолок структуры реплик | 16 МиБ | там же | шестичасовой разговор даёт порядка мегабайта текста с временем |
| Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было | | Задержка перед первой проверкой операции | 10 секунд | `service/transcribe.go` | как было |
| Задержка между проверками операции | 5 секунд | там же | как было | | Задержка между проверками операции | 5 секунд | там же | как было |
| Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было | | Пауза воркера между прогонами | 1 секунда | `controller/worker/worker.go` | как было |
| Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — | | Таймаут мягкой остановки | 5 секунд | конфиг, `[server] shutdown_timeout` | — |
| Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — | | Таймаут жёсткой остановки | 20 секунд | конфиг, `[server] force_shutdown_timeout` | — |
| Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — | | Качество кодирования vorbis | `-q:a 4` | `adapter/converter/ffmpeg/ffmpeg.go` | — |
| Жизнь приглашения завести владельца панели | 30 минут | умолчание PocketBase | — |
| Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео | | Потолок размера одной записи | 8 ГиБ | `entity.MaxRecordSize` | расчётный потолок в шесть часов с запасом на видео |
| Предел длины логина у провайдера | 255 знаков | `pbrepo.MaxProviderLoginLength` и колонка `provider_login` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени в умолчании библиотеки | | Предел длины логина у провайдера | 255 знаков | `entity.MaxProviderLoginLength` | значение приходит заголовком, то есть задаётся тем, кто шлёт запрос; число то же, что у имени, пригодного к показу |
| Предел длины имени, пригодного к показу | 255 знаков | `entity.MaxDisplayNameLength` | то же |
| Длина идентификатора | 26 знаков | `ident.Len` | ширина записи ULID |
Три числа отсюда ушли 2026-08-22 вместе с собственным входом: срок жизни сессии, **Адрес спрашивающего ограничитель берёт из `X-Forwarded-For` — и только тогда,
потолок времени на вход у провайдера и таймаут обмена кода. Сессия не выдаётся когда соединение пришло с адреса из объявленного перечня доверенных.** Без этого
вовсе, обменивать код не на что, а отзыв доступа судит провайдер на каждом счётчик ведётся по адресу пира, а пир с переездом входа на заголовок всегда один
запросе — задержке, которую измерял срок сессии, теперь неоткуда взяться. и тот же — обратный прокси; бюджет тогда становится общим на весь сервис, и
восемь одновременно открытых карточек выбирают его целиком. Обратная ошибка —
верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого он
ограничивает. Сама цепочка читается справа налево с отбрасыванием доверенных
адресов, поэтому дописывающий прокси правилом покрыт; почему так —
[security.md](security.md), «Периметр».
Числа, ушедшие отсюда со встроенным хранилищем: потолок сохранённого ответа
провайдера и потолок структуры реплик — их держало поле коллекции, а теперь ответ
лежит файлом, а структура текстовой колонкой; жизнь приглашения завести владельца
панели — панели нет. Прежде, вместе с собственным входом, ушли срок жизни сессии,
потолок времени на вход у провайдера и таймаут обмена кода.
**У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел **У сторожа простоя есть второй потолок, и он не тот, что в настройке.** Предел
простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок простоя проверяется в момент захвата, а захват не выдаёт запись, чей срок
@@ -381,15 +443,12 @@ capability, и третий смысл развёл бы одно слово п
остановка «застряла» наступает только после него. Мягкая остановка сюда не остановка «застряла» наступает только после него. Мягкая остановка сюда не
подпадает: она снимает захват сама. подпадает: она снимает захват сама.
**Потолок размера назван числом в двух местах сразу** — у поля файла в схеме и у **Потолок размера назван числом там, где иначе действует умолчание**у тела
тела запроса приёма, — и оба умолчания пришлось перекрыть: нулевой потолок поля запроса приёма, и назван дважды: объявленная длина судится заранее, а
библиотека читает не как «без предела», а как свои 5 МиБ, а роутер отсекает тело необъявленная и солгавшая ловятся на чтении. Умолчания здесь не «без предела», а
на 32 МиБ раньше обработчика. Оставленные умолчания отвергали бы всё длиннее величины на два-три порядка меньше нужного. Таймаут чтения запроса снят: шесть
примерно пяти минут. Таймаут чтения запроса снят: шесть часов записи по часов записи по медленному каналу переживают любой фиксированный, а стойкость к
медленному каналу переживают любой фиксированный, а стойкость к целенаправленной целенаправленной нагрузке объявлена вне модели угроз.
нагрузке объявлена вне модели угроз.
Чего среди настроек **нет**: режим журналирования, таймаут занятости и размер Чего среди настроек **нет**: срока хранения файлов и объектов нет вовсе.
пула соединений задаёт хранилище своими умолчаниями, а не мы; срока хранения Таймаутов у обращений к S3 и SpeechKit тоже нет — ни одного.
файлов и объектов нет вовсе. Таймаутов у
обращений к S3 и SpeechKit тоже нет — ни одного.
+11 -10
View File
@@ -65,9 +65,9 @@ Telegram.
записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит** записи у сервиса при этом есть, и границы это не двигает: сервис **зеркалит**
имя, названное провайдером, — заводит строку при первом обращении под новым имя, названное провайдером, — заводит строку при первом обращении под новым
именем и связывает с ней записи владельца. Кто этот человек и пускать ли его, именем и связывает с ней записи владельца. Кто этот человек и пускать ли его,
сервис не решает никогда. Одно исключение появилось 2026-08-11 вместе с сервис не решает никогда. Исключений у этого больше нет: панель администратора
решением про PocketBase: в панель администратора владелец входит своим со своим паролем владельца жила здесь с 2026-08-11 по 2026-08-22 и ушла вместе
паролем, потому что подпустить к ней внешнего провайдера PocketBase не даёт. со встроенным хранилищем — своего входа сервис не ведёт вовсе.
- **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не - **Живая расшифровка.** Работаем с готовой записью, поток в реальном времени не
обрабатываем. обрабатываем.
- **Диктофон.** Запись звука делает телефон, а приложение принимает готовый - **Диктофон.** Запись звука делает телефон, а приложение принимает готовый
@@ -117,10 +117,11 @@ Telegram.
которой пользуемся: она и задаёт потолок по длине записи и формату. которой пользуемся: она и задаёт потолок по длине записи и формату.
- **Whisper и его серверные обёртки** — запасной путь, если внешний сервис - **Whisper и его серверные обёртки** — запасной путь, если внешний сервис
перестанет устраивать по цене или по качеству русской речи. перестанет устраивать по цене или по качеству русской речи.
**PocketBase** из референсов ушла: она больше не кандидат — в стек её перевела **PocketBase** побывала и референсом, и стеком, и ушла из проекта целиком.
задача `pocketbase-storage` 2026-08-12 Референсом она быть перестала 2026-08-12, когда задача `pocketbase-storage`
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)); там она перевела её в стек; стеком — 2026-08-22, когда задача
держит хранилище, файлы и панель владельца. Схема и `storage-without-pocketbase`
раскладка — [database.md](database.md). Учётные записи она хранит, а заводит их ([adr](adr/ADR-2026-08-22-storage-without-pocketbase.md)) убрала её вместе с
сервис по имени, названному Authelia; источником людей она при этом не панелью владельца и собственным адресным пространством. Хранилище у сервиса своё:
становится: заводит и проверяет их по-прежнему Authelia. SQLite напрямую и файлы записей своим каталогом. Схема и раскладка —
[database.md](database.md).
+5
View File
@@ -20,6 +20,11 @@ SpeechKit, Yandex Object Storage и `ffmpeg`. Мерить нужно то, чт
## Записи ## Записи
Две записи о PocketBase — [pocketbase.md](pocketbase.md) и
[pocketbase-defaults.md](pocketbase-defaults.md) — описывают библиотеку, ушедшую
из проекта 2026-08-22. Они остаются записями о прошлом, и строкой в каждой это
сказано.
| Дата | Запись | О чём | | Дата | Запись | О чём |
| --- | --- | --- | | --- | --- | --- |
| 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё | | 2026-08-22 | [Хранилище: PocketBase против голого SQLite с каталогом файлов](storage-without-pocketbase.md) | Шесть ролей библиотеки в этом коде, отпавший довод перевода, объём кода на её типах, шесть модулей только через неё |
+8
View File
@@ -1,5 +1,13 @@
# PocketBase: умолчания, которые ломают штатный сценарий # 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-storage` уже на своём коде. От
[записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт [записки разведки](pocketbase.md) отличаются предметом: та мерила, **что даёт
панель**, эта — **что библиотека делает молча**, если её не переубедить. панель**, эта — **что библиотека делает молча**, если её не переубедить.
+6
View File
@@ -1,5 +1,11 @@
# PocketBase: что даёт панель администратора # 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`: что панель показывает и Отвечает на вопрос разведки `pocketbase-admin-fit`: что панель показывает и
правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы правит по трём частям — записи, пользователи, файлы, — и хватает ли этого, чтобы
держать перевод хранилища в планах. держать перевод хранилища в планах.
+86 -18
View File
@@ -72,11 +72,11 @@
- вшито то, что собрано этим прогоном, а не то, что осталось от прошлого; - вшито то, что собрано этим прогоном, а не то, что осталось от прошлого;
- шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он - шаг следует словарю кодов: отказ сети и реестра — 3, красная сборка — 1, и он
**отказывает, а не висит**; **отказывает, а не висит**;
- путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала — и - путь, выбранный анонимом, не уходит ни меткой метрики, ни строкой журнала.
журналов **два**: свой, в вывод контейнера, и журнал хранилища, куда Журнал у сервиса с 2026-08-22 **один** свой, в вывод контейнера: второй
библиотека кладёт путь целиком вместе с адресом отправителя. Второй молчит ушёл вместе со встроенным хранилищем, которое клало путь целиком вместе с
только на успехе и только потому, что признак отказа от записи поставлен адресом отправителя. Правило при этом расширилось, а не сузилось: путь не
руками: готовая раздача статики ставит его сама, своя — нет. пишется дословно ни под каким корнем, включая корень приложения.
**Клиент внешнего сервиса** (`adapter/recognizer/yandex`): **Клиент внешнего сервиса** (`adapter/recognizer/yandex`):
@@ -86,17 +86,20 @@
- вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в - вырожденный ответ (пустой, усечённый, без ожидаемого поля) не превращает в
успех молча. успех молча.
**Репозиторий хранилища** (`internal/adapter/repo/pocketbase`; шаги схемы — **Репозиторий хранилища** (`internal/adapter/repo/sqlite`; шаги схемы —
подпакетом `migrations`): подпакетом `migrations`):
- список колонок совпадает в обоих местах — `applyOwnedByPipeline` вместе с - список колонок совпадает во всех трёх местах — `writeOwnedByPipeline` вместе с
`applyToRecord` и `recordToAudioRecord` — и в шаге схемы (инвариант `writeRecord`, `readRecordColumns` и `rowToAudioRecord` — и в шаге схемы
[CLAUDE.md](../CLAUDE.md), «Инварианты»); (инвариант [CLAUDE.md](../CLAUDE.md), «Инварианты»). Колонки называются
**именами**: именованный параметр запроса и место назначения по имени, а не
позиция в списке;
- захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только - захват задачи не выдаёт одну строку двум вызывающим, а результат пишет только
держатель захвата; держатель захвата, и держатель узнаётся значением признака;
- репозиторий кладёт время в сыром запросе тем же видом, каким хранилище пишет - репозиторий кладёт время тем же видом, каким его кладут остальные, и берёт его
свои `created`/`updated` ([database.md](database.md), «Представление данных»); из единой точки ([database.md](database.md), «Представление данных»);
- отказ хранилища не выходит наружу дословно: он несёт ключ файла целиком. - отказ хранилища не выходит наружу дословно: он несёт ключ файла и путь к нему
целиком.
**Обёртка над внешним процессом** (`adapter/converter/ffmpeg`, **Обёртка над внешним процессом** (`adapter/converter/ffmpeg`,
`adapter/metaviewer/ffmpeg`): `adapter/metaviewer/ffmpeg`):
@@ -256,7 +259,7 @@
- изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и - изменение, трогающее конвейер задач целиком: состояние, воркер, шаг сервиса и
колонку разом; колонку разом;
- замена хранилища или переход на PocketBase — любой её кусок; - замена хранилища — любой её кусок;
- смена модели очереди: захват, повторы и воркеры разом; - смена модели очереди: захват, повторы и воркеры разом;
- каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом; - каркас приложения: сборка фронтенда, раздача статики и шаг гейта разом;
- изменение, убирающее или возвращающее вход приёма целиком. - изменение, убирающее или возвращающее вход приёма целиком.
@@ -301,9 +304,10 @@ API и имя не откатываются обратной правкой по
**весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать **весь** барьер: он обязан заголовки `Remote-*` перезаписывать, а не пропускать
пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server` пришедшие. Проверить это отсюда нечем — правило живёт в `pet-project-server`
([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md)); ([adr/ADR-2026-08-12-access-delegated-to-provider.md](adr/ADR-2026-08-12-access-delegated-to-provider.md));
- `security`: поведение браузера с куками. Своих кук сервис больше не ставит - `security`: поведение браузера с куками. Своих кук сервис не ставит с
(2026-08-22), и класс сузился до кук, которые ставит панель хранилища; браузера 2026-08-22, а вместе со встроенным хранилищем ушли и те, что ставила его
в прогоне нет, и находки этого рода остаются гипотезами. панель. Класс опустел, и строка стоит здесь затем, чтобы возврат кук читался
как возврат недоступного проверке, а не как обычная работа.
**Перестали проверять сознательно:** **Перестали проверять сознательно:**
@@ -315,7 +319,8 @@ API и имя не откатываются обратной правкой по
- **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять - **работа сервиса с настоящими внешними собеседниками.** Сам сервис поднять
можно: он встаёт своим единственным входом на выдуманных непустых ключах можно: он встаёт своим единственным входом на выдуманных непустых ключах
секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон — секций `[auth]` и `[yandex]` — наружу они на старте не ходят. Живой прогон —
осмотр HTTP, панели, журнала, метрик и остановки — доступен любой задаче. осмотр HTTP, журнала, метрик и остановки — доступен любой задаче; панели среди
предметов осмотра нет с 2026-08-22.
Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей Прежняя формулировка «всё, что требует поднять сервис целиком» снята задачей
`local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды — `local-run-without-telegram-token` 2026-08-13; рецепт прогона менялся дважды —
с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же с пустого ключа доступа на выключенный вход (`telegram-enabled-flag` того же
@@ -341,6 +346,69 @@ API и имя не откатываются обратной правкой по
истории git 2026-08-10: поле «Чем воспроизведён» называет у них коммит, а не истории 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 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью] ## 2026-08-15 — короткая форма рецепта входа не работала, а проверяли длинную [пойман ревью]
- **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа; - **Где:** `cmd/oidcstub` — подставной провайдер OIDC для локального входа;
+103 -109
View File
@@ -43,27 +43,22 @@ Telegram — связи чата с учётной записью сервис
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
уходит содержимое записи» и «Что вне модели». уходит содержимое записи» и «Что вне модели».
**Третий сдвиг — панель администратора.** Решением от 2026-08-11 **Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
становится PocketBase, и вместе с ним на том же порту появляется панель по появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом. разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
панель через Authelia не пускает — у неё свой пароль суперпользователя. панели не существует, второго периметра на порту сервиса не осталось, и правилу
**Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11 прокси нечего закрывать.
адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
**И этот барьер обходится подменой одного знака.** Маршрутизатор сравнивает **Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
сегменты пути **после** раскодирования, поэтому `/%5f/` попадает в ту же группу, сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
что и `/_/`, а правило прокси написано на литерал и такой формы не видит. `/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
Проверено прогоном 2026-08-15 ревью задачи `spa-skeleton`: обе формы отвечают клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом `spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
не обходится — `/%61pp/me` отвечает `401`. Дефект старше задачи, которая его оба попадают под общее правило неизвестного пути и отдают разметку приложения.
нашла, и **сегодня не закрыт**: лечение — приведение пути к канонической форме на Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
прокси живёт в `pet-project-server`, вне этого репозитория.
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача **Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его `oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
@@ -82,14 +77,17 @@ Telegram — связи чата с учётной записью сервис
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает `Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
человек. человек.
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.** **`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса С 2026-08-22 адрес спрашивающего ограничитель частоты берёт из этого заголовка:
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а иначе счётчик ведётся по адресу пира, а пир теперь всегда один — прокси, — и
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси, бюджет становится общим на весь сервис. Цепочка читается **справа налево**,
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика доверенные адреса отбрасываются, и ключом становится первый недоверенный: левым
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером значением распоряжается сам спрашивающий, а правое приписал ближайший к нам
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого прокси. Заголовок читается всеми строками, а не одной: цепочка законно приходит
соединения. несколькими. Прокси, дописывающий `X-Forwarded-For` к присланному, этим правилом
покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в
отличие от `Remote-*`. Барьером узнавания заголовок при этом не служит: кто
пришёл, решает адрес самого соединения.
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.** **Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
@@ -150,7 +148,7 @@ Telegram — связи чата с учётной записью сервис
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом 2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
и в панели владельца. приложения.
Целевой периметр добавляет три пути, каждый — своей задачей: Целевой периметр добавляет три пути, каждый — своей задачей:
@@ -170,44 +168,40 @@ Storage, оттуда его читает SpeechKit. Третий путь —
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался. путь не предполагался.
- **Путь на диске** выбирает хранилище: - **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени `<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
отправителя, не применяется: имя отправителя в хранилище не попадает. них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла. каталогов, но это единственное, что стоит между входом и именем файла. Длина
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
роняет заведение временного файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет. С расширением. Бакет один на все записи, префикса по пользователю нет. С
2026-08-14 копия там файлом записи не считается: она существует лишь потому, 2026-08-14 копия там файлом записи не считается: она существует лишь потому,
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
распознавания. распознавания.
- **Вторая раскладка файла на диске** появилась 2026-08-14 вместе с сохранённым - **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
ответом провайдера: `data/storage/<recognitions>/<попытка>/<имя>.payload`. Имя записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
задаёт сервис, как и у аудио. Содержимое там — **полный текст речи**, а не речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
метаданные, поэтому поле помечено защищённым, правило просмотра коллекции которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и журнал, ни в метку метрики, ни в ответ.
ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с - **Адрес файла**`GET /app/audiorecords/{id}/file?copy=original|normalized`.
чужим и со своим токеном файла ссылка отвечает «не найдено». Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
- **Ссылка на файл**`/api/files/<коллекция>/<запись>/<имя>`. Поле файла судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
можно только с коротким токеном файла, который выдаётся по сессии, и запрос хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет — выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
остаётся: **имя файла в хранилище в журнал не пишется** идёт расширение своим полем.
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку - **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
целиком и работала бы бессрочно. В журнал идёт расширение своим полем. единственное, что защищает карточку записи, её текст и её файл сверх владения.
- **Идентификатор записи** — 15 знаков, выдаёт хранилище. Он же единственное, - **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
что защищает карточку записи и её текст. `/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то [webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
есть доступны они только владельцу панели, — и коллекции, заведённые везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не
поверхность хранилища. Коды, снятые прогоном, —
[database.md](database.md), «Коллекции», норма —
[storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только
то, что заказано».
Целевой периметр добавляет сюда три вещи, и все три — от новых задач: Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
@@ -227,33 +221,34 @@ Storage, оттуда его читает SpeechKit. Третий путь —
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения, заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
отзыв доступа у Authelia действует со следующего обращения. отзыв доступа у Authelia действует со следующего обращения.
Предъявленный собственный токен хранилища побеждает заголовок: им работает Собственных токенов сервис не принимает вовсе: значения, предъявленного
владелец панели, и подмена его учётной записью пользователя отобрала бы у него запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
панель. Протухший и негодный токен предъявленными не считаются. значение било заголовок — им работал владелец панели; панели нет, и правило
**Область узнавания сужена** до корня приложения и адреса выдачи файлового приоритета осталось бы правилом без предмета.
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный **Область узнавания — корень приложения**, и выводится она из объявленного
переписал бы себе ключ учётной записи на чужое имя. адресного пространства сервиса: слои одеты на корень целиком, вторым списком
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
базе, а первый такой запрос с новым именем — записи в неё.
- **Учётная запись** — заводится первым обращением с новым логином и находится - **Учётная запись** — заводится первым обращением с новым логином и находится
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
схемы `202608220001`. своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла библиотечным правилом, не осталось.
помечено защищённым, правило просмотра коллекции пускает только владельца - **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
единственное значение, переживающее запрос**, и на его срок отзыв доступа до сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
файловой ссылки не доходит.
- **Кто допущен****решает Authelia, а не сервис.** Своей проверки группы - **Кто допущен****решает Authelia, а не сервис.** Своей проверки группы
приложение не делает: кого пускать, определяет правило провайдера на этого приложение не делает: кого пускать, определяет правило провайдера на этого
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
его не проверить. Клиент, настроенный слишком широко, открывает сервис его не проверить. Клиент, настроенный слишком широко, открывает сервис
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
2026-08-12. 2026-08-12.
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по - **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
доступа и продление — ни один не даёт доступа и не меняет учётной записи: продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
хранилище заводит коллекцию пользователей открытой, и без этого закрытия больше нечего, и адресов этих не существует.
узнавание обходилось бы двумя запросами.
- **Метрики и здоровье**`GET /metrics` и `GET /health` открыты неузнанному: - **Метрики и здоровье**`GET /metrics` и `GET /health` открыты неузнанному:
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
учётной записи на них не заводит. Наружу их закрывает правило обратного учётной записи на них не заводит. Наружу их закрывает правило обратного
@@ -284,23 +279,27 @@ Storage, оттуда его читает SpeechKit. Третий путь —
Откуда он берётся — из группы OIDC или из конфигурации — не решено Откуда он берётся — из группы OIDC или из конфигурации — не решено
(`admin-stats-screen`). (`admin-stats-screen`).
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.** **Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей 2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia. всех пользователей мимо любого из механизмов разграничения, а пускал его свой
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя: пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение сервиса осталось одно — владение записью.
по списку адресов (`superuserIPs`), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет. Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
поверхности, открытой в интернет, у неё нет вовсе.
## Что чувствительнее чего ## Что чувствительнее чего
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка; 1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание), колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем), `texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
`recognitions` (**сырой ответ провайдера вложением — полный текст речи**), `recognitions` (попытка распознавания; **сохранённый ответ провайдера —
полный текст речи — лежит файлом в подкаталоге записи**),
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь `record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
наравне с записью — норму держит спека `storage`. наравне с записью — норму держит спека `storage`.
2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage. 2. **Ключи Yandex Cloud**`speech_kit_api_key` и пара ключей Object Storage.
Утечка оплачивается деньгами и доступом к бакету. Утечка оплачивается деньгами и доступом к бакету.
@@ -325,14 +324,10 @@ Storage, оттуда его читает SpeechKit. Третий путь —
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит, 5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
но говорит, кто и когда пользовался сервисом и сколько; страница расхода но говорит, кто и когда пользовался сервисом и сколько; страница расхода
открыта только владельцу. открыта только владельцу.
6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
пользователей разом, то есть стоит вровень с самым чувствительным из списка самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
выше. Второй секрет после токенов пользователей, который лежит **не в пропал, и ключа под него в конфигурации не заводится по той простой причине, что
конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец заводить нечего.
по приглашению, которое сервис печатает в журнал при первом запуске. У
приглашения тридцать минут жизни, и после того как владелец заведён, оно не
печатается вовсе — иначе строка журнала отдавала бы панель всякому его
читателю навсегда.
Тексты расшифровок в логи не пишутся — логируется длина текста и Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
@@ -355,7 +350,7 @@ Storage, оттуда его читает SpeechKit. Третий путь —
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем. Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила `mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
никогда. Ряды, собранные до выкладки, перестают пополняться: панель, отобранная никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
по старому значению, покажет пустоту, и это не поломка. по старому значению, покажет пустоту, и это не поломка.
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки приведения хвост читал бы кто угодно из интернета, а множеством значений метки
@@ -422,14 +417,13 @@ Storage, оттуда его читает SpeechKit. Третий путь —
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
строки потребления текста не содержат. строки потребления текста не содержат.
**Руками запись сегодня не удаляется, и прежняя строка об этом была неверна.** **Руками запись сегодня убирается только запросом к базе, и порядок в нём
Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
выше («Что чувствительнее чего»), связи приложений с записью обязательны и чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
каскада не имеют, поэтому удаление самой удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
строки записи отвергается хранилищем, а удаление её файлов проходит молча. сперва строки приложений — журнал событий, попытка распознавания, структура,
Владелец, выполнивший прежнюю процедуру, стирает аудио и **оставляет полный тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
текст речи** — расшифровку, разбивку по репликам и сырой ответ провайдера убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
файлом на диске. Порядок, которым запись убирается на самом деле: сперва кто убрал только файлы, стирает аудио и **оставляет полный текст речи**
строки приложений — журнал событий, попытка распознавания вместе с её расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
вложением, структура, тексты, — потом сама запись, потом её файлы. До
`delete-record` это единственный способ, и он ручной целиком. `delete-record` это единственный способ, и он ручной целиком.
+11 -26
View File
@@ -12,18 +12,16 @@ require (
github.com/aws/smithy-go v1.27.7 github.com/aws/smithy-go v1.27.7
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.3
github.com/pocketbase/ozzo-validation/v4 v4.3.0
github.com/pocketbase/pocketbase v0.39.10
github.com/prometheus/client_golang v1.23.0 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 github.com/yandex-cloud/go-genproto v0.17.0
google.golang.org/grpc v1.82.1 google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.57.0
) )
require ( 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/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/feature/ec2/imds v1.18.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
@@ -40,41 +38,28 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/mfridman/interpolate v0.0.2 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect go.uber.org/multierr v1.11.0 // 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
golang.org/x/net v0.57.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/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.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/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 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/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.55.0 // indirect
) )
+35 -89
View File
@@ -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 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= 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= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
@@ -47,147 +42,99 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 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-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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= 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 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pressly/goose/v3 v3.27.3/go.mod h1:Dag+xpV6o20HR2LFY1j0q6MDwc3f7vPUFDA77R+0yGY=
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/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= 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_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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 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 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 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.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/yandex-cloud/go-genproto v0.17.0 h1:uQ5Lr8B/xIyY1KrOm7pItYY3YT/DL1O8gVaY03ouYKM= 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= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= 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 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= 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 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= 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.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= 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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
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=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= 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 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= 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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= 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 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= 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 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= 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 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/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-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
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/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= 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/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
@@ -195,11 +142,10 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= 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 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
@@ -210,8 +156,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/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 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 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.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -220,8 +166,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 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.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= 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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-65
View File
@@ -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,264 +0,0 @@
package pocketbase
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
validation "github.com/pocketbase/ozzo-validation/v4"
"github.com/pocketbase/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// MaxProviderLoginLength — предел длины логина у провайдера.
//
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
// же, что у имени в умолчании библиотеки: длиннее имени логин не бывает, а два
// разных предела на соседних колонках одной записи разошлись бы молча.
const MaxProviderLoginLength = 255
// MaxDisplayNameLength — предел длины имени, пригодного к показу. Число то же и
// по той же причине: столько держит колонка имени в умолчании библиотеки.
const MaxDisplayNameLength = 255
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а
// негодный ввод, и звать по нему учётную запись не надо.
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
// Identity — то, чем доверенный источник называет пришедшего.
//
// Логин — ключ, остальное берётся только при заведении записи.
type Identity struct {
Login string
Name string
Email string
}
// EnsureUser находит учётную запись по логину у провайдера, а не найдя —
// заводит её.
//
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
// представиться — личные токены — придёт следующей задачей и возьмёт этот же
// метод; правило, уложенное куском в слой транспорта, пришлось бы тогда либо
// дублировать вторым куском, либо вытаскивать задним числом.
//
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
// его работы.
//
// Сравнение точное, знак в знак: приведение регистра завело бы правило, которого
// у провайдера нет, — считает ли он `admin` и `Admin` одним человеком, сервису
// неизвестно, а угаданное правило склеило бы двух разных людей.
func EnsureUser(app core.App, identity Identity) (record *core.Record, created bool, err error) {
login, ok := AcceptProviderLogin(identity.Login)
if !ok {
return nil, false, ErrLoginNotAcceptable
}
record, err = findUserByLogin(app, login)
if err != nil {
return nil, false, err
}
if record != nil {
return record, false, nil
}
users, err := findCollection(app, migrations.UsersCollection)
if err != nil {
return nil, false, err
}
record = core.NewRecord(users)
record.Set(migrations.ProviderLoginField, login)
// Имя и почта принимаются так же, как логин, а не кладутся как есть.
// Значения приходят заголовками, то есть задаются тем, кто шлёт запрос;
// имя длиннее предела колонки отвергается проверкой записи, и человек с
// таким именем у провайдера не завёлся бы **никогда** — каждый его запрос
// отвечал бы отказом сервиса. Негодное значение необязательного поля не
// вправе отменять заведение записи.
record.Set("name", acceptDisplayName(identity.Name))
if email, ok := acceptEmail(identity.Email); ok {
record.SetEmail(email)
}
// Пароль записи обязателен при любом значении признака — это проверка самой
// библиотеки, а не колонки. Ставится случайный: употребить его нельзя,
// потому что вход по паролю у коллекции выключен шагом схемы.
record.SetRandomPassword()
if err := app.Save(record); err != nil {
record, err = retryAfterConflict(app, login, record, err)
return record, record != nil, err
}
return record, true, nil
}
// retryAfterConflict разбирает отказ сохранения. Два отказа уникальности здесь
// разные, и исход у них разный.
//
// По **ключевой** колонке — это гонка двух первых обращений одним логином:
// запись успел завести соседний запрос, и надо просто взять его. Отказ, который
// после повторного поиска никуда не делся, — уже не гонка, и его отдают наверх.
//
// По **любой другой** — почта, пришедшая от провайдера, занята другой учётной
// записью: общий почтовый ящик, семья, группа. Запись заводится без почты; она
// необязательна, а ключом не служит. Без этого разреза второй человек с общим
// адресом не завёлся бы никогда — повторный поиск по логину снова ничего не
// нашёл бы, и исход выродился бы либо в цикл, либо в вечный отказ без внятной
// причины.
func retryAfterConflict(app core.App, login string, record *core.Record, saveErr error) (*core.Record, error) {
if isUniqueViolation(saveErr, migrations.ProviderLoginField) {
existing, err := findUserByLogin(app, login)
if err != nil {
return nil, err
}
if existing != nil {
return existing, nil
}
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
}
if !isUniqueViolation(saveErr, core.FieldNameEmail) {
return nil, fmt.Errorf("failed to create user account: %w", saveErr)
}
record.SetEmail("")
if err := app.Save(record); err != nil {
return nil, fmt.Errorf("failed to create user account without email: %w", err)
}
return record, nil
}
// findUserByLogin ищет учётную запись по ключу. Значение уходит хранилищу
// **параметром** запроса, а не подстановкой в текст фильтра: строка приходит
// снаружи, и подставленная в текст она правила бы сам запрос, а не только его
// аргумент.
func findUserByLogin(app core.App, login string) (*core.Record, error) {
records, err := app.FindRecordsByFilter(
migrations.UsersCollection,
migrations.ProviderLoginField+" = {:login}",
"", 1, 0,
map[string]any{"login": login},
)
if err != nil {
return nil, fmt.Errorf("failed to look up user account: %w", err)
}
if len(records) == 0 {
return nil, nil
}
return records[0], nil
}
// uniqueViolationCode — каким кодом библиотека называет отказ уникальности.
//
// Разбор идёт по **коду**, а не по имени текста и не по тексту драйвера: текст
// у драйвера свой на каждую версию, а имя колонки не говорит о причине.
const uniqueViolationCode = "validation_not_unique"
// isUniqueViolation говорит, отказала ли по названной колонке проверка
// **уникальности** — а не какая-нибудь другая.
//
// Разница не педантизм. Под ключом `email` библиотека складывает и отказ
// уникальности, и отказ формы адреса; проверка «есть ли ключ в карте» считала
// бы опечатку прокси занятым семейным ящиком и молча заводила бы запись без
// почты. На ключевой колонке та же неточность когда-нибудь выстрелит громче:
// любой отказ проверки логина читался бы как гонка двух первых обращений.
func isUniqueViolation(err error, field string) bool {
var errs validation.Errors
if !errors.As(err, &errs) {
return false
}
fieldErr, ok := errs[field]
if !ok {
return false
}
var object validation.ErrorObject
if !errors.As(fieldErr, &object) {
return false
}
return object.Code() == uniqueViolationCode
}
// acceptDisplayName приводит имя к годному для колонки значению.
//
// Обрезается по пределу колонки и чистится от управляющих знаков — тем же
// приёмом, каким приём записи чистит имя файла отправителя. Пустое значение
// законно: имени у человека может не быть вовсе.
func acceptDisplayName(value string) string {
name := strings.TrimSpace(stripControls(value))
runes := []rune(name)
if len(runes) > MaxDisplayNameLength {
return string(runes[:MaxDisplayNameLength])
}
return name
}
// acceptEmail отдаёт адрес почты, если он вообще похож на адрес.
//
// Негодный отбрасывается **здесь**, а не отказом сохранения: иначе опечатка в
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью
// «почта занята», и владелец искал бы общий ящик там, где сломан контур.
func acceptEmail(value string) (string, bool) {
email := strings.TrimSpace(value)
if email == "" {
return "", false
}
if err := is.EmailFormat.Validate(email); err != nil {
return "", false
}
return email, true
}
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
// им делать нечего.
func stripControls(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, value)
}
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
// отвергает его.
//
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
//
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
// называет одного человека, а две записи о нём разошлись бы молча.
func AcceptProviderLogin(value string) (string, bool) {
login := strings.TrimSpace(value)
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и
// два предела в разных единицах разошлись бы вдвое на любой кириллице.
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
return "", false
}
for _, r := range login {
if unicode.IsControl(r) {
return "", false
}
}
return login, true
}
@@ -1,316 +0,0 @@
package pocketbase
import (
"strings"
"sync"
"testing"
"unicode/utf8"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// Проверки узнавания: как учётная запись находится и как заводится.
// Заведение идемпотентно: второе обращение попадает в ту же запись и не
// переписывает её.
//
// Не переписывает — половина требования, и она отдельная: перепись на каждом
// запросе означала бы запись в базу на каждый запрос, а правка имени у
// провайдера меняла бы карточку человека молча, посреди его работы.
func TestEnsureUserIsIdempotent(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Алиса", Email: "alice@example.test"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Другое имя", Email: "other@example.test"})
require.NoError(t, err)
assert.Equal(t, first.Id, second.Id, "второе обращение завело вторую запись")
assert.Equal(t, "Алиса", second.GetString("name"), "имя переписано вторым обращением")
assert.Equal(t, "alice@example.test", second.Email(), "почта переписана вторым обращением")
assert.Equal(t, 1, countUsers(t, app))
}
// Одновременные первые обращения одним логином дают одну учётную запись.
//
// Проверка стоит потому, что норма без неё держалась бы на одном уникальном
// индексе: забытый в шаге схемы, он дал бы зелёную приёмку и две учётные записи
// на одного человека — а архив разъехался бы между ними молча и склеить его было
// бы нечем.
func TestEnsureUserSurvivesConcurrentFirstRequests(t *testing.T) {
app := newTestStorage(t)
const racers = 8
var wg sync.WaitGroup
ids := make([]string, racers)
errs := make([]error, racers)
start := make(chan struct{})
for i := range racers {
wg.Add(1)
go func() {
defer wg.Done()
<-start
record, _, err := EnsureUser(app, Identity{Login: "racer", Name: "Гонщик"})
errs[i] = err
if record != nil {
ids[i] = record.Id
}
}()
}
close(start)
wg.Wait()
for i, err := range errs {
require.NoError(t, err, "обращение %d отказало", i)
}
for i, id := range ids {
assert.Equal(t, ids[0], id, "обращение %d попало в другую учётную запись", i)
}
assert.Equal(t, 1, countUsers(t, app), "гонка завела больше одной учётной записи")
}
// Занятая почта не мешает завести запись: она достаётся первому, а второй
// заводится без неё.
//
// Общий почтовый ящик — обычное дело в семье, а Authelia вправе отдать один
// адрес группе. Без разреза двух отказов уникальности второй человек не завёлся
// бы никогда: повторный поиск по логину снова ничего не находит.
func TestEnsureUserWithTakenEmail(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "one", Email: "family@example.test"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: "two", Email: "family@example.test"})
require.NoError(t, err)
assert.NotEqual(t, first.Id, second.Id)
assert.Equal(t, "family@example.test", first.Email(), "почта досталась первому")
assert.Empty(t, second.Email(), "второму почта не досталась, но запись завелась")
assert.Equal(t, 2, countUsers(t, app))
}
// Вырожденный логин никого не узнаёт и ничего не заводит.
func TestEnsureUserRejectsDegenerateLogin(t *testing.T) {
app := newTestStorage(t)
values := map[string]string{
"пустой": "",
"одни пробелы": " \t ",
"управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", MaxProviderLoginLength+1),
}
for name, value := range values {
t.Run(name, func(t *testing.T) {
record, _, err := EnsureUser(app, Identity{Login: value})
assert.Nil(t, record)
require.ErrorIs(t, err, ErrLoginNotAcceptable)
assert.Equal(t, 0, countUsers(t, app))
})
}
}
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
// называет одного человека, и две записи о нём разошлись бы молча.
func TestEnsureUserTrimsSurroundingSpaces(t *testing.T) {
app := newTestStorage(t)
first, _, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
second, _, err := EnsureUser(app, Identity{Login: " alice "})
require.NoError(t, err)
assert.Equal(t, first.Id, second.Id)
assert.Equal(t, 1, countUsers(t, app))
}
// Сравнение точное: приведение регистра завело бы правило, которого у
// провайдера нет, — и склеило бы двух разных людей.
func TestEnsureUserComparesExactly(t *testing.T) {
app := newTestStorage(t)
lower, _, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
upper, _, err := EnsureUser(app, Identity{Login: "Alice"})
require.NoError(t, err)
assert.NotEqual(t, lower.Id, upper.Id)
assert.Equal(t, 2, countUsers(t, app))
}
// Значение, похожее на условие отбора, ищется как значение, а не как часть
// запроса: оно уходит хранилищу параметром.
func TestEnsureUserDoesNotLetLoginChangeTheQuery(t *testing.T) {
app := newTestStorage(t)
victim, _, err := EnsureUser(app, Identity{Login: "victim"})
require.NoError(t, err)
attacker, _, err := EnsureUser(app, Identity{Login: `x" || provider_login = "victim`})
require.NoError(t, err)
assert.NotEqual(t, victim.Id, attacker.Id,
"значение изменило сам запрос и вернуло чужую учётную запись")
assert.Equal(t, 2, countUsers(t, app))
}
func countUsers(t *testing.T, app core.App) int {
t.Helper()
records, err := app.FindAllRecords(migrations.UsersCollection)
require.NoError(t, err)
return len(records)
}
// Признак заведения отличает первое обращение от всех следующих.
//
// По нему слой узнавания пишет строку журнала, и без него владелец не отличит
// «никто не заходил» от «завелось двадцать»: убрать заведённую запись потом
// нечем.
func TestEnsureUserReportsWhetherItCreated(t *testing.T) {
app := newTestStorage(t)
_, created, err := EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
assert.True(t, created, "первое обращение не назвалось заведением")
_, created, err = EnsureUser(app, Identity{Login: "alice"})
require.NoError(t, err)
assert.False(t, created, "второе обращение назвалось заведением")
}
// Негодное имя не отменяет заведения: оно обрезается по пределу колонки.
//
// Прежде имя уходило в колонку как есть, и человек с длинным именем у
// провайдера получал отказ сервиса на **каждом** запросе — учётная запись не
// заводилась никогда, а починить у себя он ничего не мог.
func TestEnsureUserAcceptsDegenerateName(t *testing.T) {
app := newTestStorage(t)
long := strings.Repeat("я", MaxDisplayNameLength+50)
record, created, err := EnsureUser(app, Identity{Login: "bob", Name: long})
require.NoError(t, err, "негодное имя отменило заведение записи")
require.True(t, created)
name := record.GetString("name")
assert.Equal(t, MaxDisplayNameLength, utf8.RuneCountInString(name), "имя не обрезано по пределу")
assert.NotEmpty(t, name)
}
// Управляющие знаки из имени убираются: значение приезжает заголовком.
func TestEnsureUserStripsControlsFromName(t *testing.T) {
app := newTestStorage(t)
record, _, err := EnsureUser(app, Identity{Login: "carol", Name: "Ка\x00ро\nл"})
require.NoError(t, err)
assert.Equal(t, "Карол", record.GetString("name"))
}
// Негодная почта отбрасывается **явно**, а не через ветвь «почта занята».
//
// Иначе опечатка в контуре неотличима от общего семейного ящика, и владелец
// ищет второго человека там, где сломан прокси.
func TestEnsureUserDropsMalformedEmail(t *testing.T) {
app := newTestStorage(t)
record, created, err := EnsureUser(app, Identity{Login: "dave", Email: "не-адрес"})
require.NoError(t, err, "негодная почта отменила заведение записи")
require.True(t, created)
assert.Empty(t, record.Email())
}
// Предел логина считается в знаках, а не в байтах: колонка считает знаки.
//
// Прежде кириллический логин длиннее половины предела отвергался навсегда,
// хотя колонка приняла бы его.
func TestEnsureUserCountsLoginInRunes(t *testing.T) {
app := newTestStorage(t)
login := strings.Repeat("я", MaxProviderLoginLength)
record, _, err := EnsureUser(app, Identity{Login: login})
require.NoError(t, err, "логин ровно на пределе отвергнут: предел считается в байтах")
assert.Equal(t, login, record.GetString(migrations.ProviderLoginField))
_, _, err = EnsureUser(app, Identity{Login: strings.Repeat("я", MaxProviderLoginLength+1)})
assert.ErrorIs(t, err, ErrLoginNotAcceptable, "логин сверх предела принят")
}
// Ключ учётной записи не меняется после заведения — ни правкой в панели, ни
// прямым сохранением.
//
// Правила доступа коллекции закрывают только путь снаружи; панель работает
// суперпользователем. Переписанный ключ отдал бы весь архив следующему, кто
// придёт с этим именем, и вернуть его было бы нечем.
func TestProviderLoginIsImmutable(t *testing.T) {
app := newTestStorage(t)
BindPanelRules(app)
record, _, err := EnsureUser(app, Identity{Login: "victim-owner"})
require.NoError(t, err)
record.Set(migrations.ProviderLoginField, "someone-else")
err = app.Save(record)
require.Error(t, err, "ключ учётной записи переписан прямым сохранением")
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, "victim-owner", stored.GetString(migrations.ProviderLoginField))
}
// Правка прочих полей учётной записи при этом проходит: хук сторожит один ключ,
// а не запирает коллекцию целиком.
func TestUserRecordStaysEditableExceptTheKey(t *testing.T) {
app := newTestStorage(t)
BindPanelRules(app)
record, _, err := EnsureUser(app, Identity{Login: "editable", Name: "Прежнее"})
require.NoError(t, err)
record.Set("name", "Новое")
require.NoError(t, app.Save(record), "правка имени в панели отвергнута")
stored, err := app.FindRecordById(migrations.UsersCollection, record.Id)
require.NoError(t, err)
assert.Equal(t, "Новое", stored.GetString("name"))
}
// Две учётные записи без ключа уживаются: индекс частичный, как и соседний
// индекс почты.
//
// Сплошной индекс ронял бы накатку шага на всякой базе, где записей больше
// одной, — то есть у разработчика, ходившего прежним рецептом входа.
func TestEmptyProviderLoginDoesNotCollide(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
for _, name := range []string{"Первый", "Второй"} {
record := core.NewRecord(users)
record.Set("name", name)
record.SetRandomPassword()
require.NoError(t, app.Save(record), "вторая запись без ключа отвергнута индексом")
}
assert.Equal(t, 2, countUsers(t, app))
}
@@ -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,94 +0,0 @@
package migrations
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// up202608150001 заводит у аудиозаписи три колонки, которые показывает список
// приложения: имя файла, данное отправителем, длительность и размер принятого.
//
// Шаг один на все три намеренно. Применённый шаг не переписывается, и три шага
// вместо одного стоили бы трёх необратимых решений там, где хватает одного.
//
// **Имя файла ложится своей колонкой, а не в заголовок.** Заголовок несёт
// название, которое дал человек либо посчитала языковая модель; имя файла — то,
// по чему человек узнаёт свою запись, пока заголовка нет. Одной колонкой на оба
// смысла посчитанное название затирало бы имя, и вернуть затёртое было бы
// неоткуда.
//
// **Длительность и размер дублируют строку файла, и это решение владельца от
// 2026-08-15.** Равенство между ними не поддерживается никем: на записи лежит
// снимок принятого, взятый приёмом один раз, на файле — величины той копии,
// которой файл является сейчас. Расхождение — не поломка, а разные вопросы;
// норму держит capability `storage`.
//
// Единица стоит в имени колонки, а не в комментарии: расхождение «секунды против
// миллисекунд» между колонкой, ответом списка и объявленным пределом не увидит
// ни компилятор, ни гейт — оба конца числа.
func up202608150001(app core.App) error {
records, err := app.FindCollectionByNameOrId(RecordsCollection)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err)
}
records.Fields.Add(
// Имя приходит извне и содержимым своим приёму не подконтрольно, поэтому
// приём режет его по пределу и убирает управляющие знаки прежде, чем
// сохранить. Схема держит потолок вторым рубежом: значение сверх него
// отвергается хранилищем, а не доезжает до экрана.
&core.TextField{Name: "original_filename", Max: entity.MaxOriginalFilenameLen},
// Длительность и размер принятого. «Неизвестно» колонки не выражают:
// числовая колонка хранилища пустого значения не держит, пустое кладётся
// нулём. Обе ставит приём и ставит всегда — запись с непрочитанными
// метаданными отвергается отказом и не заводится. Решение владельца
// 2026-08-15.
&core.NumberField{Name: "duration_ms", OnlyInt: true, Min: ptr(0.0)},
&core.NumberField{Name: "size_bytes", OnlyInt: true, Min: ptr(0.0)},
)
// Индекс под ленту приложения. Единственный прежний индекс — по рубежу и
// признаку остановки — заведён под захват воркера и выборке владельца не
// помогает ничем: страница сканирует таблицу целиком и досортировывает
// результат во временном дереве.
//
// Замер на этом же изменении: рост архива с 5 тысяч строк до 200 тысяч —
// сорокакратный — растит время одной страницы владельца в двадцать-тридцать
// раз, хотя записей у него всё те же сорок. Цена растёт с **чужими**
// записями, потому что сервис объявлен архивом и хранит их бессрочно.
//
// Порядок колонок повторяет порядок выборки: сужение по владельцу, затем
// сортировка «новыми сверху» полным ключом.
records.AddIndex("idx_audio_records_owner_feed", false, "owner, created DESC, id DESC", "")
// Отбор тремя состояниями сужает по владельцу вместе с рубежом и признаком
// остановки — своим индексом, потому что ведущей колонкой здесь владелец.
records.AddIndex("idx_audio_records_owner_state", false, "owner, state, halted_at", "")
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to add contract columns to %s: %w", RecordsCollection, err)
}
return nil
}
// down202608150001 снимает три колонки. Данные в них при этом теряются, и
// восстановить их неоткуда: имя файла отправителя нигде больше не хранится.
func down202608150001(app core.App) error {
records, err := app.FindCollectionByNameOrId(RecordsCollection)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", RecordsCollection, err)
}
for _, name := range []string{"original_filename", "duration_ms", "size_bytes"} {
records.Fields.RemoveByName(name)
}
records.RemoveIndex("idx_audio_records_owner_feed")
records.RemoveIndex("idx_audio_records_owner_state")
if err := app.Save(records); err != nil {
return fmt.Errorf("failed to drop contract columns from %s: %w", RecordsCollection, err)
}
return nil
}
@@ -1,125 +0,0 @@
package migrations
import (
"errors"
"fmt"
"github.com/pocketbase/pocketbase/core"
)
// ProviderLoginField — колонка, в которой лежит ключ учётной записи: логин
// человека у провайдера, тот самый, которым его называет обратный прокси.
//
// Имя говорит о происхождении значения, а не о заголовке, которым оно приехало:
// заголовок — способ доставки и может смениться, а логин у провайдера — то, чем
// значение является. Колонка уезжает шагом схемы и потому не переименовывается.
const ProviderLoginField = "provider_login"
// providerLoginIndex — имя уникального индекса по ключу учётной записи.
const providerLoginIndex = "idx_users_provider_login"
// up202608220001 переводит узнавание пришедшего с протокола OIDC на логин,
// который называет доверенный источник.
//
// Три части, и каждая закрывает своё.
//
// Первая — ключ учётной записи. Прежде идентичность человека лежала в системной
// таблице внешних учётных записей библиотеки: её вела механика обмена кода, и
// правил её только владелец панели. Механика уходит, и ключу нужен свой дом —
// колонка с уникальным индексом. Почта ключом не годится: провайдер не обязан
// её приносить, человек её меняет, а первое обращение с чужим адресом досталось
// бы чужой записи.
//
// Вторая — необязательная почта. Умолчание библиотеки требует непустого адреса
// у всякой учётной записи; заголовка с почтой может не быть вовсе, а
// уникальность почты держится **частичным** индексом (`WHERE email != ”`),
// поэтому записи без почты уживаются друг с другом. Пароль остаётся
// обязательным при любом значении признака — ему ставится случайный, употребить
// его нельзя: вход по паролю у коллекции выключен прежним шагом.
//
// Третья — поверхность коллекции пользователей. Умолчание библиотеки открывает
// владельцу записи чтение, правку и удаление собственной строки, и до сих пор
// это ничему не мешало ровно потому, что до поверхности хранилища браузер с
// кукой не дотягивался: слой предъявления жил под корнем приложения. С
// узнаванием по заголовку такая защита перестаёт быть защитой, а ключ учётной
// записи лежит теперь обычной колонкой — то есть правка своей записи и есть
// захват чужого имени: поставил себе ключом чужой логин, и первое обращение
// настоящего его владельца попало бы в твою запись вместе со всем архивом.
// Правила снимаются в пустое, что у хранилища означает «только владелец
// панели»; наш код читает и заводит запись мимо правил, панель работает
// суперпользователем, своих экранов профиля сервис не заводит.
func up202608220001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.Fields.Add(&core.TextField{
Name: ProviderLoginField,
// Предел тот же, что у имени в умолчании библиотеки: логин длиннее
// имени не бывает, а колонка без предела принимала бы килобайты,
// пришедшие заголовком.
Max: 255,
})
// Индекс **частичный** — ровно как соседний индекс почты у той же коллекции.
// Сплошной запретил бы вторую запись с пустым ключом, а такая заводится
// рукой в панели и остаётся у всякой базы, пережившей прежний вход: подъём
// на ней ронял бы накатку шага отказом уникальности, и сервис не стартовал
// бы вовсе. Пустым ключом при этом не узнаётся никто — это держит приём
// значения, а не индекс.
users.AddIndex(providerLoginIndex, true, ProviderLoginField, ProviderLoginField+" != ''")
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
if !ok {
return errors.New("users collection has no email field")
}
email.Required = false
// Механика OIDC снимается целиком: настройки провайдера больше не приводятся
// к конфигу при подъёме, и обменивать код не на что.
users.OAuth2.Enabled = false
users.OAuth2.Providers = nil
// Наглухо все пять: заведение записи идёт нашим кодом, мимо правил.
users.ListRule = nil
users.ViewRule = nil
users.CreateRule = nil
users.UpdateRule = nil
users.DeleteRule = nil
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to switch users collection to provider login: %w", err)
}
return nil
}
// down202608220001 убирает ключ учётной записи и возвращает обязательность
// почты.
//
// Правила доступа сюда не возвращаются намеренно — ни открытое создание записи,
// которое закрывал прежний шаг, ни открытая правка, которую закрывает этот.
// Откат, восстанавливающий их, оставил бы сервис хуже, чем он был: правка своей
// записи открыта только тому, кто узнан, а узнают теперь по колонке, которую эта
// правка и переписывает.
func down202608220001(app core.App) error {
users, err := app.FindCollectionByNameOrId(UsersCollection)
if err != nil {
return fmt.Errorf("failed to find users collection: %w", err)
}
users.RemoveIndex(providerLoginIndex)
users.Fields.RemoveByName(ProviderLoginField)
email, ok := users.Fields.GetByName(core.FieldNameEmail).(*core.EmailField)
if !ok {
return errors.New("users collection has no email field")
}
email.Required = true
if err := app.Save(users); err != nil {
return fmt.Errorf("failed to restore users collection: %w", err)
}
return nil
}
@@ -1,57 +0,0 @@
// Package migrations — шаги схемы хранилища и имена коллекций, которые они
// заводят.
//
// Схема заводится версионированными шагами, и применённый шаг не переписывается
// — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает
// применённое по **имени шага**, а не по пути файла, поэтому имена в
// `Register` ниже не переносятся и не переименовываются, даже если файл переехал.
//
// Шаги лежат своим каталогом, а не файлом внутри пакета репозитория, и причина
// внешняя: сверка документов ловит изменённый шаг схемы при нетронутом
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
// `[docs]`), а префикс наводится только на каталог. Пока шаги лежали файлом,
// наводить его
// было не на что, и проверка молчала на всякой правке схемы.
package migrations
import (
pbmigrations "github.com/pocketbase/pocketbase/migrations"
)
// Имена коллекций живут здесь, рядом с шагом, который их заводит. Они же — часть
// пути к файлу в раскладке хранилища и часть адреса ссылки на него, поэтому
// меняются только новым шагом схемы.
const (
FilesCollection = "files"
// JobsCollection — прежняя коллекция задач. Шаг 202608140002 её удаляет;
// имя остаётся здесь, потому что на него ссылаются прежние шаги схемы, а
// применённый шаг не переписывается.
JobsCollection = "transcribe_jobs"
// RecordsCollection — аудиозапись, центральная сущность сервиса. Имя в
// snake_case, как у соседей по схеме: одно исключение разошлось бы молча по
// константе имён, запросу захвата, правилам панели и запрету удаления.
RecordsCollection = "audio_records"
TextsCollection = "texts"
StructuresCollection = "structures"
RecognitionsCollection = "recognitions"
RecordEventsCollection = "record_events"
TopicsCollection = "topics"
// UsersCollection заводит не наш шаг, а системный шаг библиотеки. Имя стоит
// здесь потому, что на него ссылаются и шаги схемы, и проверка предъявителя
// на приёме: строковый литерал в двух местах разошёлся бы молча.
UsersCollection = "users"
)
// Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его
// `apis.Serve` прежде, чем поднять сервер.
func init() {
pbmigrations.Register(up202608110001, down202608110001, "202608110001_init.go")
pbmigrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go")
pbmigrations.Register(up202608140001, down202608140001, "202608140001_record_owner.go")
pbmigrations.Register(up202608140002, down202608140002, "202608140002_record_centric_model.go")
pbmigrations.Register(up202608140003, down202608140003, "202608140003_owner_required.go")
pbmigrations.Register(up202608150001, down202608150001, "202608150001_record_contract_columns.go")
pbmigrations.Register(up202608220001, down202608220001, "202608220001_trusted_header_login.go")
}
func ptr[T any](v T) *T { return &v }
@@ -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, "ничей файл в хранилище не ложится")
}
-131
View File
@@ -1,131 +0,0 @@
package pocketbase
import (
"errors"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// BindPanelRules подчиняет правку записи в панели тем же правилам, что и правку
// из кода.
//
// Панель — вход в запись наравне с конвейером, а не окно просмотра: ради правки
// она и покупалась, остановленная запись возвращается в работу снятием признака.
// Но правка полем идёт мимо кода, который чистит служебные поля, и владелец,
// «вернувший запись в работу», получил бы запись с прежним признаком захвата
// (захвату она не выдастся до конца срока), с числом отказов на пределе
// (остановится от первого же отказа) и со старым временем входа в рубеж
// (остановится снова первым же захватом по пределу простоя). Узнать об этом ему
// неоткуда.
//
// Правило живёт **одним местом** — доменными `Resume` и `MoveToState`, — и хук
// зовёт именно их, а не повторяет перечень служебных полей колонками. Повтор
// перечня был бы вторым домом того же правила: новый сторож попал бы в домен и
// не попал в панель, и владелец «вернул бы запись в работу», а она снова выпала
// бы из выборки — молча.
//
// Хук стоит на правке **запросом**, а не на всяком сохранении записи. Модельное
// событие не различает, кто пишет, и срабатывало бы на каждом переходе
// конвейера: тогда пауза, поставленная шагом вместе со сменой рубежа, стиралась
// бы тем же сохранением, а число отказов остановленной записи — которое
// остановка хранит намеренно — приходило бы владельцу нулём.
func BindPanelRules(app core.App) {
bindProviderLoginIsImmutable(app)
app.OnRecordUpdateRequest(migrations.RecordsCollection).BindFunc(func(e *core.RecordRequestEvent) error {
original := e.Record.Original()
if original == nil {
return e.Next()
}
stateChanged := original.GetString("state") != e.Record.GetString("state")
// Снятие признака остановки — то самое движение, ради которого признак и
// заведён: запись возвращается в работу с сохранённого рубежа.
resumed := !original.GetDateTime("halted_at").IsZero() &&
e.Record.GetDateTime("halted_at").IsZero()
if !stateChanged && !resumed {
return e.Next()
}
// Запись читается уже с правкой человека: рубеж здесь тот, который он
// выбрал, а признак остановки — тот, который он снял или оставил.
record := recordToAudioRecord(e.Record)
switch {
case resumed:
record.Resume()
default:
record.MoveToState(record.State)
}
applyOwnedByPipeline(e.Record, record)
if err := e.Next(); err != nil {
return err
}
if resumed {
// Перезапуск виден в журнале событий с указанием, что его сделал
// человек: иначе запись, вернувшаяся в работу, выглядела бы как
// запись, которая туда и не уходила.
//
// Строка пишется **после** сохранения: событие о правке, которая не
// прошла, соврало бы о состоянии записи. Отказ записи журнала саму
// правку не отменяет — журнал никем не читается ради решения.
event := &entity.RecordEvent{
RecordID: e.Record.Id,
Origin: entity.EventOriginHuman,
Step: "resume",
Outcome: entity.EventOutcomeResumed,
}
if err := NewRecordEventRepository(e.App).Append(event); err != nil {
e.App.Logger().Error("Failed to log record resume", "error", err, "record_id", e.Record.Id)
}
}
return nil
})
}
// bindProviderLoginIsImmutable запрещает менять ключ учётной записи после
// заведения.
//
// Ключ — логин человека у провайдера, и по нему сервис узнаёт пришедшего.
// Переписанный, он отдаёт весь архив прежнего владельца следующему, кто придёт
// с этим именем: владелец записи назначается один раз и не меняется, так что
// вернуть архив будет нечем. Молча — журнала событий у коллекции пользователей
// нет.
//
// Правила доступа коллекции закрывают этот путь **снаружи**, но не изнутри:
// панель работает суперпользователем и правила обходит по построению. Отсюда
// хук, и он вешается на **модельное** событие, а не на правку запросом — иначе
// панель осталась бы незакрытой, а закрывать её и есть весь смысл.
//
// Заведение проходит: событие правки на нём не срабатывает вовсе.
//
// Прежнее значение читается **из базы**, а не из снимка правящейся записи.
// Снимок у записи, только что заведённой в этом же процессе, пуст — он не
// обновляется сохранением, — и сторож, опирающийся на него, пропускал бы правку
// в зависимости от того, откуда вызывающий взял запись. Панель её загружает, и
// на ней сторож сработал бы; молчаливая же зависимость от способа получения —
// ровно тот класс, из-за которого правило и заводится.
func bindProviderLoginIsImmutable(app core.App) {
app.OnRecordUpdate(migrations.UsersCollection).BindFunc(func(e *core.RecordEvent) error {
stored, err := e.App.FindRecordById(migrations.UsersCollection, e.Record.Id)
if err != nil {
// Записи в базе нет — правки тоже нет: сохранение отвергнется само.
return e.Next()
}
was := stored.GetString(migrations.ProviderLoginField)
now := e.Record.GetString(migrations.ProviderLoginField)
if was != "" && was != now {
return errors.New("provider login is assigned once and never changes")
}
return e.Next()
})
}
@@ -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,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,155 +0,0 @@
package pocketbase
import (
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/entity"
)
// Отображение аудиозаписи в запись коллекции и обратно живёт одним местом.
//
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
// колонки поимённо, а возвращает идентификатор и признак своего захвата.
// Инвариант проекта о колонках очереди этим съёживается и перестаёт расти с
// моделью — иначе каждая новая колонка записи попадала бы под него.
// applyOwnedByPipeline кладёт в запись только те поля, которыми распоряжается
// конвейер. Поля, которые он не меняет никогда — владелец, вход, заголовок,
// краткое описание, темы и адресат ответа, — не трогаются вовсе.
//
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
// своего сохранения, а это часы. Всё, что владелец правил в панели за это время,
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа в
// панели — владелец видел бы успешное сохранение и был бы уверен, что правка на
// месте.
func applyOwnedByPipeline(record *core.Record, r *entity.AudioRecord) {
record.Set("state", r.State)
record.Set("state_entered_at", dateOrEmpty(&r.StateEnteredAt))
record.Set("halted_at", dateOrEmpty(r.HaltedAt))
record.Set("halt_reason", derefString(r.HaltReason))
record.Set("error_text", derefString(r.ErrorText))
record.Set("acquisition_id", derefString(r.AcquisitionID))
record.Set("acquire_expires_at", dateOrEmpty(r.AcquireExpiresAt))
record.Set("delay_time", dateOrEmpty(r.DelayTime))
record.Set("attempts", r.Attempts)
record.Set("original_file", derefString(r.OriginalFileID))
record.Set("normalized_file", derefString(r.NormalizedFileID))
record.Set("transcript_text", derefString(r.TranscriptTextID))
record.Set("literary_text", derefString(r.LiteraryTextID))
record.Set("structure", derefString(r.StructureID))
record.Set("recognition", derefString(r.RecognitionID))
}
// applyToRecord кладёт запись целиком — это заведение, и спорить за поля здесь
// не с кем.
func applyToRecord(record *core.Record, r *entity.AudioRecord) {
applyOwnedByPipeline(record, r)
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
// записанный поверх, стёр бы его молча.
record.Set("owner", r.OwnerID)
record.Set("source", r.Source)
record.Set("title", derefString(r.Title))
record.Set("brief", derefString(r.Brief))
// Имя файла отправителя, длительность и размер кладёт приём и только он: это
// снимок принятого, и конвейер его не пересчитывает. В applyOwnedByPipeline их
// нет намеренно — снимок шага, записанный поверх, стёр бы их молча.
record.Set("original_filename", derefString(r.OriginalFilename))
record.Set("duration_ms", numberOrZero(r.DurationMs))
record.Set("size_bytes", numberOrZero(r.SizeBytes))
// Темы кладутся при заведении пустыми и конвейером не трогаются: считает их
// языковая модель отдельной задачей. Пишутся здесь ради симметрии с чтением —
// колонка, которую читают и не пишут, ничем не отличима от забытой.
record.Set("topics", r.TopicIDs)
}
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
return &entity.AudioRecord{
Id: record.Id,
OwnerID: record.GetString("owner"),
Source: record.GetString("source"),
Title: nilIfEmpty(record.GetString("title")),
Brief: nilIfEmpty(record.GetString("brief")),
State: record.GetString("state"),
StateEnteredAt: record.GetDateTime("state_entered_at").Time(),
HaltedAt: timeOrNil(record.GetDateTime("halted_at")),
HaltReason: nilIfEmpty(record.GetString("halt_reason")),
ErrorText: nilIfEmpty(record.GetString("error_text")),
AcquisitionID: nilIfEmpty(record.GetString("acquisition_id")),
AcquireExpiresAt: timeOrNil(record.GetDateTime("acquire_expires_at")),
DelayTime: timeOrNil(record.GetDateTime("delay_time")),
Attempts: record.GetInt("attempts"),
OriginalFileID: nilIfEmpty(record.GetString("original_file")),
NormalizedFileID: nilIfEmpty(record.GetString("normalized_file")),
TranscriptTextID: nilIfEmpty(record.GetString("transcript_text")),
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
StructureID: nilIfEmpty(record.GetString("structure")),
RecognitionID: nilIfEmpty(record.GetString("recognition")),
OriginalFilename: nilIfEmpty(record.GetString("original_filename")),
// Имя колонки стоит литералом рядом с `.Get…`, а не уезжает в аргумент
// помощника: сверка колонок в `internal/archrules` ищет именно эту форму, а
// инвариант о колонках компилятор не проверяет.
DurationMs: numberValue(record.GetInt("duration_ms")),
SizeBytes: numberValue(record.GetInt("size_bytes")),
TopicIDs: record.GetStringSlice("topics"),
CreatedAt: record.GetDateTime("created").Time(),
UpdatedAt: record.GetDateTime("updated").Time(),
}
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
// хранилище это пустая строка, и она же значит «времени нет».
func dateOrEmpty(v *time.Time) any {
if v == nil || v.IsZero() {
return ""
}
date, err := types.ParseDateTime(*v)
if err != nil {
return ""
}
return date
}
// numberOrZero отдаёт ноль вместо отсутствующего числа.
//
// «Неизвестно» числовая колонка хранилища не выражает вовсе: пустое значение она
// не держит и кладёт нулём. Отличимость потребовала бы четвёртой колонки-признака
// либо текстового типа у чисел, и платить за это нечем — обе величины ставит
// приём и ставит всегда. Решение владельца 2026-08-15.
func numberOrZero(v *int64) int64 {
if v == nil {
return 0
}
return *v
}
// numberValue читает колонку числом. Ноль здесь означает ноль — см. numberOrZero.
func numberValue(value int) *int64 {
v := int64(value)
return &v
}
func nilIfEmpty(v string) *string {
if v == "" {
return nil
}
return &v
}
func timeOrNil(v types.DateTime) *time.Time {
if v.IsZero() {
return nil
}
t := v.Time()
return &t
}
@@ -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,139 +0,0 @@
package pocketbase
import (
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
)
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
// тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) core.App {
t.Helper()
app, err := New(t.TempDir())
require.NoError(t, err)
t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err)
}
})
return app
}
// Критерий приёмки 10. Содержимое записи закрыто во всех коллекциях, куда оно
// переехало.
//
// Прежде содержимое лежало одной колонкой задачи, и закрывала его одна норма про
// файл записи. Теперь оно живёт в шести коллекциях, и реализация, следующая
// только прежней норме, завела бы поле вложения с умолчанием библиотеки: ссылка
// на сырой ответ провайдера — а это полный текст речи — отдавала бы его любому,
// кто её знает, без сессии.
func TestRecordContentIsClosedEverywhere(t *testing.T) {
app := newTestStorage(t)
// Правило просмотра остаётся незаданным, то есть «только владелец панели».
// Содержимое отдаёт собственный адрес сервиса, а не поверхность хранилища;
// непустое правило открыло бы перечисление коллекции впрок.
for _, name := range []string{
migrations.RecordsCollection,
migrations.TextsCollection,
migrations.StructuresCollection,
migrations.RecognitionsCollection,
migrations.RecordEventsCollection,
migrations.TopicsCollection,
} {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err, "коллекция %s заведена шагом схемы", name)
assert.Nil(t, collection.ListRule, "перечисление %s закрыто", name)
assert.Nil(t, collection.ViewRule, "чтение %s закрыто", name)
assert.Nil(t, collection.CreateRule, "заведение записи в %s закрыто", name)
assert.Nil(t, collection.UpdateRule, "правка %s закрыта", name)
assert.Nil(t, collection.DeleteRule, "удаление из %s закрыто", name)
}
// А поле вложения помечено защищённым: без пометки ссылка открывает
// содержимое любому, кто её знает, и знание ссылки становится правом.
recognitions, err := app.FindCollectionByNameOrId(migrations.RecognitionsCollection)
require.NoError(t, err)
field := recognitions.Fields.GetByName("payload")
require.NotNil(t, field, "поле сохранённого ответа заведено")
file, ok := field.(*core.FileField)
require.True(t, ok, "сохранённый ответ лежит вложением, а не колонкой")
assert.True(t, file.Protected, "поле вложения защищено")
}
// Прежняя коллекция задач уходит вместе с моделью: данных под ней не было, а
// пустая копия висела бы в панели вторым домом для понятия, которого больше нет.
// Поверхность коллекции пользователей закрыта наглухо — все пять правил.
//
// Проверка стоит отдельно от соседней намеренно: та сторожит коллекции, которые
// заводит наш шаг схемы, а эту заводит системный шаг библиотеки, и её умолчания
// открывают владельцу записи чтение, правку и удаление собственной строки. Пока
// узнавание жило под корнем приложения, до этой поверхности браузер не
// дотягивался вовсе; с узнаванием по заголовку она достижима, а ключ учётной
// записи лежит здесь обычной колонкой — правка своей записи и есть захват чужого
// имени.
func TestUsersCollectionSurfaceIsClosed(t *testing.T) {
app := newTestStorage(t)
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
require.NoError(t, err)
assert.Nil(t, users.ListRule, "перечисление учётных записей закрыто")
assert.Nil(t, users.ViewRule, "чтение учётной записи закрыто")
assert.Nil(t, users.CreateRule, "заведение учётной записи снаружи закрыто")
assert.Nil(t, users.UpdateRule, "правка учётной записи снаружи закрыта")
assert.Nil(t, users.DeleteRule, "удаление учётной записи снаружи закрыто")
// Собственные способы войти выключены там же: без этого узнавание по
// заголовку обходится двумя запросами — завести себе запись и войти паролем.
assert.False(t, users.PasswordAuth.Enabled, "вход по паролю выключен")
assert.False(t, users.OTP.Enabled, "вход по одноразовому коду выключен")
assert.False(t, users.OAuth2.Enabled, "обмен кода у внешнего провайдера выключен")
assert.Empty(t, users.OAuth2.Providers, "настроенных провайдеров не осталось")
}
func TestFormerJobsCollectionIsGone(t *testing.T) {
app := newTestStorage(t)
_, err := app.FindCollectionByNameOrId(migrations.JobsCollection)
assert.Error(t, err, "прежней коллекции задач не осталось")
}
// Пара «запись и вид» уникальна: повтор прерванного шага не заводит второго
// комплекта строк, и вопрос «какой текст отдавать человеку» не становится
// вопросом порядка записи.
func TestAppendicesAreUniquePerRecord(t *testing.T) {
app := newTestStorage(t)
indexes := map[string][]string{
migrations.TextsCollection: {"idx_texts_record_kind"},
migrations.StructuresCollection: {"idx_structures_record_version"},
migrations.TopicsCollection: {"idx_topics_owner_name"},
}
for name, expected := range indexes {
collection, err := app.FindCollectionByNameOrId(name)
require.NoError(t, err)
for _, index := range expected {
var found bool
for _, declared := range collection.Indexes {
if strings.Contains(declared, index) && strings.Contains(declared, "UNIQUE") {
found = true
}
}
assert.Truef(t, found, "у %s есть уникальный индекс %s", name, index)
}
}
}
@@ -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
}
+172
View File
@@ -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...)
}
+554
View File
@@ -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, &notUsed, &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))
}
+237
View File
@@ -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
}
+159
View File
@@ -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)
}
}
+110
View File
@@ -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
}
@@ -1,12 +1,10 @@
package pocketbase package sqlite
import ( import (
"context"
"fmt" "fmt"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -47,69 +45,86 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor
q.Limit = defaultListLimit q.Limit = defaultListLimit
} }
collection, err := findCollection(repo.app, migrations.RecordsCollection) conditions := []string{"owner_id = ?"}
args := []any{q.OwnerID}
state, stateArgs, err := stateCondition(q.Filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if state != "" {
filter := dbx.HashExp{"owner": q.OwnerID}
conditions := []dbx.Expression{filter}
state, err := stateCondition(q.Filter)
if err != nil {
return nil, err
}
if state != nil {
conditions = append(conditions, state) conditions = append(conditions, state)
args = append(args, stateArgs...)
} }
total, err := repo.countRecords(conditions) total, err := repo.countRecords(conditions, args)
if err != nil { if err != nil {
return nil, err return nil, err
} }
pageConditions := conditions
pageArgs := args
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени // Курсор режет ленту по паре: строго раньше по времени, а при равном времени
// — строго меньше по идентификатору. Идентификаторы хранилища монотонны в // — строго меньше по идентификатору.
// пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и
// этого довольно: задача ключа — не пропустить и не повторить.
if q.Cursor != nil { if q.Cursor != nil {
conditions = append(conditions, dbx.Or( pageConditions = append(append([]string{}, conditions...),
dbx.NewExp("created < {:created}", dbx.Params{"created": q.Cursor.CreatedAt}), "(created_at < ? OR (created_at = ? AND id < ?))")
dbx.And( cursorTime := formatTime(q.Cursor.CreatedAt)
dbx.NewExp("created = {:created}", dbx.Params{"created": q.Cursor.CreatedAt}), pageArgs = append(append([]any{}, args...),
dbx.NewExp("id < {:id}", dbx.Params{"id": q.Cursor.ID}), cursorTime, cursorTime, q.Cursor.ID)
),
))
} }
row := &recordRow{}
columns, targets := selectList(readRecordColumns(row), "")
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли // Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
// следующая страница» без второго запроса и без вычислений по общему числу, // следующая страница» без второго запроса и без вычислений по общему числу,
// которое к этому моменту могло измениться. // которое к этому моменту могло измениться.
records := []*core.Record{} query := "SELECT " + columns + " FROM " + recordsTable +
err = repo.app.RecordQuery(collection). " WHERE " + strings.Join(pageConditions, " AND ") +
AndWhere(dbx.And(conditions...)). " ORDER BY created_at DESC, id DESC LIMIT ?"
OrderBy("created DESC", "id DESC"). pageArgs = append(pageArgs, q.Limit+1)
Limit(int64(q.Limit) + 1).
All(&records) rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list audio records: %w", err) 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} page := &contract.RecordPage{TotalItems: total}
if len(records) > q.Limit { if len(items) > q.Limit {
last := records[q.Limit-1] last := items[q.Limit-1]
page.NextCursor = &contract.RecordCursor{ page.NextCursor = &contract.RecordCursor{
CreatedAt: last.GetDateTime("created").String(), CreatedAt: last.CreatedAt,
ID: last.Id, ID: last.Id,
} }
records = records[:q.Limit] items = items[:q.Limit]
} }
page.Items = make([]*entity.AudioRecord, 0, len(records)) ids := make([]string, 0, len(items))
for _, record := range records { for _, item := range items {
page.Items = append(page.Items, recordToAudioRecord(record)) 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 return page, nil
} }
@@ -118,84 +133,128 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор // Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе // списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
// молча поменял бы состав всех трёх состояний. // молча поменял бы состав всех трёх состояний.
func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) { func stateCondition(filter *entity.ListFilter) (string, []any, error) {
if filter == nil { if filter == nil {
return nil, nil return "", nil, nil
} }
notHalted := dbx.NewExp("halted_at = ''")
halted := dbx.NewExp("halted_at != ''")
switch *filter { switch *filter {
case entity.ListFilterHalted: case entity.ListFilterHalted:
return halted, nil return "halted_at IS NOT NULL", nil, nil
case entity.ListFilterWorking: case entity.ListFilterWorking:
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil condition, args := stateIn(entity.WorkingStages())
return "halted_at IS NULL AND " + condition, args, nil
case entity.ListFilterDone: case entity.ListFilterDone:
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil condition, args := stateIn(entity.TerminalStages())
return "halted_at IS NULL AND " + condition, args, nil
} }
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в // Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив // перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
// под именем отбора — и заметить это было бы нечем. // под именем отбора — и заметить это было бы нечем.
return nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter) return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
} }
func stageNameValues(stages []entity.Stage) []any { func stateIn(stages []entity.Stage) (string, []any) {
names := entity.StageNames(stages) names := entity.StageNames(stages)
out := make([]any, 0, len(names)) args := make([]any, 0, len(names))
for _, n := range names { placeholders := make([]string, 0, len(names))
out = append(out, n) for _, name := range names {
args = append(args, name)
placeholders = append(placeholders, "?")
} }
return out return "state IN (" + strings.Join(placeholders, ", ") + ")", args
} }
func (repo *AudioRecordRepository) countRecords(conditions []dbx.Expression) (int, error) { func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) {
var counter struct { var total int
Total int `db:"total"` query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ")
} if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil {
err := repo.app.RecordQuery(migrations.RecordsCollection).
Select("count(*) as total").
AndWhere(dbx.And(conditions...)).
One(&counter)
if err != nil {
return 0, fmt.Errorf("failed to count audio records: %w", err) return 0, fmt.Errorf("failed to count audio records: %w", err)
} }
return counter.Total, nil return total, nil
}
// topicsOf читает темы страницы **одним запросом**: страница в сотню записей
// иначе стоила бы сотни обращений к базе.
func (repo *AudioRecordRepository) topicsOf(recordIDs []string) (map[string][]string, error) {
out := map[string][]string{}
if len(recordIDs) == 0 {
return out, nil
}
placeholders := make([]string, 0, len(recordIDs))
args := make([]any, 0, len(recordIDs))
for _, id := range recordIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
query := "SELECT record_id, topic_id FROM record_topics WHERE record_id IN (" +
strings.Join(placeholders, ", ") + ") ORDER BY topic_id"
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
if err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var recordID, topicID string
if err := rows.Scan(&recordID, &topicID); err != nil {
return nil, fmt.Errorf("failed to read a record topic: %w", err)
}
out[recordID] = append(out[recordID], topicID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to read record topics: %w", err)
}
return out, nil
} }
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а // ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
// не по запросу на запись: страница в сотню записей иначе стоила бы сотни // не по запросу на запись.
// обращений к хранилищу.
// //
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы // Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради // ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
// чего контракт согласуется один раз. // чего контракт согласуется один раз.
//
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара
// «владелец и название» уникальна, — и разрешение без сужения отдало бы название
// чужой темы, как только темы начнёт писать языковая модель.
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) { func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
out := map[string]string{} out := map[string]string{}
if len(ids) == 0 || ownerID == "" { if len(ids) == 0 || ownerID == "" {
return out, nil return out, nil
} }
values := make([]any, 0, len(ids)) placeholders := make([]string, 0, len(ids))
args := []any{ownerID}
for _, id := range ids { for _, id := range ids {
values = append(values, id) placeholders = append(placeholders, "?")
args = append(args, id)
} }
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" +
// пара «владелец и название» уникальна, — и разрешение без сужения отдало бы strings.Join(placeholders, ", ") + ")"
// название чужой темы, как только темы начнёт писать языковая модель.
records := []*core.Record{} rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
err := repo.app.RecordQuery(migrations.TopicsCollection).
AndWhere(dbx.HashExp{"owner": ownerID}).
AndWhere(dbx.In("id", values...)).
All(&records)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to resolve topics: %w", err) return nil, fmt.Errorf("failed to resolve topics: %w", err)
} }
defer func() { _ = rows.Close() }()
for _, record := range records { for rows.Next() {
out[record.Id] = record.GetString("name") 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 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),
}
}
+237
View File
@@ -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
}
+626
View File
@@ -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)
}
+170
View File
@@ -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
}
+205
View File
@@ -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
}
+190
View File
@@ -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
}
+151 -39
View File
@@ -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) { func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
for pkg, imports := range internalImports(t) { for pkg, imports := range internalImports(t) {
if !strings.HasPrefix(pkg, adapterPrefix) { if !strings.HasPrefix(pkg, adapterPrefix) {
@@ -173,16 +198,19 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
} }
} }
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`, // Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline`
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре // вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает // `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения. // цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым
// полем, и первое же сохранение пишет этот ноль поверх значения.
// //
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет // Отображение работает **по имени колонки** — именованные параметры запроса и
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила // место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а
// ниже держат оставшуюся пару плюс шаг схемы. // не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта
// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у
// отображения нет вовсе.
const ( const (
repoPkg = "internal/adapter/repo/pocketbase" repoPkg = "internal/adapter/repo/sqlite"
mappingFile = repoPkg + "/record_mapping.go" mappingFile = repoPkg + "/record_mapping.go"
migrationsPath = repoPkg + "/migrations" migrationsPath = repoPkg + "/migrations"
stageFile = "internal/entity/stage.go" stageFile = "internal/entity/stage.go"
@@ -190,46 +218,64 @@ const (
serviceFile = "internal/service/transcribe.go" serviceFile = "internal/service/transcribe.go"
) )
// Колонки, которые заводит и заполняет само хранилище: нашего кода они не
// касаются.
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) { func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
written := writtenColumns(t) written := writtenColumns(t)
read := readColumns(t) read := readColumns(t)
for col := range written { for col := range written {
if storageOwned[col] {
continue
}
if !read[col] { if !read[col] {
t.Errorf( t.Errorf(
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+ "колонку %q пишет отображение записи, но readRecordColumns её не "+
"читает: запись приедет из хранилища без этого поля", "читает: запись приедет из базы без этого поля",
col, col,
) )
} }
} }
for col := range read { for col := range read {
if storageOwned[col] {
continue
}
if !written[col] { if !written[col] {
t.Errorf( t.Errorf(
"колонку %q читает recordToAudioRecord, но её не пишет ни "+ "колонку %q читает readRecordColumns, но её не пишет ни "+
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится", "writeOwnedByPipeline, ни writeRecord: поле не сохранится",
col, 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) { func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
declared := schemaFieldNames(t) declared := schemaFieldNames(t)
for col := range writtenColumns(t) { for col := range writtenColumns(t) {
if storageOwned[col] {
continue
}
if !declared[col] { if !declared[col] {
t.Errorf( t.Errorf(
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+ "колонка %q пишется отображением записи, но ни один шаг схемы её не "+
@@ -312,13 +358,16 @@ func TestШагиОбъявленыРубежамиДескриптора(t *tes
} }
} }
// writtenColumns — колонки, которые пишет отображение записи в хранилище. // columnKey — имя колонки в карте отображения: строковый ключ в начале строки.
var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`)
// writtenColumns — колонки, которые пишет отображение записи в базу.
func writtenColumns(t *testing.T) map[string]bool { func writtenColumns(t *testing.T) map[string]bool {
t.Helper() t.Helper()
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") + body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") +
funcBody(t, mappingFile, "func applyToRecord(") funcBody(t, mappingFile, "func writeRecord(")
out := map[string]bool{} 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 out[m[1]] = true
} }
if len(out) == 0 { if len(out) == 0 {
@@ -327,16 +376,51 @@ func writtenColumns(t *testing.T) map[string]bool {
return out return out
} }
// readColumns — колонки, которые читает обратное отображение. // readColumns — колонки, которые читает обратное отображение. Перечень выборки
// собирается из той же карты, поэтому сверяется именно она.
func readColumns(t *testing.T) map[string]bool { func readColumns(t *testing.T) map[string]bool {
t.Helper() t.Helper()
body := funcBody(t, mappingFile, "func recordToAudioRecord(") body := funcBody(t, mappingFile, "func readRecordColumns(")
out := map[string]bool{} 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 out[m[1]] = true
} }
if len(out) == 0 { 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 return out
} }
@@ -447,9 +531,11 @@ func funcBody(t *testing.T, file, header string) string {
return body[start : start+end] return body[start : start+end]
} }
// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в // schemaFieldNames собирает имена колонок аудиозаписи, заведённых шагами схемы.
// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг, //
// который её добавил, а переписывать применённый шаг нельзя. // Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот
// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень
// поэтому объединённый — по всем шагам сразу.
func schemaFieldNames(t *testing.T) map[string]bool { func schemaFieldNames(t *testing.T) map[string]bool {
t.Helper() t.Helper()
dir := filepath.Join(repoRoot, migrationsPath) dir := filepath.Join(repoRoot, migrationsPath)
@@ -457,7 +543,8 @@ func schemaFieldNames(t *testing.T) map[string]bool {
if err != nil { if err != nil {
t.Fatalf("читаю каталог шагов схемы: %v", err) 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{} out := map[string]bool{}
for _, e := range entries { for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") { if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
@@ -467,16 +554,41 @@ func schemaFieldNames(t *testing.T) map[string]bool {
if err != nil { if err != nil {
t.Fatalf("читаю %s: %v", e.Name(), err) t.Fatalf("читаю %s: %v", e.Name(), err)
} }
for _, m := range re.FindAllStringSubmatch(string(body), -1) { for _, block := range tableBlocks(string(body), recordsTable) {
for _, m := range column.FindAllStringSubmatch(block, -1) {
out[m[1]] = true out[m[1]] = true
} }
} }
}
if len(out) == 0 { if len(out) == 0 {
t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет") t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет")
} }
return out 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 { func readFile(t *testing.T, rel string) string {
t.Helper() t.Helper()
body, err := os.ReadFile(filepath.Join(repoRoot, rel)) body, err := os.ReadFile(filepath.Join(repoRoot, rel))
+37 -2
View File
@@ -64,10 +64,38 @@ type ServerConfig struct {
ForceShutdownTimeout int `toml:"force_shutdown_timeout"` ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
} }
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы // StorageConfig — хранилище сервиса: каталог данных и числа его базы.
// записей. Двух путей, как было раньше, у хранилища не бывает. //
// Каталог единственный: под ним лежат и база, и файлы записей. Двух путей, как
// было раньше, у хранилища не бывает.
type StorageConfig struct { type StorageConfig struct {
DataDir string `toml:"data_dir"` 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 { type YandexConfig struct {
@@ -156,6 +184,13 @@ func defaultConfig() *Config {
}, },
Storage: StorageConfig{ Storage: StorageConfig{
DataDir: "data", DataDir: "data",
// Пять секунд ожидания и четыре читающих соединения: числа выведены
// из числа воркеров по умолчанию, а не из замера. Смысл ожидания —
// пережить чужую запись, а не чужую работу: пишет сервис короткими
// операциями, и очередь из трёх воркеров укладывается в него с
// запасом.
BusyTimeoutMs: 5000,
ReadConnections: 4,
}, },
Pipeline: PipelineConfig{ Pipeline: PipelineConfig{
Workers: 3, Workers: 3,
+39
View File
@@ -216,3 +216,42 @@ func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) {
} }
} }
} }
// Настройки хранилища проверяются на старте, и каждая ветвь проверки закрывает
// свою поломку. Ноль и отрицательное — опечатка, а не режим: нулевое ожидание
// отдаёт «база занята» первому же воркеру, нулевой пул чтения означает пул без
// предела, а пустой каталог данных оставляет сервис без места под базу и файлы.
// Без проверки такая опечатка проявилась бы отказом под нагрузкой, а не на
// подъёме.
func TestStorageConfigValidate(t *testing.T) {
valid := StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 4}
if err := valid.Validate(); err != nil {
t.Fatalf("заполненные настройки отвергнуты: %v", err)
}
cases := []struct {
name string
config StorageConfig
mention string
}{
{"каталог данных не заполнен", StorageConfig{DataDir: "", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"},
{"каталог данных из одних пробелов", StorageConfig{DataDir: " ", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"},
{"ожидание нулевое", StorageConfig{DataDir: "data", BusyTimeoutMs: 0, ReadConnections: 4}, "busy_timeout_ms"},
{"ожидание отрицательное", StorageConfig{DataDir: "data", BusyTimeoutMs: -1, ReadConnections: 4}, "busy_timeout_ms"},
{"пул чтения нулевой", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 0}, "read_connections"},
{"пул чтения отрицательный", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: -3}, "read_connections"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.config.Validate()
if err == nil {
t.Fatal("негодная настройка принята: поломка дошла бы до боя")
}
if !strings.Contains(err.Error(), tc.mention) {
t.Fatalf("имя ключа %q не названо: %v", tc.mention, err)
}
})
}
}
+22
View File
@@ -8,6 +8,11 @@ import (
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не // ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в // назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
// него не кладётся никогда. // него не кладётся никогда.
//
// Достижимого случая у него нет: узнавание заводит учётную запись само, и
// предъявителя без неё под корнем приложения не бывает. Отдельной ветви ответа
// он поэтому не получает — ветвь по умолчанию читает его как аварию сервиса,
// каковой он и был бы.
var ErrOwnerRequired = errors.New("owner is required to accept a record") var ErrOwnerRequired = errors.New("owner is required to accept a record")
// ErrRecordUnreadable — присланную запись не удалось прочитать: источник // ErrRecordUnreadable — присланную запись не удалось прочитать: источник
@@ -31,6 +36,18 @@ var ErrRecordTooLarge = errors.New("uploaded record exceeds size limit")
// человек увидел бы «не найдено» на своей записи, загруженной минуту назад. // человек увидел бы «не найдено» на своей записи, загруженной минуту назад.
var ErrTextNotReady = errors.New("requested text view is not ready yet") 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 — во входе запроса негодное значение: неизвестный вид текста, // ErrBadRequest — во входе запроса негодное значение: неизвестный вид текста,
// нечитаемый ключ страницы, отрицательный размер. Отличается от ErrRecordUnreadable // нечитаемый ключ страницы, отрицательный размер. Отличается от ErrRecordUnreadable
// тем, что негодна **просьба**, а не присланная запись. // тем, что негодна **просьба**, а не присланная запись.
@@ -47,6 +64,11 @@ var ErrUnauthorized = errors.New("session is required")
// отсутствующий адрес отсутствующей записью. // отсутствующий адрес отсутствующей записью.
var ErrNotFound = errors.New("address not found") var ErrNotFound = errors.New("address not found")
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а негодный
// ввод, и звать по нему учётную запись не надо.
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
type JobNotFoundError struct { type JobNotFoundError struct {
State string State string
Message string Message string
+47 -8
View File
@@ -2,6 +2,7 @@ package contract
import ( import (
"io" "io"
"time"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -40,19 +41,24 @@ type FileRepository interface {
StageEmpty(ext string) (WorkFile, error) StageEmpty(ext string) (WorkFile, error)
// Localize выдаёт рабочую копию хранимого файла. // Localize выдаёт рабочую копию хранимого файла.
Localize(fileID string) (WorkFile, error) Localize(fileID string) (WorkFile, error)
// Create кладёт рабочую копию в хранилище под именем name и заводит запись о // Create кладёт рабочую копию в каталог данных под именем name и заводит
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени // строку о файле. Имя задаёт сервис, и имя, данное отправителем, в него не
// отправителя, не применяется. // попадает: от него взято только расширение.
//
// recordID — запись, которой копия принадлежит: копии одной записи лежат её
// подкаталогом, и имя этого подкаталога и есть идентификатор записи. Приём
// знает его раньше, чем кладёт файл, потому что назначает сам.
// //
// ownerID — владелец записи, которой файл принадлежит, и он обязателен: // 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) GetByID(id string) (*entity.File, error)
// Open отдаёт содержимое хранимого файла потоком. // Open отдаёт содержимое хранимого файла потоком с перемоткой: отдача по
Open(fileID string) (io.ReadCloser, error) // диапазону читает запрошенный кусок, а не файл целиком.
Open(fileID string) (io.ReadSeekCloser, error)
} }
// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак // AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
@@ -74,8 +80,12 @@ type AcquiredRecord struct {
// RecordCursor — положение в ленте записей, заданное **полным** ключом // RecordCursor — положение в ленте записей, заданное **полным** ключом
// сортировки. Одного времени мало: у записей, принятых одним запросом, оно // сортировки. Одного времени мало: у записей, принятых одним запросом, оно
// совпадает, и порядок между ними иначе не определён. // совпадает, и порядок между ними иначе не определён.
//
// Время лежит здесь значением времени, а не строкой: вид, каким оно уходит в
// запрос, принадлежит хранилищу — сравнение там побайтово, и вид, собранный
// транспортом, разошёлся бы с колонкой молча, обратив условие в постоянную ложь.
type RecordCursor struct { type RecordCursor struct {
CreatedAt string CreatedAt time.Time
ID string ID string
} }
@@ -154,7 +164,8 @@ type RecognitionRepository interface {
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По // Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
// последнему повторный шаг узнаёт, что за эту запись уже заплачено. // последнему повторный шаг узнаёт, что за эту запись уже заплачено.
Submitted(id, sourceURI, externalID string) error Submitted(id, sourceURI, externalID string) error
// Finish отмечает завершение операции и кладёт сырой ответ вложением. // Finish отмечает завершение операции и кладёт сохранённый ответ отдельным
// файлом в подкаталоге записи.
Finish(id string, raw []byte) error Finish(id string, raw []byte) error
GetByID(id string) (*entity.Recognition, error) GetByID(id string) (*entity.Recognition, error)
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда // ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда
@@ -166,3 +177,31 @@ type RecognitionRepository interface {
type RecordEventRepository interface { type RecordEventRepository interface {
Append(event *entity.RecordEvent) error 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)
}
+272 -167
View File
@@ -11,25 +11,18 @@ import (
"strings" "strings"
"time" "time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
"github.com/pocketbase/pocketbase/tools/types"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "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/metrics"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
) )
// AppRoot — корень адресов приложения. // AppRoot — корень адресов приложения.
// //
// Приложение живёт своим пространством, а не в общем `/api/`: последнее // Корень остался **один**: пространства `/api/`, принадлежавшего встроенному
// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять // хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён // Соседство, ради которого корень был выбран, кончилось вместе с соседом.
// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с
// нашим, и разойдутся они молча.
const AppRoot = "/app" const AppRoot = "/app"
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона // Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
@@ -55,17 +48,23 @@ const pollBudgetShare = 8
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с // задаётся своей константой: иначе приложение, честно опрашивающее карточку с
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ, // объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
// которого сервис сам же ему обещал избежать. // которого сервис сам же ему обещал избежать.
//
// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один
// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная
// частота — доля бюджета, и неравенство «объявленное меньше применяемого»
// выполняется с запасом.
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests) const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
// Значения параметра `copy` у адреса файла записи. Перечень закрыт, и каждое
// значение называет ровно одну хранимую вещь: имя параметра нормативно наравне
// со значениями — разбирает его каждый экран, и выбранное кодом оно стало бы
// публичным контрактом молча.
const (
CopyParam = "copy"
CopyOriginal = "original"
CopyNormalized = "normalized"
)
type AppHandler struct { type AppHandler struct {
recordRepo contract.AudioRecordRepository recordRepo contract.AudioRecordRepository
textRepo contract.TextRepository textRepo contract.TextRepository
structureRepo contract.StructureRepository structureRepo contract.StructureRepository
fileRepo contract.FileRepository
trsService *service.TranscribeService trsService *service.TranscribeService
logger *slog.Logger logger *slog.Logger
} }
@@ -74,6 +73,7 @@ func NewAppHandler(
recordRepo contract.AudioRecordRepository, recordRepo contract.AudioRecordRepository,
textRepo contract.TextRepository, textRepo contract.TextRepository,
structureRepo contract.StructureRepository, structureRepo contract.StructureRepository,
fileRepo contract.FileRepository,
trsService *service.TranscribeService, trsService *service.TranscribeService,
logger *slog.Logger, logger *slog.Logger,
) *AppHandler { ) *AppHandler {
@@ -84,6 +84,7 @@ func NewAppHandler(
recordRepo: recordRepo, recordRepo: recordRepo,
textRepo: textRepo, textRepo: textRepo,
structureRepo: structureRepo, structureRepo: structureRepo,
fileRepo: fileRepo,
trsService: trsService, trsService: trsService,
logger: logger, logger: logger,
} }
@@ -95,8 +96,11 @@ func NewAppHandler(
// //
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса. // Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
// Причина остановки — значение из закрытого перечня, и она не он: без причины // Причина остановки — значение из закрытого перечня, и она не он: без причины
// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения // признак остановки не говорит человеку, чего ждать.
// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном. //
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
// выводится из рубежа записи, который карточка несёт и так. Второе поле
// повторяло бы рубеж и разошлось бы с ним молча.
type RecordView struct { type RecordView struct {
ID string `json:"id"` ID string `json:"id"`
Title *string `json:"title"` Title *string `json:"title"`
@@ -120,11 +124,6 @@ type RecordView struct {
} }
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла. // IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
//
// Место под признак заведено вперёд и заполняется другой задачей. Форма
// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать
// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую
// форму.
type IntakeItem struct { type IntakeItem struct {
RecordView RecordView
Duplicate bool `json:"duplicate"` Duplicate bool `json:"duplicate"`
@@ -161,68 +160,134 @@ type ReplicaView struct {
Text string `json:"text"` Text string `json:"text"`
} }
// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у // Routes — адреса приложения одним обработчиком.
// панели один, поэтому и роутер один.
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
app := r.Group(AppRoot)
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
// иначе ушли бы телом библиотеки, мимо единой формы.
// //
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и // Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
// адрес выдачи файлового токена из пространства хранилища. Область его // вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
// действия при этом выводится из **этого же** перечня адресного // адресного пространства. Так область их действия выводится из объявленного
// пространства — см. `underIdentifiedArea`. // пространства, а не перечисляется вторым списком.
app.Bind(OneErrorForm())
app.Bind(RequireUser(migrations.UsersCollection))
app.GET("/me", h.Me)
app.GET("/config", h.Config)
// Приём стоит тем же адресом, что и список, и отличается только методом: он
// заводит аудиозапись, а не кладёт файл.
// //
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись // Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
// раньше обработчика, без строки в журнале приёма. Предел тела равен потолку // на неверный метод ушёл бы его формой тела, а форма отказа под корнем
// самой записи, а отказ по нему уходит нашей формой. // приложения одна.
app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize)) func (h *AppHandler) Routes() http.Handler {
app.GET("/audiorecords", h.ListRecords) mux := http.NewServeMux()
app.GET("/audiorecords/{id}", h.GetRecord)
app.GET("/audiorecords/{id}/text", h.GetRecordText)
// Перехват «под нашим корнем такого адреса нет». for _, pattern := range AppRoutePatterns {
// mux.HandleFunc(pattern, h.handlerOf(pattern))
// Слой единой формы его не покрывает, и это не оплошность приоритета: отказ
// «ничего не совпало» рождается маршрутом **корневой** группы, к которому
// слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный
// путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа
// под корнем приложения было бы две.
//
// Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии
// отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса
// приложения, и по той же причине: сперва «кто спрашивает», потом «что».
app.Any("/{path...}", func(e *core.RequestEvent) error {
return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
})
} }
func (h *AppHandler) Me(e *core.RequestEvent) error { // Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
// же: он принадлежит корню приложения, адресом приложения не является и
// потому отвечает как неизвестный путь под ним.
//
// Оба образца обязательны: без точного `/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)
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит // Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у // человеку, а не сервису. Логин у провайдера — тоже: это его имя у
// провайдера, и правило о непечатаемых значениях запрещает ему выходить // провайдера, и правило о непечатаемых значениях запрещает ему выходить
// наружу наравне с журналом. // наружу наравне с журналом.
return e.JSON(http.StatusOK, MeView{ writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name})
ID: e.Auth.Id,
Name: e.Auth.GetString("name"),
})
} }
func (h *AppHandler) Config(e *core.RequestEvent) error { func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) {
// Каждый предел — то же значение, которое сервис применяет, а не его копия. // Каждый предел — то же значение, которое сервис применяет, а не его копия.
// Приложение, знающее предел своей константой, расходится с сервером молча — // Приложение, знающее предел своей константой, расходится с сервером молча —
// до первого отказа на записи, которую человек уже успел отправить. // до первого отказа на записи, которую человек уже успел отправить.
return e.JSON(http.StatusOK, ConfigView{ writeJSON(w, http.StatusOK, ConfigView{
MaxRecordSizeBytes: entity.MaxRecordSize, MaxRecordSizeBytes: entity.MaxRecordSize,
MaxPageSize: MaxPageLimit, MaxPageSize: MaxPageLimit,
PollIntervalMs: PollIntervalMs, PollIntervalMs: PollIntervalMs,
@@ -231,17 +296,35 @@ func (h *AppHandler) Config(e *core.RequestEvent) error {
}) })
} }
func (h *AppHandler) CreateRecord(e *core.RequestEvent) error { func (h *AppHandler) CreateRecord(w http.ResponseWriter, r *http.Request) {
file, header, err := e.Request.FormFile("audio") account, _ := AccountOf(r)
if err != nil {
// Предел тела ловит объявленную длину заранее, слоем; необъявленную — // Предел тела назван числом: умолчания здесь не «без предела», а величины на
// на чтении, уже здесь. Не различив эти два отказа, приём сказал бы // два-три порядка меньше нужного, и оставленные как есть они отвергли бы
// человеку «вы не приложили файл» о записи, которую он приложил и // штатную запись сервиса. Отказ по нему уходит нашей формой тела.
// которая просто больше потолка. //
if errors.Is(err, apis.ErrRequestEntityTooLarge) { // Ловится он **дважды**, и это не избыточность. Объявленная длина судится
return fail(e, contract.ErrRecordTooLarge) // заранее: запись, за которую сервис платить не станет, не должна попасть
// даже в память. Необъявленная и солгавшая ловятся на чтении — объявленной
// длины у запроса с кусочной передачей нет вовсе.
if r.ContentLength > entity.MaxRecordSize {
fail(w, contract.ErrRecordTooLarge)
return
} }
return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу")) 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() { defer func() {
if err := file.Close(); err != nil { if err := file.Close(); err != nil {
@@ -252,71 +335,78 @@ func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся // Запись доехала целиком, поэтому она заводится независимо от того, дождётся
// ли отправитель ответа: на контексте запроса приём терял бы полностью // ли отправитель ответа: на контексте запроса приём терял бы полностью
// загруженную запись от одного обрыва соединения, а забрать результат он // загруженную запись от одного обрыва соединения, а забрать результат он
// может и позже — карточкой записи. Значения контекста (журнал запроса, // может и позже — карточкой записи.
// сессия) при этом сохраняются, теряется только отмена. ctx := context.WithoutCancel(r.Context())
ctx := context.WithoutCancel(e.Request.Context())
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец, // Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец,
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на // пришедший полем запроса, дал бы всякому узнанному право завести запись на
// чужое имя. // чужое имя.
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id) record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID)
if err != nil { if err != nil {
// Второй раз отказ не логируем: приём назван конвенцией логирующей // Второй раз отказ не логируем: приём назван конвенцией логирующей
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и // границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
// делает это одним местом — по причине отказа, а не по месту. // делает это одним местом — по причине отказа, а не по месту.
return fail(e, err) fail(w, err)
return
} }
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд, // Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
// чтобы приём нескольких файлов и распознавание повтора её не переписывали. // чтобы приём нескольких файлов и распознавание повтора её не переписывали.
return e.JSON(http.StatusCreated, []IntakeItem{{ writeJSON(w, http.StatusCreated, []IntakeItem{{
// Свежая запись текстов не имеет, но поле обязано быть на проводе: // Свежая запись текстов не имеет, но поле обязано быть на проводе:
// отсутствие поля и пустой перечень приложение не различит. // отсутствие поля и пустой перечень приложение не различит.
RecordView: h.viewOf(record, nil, &[]string{}), RecordView: h.viewOf(record, nil, &[]string{}),
}}) }})
} }
func (h *AppHandler) ListRecords(e *core.RequestEvent) error { func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit} account, _ := AccountOf(r)
if raw := e.Request.URL.Query().Get("limit"); raw != "" { q := contract.RecordQuery{OwnerID: account.ID, Limit: DefaultPageLimit}
if raw := r.URL.Query().Get("limit"); raw != "" {
limit, err := strconv.Atoi(raw) limit, err := strconv.Atoi(raw)
if err != nil || limit <= 0 { if err != nil || limit <= 0 {
return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом")) fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
return
} }
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем // Сверх потолка — усечение, а не отказ: человек попросил больше, чем
// сервис отдаёт, но просьба сама по себе не негодна. // сервис отдаёт, но просьба сама по себе не негодна.
q.Limit = min(limit, MaxPageLimit) q.Limit = min(limit, MaxPageLimit)
} }
if raw := e.Request.URL.Query().Get("filter"); raw != "" { if raw := r.URL.Query().Get("filter"); raw != "" {
filter, ok := entity.ParseListFilter(raw) filter, ok := entity.ParseListFilter(raw)
if !ok { if !ok {
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора")) fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
return
} }
q.Filter = &filter q.Filter = &filter
} }
if raw := e.Request.URL.Query().Get("cursor"); raw != "" { if raw := r.URL.Query().Get("cursor"); raw != "" {
cursor, err := decodeCursor(raw) cursor, err := decodeCursor(raw)
if err != nil { if err != nil {
// Молчаливая отдача первой страницы вместо отказа дала бы человеку // Молчаливая отдача первой страницы вместо отказа дала бы человеку
// архив, листающийся по кругу, и ни строки в журнале. // архив, листающийся по кругу, и ни строки в журнале.
return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается")) fail(w, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
return
} }
q.Cursor = cursor q.Cursor = cursor
} }
page, err := h.recordRepo.List(q) page, err := h.recordRepo.List(q)
if err != nil { if err != nil {
h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id) h.logger.Error("Failed to list audio records", "error", err, "owner_id", account.ID)
return fail(e, err) fail(w, err)
return
} }
names, err := h.topicNames(e.Auth.Id, page.Items) names, err := h.topicNames(account.ID, page.Items)
if err != nil { if err != nil {
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id) h.logger.Error("Failed to resolve topics", "error", err, "owner_id", account.ID)
return fail(e, err) fail(w, err)
return
} }
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems} view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
@@ -331,55 +421,71 @@ func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
view.NextCursor = &encoded view.NextCursor = &encoded
} }
return e.JSON(http.StatusOK, view) writeJSON(w, http.StatusOK, view)
} }
func (h *AppHandler) GetRecord(e *core.RequestEvent) error { func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
record, err := h.readOwn(e) account, _ := AccountOf(r)
record, err := h.readOwn(r, account.ID)
if err != nil { if err != nil {
return fail(e, err) fail(w, err)
return
} }
names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record}) names, err := h.topicNames(account.ID, []*entity.AudioRecord{record})
if err != nil { if err != nil {
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id) h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
return fail(e, err) fail(w, err)
return
} }
views := h.availableViews(record) views := h.availableViews(record)
return e.JSON(http.StatusOK, h.viewOf(record, names, &views)) writeJSON(w, http.StatusOK, h.viewOf(record, names, &views))
} }
func (h *AppHandler) GetRecordText(e *core.RequestEvent) error { func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
view := e.Request.URL.Query().Get("view") account, _ := AccountOf(r)
view := r.URL.Query().Get("view")
if !entity.IsKnownTextView(view) { if !entity.IsKnownTextView(view) {
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста")) fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
return
} }
record, err := h.readOwn(e) record, err := h.readOwn(r, account.ID)
if err != nil { if err != nil {
return fail(e, err) fail(w, err)
return
} }
if view == entity.TextViewReplicas { if view == entity.TextViewReplicas {
return h.replicasOf(e, record) h.replicasOf(w, record)
return
} }
return h.plainTextOf(e, record, view) h.plainTextOf(w, record, view)
} }
// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают // readOwn читает запись спрашивающего. Чужая, ничья, несуществующая и
// одним и тем же: по разнице ответов иначе перебирается список заведённых // нечитаемая по виду идентификатора отвечают одним и тем же: по разнице ответов
// записей. // иначе перебирается список заведённых записей.
func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) { func (h *AppHandler) readOwn(r *http.Request, ownerID string) (*entity.AudioRecord, error) {
recordID := e.Request.PathValue("id") // Идентификатор разбирается на границе: он приходит от спрашивающего, а
// сравнение в базе побайтово — запись в верхнем регистре не совпала бы ни с
// одной строкой. Негодный по виду считается несуществующим и до базы не
// доходит вовсе.
recordID, ok := ident.Parse(r.PathValue("id"))
if !ok {
return nil, &contract.JobNotFoundError{Message: "record not found"}
}
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id) record, err := h.recordRepo.GetByID(recordID, ownerID)
if err != nil { if err != nil {
// Наружу ответ один на все исходы, а в журнал они идут по-разному. // Наружу ответ один на все исходы, а в журнал они идут по-разному.
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней // «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
// писать нечего; всё прочее — отказ хранилища, и без этой строки он // писать нечего; всё прочее — отказ базы, и без этой строки он приходит
// приходит отправителю как «вашей записи нет», а владелец сервиса об // отправителю как «вашей записи нет», а владелец сервиса об аварии не
// аварии не узнаёт ниоткуда. // узнаёт ниоткуда.
var notFound *contract.JobNotFoundError var notFound *contract.JobNotFoundError
if !errors.As(err, &notFound) { if !errors.As(err, &notFound) {
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID) h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
@@ -389,52 +495,62 @@ func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error)
return record, nil return record, nil
} }
func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error { func (h *AppHandler) plainTextOf(w http.ResponseWriter, record *entity.AudioRecord, view string) {
textID := record.TranscriptTextID textID := record.TranscriptTextID
if view == entity.TextViewLiterary { if view == entity.TextViewLiterary {
textID = record.LiteraryTextID textID = record.LiteraryTextID
} }
if textID == nil { if textID == nil {
return fail(e, contract.ErrTextNotReady) fail(w, contract.ErrTextNotReady)
return
} }
text, err := h.textRepo.GetByID(*textID) text, err := h.textRepo.GetByID(*textID)
if err != nil { if err != nil {
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id) h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
return fail(e, err) fail(w, err)
return
} }
if text.Contents == "" { if text.Contents == "" {
return fail(e, contract.ErrTextNotReady) fail(w, contract.ErrTextNotReady)
return
} }
return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents}) writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents})
} }
func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error { func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) {
if record.StructureID == nil { if record.StructureID == nil {
return fail(e, contract.ErrTextNotReady) fail(w, contract.ErrTextNotReady)
return
} }
structure, err := h.structureRepo.GetByID(*record.StructureID) structure, err := h.structureRepo.GetByID(*record.StructureID)
if err != nil { if err != nil {
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id) h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
return fail(e, err) fail(w, err)
return
} }
if len(structure.Replicas) == 0 { if len(structure.Replicas) == 0 {
return fail(e, contract.ErrTextNotReady) fail(w, contract.ErrTextNotReady)
return
} }
replicas := make([]ReplicaView, 0, len(structure.Replicas)) replicas := make([]ReplicaView, 0, len(structure.Replicas))
for _, r := range structure.Replicas { for _, replica := range structure.Replicas {
replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text}) replicas = append(replicas, ReplicaView{
StartMs: replica.StartMs,
EndMs: replica.EndMs,
Text: replica.Text,
})
} }
return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas}) writeJSON(w, http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
} }
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в // topicNames разрешает темы всех записей страницы **одним** запросом: страница в
// сотню записей иначе стоила бы сотни обращений к хранилищу. // сотню записей иначе стоила бы сотни обращений к базе.
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) { func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
seen := map[string]bool{} seen := map[string]bool{}
ids := []string{} ids := []string{}
@@ -481,9 +597,7 @@ func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string,
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка // Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт // без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка // нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно: // объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно.
// приложение опрашивало бы его без конца, а человек видел бы завершённую запись,
// из которой текст «вот-вот появится».
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string { func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
views := []string{} views := []string{}
@@ -501,8 +615,7 @@ func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается // hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить // как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса // из-за неё карточку хуже, чем недосказать.
// журналом, который пишет чтение текста.
func (h *AppHandler) hasText(textID *string) bool { func (h *AppHandler) hasText(textID *string) bool {
if textID == nil { if textID == nil {
return false return false
@@ -531,11 +644,12 @@ func (h *AppHandler) hasReplicas(structureID *string) bool {
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а // непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
// составлять ключ руками значило бы завязаться на порядок сортировки. // составлять ключ руками значило бы завязаться на порядок сортировки.
// //
// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара // Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите — // телом.
// ключ уезжает параметром, а не телом.
func encodeCursor(c *contract.RecordCursor) string { func encodeCursor(c *contract.RecordCursor) string {
return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID)) return base64.RawURLEncoding.EncodeToString(
[]byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID),
)
} }
func decodeCursor(raw string) (*contract.RecordCursor, error) { func decodeCursor(raw string) (*contract.RecordCursor, error) {
@@ -545,32 +659,23 @@ func decodeCursor(raw string) (*contract.RecordCursor, error) {
} }
createdAt, id, ok := strings.Cut(string(decoded), "|") createdAt, id, ok := strings.Cut(string(decoded), "|")
if !ok || createdAt == "" || id == "" { if !ok {
return nil, errors.New("malformed cursor") return nil, errors.New("malformed cursor")
} }
// Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым // Обе половины ключа разбираются, а не берутся строкой: время уходит в
// сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча // запрос сравнением, а идентификатор — точным совпадением, и негодная
// обращает условие в постоянную истину или ложь — человек получает либо // половина дала бы человеку либо пустой архив при непустом счётчике, либо
// пустой архив при непустом счётчике, либо ленту с начала. // ленту с начала.
parsed, err := types.ParseDateTime(createdAt) parsed, err := time.Parse(time.RFC3339, createdAt)
if err != nil || parsed.IsZero() { if err != nil {
return nil, errors.New("cursor carries no readable time") return nil, errors.New("cursor carries no readable time")
} }
return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil recordID, valid := ident.Parse(id)
if !valid {
return nil, errors.New("cursor carries no readable record key")
} }
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
// всех одна, а сказать человеку надо разное.
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 }
+118 -304
View File
@@ -7,16 +7,13 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
) )
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем // Проверки этого файла судят допуск: кого пускают к адресам приложения, чем
// называется пришедший, кому верят и какие адреса остаются открытыми. // называется пришедший, кому верят и какие адреса остаются открытыми.
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает // TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
@@ -33,31 +30,22 @@ func TestApiRequiresIdentity(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.NotContains(t, w.Body.String(), "job_id") assert.NotContains(t, w.Body.String(), "job_id")
// Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в // Ни файла, ни записи: отказ наступает раньше, чем запись попадает в
// хранилище. // каталог данных.
files, err := env.app.FindAllRecords(migrations.FilesCollection) assert.Equal(t, 0, countFiles(t, env))
require.NoError(t, err) assert.Equal(t, 0, countJobs(t, env))
assert.Empty(t, files)
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
require.NoError(t, err)
assert.Empty(t, jobs)
}) })
t.Run("карточка записи неузнанному", func(t *testing.T) { t.Run("карточка записи неузнанному", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil) w := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.NotContains(t, w.Body.String(), "transcription_text")
assert.NotContains(t, w.Body.String(), "created_at") assert.NotContains(t, w.Body.String(), "created_at")
}) })
} }
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному // TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
// не перебирается список заведённых задач. // не перебирается список заведённых записей.
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) { func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
@@ -65,24 +53,16 @@ func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio"))) env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
require.Equal(t, http.StatusCreated, created.Code) require.Equal(t, http.StatusCreated, created.Code)
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) existing := env.get("/app/audiorecords/" + intakeItemOf(t, created).ID)
require.NoError(t, err) missing := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
require.Len(t, jobs, 1)
existing := httptest.NewRecorder()
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil))
missing := httptest.NewRecorder()
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil))
assert.Equal(t, http.StatusUnauthorized, existing.Code) assert.Equal(t, http.StatusUnauthorized, existing.Code)
assert.Equal(t, missing.Code, existing.Code) assert.Equal(t, missing.Code, existing.Code)
assert.Equal(t, missing.Body.String(), existing.Body.String())
} }
// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**. // TestFirstRequestCreatesAccountAndSecondReuses — учётная запись заводится
// // первым обращением и находится вторым, а строка её в базе одна.
// Два запроса подряд с одним значением заголовка: учётная запись заводится
// первым и находится вторым, а в хранилище её строка одна.
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) { func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
@@ -90,12 +70,10 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
before := countAccounts(t, env) before := countAccounts(t, env)
first := httptest.NewRecorder() first := env.getAs(login, "/app/me")
env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано") require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
second := httptest.NewRecorder() second := env.getAs(login, "/app/me")
env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
require.Equal(t, http.StatusOK, second.Code) require.Equal(t, http.StatusOK, second.Code)
assert.Equal(t, before+1, countAccounts(t, env), assert.Equal(t, before+1, countAccounts(t, env),
@@ -104,8 +82,8 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
"второе обращение попало в другую учётную запись") "второе обращение попало в другую учётную запись")
} }
// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же // TestUntrustedPeerIsNotIdentified — тот же заголовок с недоверенного адреса
// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем. // даёт отказ, а не вход под названным именем.
func TestUntrustedPeerIsNotIdentified(t *testing.T) { func TestUntrustedPeerIsNotIdentified(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
@@ -123,118 +101,75 @@ func TestUntrustedPeerIsNotIdentified(t *testing.T) {
"заголовок с недоверенного адреса завёл учётную запись") "заголовок с недоверенного адреса завёл учётную запись")
} }
// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**. // TestStorageAddressSpaceIsGone — **критерий приёмки**: пространства хранилища
// не существует.
// //
// Перечень собственных адресов входа хранилища: каждый отвечает отказом и // Прежде под корнем `/api/` жила собственная поверхность встроенного хранилища:
// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один // собственные входы, перечисление коллекции пользователей, правка своей строки —
// из них работает, узнавание по заголовку обходится двумя запросами. // то есть путь захвата чужого имени. Хранилище ушло целиком, и адресов этих нет:
func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) { // они отвечают тем же, чем отвечает всякий путь вне корней сервиса.
//
// Проверка судит **и то, что ответ прежний, и то, что ничего не произошло**:
// число учётных записей после обхода то же самое.
func TestStorageAddressSpaceIsGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
const usersRoot = "/api/collections/users"
cases := []struct {
name string
path string
body string
}{
{
name: "завести учётную запись самому",
path: usersRoot + "/records",
body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`,
},
{
name: "вход по паролю",
path: usersRoot + "/auth-with-password",
body: `{"identity":"person@example.com","password":"whatever"}`,
},
{
name: "обмен кода у внешнего провайдера",
path: usersRoot + "/auth-with-oauth2",
body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`,
},
{
name: "вход по одноразовому коду",
path: usersRoot + "/auth-with-otp",
body: `{"otpId":"whatever","password":"whatever"}`,
},
{
name: "запрос одноразового кода",
path: usersRoot + "/request-otp",
body: `{"email":"person@example.com"}`,
},
{
name: "восстановление пароля",
path: usersRoot + "/request-password-reset",
body: `{"email":"person@example.com"}`,
},
{
name: "продление сессии",
path: usersRoot + "/auth-refresh",
body: `{}`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
before := countAccounts(t, env) before := countAccounts(t, env)
req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body)) // Эталон: путь вне корней сервиса, за которым не стояло ничего никогда.
req.Header.Set("Content-Type", "application/json") reference := env.get("/nothing-was-ever-here")
require.Equal(t, http.StatusOK, reference.Code)
w := httptest.NewRecorder() cases := map[string]string{
env.mux.ServeHTTP(w, req) "перечисление учётных записей": "/api/collections/users/records",
"вход по паролю": "/api/collections/users/auth-with-password",
"обмен кода у провайдера": "/api/collections/users/auth-with-oauth2",
"выдача токена файла": "/api/files/token",
"адрес панели": "/_/",
"адрес панели знаком кода": "/%5f/",
}
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, for name, path := range cases {
"адрес %s ответил успехом: собственный вход хранилища открыт", c.path) t.Run(name, func(t *testing.T) {
w := env.getOwn(path)
assert.Equal(t, reference.Code, w.Code,
"адрес %s отвечает не как всякий неизвестный путь", path)
assert.Equal(t, reference.Body.String(), w.Body.String(),
"адрес %s отвечает не тем же телом, что всякий неизвестный путь", path)
assert.NotContains(t, w.Body.String(), `"token"`, assert.NotContains(t, w.Body.String(), `"token"`,
"адрес %s выдал значение доступа", c.path) "адрес %s выдал значение доступа", path)
assert.Equal(t, before, countAccounts(t, env),
"адрес %s изменил число учётных записей", c.path)
}) })
} }
assert.Equal(t, before, countAccounts(t, env),
"обход прежнего пространства хранилища изменил число учётных записей")
} }
// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт. // TestAccountKeyHasNoEditAddress: ключ учётной записи не правится ничем, кроме
// заведения самим сервисом.
// //
// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает // Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
// владельцу записи правку собственной строки. Пока узнавание жило под корнем // его будет нечем. Держится это тем, что адреса правки учётной записи у сервиса
// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она // нет вовсе — своих экранов профиля он не заводит, а поверхности хранилища,
// достижима, и правка своей записи была бы захватом чужого имени: поставил себе // правившей запись библиотечным правилом, не осталось.
// чужой логин — и первое обращение настоящего его владельца попало бы в твою func TestAccountKeyHasNoEditAddress(t *testing.T) {
// запись вместе со всем архивом.
func TestUserRecordCannotBeEditedFromOutside(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`) body := strings.NewReader(`{"provider_login":"victim"}`)
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body) req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.ID, body)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(req, env.login)) env.mux.ServeHTTP(w, asUser(req, env.login))
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла") // Метод правки до раздачи приложения не доходит: она открывает страницу
// только на `GET` и `HEAD`.
after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id) assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
require.NoError(t, err) assert.Equal(t, env.login, accountLogin(t, env, env.account.ID),
assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField),
"ключ учётной записи переписан снаружи") "ключ учётной записи переписан снаружи")
} }
// TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто.
// Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те
// значения, которыми довольно назваться.
func TestUserRecordsCannotBeListed(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(
httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login))
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
assert.NotContains(t, w.Body.String(), env.login)
}
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и // TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
// ничего не заводит. // ничего не заводит.
// //
@@ -248,7 +183,7 @@ func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
"пустое значение": "", "пустое значение": "",
"одни пробелы": " ", "одни пробелы": " ",
"управляющий знак": "ali\x00ce", "управляющий знак": "ali\x00ce",
"длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1), "длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
} }
for name, value := range cases { for name, value := range cases {
@@ -292,104 +227,66 @@ func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
assert.Equal(t, before, countAccounts(t, env)) assert.Equal(t, before, countAccounts(t, env))
} }
// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает // TestPresentedValueIsNotAccepted: предъявленного значения сервис не признаёт.
// заголовок, а протухший узнаванию не мешает.
// //
// Первая половина защищает владельца панели: подмена его учётной записью // Собственных токенов у него не существует — ни выдаваемых, ни принимаемых, — и
// пользователя отобрала бы у него панель посреди работы. Вторая — обычного // пришедшим считается названный заголовком. Прежде такое значение било заголовок:
// человека: негодный токен, оставшийся в браузере, не должен запирать его // им пользовался владелец панели, а панели больше нет.
// снаружи. func TestPresentedValueIsNotAccepted(t *testing.T) {
func TestStorageTokenBeatsHeader(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
stranger, strangerLogin := newSecondAccount(t, env.app) stranger := newSecondAccount(t, env)
token, err := stranger.NewAuthToken()
require.NoError(t, err)
t.Run("годный токен побеждает", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/app/me?token=whatever", nil)
req := httptest.NewRequest(http.MethodGet, "/app/me", nil) req.Header.Set("Authorization", "Bearer whatever")
req.Header.Set("Authorization", token)
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(req, env.login)) env.mux.ServeHTTP(w, asUser(req, env.login))
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), stranger.Id, assert.Contains(t, w.Body.String(), env.account.ID,
"заголовок победил предъявленный токен") "предъявленное значение победило заголовок")
_ = strangerLogin assert.NotContains(t, w.Body.String(), stranger.ID)
})
t.Run("протухший токен узнаванию не мешает", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
req.Header.Set("Authorization", "not-a-token")
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, asUser(req, env.login))
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), env.account.Id)
})
} }
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты // TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты неузнанному,
// неузнанному, а заголовок на них учётной записи не заводит. // а заголовок на них учётной записи не заводит.
// //
// Вторая половина важнее первой: узнавание сужено до области приложения именно // Вторая половина важнее первой: узнавание сужено до области приложения именно
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый // затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
// такой запрос с новым именем — записи в неё. // такой запрос с новым именем — записи в неё.
func TestOpenAddressesDoNotIdentify(t *testing.T) { func TestOpenAddressesDoNotIdentify(t *testing.T) {
app := newTestStorage(t) env := setupTestEnv(t, readableMetaViewer())
r, err := apis.NewRouter(app) before := countAccounts(t, env)
require.NoError(t, err)
appHandler := NewAppHandler(nil, nil, nil, nil, nil) for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} {
mounts := ServiceMounts(appHandler, http.NotFoundHandler()) anonymous := env.get(path)
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil))
r.GET(HealthPath, func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
r.GET(MetricsPath, func(e *core.RequestEvent) error {
return e.String(http.StatusOK, "# metrics")
})
mux, err := r.BuildMux()
require.NoError(t, err)
for _, path := range []string{HealthPath, MetricsPath} {
anonymous := httptest.NewRecorder()
mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil))
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path) assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
withHeader := httptest.NewRecorder() withHeader := env.getAs("passerby", path)
mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby"))
assert.Equal(t, http.StatusOK, withHeader.Code, assert.Equal(t, http.StatusOK, withHeader.Code,
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path) "чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
} }
records, err := app.FindAllRecords(migrations.UsersCollection) assert.Equal(t, before, countAccounts(t, env),
require.NoError(t, err) "обращение к открытому адресу завело учётную запись")
assert.Empty(t, records, "обращение к открытому адресу завело учётную запись")
} }
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки. // TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
// //
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь // Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся // суток, отозванный у провайдера человек работал до его истечения.
// кука вернула бы и это.
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) { func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
w := httptest.NewRecorder() w := env.getOwn("/app/me")
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос") assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
} }
// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не // TestIdentityValuesAreNotLogged — не печатается ничто, что даёт доступ.
// печатается ничто, что даёт доступ.
// //
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под // Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им // другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
@@ -406,16 +303,12 @@ func TestIdentityValuesAreNotLogged(t *testing.T) {
assert.NotContains(t, journal, env.login, assert.NotContains(t, journal, env.login,
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком") "логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
assert.NotContains(t, journal, env.account.Email(), assert.NotContains(t, journal, "person@example.com",
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку") "адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
} }
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и // TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
// виден **адресом пира**, а не значением заголовка. // виден **адресом пира**, а не значением заголовка.
//
// Без этой строки владелец, у которого никто не может войти, не отличит своей
// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не
// ставит) — а это разные поломки в разных местах.
func TestUntrustedPeerIsLogged(t *testing.T) { func TestUntrustedPeerIsLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
@@ -434,110 +327,46 @@ func TestUntrustedPeerIsLogged(t *testing.T) {
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал") assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
} }
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней. // countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение
func TestRecordFileIsProtected(t *testing.T) { // по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу
app := newTestStorage(t) // обработчика они неразличимы.
files, err := app.FindCollectionByNameOrId(migrations.FilesCollection)
require.NoError(t, err)
field, ok := files.Fields.GetByName("file").(*core.FileField)
require.True(t, ok)
assert.True(t, field.Protected,
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
}
// TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
// ссылке.
func TestRecordFileNeedsToken(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
require.Equal(t, http.StatusCreated, created.Code)
files, err := env.app.FindAllRecords(migrations.FilesCollection)
require.NoError(t, err)
require.Len(t, files, 1)
names := files[0].GetStringSlice("file")
require.Len(t, names, 1)
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
anonymous := httptest.NewRecorder()
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
// Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже
// своего существования. До пометки поля защищённым эта же ссылка отдавала
// содержимое кому угодно — знание ссылки и было доступом.
assert.Equal(t, http.StatusNotFound, anonymous.Code,
"ссылка отдала файл без токена: знание ссылки снова стало доступом")
assert.NotContains(t, anonymous.Body.String(), "audio content")
// А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен
// файла → ссылка» обязан работать целиком, иначе файл записи недостижим для
// браузера вовсе.
withToken := httptest.NewRecorder()
env.mux.ServeHTTP(withToken,
httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil))
require.Equal(t, http.StatusOK, withToken.Code)
assert.Equal(t, "audio content", withToken.Body.String())
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
fileRepo := pbrepo.NewFileRepository(env.app)
reader, err := fileRepo.Open(files[0].Id)
require.NoError(t, err)
defer func() {
assert.NoError(t, reader.Close())
}()
content := make([]byte, len("audio content"))
_, err = reader.Read(content)
require.NoError(t, err)
assert.Equal(t, "audio content", string(content))
}
// countAccounts — сколько учётных записей лежит в хранилище. Проверки судят
// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по
// ответу обработчика они неразличимы.
func countAccounts(t *testing.T, env *testEnv) int { func countAccounts(t *testing.T, env *testEnv) int {
t.Helper() t.Helper()
records, err := env.app.FindAllRecords(migrations.UsersCollection) return countRows(t, env, "users")
require.NoError(t, err) }
return len(records) // accountLogin читает ключ учётной записи прямо из базы.
func accountLogin(t *testing.T, env *testEnv, accountID string) string {
t.Helper()
var login string
require.NoError(t, env.db.Reader().
QueryRow("SELECT provider_login FROM users WHERE id = ?", accountID).Scan(&login))
return login
} }
// TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты // TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты
// запрос не заводит учётной записи. // запрос не заводит учётной записи.
// //
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше // Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
// ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать // ограничителя, он работал бы на запросах, которые тот уже отверг: бюджет
// запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили // выбирается, следующие запросы получают отказ — и заводят учётные записи.
// пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями // Убрать их потом нечем: учётная запись с записями не удаляется.
// не удаляется, а мусорная растёт в той же единственной базе.
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) { func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
require.NoError(t, ApplyAppRateLimit(env.app))
before := countAccounts(t, env) before := countAccounts(t, env)
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в // Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
// потолок раньше, чем начнутся новые имена. // потолок раньше, чем начнутся новые имена.
for range appRateMaxRequests + 5 { for range appRateMaxRequests + 5 {
w := httptest.NewRecorder() env.getOwn("/app/me")
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
} }
rejected := 0 rejected := 0
for i := range 20 { for i := range 20 {
w := httptest.NewRecorder() w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me")
env.mux.ServeHTTP(w, asUser(
httptest.NewRequest(http.MethodGet, "/app/me", nil),
fmt.Sprintf("newcomer-%d", i)))
if w.Code == http.StatusTooManyRequests { if w.Code == http.StatusTooManyRequests {
rejected++ rejected++
} }
@@ -549,18 +378,12 @@ func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
} }
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу. // TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
//
// Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а
// прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение
// заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим
// человеком.
func TestAccountCreationIsLogged(t *testing.T) { func TestAccountCreationIsLogged(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
const login = "brand-new-person" const login = "brand-new-person"
w := httptest.NewRecorder() w := env.getAs(login, "/app/me")
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
journal := env.journal.String() journal := env.journal.String()
@@ -570,8 +393,7 @@ func TestAccountCreationIsLogged(t *testing.T) {
// Второе обращение новой строки не прибавляет: заводится запись однажды. // Второе обращение новой строки не прибавляет: заводится запись однажды.
before := strings.Count(journal, "Account created from login header") before := strings.Count(journal, "Account created from login header")
again := httptest.NewRecorder() again := env.getAs(login, "/app/me")
env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
require.Equal(t, http.StatusOK, again.Code) require.Equal(t, http.StatusOK, again.Code)
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"), assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
@@ -579,10 +401,6 @@ func TestAccountCreationIsLogged(t *testing.T) {
} }
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою. // TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
//
// Два значения заголовка означают прокси, который его добавляет вместо замены,
// — модель угроз называет это главным барьером. Отладочным уровнем такая
// поломка в бою не видна вовсе: боевой уровень журнала информационный.
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) { func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
@@ -601,27 +419,23 @@ func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал") assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
} }
// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути // TestStorageFailureOnIdentityIsServiceFailure — отказ базы на пути узнавания
// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным. // кончается отказом сервиса, а не молчаливым проходом неузнанным.
// //
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя // Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя то,
// то, что сломано не у него. // что сломано не у него.
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) { func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
// Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так // Колонка ключа переименовывается: выборка по ней перестаёт работать — так
// же, как она перестанет работать при отказе хранилища. // же, как она перестанет работать при отказе базы.
users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection) _, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone")
require.NoError(t, err) require.NoError(t, err)
users.RemoveIndex("idx_users_provider_login")
users.Fields.RemoveByName(migrations.ProviderLoginField)
require.NoError(t, env.app.Save(users))
w := httptest.NewRecorder() w := env.getAs("somebody", "/app/me")
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody"))
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError, assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
"отказ хранилища выдан за «вас не узнали»") "отказ базы выдан за «вас не узнали»")
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header") assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
} }
@@ -632,7 +446,7 @@ func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы // начал бы отвечать отказом контракта, и старая закладка молча сменила бы
// поведение. // поведение.
func TestFormerAuthRootServesMarkup(t *testing.T) { func TestFormerAuthRootServesMarkup(t *testing.T) {
env := setupWebappEnv(t, builtDist(), true) env := setupTestEnv(t, readableMetaViewer())
res := env.get("/auth/login") res := env.get("/auth/login")
+389 -33
View File
@@ -1,16 +1,24 @@
package http package http
import ( import (
"bufio"
"context"
"encoding/json" "encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/metrics" "git.vakhrushev.me/av/transcriber/internal/metrics"
@@ -57,7 +65,7 @@ func TestMe_CarriesAccountWithoutEmail(t *testing.T) {
var me MeView var me MeView
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))
assert.Equal(t, env.account.Id, me.ID) assert.Equal(t, env.account.ID, me.ID)
assert.NotContains(t, w.Body.String(), "person@example.com", assert.NotContains(t, w.Body.String(), "person@example.com",
"адрес почты принадлежит человеку, а не сервису") "адрес почты принадлежит человеку, а не сервису")
} }
@@ -73,7 +81,7 @@ func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) {
env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody)) env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
unknown := httptest.NewRecorder() unknown := httptest.NewRecorder()
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/nosuchrecordid", http.NoBody)) env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody))
require.Equal(t, http.StatusUnauthorized, existing.Code) require.Equal(t, http.StatusUnauthorized, existing.Code)
require.Equal(t, http.StatusUnauthorized, unknown.Code) require.Equal(t, http.StatusUnauthorized, unknown.Code)
@@ -97,7 +105,7 @@ func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) {
item := intakeItemOf(t, w) item := intakeItemOf(t, w)
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id) record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, record.OriginalFilename) require.NotNil(t, record.OriginalFilename)
@@ -128,7 +136,7 @@ func TestIntake_LongFilenameIsTrimmed(t *testing.T) {
item := intakeItemOf(t, w) item := intakeItemOf(t, w)
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id) record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, record.OriginalFilename) require.NotNil(t, record.OriginalFilename)
@@ -170,7 +178,9 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
}{ }{
{ {
name: "записи нет", name: "записи нет",
req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/nosuch", http.NoBody) }, req: func() *http.Request {
return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody)
},
code: CodeNotFound, code: CodeNotFound,
}, },
{ {
@@ -209,37 +219,279 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
} }
} }
// Своё правило ограничителя частоты заведено под корнем приложения: правило // Ограничитель частоты покрывает адреса приложения и **не трогает** адресов
// хранилища настроено на его собственный корень и наших адресов не покрывает. // наблюдения: правило своё, и настроено оно на корень приложения.
func TestRateLimitRuleCoversAppRoot(t *testing.T) { func TestRateLimitCoversAppRootOnly(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
require.NoError(t, ApplyAppRateLimit(env.app)) 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,
"бюджет под корнем приложения не исчерпался — проверке не на чем сработать")
var found int // Проба здоровья тем же бюджетом не ограничена: она лежит вне корня
for _, rule := range env.app.Settings().RateLimits.Rules { // приложения, а слои одеты на корень.
if strings.HasPrefix(rule.Label, AppRoot+"/") { assert.Equal(t, http.StatusOK, env.get(HealthPath).Code,
found++ "ограничитель приложения закрыл наблюдение за сервисом")
assert.Positive(t, rule.MaxRequests)
assert.Positive(t, rule.Duration)
} }
}
assert.Equal(t, 1, found, "правило под корнем приложения заведено, и оно одно")
assert.True(t, env.app.Settings().RateLimits.Enabled, "и ограничитель включён")
// Правило приводится к настройке **при каждом подъёме**, то есть на каждом // Два клиентских адреса через один доверенный прокси расходуют **разные**
// рестарте сервиса. Без этой проверки ветвь замены не исполнялась бы ни разу, // бюджеты, а заголовок пересылки с недоверенного адреса на ключ бюджета не
// и правила молча копились бы с каждой выкладкой. // влияет.
require.NoError(t, ApplyAppRateLimit(env.app)) //
require.NoError(t, ApplyAppRateLimit(env.app)) // Обе половины закрывают свою поломку: бюджет, посчитанный по пиру, становится
// общим на весь сервис, а вера заголовку без сверки пира отдаёт обход
// ограничителя ровно тому, кого он ограничивает.
func TestRateLimitKeyNamesTheClient(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
again := 0 spend := func(peer, forwarded string) int {
for _, rule := range env.app.Settings().RateLimits.Rules { refused := 0
if strings.HasPrefix(rule.Label, AppRoot+"/") { for range appRateMaxRequests + 1 {
again++ 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++
} }
} }
assert.Equal(t, 1, again, "повторный подъём правило заменяет, а не добавляет второе") 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), logger),
http.NotFoundHandler(),
)
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
account, _, err := users.EnsureUser(contract.Identity{Login: "person"})
require.NoError(t, err)
require.NotEmpty(t, account.ID)
w := httptest.NewRecorder()
mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", http.NoBody), "person"))
require.Equal(t, http.StatusInternalServerError, w.Code)
var body ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "тело отказа — не наша форма")
assert.Equal(t, CodeInternal, body.Code)
assert.NotEmpty(t, body.Message)
assert.NotContains(t, w.Body.String(), "шаг обработчика упал",
"значение паники ушло спрашивающему")
assert.Contains(t, journal.String(), "Handler panicked",
"владелец сервиса о панике не узнал")
// Процесс жив: следующий запрос отвечает как ни в чём не бывало.
alive := httptest.NewRecorder()
mux.ServeHTTP(alive, httptest.NewRequest(http.MethodGet, HealthPath, http.NoBody))
assert.Equal(t, http.StatusOK, alive.Code, "после паники поверхность перестала отвечать")
} }
// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст // Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст
@@ -250,13 +502,13 @@ func TestAvailableViewsCoverEveryKind(t *testing.T) {
record := jobWithFile(t, env) record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app) texts := env.handler.textRepo
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст") literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
require.NoError(t, err) require.NoError(t, err)
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err) require.NoError(t, err)
structures := pbrepo.NewStructureRepository(env.app) structures := env.handler.structureRepo
structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}}) structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
require.NoError(t, err) require.NoError(t, err)
@@ -318,7 +570,6 @@ func TestTooLargeOnTheRealPath(t *testing.T) {
// частом отказе после превышения размера. // частом отказе после превышения размера.
func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) { func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
require.NoError(t, ApplyAppRateLimit(env.app))
var last *httptest.ResponseRecorder var last *httptest.ResponseRecorder
for range appRateMaxRequests + 1 { for range appRateMaxRequests + 1 {
@@ -403,7 +654,7 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
record := jobWithFile(t, env) record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app) texts := env.handler.textRepo
empty, err := texts.Put(record.Id, entity.TextKindTranscript, "") empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
require.NoError(t, err) require.NoError(t, err)
@@ -425,3 +676,108 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
// И адрес текста отвечает тем же: состоянием, а не обещанием. // И адрес текста отвечает тем же: состоянием, а не обещанием.
assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code) 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
}
+69 -116
View File
@@ -1,14 +1,11 @@
package http package http
import ( import (
"encoding/json"
"errors" "errors"
"log/slog"
"net/http" "net/http"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/router"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -17,9 +14,13 @@ import (
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все // HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать // три `400`, — а приложению надо решать, предлагать ли повтор и что показать
// человеку. Разбор русской фразы был бы единственным оставшимся путём. // человеку. Разбор русской фразы был бы единственным оставшимся путём.
//
// Кода `forbidden` в перечне больше нет: его единственным случаем был владелец
// панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у
// сервиса не осталось, а узнавание по заголовку учётную запись заводит само —
// предъявителя без неё не бывает.
const ( const (
CodeUnauthorized = "unauthorized" CodeUnauthorized = "unauthorized"
CodeForbidden = "forbidden"
CodeNotFound = "not_found" CodeNotFound = "not_found"
CodeBadRequest = "bad_request" CodeBadRequest = "bad_request"
CodeTooLarge = "too_large" CodeTooLarge = "too_large"
@@ -32,7 +33,7 @@ const (
// //
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой // Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена // текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в // внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в
// журнале владельца сервиса. // журнале владельца сервиса.
// //
// Limit заполняется только у отказа по размеру: экран обязан показать предел // Limit заполняется только у отказа по размеру: экран обязан показать предел
@@ -46,8 +47,7 @@ type ErrorBody struct {
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом // mapDomainError — **единственная** точка, где доменная ошибка становится кодом
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик // ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя // решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а // ошибка» на негодный файл.
// второе не говорило ему ничего.
// //
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится // Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в // добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
@@ -87,6 +87,18 @@ func mapDomainError(err error) (int, ErrorBody) {
Message: "Текст этого вида для записи ещё не готов", 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): case errors.Is(err, contract.ErrNotFound):
message := "Адрес не найден" message := "Адрес не найден"
var owned *messagedError var owned *messagedError
@@ -104,12 +116,6 @@ func mapDomainError(err error) (int, ErrorBody) {
Code: CodeUnauthorized, Code: CodeUnauthorized,
Message: "Сервис вас не узнал", Message: "Сервис вас не узнал",
} }
case errors.Is(err, contract.ErrOwnerRequired):
return http.StatusForbidden, ErrorBody{
Code: CodeForbidden,
Message: "У предъявителя нет учётной записи пользователя",
}
} }
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице // Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
@@ -130,114 +136,61 @@ func mapDomainError(err error) (int, ErrorBody) {
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ // fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
// уходит наружу с адресов приложения. // уходит наружу с адресов приложения.
func fail(e *core.RequestEvent, err error) error { //
// Отказы, рождающиеся **не в обработчике** — предел тела, ограничитель частоты,
// неизвестный путь под корнем приложения, — приходят сюда же: слои сервиса
// написаны нами и отвечают своей доменной ошибкой, а не телом библиотеки. Второй
// формы тела на адресах приложения не существует.
func fail(w http.ResponseWriter, err error) {
status, body := mapDomainError(err) status, body := mapDomainError(err)
return e.JSON(status, body) writeJSON(w, status, body)
} }
// OneErrorForm переводит отказ библиотеки в нашу форму тела. // 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` нашей
// формой тела, а процесс живёт.
// //
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в // Слой свой, потому что своим стал и роутер: прежде его вешала чужая библиотека.
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые: // У воркеров такой границы по-прежнему нет — паника в шаге конвейера роняет
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим // процесс целиком, и это осознанно.
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной. if logger == nil {
// logger = slog.Default()
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
// «внутренняя ошибка» вместо предела числом.
//
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
// него, включая предел тела и ограничитель частоты.
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberOneErrorForm",
Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
Func: func(e *core.RequestEvent) error {
err := e.Next()
if err == nil {
return nil
} }
// Обработчик, ответивший через fail, ошибки не возвращает — его return func(next http.Handler) http.Handler {
// форма уже ушла в ответ, и сюда доходит только чужая. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var apiErr *router.ApiError defer func() {
if !errors.As(err, &apiErr) { if recovered := recover(); recovered != nil {
return err logger.Error("Handler panicked",
"error", recovered, "transport", "http")
fail(w, errors.New("handler panicked"))
} }
}()
status, translated := translateAPIError(apiErr) next.ServeHTTP(w, r)
return e.JSON(status, translated) })
},
}
}
// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
// названы поимённо: значение вне перечня приложению разбирать нечем.
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
switch apiErr.Status {
case http.StatusRequestEntityTooLarge:
limit := entity.MaxRecordSize
return http.StatusRequestEntityTooLarge, ErrorBody{
Code: CodeTooLarge,
Message: "Запись больше допустимого размера",
Limit: &limit,
}
case http.StatusTooManyRequests:
return http.StatusTooManyRequests, ErrorBody{
Code: CodeTooManyRequests,
Message: "Слишком много запросов подряд, попробуйте позже",
}
case http.StatusNotFound:
return http.StatusNotFound, ErrorBody{
Code: CodeNotFound,
Message: "Адрес не найден",
}
case http.StatusUnauthorized:
return mapDomainError(contract.ErrUnauthorized)
}
return apiErr.Status, ErrorBody{
Code: CodeInternal,
Message: "Внутренняя ошибка сервиса",
}
}
// RequireUser — слой предъявления адресов приложения.
//
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
// поимённо: без имени пускается всякая учётная запись хранилища, включая
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
// записи он стать не может.
//
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
// уложенный файл — чего сервис не умеет вовсе.
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
return &hook.Handler[*core.RequestEvent]{
Id: "transcriberRequireUser",
// Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий
// запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за
// ограничителем частоты — см. `TrustedHeaderIdentity`.
//
// Предел тела библиотеки идёт следом (−990), и это обязательно: отказ
// неузнанному наступает **до** чтения тела.
Priority: apis.DefaultRateLimitMiddlewarePriority + 2,
Func: func(e *core.RequestEvent) error {
if e.Auth == nil {
return fail(e, contract.ErrUnauthorized)
}
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
// предъявитель себя предъявил.
if e.Auth.Collection().Name != usersCollection {
return fail(e, contract.ErrOwnerRequired)
}
return e.Next()
},
} }
} }
+274
View File
@@ -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
}
}
+81 -106
View File
@@ -1,121 +1,93 @@
package http package http
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"net" "net"
"net/http"
"net/netip" "net/netip"
"github.com/pocketbase/pocketbase/apis" "git.vakhrushev.me/av/transcriber/internal/contract"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/hook"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
) )
// Заголовки, которыми обратный прокси называет пришедшего. // Заголовки, которыми обратный прокси называет пришедшего.
// //
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же // Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка,
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама // которая сама ставит и сама читает своё имя, этого не замечает. Контур уже
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена // пишет эти имена соседним сервисам, и настройкой они не делаются: второе место,
// соседним сервисам, и настройкой они не делаются: второе место, где их можно // где их можно написать неверно, выгоды не даёт.
// написать неверно, выгоды не даёт.
const ( const (
LoginHeader = "Remote-User" LoginHeader = "Remote-User"
NameHeader = "Remote-Name" NameHeader = "Remote-Name"
EmailHeader = "Remote-Email" EmailHeader = "Remote-Email"
) )
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла. // ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
// // Читает его только ограничитель частоты: барьером узнавания он не служит и
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь // служить не может — кто пришёл, решает адрес самого соединения.
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе — const ForwardedForHeader = "X-Forwarded-For"
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
const FileTokenPath = StorageRoot + "/files/token" // 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 узнаёт пришедшего по заголовку доверенного источника. // TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
// //
// # Область // # Область
// //
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не // Слой вешается на цепочку корня приложения и только на неё. Область поэтому
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но // выводится из объявленного адресного пространства сервиса, а не перечисляется
// узнаёт он **только на объявленной области**: корень приложения плюс этот // вторым списком: корень, переехавший в перечне, уносит слой с собой.
// адрес. Область выводится из перечня адресного пространства, а не пишется
// вторым списком.
// //
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной // Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища // срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение // запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с
// настоящего владельца этого имени попало бы в чужую запись вместе со всем // новым именем — записи в неё.
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
// два барьера здесь именно потому, что прежний был один и держался на
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
//
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
// базе, а первый такой запрос с новым именем — записи в неё.
//
// # Кто побеждает
//
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
// учётной записью пользователя отобрала бы у него панель посреди работы.
// Протухший и негодный токен предъявленными не считаются: библиотека их не
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
// //
// # Чего слой не делает // # Чего слой не делает
// //
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты // Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто // учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием // не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там,
// учётной записи на адресах приложения. // где легла база.
//
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
// легла база.
func TrustedHeaderIdentity( func TrustedHeaderIdentity(
app core.App, users contract.UserRepository,
mounts []Mount,
trusted []netip.Prefix, trusted []netip.Prefix,
logger *slog.Logger, logger *slog.Logger,
) *hook.Handler[*core.RequestEvent] { ) func(http.Handler) http.Handler {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
return &hook.Handler[*core.RequestEvent]{ return func(next http.Handler) http.Handler {
Id: "transcriberTrustedHeaderIdentity", return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
// убираются ничем.
//
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
// узнавание (−999) → требование учётной записи (−998) → предел тела
// (−990). Требование стоит перед пределом тела намеренно: отказ
// неузнанному обязан наступать до чтения тела.
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
Func: func(e *core.RequestEvent) error {
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
return e.Next()
}
// Более одного значения — не выбор, а отказ. Прокси, настроенный // Более одного значения — не выбор, а отказ. Прокси, настроенный
// добавлять заголовок вместо замены, оставляет рядом со своим // добавлять заголовок вместо замены, оставляет рядом со своим
// значением присланное анонимом, и умолчание «берём первое» отдало // значением присланное анонимом, и умолчание «берём первое» отдало
// бы вход анониму. // бы вход анониму.
values := e.Request.Header.Values(LoginHeader) values := r.Header.Values(LoginHeader)
if len(values) != 1 { if len(values) != 1 {
if len(values) > 1 { if len(values) > 1 {
// Уровень предупреждающий: два значения означают прокси, // Уровень предупреждающий: два значения означают прокси,
// который заголовок **добавляет** вместо замены, — то есть // который заголовок **добавляет** вместо замены, — то есть
// ровно ту поломку контура, которую модель угроз называет // ровно ту поломку контура, которую модель угроз называет
// главной. Отладочным уровнем она в бою не видна вовсе: // главной. Отладочным уровнем она в бою не видна вовсе.
// боевой уровень журнала информационный.
logger.Warn("Request carries more than one login header", logger.Warn("Request carries more than one login header",
"http.peer_addr", e.Request.RemoteAddr, "http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http") "capability", "access", "transport", "http")
} else { } else {
// Заголовка нет вовсе. В этом контуре это значит, что прокси // Заголовка нет вовсе. В этом контуре это значит, что прокси
@@ -123,13 +95,14 @@ func TrustedHeaderIdentity(
// штатный — так выглядит и человек, которому провайдер // штатный — так выглядит и человек, которому провайдер
// отказал. // отказал.
logger.Debug("Request carries no login header", logger.Debug("Request carries no login header",
"http.peer_addr", e.Request.RemoteAddr, "http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http") "capability", "access", "transport", "http")
} }
return e.Next() next.ServeHTTP(w, r)
return
} }
peer, ok := peerAddress(e.Request.RemoteAddr) peer, ok := peerAddress(r.RemoteAddr)
if !ok || !isTrusted(trusted, peer) { if !ok || !isTrusted(trusted, peer) {
// Уровень предупреждающий, а не отладочный, и это решение о // Уровень предупреждающий, а не отладочный, и это решение о
// цене. Заголовок с недоверенного адреса в этом контуре — не // цене. Заголовок с недоверенного адреса в этом контуре — не
@@ -143,31 +116,34 @@ func TrustedHeaderIdentity(
// видно, чья это поломка: своя (перечень) или контура (прокси // видно, чья это поломка: своя (перечень) или контура (прокси
// заголовка не ставит). // заголовка не ставит).
logger.Warn("Login header came from an untrusted peer", logger.Warn("Login header came from an untrusted peer",
"http.peer_addr", e.Request.RemoteAddr, "http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http") "capability", "access", "transport", "http")
return e.Next() next.ServeHTTP(w, r)
return
} }
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{ account, created, err := users.EnsureUser(contract.Identity{
Login: values[0], Login: values[0],
Name: e.Request.Header.Get(NameHeader), Name: r.Header.Get(NameHeader),
Email: e.Request.Header.Get(EmailHeader), Email: r.Header.Get(EmailHeader),
}) })
if err != nil { if err != nil {
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) { if errors.Is(err, contract.ErrLoginNotAcceptable) {
// Негодный логин — это негодный ввод, а не поломка сервиса: // Негодный логин — это негодный ввод, а не поломка сервиса:
// пустой заголовок прокси шлёт штатно там, где никого не // пустой заголовок прокси шлёт штатно там, где никого не
// назвал. Уровень поэтому отладочный, и запрос идёт дальше // назвал. Уровень поэтому отладочный, и запрос идёт дальше
// неузнанным. // неузнанным.
logger.Debug("Login header value is not acceptable", logger.Debug("Login header value is not acceptable",
"http.peer_addr", e.Request.RemoteAddr, "http.peer_addr", r.RemoteAddr,
"capability", "access", "transport", "http") "capability", "access", "transport", "http")
return e.Next() next.ServeHTTP(w, r)
return
} }
logger.Error("Failed to resolve account by login header", logger.Error("Failed to resolve account by login header",
"error", err, "capability", "access", "transport", "http") "error", err, "capability", "access", "transport", "http")
return fail(e, err) fail(w, err)
return
} }
if created { if created {
@@ -181,38 +157,37 @@ func TrustedHeaderIdentity(
// чтобы стать этим человеком. Идут адрес пира и идентификатор // чтобы стать этим человеком. Идут адрес пира и идентификатор
// записи — оба выданы не спрашивающим. // записи — оба выданы не спрашивающим.
logger.Info("Account created from login header", logger.Info("Account created from login header",
"http.peer_addr", e.Request.RemoteAddr, "http.peer_addr", r.RemoteAddr,
"account_id", record.Id, "account_id", account.ID,
"capability", "access", "transport", "http") "capability", "access", "transport", "http")
} }
e.Auth = record next.ServeHTTP(w, withAccount(r, account))
})
return e.Next()
},
} }
} }
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути. // RequireUser — слой предъявления адресов приложения.
// //
// Область — корень приложения из перечня адресного пространства плюс адрес // Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень // отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
// объявлен единой точкой адресного пространства, и записанный здесь второй раз // уложенный файл — чего сервис не умеет вовсе.
// он разошёлся бы с ней молча. //
func underIdentifiedArea(mounts []Mount, requestPath string) bool { // Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит
if requestPath == FileTokenPath { // учётную запись само, и предъявителя без неё не бывает.
return true func RequireUser() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := AccountOf(r); !ok {
fail(w, contract.ErrUnauthorized)
return
} }
for _, mount := range mounts { next.ServeHTTP(w, r)
if mount.Path == AppRoot { })
return mount.Covers(requestPath)
} }
} }
return false
}
// peerAddress достаёт адрес того, кто открыл соединение. // peerAddress достаёт адрес того, кто открыл соединение.
// //
// Берётся именно он, а не пересылаемый заголовок: значением пересылаемого // Берётся именно он, а не пересылаемый заголовок: значением пересылаемого
+175
View File
@@ -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)
}
+149
View File
@@ -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,
"строка журнала растёт вместе с длиной запрошенного пути")
}
+15 -41
View File
@@ -9,12 +9,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -168,7 +165,7 @@ func TestList_MalformedCursorIsRejected(t *testing.T) {
}, },
{ {
name: "декодируется, но идентификатора нет", name: "декодируется, но идентификатора нет",
cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15 10:00:00.000Z|")), cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15T10:00:00Z|")),
}, },
{ {
name: "пара пуста целиком", name: "пара пуста целиком",
@@ -202,25 +199,11 @@ func TestTopicsResolveToNames(t *testing.T) {
// Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его // Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его
// задача языковой модели, которой ещё нет. // задача языковой модели, которой ещё нет.
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection) topicID := newTopic(t, env, env.account.ID, "семейный архив")
require.NoError(t, err) attachTopic(t, env, all.Items[0].ID, topicID)
topic := core.NewRecord(topics)
topic.Set("owner", env.account.Id)
topic.Set("name", "семейный архив")
require.NoError(t, env.app.Save(topic))
repo := pbrepo.NewAudioRecordRepository(env.app) record, err := env.handler.recordRepo.GetByID(all.Items[0].ID, env.account.ID)
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
require.NoError(t, err) require.NoError(t, err)
record.TopicIDs = []string{topic.Id}
require.NoError(t, repo.Save(record, ""))
// Правку тем конвейер не делает, поэтому кладём их тем же путём, каким это
// сделает задача языковой модели, — прямым сохранением записи коллекции.
raw, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
require.NoError(t, err)
raw.Set("topics", []string{topic.Id})
require.NoError(t, env.app.Save(raw))
page := pageOf(t, env, "") page := pageOf(t, env, "")
require.Len(t, page.Items, 1) require.Len(t, page.Items, 1)
@@ -247,14 +230,14 @@ func TestList_ThreeStatesEachRecordOnce(t *testing.T) {
all := pageOf(t, env, "") all := pageOf(t, env, "")
require.Len(t, all.Items, 3) require.Len(t, all.Items, 3)
repo := pbrepo.NewAudioRecordRepository(env.app) repo := env.handler.recordRepo
halted, err := repo.GetByID(all.Items[0].ID, env.account.Id) halted, err := repo.GetByID(all.Items[0].ID, env.account.ID)
require.NoError(t, err) require.NoError(t, err)
halted.Halt(entity.HaltReasonStuck, "застряла") halted.Halt(entity.HaltReasonStuck, "застряла")
require.NoError(t, repo.Save(halted, "")) require.NoError(t, repo.Save(halted, ""))
done, err := repo.GetByID(all.Items[1].ID, env.account.Id) done, err := repo.GetByID(all.Items[1].ID, env.account.ID)
require.NoError(t, err) require.NoError(t, err)
done.MoveToState(entity.StateDone) done.MoveToState(entity.StateDone)
require.NoError(t, repo.Save(done, "")) require.NoError(t, repo.Save(done, ""))
@@ -295,11 +278,11 @@ func TestList_ShowsOnlyOwnRecords(t *testing.T) {
acceptRecords(t, env, 2) acceptRecords(t, env, 2)
_, stranger := newSecondAccount(t, env.app) newSecondAccount(t, env)
w := httptest.NewRecorder() w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody) req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
asUser(req, stranger) asUser(req, "stranger")
env.mux.ServeHTTP(w, req) env.mux.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code) require.Equal(t, http.StatusOK, w.Code)
@@ -319,12 +302,12 @@ func TestList_DoesNotReadTranscript(t *testing.T) {
all := pageOf(t, env, "") all := pageOf(t, env, "")
require.Len(t, all.Items, 1) require.Len(t, all.Items, 1)
repo := pbrepo.NewAudioRecordRepository(env.app) repo := env.handler.recordRepo
record, err := repo.GetByID(all.Items[0].ID, env.account.Id) record, err := repo.GetByID(all.Items[0].ID, env.account.ID)
require.NoError(t, err) require.NoError(t, err)
const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР" const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР"
texts := pbrepo.NewTextRepository(env.app) texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker) transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker)
require.NoError(t, err) require.NoError(t, err)
record.TranscriptTextID = &transcript.Id record.TranscriptTextID = &transcript.Id
@@ -413,19 +396,10 @@ func TestForeignTopicDoesNotResolve(t *testing.T) {
all := pageOf(t, env, "") all := pageOf(t, env, "")
require.Len(t, all.Items, 1) require.Len(t, all.Items, 1)
stranger, _ := newSecondAccount(t, env.app) stranger := newSecondAccount(t, env)
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection) foreign := newTopic(t, env, stranger.ID, "ЧУЖАЯ-ТЕМА-МАРКЕР")
require.NoError(t, err) attachTopic(t, env, all.Items[0].ID, foreign)
foreign := core.NewRecord(topics)
foreign.Set("owner", stranger.Id)
foreign.Set("name", "ЧУЖАЯ-ТЕМА-МАРКЕР")
require.NoError(t, env.app.Save(foreign))
raw, err := env.app.FindRecordById(migrations.RecordsCollection, all.Items[0].ID)
require.NoError(t, err)
raw.Set("topics", []string{foreign.Id})
require.NoError(t, env.app.Save(raw))
w := httptest.NewRecorder() w := httptest.NewRecorder()
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)) env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody))
+124
View File
@@ -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)
}
}
+221 -118
View File
@@ -4,71 +4,71 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
// Проверки разграничения записей по владельцу. Все идут через собранный роутер: // Проверки разграничения записей по владельцу. Все идут через собранную
// сужение живёт в хранилище, но судится по тому, что видит отправитель. // поверхность: сужение живёт в хранилище, но судится по тому, что видит
// отправитель.
// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный // unknownRecordID — идентификатор годного вида, которого нет ни у одной записи.
// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь const unknownRecordID = "00000000000000000000000000"
// свой.
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) { // newSecondAccount заводит вторую учётную запись со своим логином.
func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount {
t.Helper() t.Helper()
const login = "stranger" account, created, err := env.users.EnsureUser(contract.Identity{
Login: "stranger",
users, err := app.FindCollectionByNameOrId("users") Name: "Посторонний",
Email: "stranger@example.com",
})
require.NoError(t, err) require.NoError(t, err)
require.True(t, created)
record := core.NewRecord(users) return account
record.Set(migrations.ProviderLoginField, login)
record.Set("email", "stranger@example.com")
record.Set("verified", true)
record.SetRandomPassword()
require.NoError(t, app.Save(record))
return record, login
} }
// serveAs шлёт запрос от имени названного логина, а не логина окружения. // Чужая запись неотличима от несуществующей: тот же код и то же тело. Разница
func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) { // ответов обратила бы чтение в перебор — по ней считывается, какие записи
env.mux.ServeHTTP(w, asUser(req, login))
}
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
// ответов обратила бы опрос в перебор — по ней считывается, какие задачи
// заведены. // заведены.
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) { func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env) job := jobWithFile(t, env)
_, stranger := newSecondAccount(t, env.app) newSecondAccount(t, env)
foreign := httptest.NewRecorder() foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id)
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody)) unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID)
unknown := httptest.NewRecorder() require.Equal(t, http.StatusNotFound, foreign.Code, "чужая запись не отдаётся")
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody))
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора") assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора")
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же") assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
// Ни состояния, ни текста расшифровки в теле нет.
assert.NotContains(t, foreign.Body.String(), entity.StateUploaded) assert.NotContains(t, foreign.Body.String(), entity.StateUploaded)
assert.NotContains(t, foreign.Body.String(), "transcription_text")
} }
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у // Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт
// её файла. // на границе, и до базы такой запрос не доходит вовсе.
func TestRecordCard_MalformedIDLooksLikeUnknown(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
malformed := env.getOwn("/app/audiorecords/не-идентификатор")
unknown := env.getOwn("/app/audiorecords/" + unknownRecordID)
assert.Equal(t, http.StatusNotFound, malformed.Code)
assert.JSONEq(t, unknown.Body.String(), malformed.Body.String())
}
// Владельцем принятой записи становится узнанный предъявитель — и у записи, и у
// её копии.
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) { func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
@@ -78,24 +78,26 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
response := intakeItemOf(t, w) response := intakeItemOf(t, w)
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID) var owner string
require.NoError(t, err) require.NoError(t, env.db.Reader().
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель") QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
assert.Equal(t, env.account.ID, owner, "владелец записи — предъявитель")
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file")) var fileOwner string
require.NoError(t, err) require.NoError(t, env.db.Reader().
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же") QueryRow("SELECT owner_id FROM files WHERE record_id = ?", response.ID).Scan(&fileOwner))
assert.Equal(t, env.account.ID, fileOwner, "владелец копии — он же")
} }
// Владельца не задают запросом: своё значение в форме на результат не влияет. // Владельца не задают запросом: своё значение в форме на результат не влияет.
func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) { func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
_, stranger := newSecondAccount(t, env.app) stranger := newSecondAccount(t, env)
req := createMultipartRequest(t, "sample.mp3", []byte("запись")) req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
query := req.URL.Query() query := req.URL.Query()
query.Set("owner", stranger) query.Set("owner", stranger.ID)
req.URL.RawQuery = query.Encode() req.URL.RawQuery = query.Encode()
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -104,97 +106,198 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
response := intakeItemOf(t, w) response := intakeItemOf(t, w)
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID) var owner string
require.NoError(t, err) require.NoError(t, env.db.Reader().
assert.Equal(t, env.account.Id, record.GetString("owner")) QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
assert.Equal(t, env.account.ID, owner)
} }
// Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ // TestFileDownload_NarrowedByOwner — **критерий приёмки**: чужой файл недостижим
// до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в // по прямой ссылке, и ответ на него тот же, что и на несуществующую запись.
// коллекции пользователей у него нет, и владельцем записи он стать не может.
// //
// Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый // Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
// файл, а уборки файлов сервис не умеет вовсе. // наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
superusers, err := env.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
require.NoError(t, err)
admin := core.NewRecord(superusers)
admin.Set("email", "owner@example.com")
admin.SetRandomPassword()
require.NoError(t, env.app.Save(admin))
token, err := admin.NewAuthToken()
require.NoError(t, err)
// Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему
// никто не ставит, и узнавание по нему его учётной записи не касается.
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
req.Header.Set("Authorization", token)
w := httptest.NewRecorder()
env.mux.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
assert.Equal(t, 0, countFiles(t, env), "и файла тоже")
}
// Чужой файл не отдаётся по ссылке, а свой отдаётся. Проверяется именно переход
// по ссылке: токен файла хранилище выдаёт на предъявителя, а не на файл, и отказ
// наступает на скачивании, где правило просмотра судит владельца. Проверка,
// написанная на выдачу токена, зеленела бы, не касаясь пути, по которому аудио и
// уходит.
func TestFileDownload_NarrowedByOwner(t *testing.T) { func TestFileDownload_NarrowedByOwner(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env) job := jobWithFile(t, env)
_, stranger := newSecondAccount(t, env.app) newSecondAccount(t, env)
record, err := env.app.FindRecordById("files", *job.OriginalFileID) link := "/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyOriginal
require.NoError(t, err)
require.Equal(t, env.account.Id, record.GetString("owner"))
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
// Переход по ссылке идёт **без** заголовка: файл судится своим коротким
// токеном, а не узнаванием. Так же по ней пойдёт и браузер.
mine := httptest.NewRecorder()
env.mux.ServeHTTP(mine,
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody))
foreign := httptest.NewRecorder()
env.mux.ServeHTTP(foreign,
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody))
mine := env.getOwn(link)
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся") require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым") assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся") foreign := env.getAs("stranger", link)
unknown := env.getAs("stranger",
"/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"="+CopyOriginal)
assert.Equal(t, http.StatusNotFound, foreign.Code, "чужой файл не отдаётся")
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестной записи")
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет") assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
} }
// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся // Негодное значение копии у чужой записи неотличимо от неизвестной записи:
// на предъявителя: о файле хранилище при выдаче не спрашивает. // владение судится **до** разбора значения копии. Разойдись эти ответы — по их
// // разнице перебирался бы список заведённых записей одним негодным параметром.
// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на func TestFileDownload_UnknownCopyOnForeignRecordLooksLikeUnknownRecord(t *testing.T) {
// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка» env := setupTestEnv(t, readableMetaViewer())
// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера.
func fileToken(t *testing.T, env *testEnv, login string) string {
t.Helper()
w := httptest.NewRecorder() job := jobWithFile(t, env)
env.mux.ServeHTTP(w, asUser( newSecondAccount(t, env)
httptest.NewRequest("POST", "/api/files/token", http.NoBody), login))
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному")
var body struct { foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id+"/file?"+CopyParam+"=whatever")
Token string `json:"token"` unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"=whatever")
missing := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file")
assert.Equal(t, http.StatusNotFound, foreign.Code)
assert.Equal(t, unknown.Code, foreign.Code)
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String())
assert.JSONEq(t, missing.Body.String(), foreign.Body.String(),
"незаданная копия у чужой записи отвечает не как неизвестная запись")
} }
// Копия, которой у записи ещё нет, отвечает отказом состояния, а не «не
// найдено»: иначе человек увидел бы «не найдено» на своей записи, загруженной
// минуту назад.
func TestFileDownload_MissingCopyIsConflict(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
w := env.getOwn("/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyNormalized)
require.Equal(t, http.StatusConflict, w.Code)
var body ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.NotEmpty(t, body.Token) assert.Equal(t, CodeNotReady, body.Code)
return body.Token unknown := env.getOwn("/app/audiorecords/" + unknownRecordID + "/file?" + CopyParam + "=" + CopyOriginal)
assert.NotEqual(t, unknown.Code, w.Code, "отказ состояния слился с ответом на неизвестную запись")
}
// Копия, которой сервис не знает, и незаданная копия у **своей** записи дают
// отказ по негодному вводу.
func TestFileDownload_UnknownCopyIsBadRequest(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
for name, query := range map[string]string{
"копия неизвестна": "?" + CopyParam + "=whatever",
"копия не названа": "",
} {
t.Run(name, func(t *testing.T) {
w := env.getOwn("/app/audiorecords/" + job.Id + "/file" + query)
require.Equal(t, http.StatusBadRequest, w.Code)
var body ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, CodeBadRequest, body.Code)
})
}
}
// Проигрыватель просит кусок записи: ответ несёт запрошенный кусок, а не файл
// целиком.
func TestFileDownload_RangeGivesRequestedSlice(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
const content = "0123456789abcdef"
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "sample.mp3", []byte(content)))
require.Equal(t, http.StatusCreated, created.Code)
server := env.liveServer(t)
link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal
res := env.liveRequest(t, server, link, map[string]string{"Range": "bytes=4-9"})
require.Equal(t, http.StatusPartialContent, res.StatusCode)
assert.Equal(t, "456789", string(res.Body), "ответ несёт не запрошенный кусок")
assert.Equal(t, int64(len("456789")), res.ContentLength, "длина ответа — длина файла целиком")
assert.Equal(t, "bytes 4-9/16", res.Header.Get("Content-Range"))
}
// TestFileDownload_BadRangeIsOurFailureForm — **критерий приёмки**: негодный
// диапазон приходит обычным отказом сервиса, а не `416` телом библиотеки.
//
// Проверка идёт настоящим HTTP-запросом через поднятую цепочку слоёв: вызовом
// отображателя ошибки это не проверяется — код `416` рождается не в нём.
func TestFileDownload_BadRangeIsOurFailureForm(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "sample.mp3", []byte("0123456789")))
require.Equal(t, http.StatusCreated, created.Code)
server := env.liveServer(t)
link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal
cases := map[string]string{
"неудовлетворимый диапазон": "bytes=99999999-",
"два диапазона в запросе": "bytes=0-1,4-5",
}
for name, rangeHeader := range cases {
t.Run(name, func(t *testing.T) {
res := env.liveRequest(t, server, link, map[string]string{"Range": rangeHeader})
require.Equal(t, http.StatusBadRequest, res.StatusCode, "ответ не `400`")
assert.NotContains(t, res.Header.Get("Content-Type"), "multipart",
"сервис отдал ответ из нескольких частей")
var body map[string]any
require.NoError(t, json.Unmarshal(res.Body, &body))
assert.Contains(t, body, "error_code")
assert.Contains(t, body, "message")
})
}
}
// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при сохранении,
// строится из имени, данного отправителем.
func TestFileDownload_CarriesNoStoredName(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
created := httptest.NewRecorder()
env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("запись")))
require.Equal(t, http.StatusCreated, created.Code)
recordID := intakeItemOf(t, created).ID
w := env.getOwn("/app/audiorecords/" + recordID + "/file?" + CopyParam + "=" + CopyOriginal)
require.Equal(t, http.StatusOK, w.Code)
names := storedFileNames(t, env)
require.Len(t, names, 1)
headers := w.Result().Header
for _, value := range headers.Values("Content-Disposition") {
assert.NotContains(t, value, names[0], "имя файла на диске уехало в ответ")
}
assert.Contains(t, strings.Join(headers.Values("Content-Disposition"), " "), "%D1%80",
"имя, данное отправителем, в ответ не попало")
}
// Перечня доступных копий карточка не объявляет: копий две, и каждая выводится
// из рубежа записи, который карточка несёт и так.
func TestRecordCard_HasNoCopyList(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
job := jobWithFile(t, env)
w := env.getOwn("/app/audiorecords/" + job.Id)
require.Equal(t, http.StatusOK, w.Code)
var raw map[string]json.RawMessage
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
assert.NotContains(t, raw, "available_copies")
assert.NotContains(t, raw, "copies")
} }
+190 -59
View File
@@ -1,17 +1,17 @@
package http package http
import ( import (
"fmt" "net/http"
"net/netip"
"strings"
"sync"
"time"
"github.com/pocketbase/pocketbase/core" "git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract"
) )
// Своё правило ограничителя частоты под корнем приложения. // Бюджет ограничителя частоты под корнем приложения.
//
// Заводится потому, что правило хранилища настроено на **его** корень и наших
// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с
// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая —
// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле.
// //
// Числа скромные намеренно: сервисом пользуются единицы человек, а экран // Числа скромные намеренно: сервисом пользуются единицы человек, а экран
// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится // опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится
@@ -22,71 +22,202 @@ const (
appRateWindowSec = 60 appRateWindowSec = 60
) )
// ApplyAppRateLimit ставит правило ограничителя на корень приложения. // staleBudgetAge — через сколько неиспользуемый счётчик выбрасывается. Карта
// счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда
// спрашивающий кладёт по строке на каждый свой адрес.
const staleBudgetAge = 10 * time.Minute
// RateLimit — свой ограничитель частоты под корнем приложения.
// //
// Правило приводится к настройке при каждом подъёме, как и настройки провайдера: // Бюджет считается по адресу спрашивающего скользящим окном: столько-то запросов
// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и // за столько-то секунд. Отказ уходит **нашей** формой тела — слой отвечает
// правило, положенное однажды, не пережило бы ни правки числа, ни чистого // доменной ошибкой, а не телом библиотеки, и второй формы отказа на адресах
// каталога данных. // приложения не появляется.
func ApplyAppRateLimit(app core.App) error { //
settings := app.Settings() // **Адрес спрашивающего берётся из заголовка пересылки — и только тогда, когда
// соединение пришло с адреса из объявленного перечня доверенных.** Во всяком
// другом случае адресом считается адрес пира, а пришедший заголовок на ключ
// бюджета не влияет ничем.
//
// **Цепочка пересылки читается справа налево до первого недоверенного адреса.**
// Значением слева распоряжается тот, кто шлёт запрос: прокси заголовок
// дописывает, а не заменяет, и левое значение цепочки он берёт из присланного.
//
// Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси,
// и пир у него один на всех: бюджет, посчитанный по пиру, становится общим на
// весь сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка —
// верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого
// он ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
// запрос, и меняет он его на каждом запросе.
//
// **Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
// Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
// вообще. Ограничителю нужен адрес того, кого он ограничивает, а тот за прокси в
// адресе соединения не виден вовсе.
//
// Слой стоит **перед** узнаванием: узнавание читает базу, а на новом имени ещё и
// пишет в неё; выполненное раньше ограничителя, оно работало бы на запросах,
// которые тот уже отверг, и поток отвергнутых обращений заводил бы учётные
// записи, которые потом не убираются ничем.
func RateLimit(trusted []netip.Prefix) func(http.Handler) http.Handler {
limiter := newRateLimiter(appRateMaxRequests, appRateWindowSec*time.Second)
rule := core.RateLimitRule{ return func(next http.Handler) http.Handler {
Label: AppRoot + "/", return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
MaxRequests: appRateMaxRequests, if !limiter.allow(clientAddress(r, trusted)) {
Duration: appRateWindowSec, fail(w, contract.ErrTooManyRequests)
return
} }
replaced := false next.ServeHTTP(w, r)
for i, existing := range settings.RateLimits.Rules { })
if existing.Label == rule.Label {
settings.RateLimits.Rules[i] = rule
replaced = true
break
} }
} }
if !replaced {
settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule)
}
settings.RateLimits.Enabled = true // clientAddress — тот, кого ограничивают.
func clientAddress(r *http.Request, trusted []netip.Prefix) string {
peer, ok := peerAddress(r.RemoteAddr)
if !ok {
// Адрес пира не разобран — ключ берётся строкой как есть: общий бюджет
// лучше отсутствующего.
return r.RemoteAddr
}
if err := app.Save(settings); err != nil { if !isTrusted(trusted, peer) {
return fmt.Errorf("failed to apply app rate limit: %w", err) return peer.String()
} }
return nil
if client, ok := forwardedClient(r, trusted); ok {
return client
}
// Прокси адреса не назвал либо назвал одними доверенными: счётчик падает
// обратно на адрес пира — то есть на общий. Лучше общий, чем ключ, который
// выбирает сам спрашивающий.
return peer.String()
} }
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать // forwardedClient достаёт из цепочки пересылки того, кого ограничивают.
// адрес спрашивающего.
// //
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с // **Цепочка читается справа налево, а доверенные адреса отбрасываются.**
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет // Значение слева пишет тот, кто шлёт запрос: прокси заголовок **дописывает**, а
// в этом случае общий на весь сервис: восемь одновременно открытых карточек // не заменяет, поэтому левым значением цепочки распоряжается аноним, и ключ
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного // бюджета, взятый оттуда, меняется у него на каждом запросе — бюджет обходится
// запроса. Норма при этом требует обратного — бюджет считается по адресу // с первого. Справа же стоит адрес, который приписал ближайший к нам прокси, и
// спрашивающего. // каждый следующий шаг влево тем доверен, кто его приписал. Первый недоверенный
// справа и есть последний адрес, за который отвечает контур, а не спрашивающий.
// //
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес // Заголовок читается **всеми** строками, а не одной: цепочка законно приходит
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно // несколькими заголовками, и разбор одной строки увидел бы только её часть — то
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру, // есть снова кусок, которым распоряжается аноним.
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**, func forwardedClient(r *http.Request, trusted []netip.Prefix) (string, bool) {
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ hops := forwardedChain(r)
// счётчика сам и обходит ограничитель, меняя значение.
for i := len(hops) - 1; i >= 0; i-- {
addr, err := netip.ParseAddr(hops[i])
if err != nil {
// Значение, которое не разбирается адресом, доверенным считать
// нечем: цепочка на нём кончается.
return "", false
}
addr = addr.Unmap()
if isTrusted(trusted, addr) {
continue
}
return addr.String(), true
}
return "", false
}
// forwardedChain — цепочка пересылки одним списком: все строки заголовка по
// порядку, каждая разобрана по запятым.
func forwardedChain(r *http.Request) []string {
var hops []string
for _, value := range r.Header.Values(ForwardedForHeader) {
for _, hop := range strings.Split(value, ",") {
hop = strings.TrimSpace(hop)
if hop != "" {
hops = append(hops, hop)
}
}
}
return hops
}
// rateLimiter — счётчики бюджета по ключу.
// //
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл, // Окно скользящее и хранится метками времени: счётчик с обнулением по границе
// по-прежнему решает адрес самого соединения. // окна пропустил бы двойной бюджет на стыке двух окон.
func ApplyTrustedProxyHeaders(app core.App) error { type rateLimiter struct {
settings := app.Settings() mu sync.Mutex
max int
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"} window time.Duration
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда budgets map[string]*budget
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий, swept time.Time
// чем один пустой ключ на всех. }
settings.TrustedProxy.UseLeftmostIP = true
type budget struct {
if err := app.Save(settings); err != nil { hits []time.Time
return fmt.Errorf("failed to apply trusted proxy headers: %w", err) }
func newRateLimiter(maxRequests int, window time.Duration) *rateLimiter {
return &rateLimiter{
max: maxRequests,
window: window,
budgets: map[string]*budget{},
swept: clock.Now(),
}
}
func (l *rateLimiter) allow(key string) bool {
now := clock.Now()
l.mu.Lock()
defer l.mu.Unlock()
l.sweep(now)
b, ok := l.budgets[key]
if !ok {
b = &budget{}
l.budgets[key] = b
}
b.hits = fresh(b.hits, now.Add(-l.window))
if len(b.hits) >= l.max {
return false
}
b.hits = append(b.hits, now)
return true
}
// sweep выбрасывает счётчики, которыми давно не пользовались. Идёт не чаще раза
// в окно: обход карты на каждом запросе стоил бы больше самого ограничителя.
func (l *rateLimiter) sweep(now time.Time) {
if now.Sub(l.swept) < l.window {
return
}
l.swept = now
for key, b := range l.budgets {
b.hits = fresh(b.hits, now.Add(-staleBudgetAge))
if len(b.hits) == 0 {
delete(l.budgets, key)
}
}
}
// fresh отбрасывает метки старше границы. Метки лежат по возрастанию, поэтому
// достаточно найти первую свежую.
func fresh(hits []time.Time, edge time.Time) []time.Time {
for i, hit := range hits {
if hit.After(edge) {
return hits[i:]
}
} }
return nil return nil
} }
+54
View File
@@ -0,0 +1,54 @@
package http
import (
"log/slog"
"net/http"
"net/netip"
"git.vakhrushev.me/av/transcriber/internal/contract"
)
// AppChain одевает адреса приложения в их слои.
//
// Порядок один и он несущий: ограничитель частоты → узнавание → требование
// учётной записи → обработчик.
//
// - ограничитель стоит первым, потому что узнавание читает базу, а на новом
// имени ещё и пишет в неё: поставленное раньше, оно работало бы на запросах,
// которые ограничитель уже отверг, и поток отвергнутых обращений заводил бы
// учётные записи, которые потом не убираются ничем;
// - требование учётной записи стоит перед чтением тела: запись, за которую не
// заплатит узнанный отправитель, не должна попасть даже в память, а позже
// пришлось бы убирать уже уложенный файл — чего сервис не умеет вовсе.
//
// Область действия всех трёх — корень приложения, и берётся она из перечня
// адресного пространства: цепочка вешается на корень целиком, вторым списком
// адресов её не описывают.
func AppChain(
routes http.Handler,
users contract.UserRepository,
trusted []netip.Prefix,
logger *slog.Logger,
) http.Handler {
handler := RequireUser()(routes)
handler = TrustedHeaderIdentity(users, trusted, logger)(handler)
handler = RateLimit(trusted)(handler)
return handler
}
// BuildHandler собирает поверхность сервиса целиком.
//
// Раздача приложения вешается последней: она занимает корень, и всё, что не
// совпало ни с одним адресом сервиса, доходит до неё.
//
// Снаружи всех — журнал и восстановление после паники: первый обязан видеть код
// ответа, каким бы слоем тот ни родился, второй — не дать одному паникующему
// запросу уронить процесс.
func BuildHandler(mounts []Mount, webapp *WebappHandler, logger *slog.Logger) http.Handler {
mux := http.NewServeMux()
RegisterServiceRoutes(mux, mounts)
webapp.Register(mux)
return Journal(mounts, logger)(Recover(logger)(mux))
}
+83 -18
View File
@@ -8,12 +8,9 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/pocketbase/pocketbase/apis"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
) )
@@ -47,7 +44,7 @@ func TestRecordCard_CarriesNoTextButListsViews(t *testing.T) {
record := jobWithFile(t, env) record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app) texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err) require.NoError(t, err)
@@ -120,7 +117,7 @@ func TestRecordText_TranscriptView(t *testing.T) {
record := jobWithFile(t, env) record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app) texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err) require.NoError(t, err)
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст") literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
@@ -149,7 +146,7 @@ func TestRecordText_ReplicasView(t *testing.T) {
record := jobWithFile(t, env) record := jobWithFile(t, env)
structures := pbrepo.NewStructureRepository(env.app) structures := env.handler.structureRepo
structure, err := structures.Put(record.Id, 1, []entity.Replica{ structure, err := structures.Put(record.Id, 1, []entity.Replica{
{StartMs: 0, EndMs: 1500, Text: "первая реплика"}, {StartMs: 0, EndMs: 1500, Text: "первая реплика"},
{StartMs: 1500, EndMs: 3000, Text: "вторая реплика"}, {StartMs: 1500, EndMs: 3000, Text: "вторая реплика"},
@@ -179,7 +176,7 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
record := jobWithFile(t, env) record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app) texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err) require.NoError(t, err)
record.TranscriptTextID = &transcript.Id record.TranscriptTextID = &transcript.Id
@@ -204,6 +201,64 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
"«ещё не готово» и «записи нет» ведут к разным действиям человека") "«ещё не готово» и «записи нет» ведут к разным действиям человека")
} }
// Каждая ветвь «ещё не готово» отвечает одним кодом и одним телом.
//
// Ветвей четыре, и достижимы все: текста нет вовсе, текст заведён пустым,
// структуры реплик нет, структура заведена без единой реплики. Шаг завершения
// пишет результат несколькими операциями, поэтому запись законно застаётся между
// ними. Ветвь, отвечающая иначе, отправила бы приложение за текстом, которого
// нет, либо показала бы «записи нет» на своей записи.
func TestRecordText_EveryNotReadyBranchIsOneAnswer(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer())
notReady := func(t *testing.T, record *entity.AudioRecord, view string) {
t.Helper()
w := textOf(t, env, record.Id, view)
require.Equal(t, http.StatusConflict, w.Code, view)
var body ErrorBody
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, CodeNotReady, body.Code, view)
assert.NotEmpty(t, body.Message, view)
}
t.Run("текста нет вовсе", func(t *testing.T) {
record := jobWithFile(t, env)
notReady(t, record, entity.TextViewTranscript)
notReady(t, record, entity.TextViewLiterary)
notReady(t, record, entity.TextViewReplicas)
})
t.Run("текст заведён пустым", func(t *testing.T) {
record := jobWithFile(t, env)
transcript, err := env.handler.textRepo.Put(record.Id, entity.TextKindTranscript, "")
require.NoError(t, err)
record.TranscriptTextID = &transcript.Id
literary, err := env.handler.textRepo.Put(record.Id, entity.TextKindLiterary, "")
require.NoError(t, err)
record.LiteraryTextID = &literary.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
notReady(t, record, entity.TextViewTranscript)
notReady(t, record, entity.TextViewLiterary)
})
t.Run("структура заведена без единой реплики", func(t *testing.T) {
record := jobWithFile(t, env)
structure, err := env.handler.structureRepo.Put(record.Id, 1, []entity.Replica{})
require.NoError(t, err)
record.StructureID = &structure.Id
require.NoError(t, env.handler.recordRepo.Save(record, ""))
notReady(t, record, entity.TextViewReplicas)
})
}
// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу: // Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу:
// умолчание сделало бы ответ функцией того, что успел записать конвейер. // умолчание сделало бы ответ функцией того, что успел записать конвейер.
func TestRecordText_UnknownViewIsBadRequest(t *testing.T) { func TestRecordText_UnknownViewIsBadRequest(t *testing.T) {
@@ -241,7 +296,7 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
record := jobWithFile(t, env) record := jobWithFile(t, env)
texts := pbrepo.NewTextRepository(env.app) texts := env.handler.textRepo
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка") transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
require.NoError(t, err) require.NoError(t, err)
@@ -251,20 +306,21 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная // Обработчик пересобирается с отказывающим хранилищем текстов: остальная
// цепочка та же, что и в проде. // цепочка та же, что и в проде.
journal := &journalBuffer{} journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
handler := NewAppHandler( handler := NewAppHandler(
env.handler.recordRepo, env.handler.recordRepo,
&failingTextRepo{}, &failingTextRepo{},
pbrepo.NewStructureRepository(env.app), env.handler.structureRepo,
env.handler.fileRepo,
env.handler.trsService, env.handler.trsService,
slog.New(slog.NewTextHandler(journal, nil)), logger,
) )
r, err := apis.NewRouter(env.app) mounts := ServiceMounts(
require.NoError(t, err) AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger),
r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil)) http.NotFoundHandler(),
handler.Register(r) )
mux, err := r.BuildMux() mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
require.NoError(t, err)
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody) req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
asUser(req, env.login) asUser(req, env.login)
@@ -280,16 +336,25 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и // Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и
// адрес, отвечающий по-старому, означал бы два дома у одного вопроса. // адрес, отвечающий по-старому, означал бы два дома у одного вопроса.
//
// Отвечают они теперь тем же, чем отвечает всякий путь вне корней сервиса, —
// разметкой приложения: корень `/api` перестал существовать вместе со
// встроенным хранилищем, и резервировать имя за отказом сервис не берётся.
func TestFormerAddressesAreGone(t *testing.T) { func TestFormerAddressesAreGone(t *testing.T) {
env := setupTestEnv(t, readableMetaViewer()) env := setupTestEnv(t, readableMetaViewer())
record := jobWithFile(t, env) record := jobWithFile(t, env)
before := countJobs(t, env)
status := httptest.NewRecorder() status := httptest.NewRecorder()
env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody)) env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
assert.Equal(t, http.StatusNotFound, status.Code) assert.Equal(t, http.StatusOK, status.Code)
assert.Contains(t, status.Body.String(), "приложение", "прежний адрес отдал не разметку")
assert.NotContains(t, status.Body.String(), entity.StateUploaded,
"прежний адрес отдал данные записи")
intake := httptest.NewRecorder() intake := httptest.NewRecorder()
env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные"))) env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
assert.Equal(t, http.StatusNotFound, intake.Code) assert.Equal(t, http.StatusMethodNotAllowed, intake.Code)
assert.Equal(t, before, countJobs(t, env), "прежний адрес приёма завёл запись")
} }
+249 -95
View File
@@ -6,6 +6,8 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -15,20 +17,19 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"testing/fstest"
"time" "time"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
"git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
"git.vakhrushev.me/av/transcriber/internal/service" "git.vakhrushev.me/av/transcriber/internal/service"
) )
@@ -66,19 +67,33 @@ func readableMetaViewer() *stubMetaViewer {
return &stubMetaViewer{seconds: 42} return &stubMetaViewer{seconds: 42}
} }
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>`
// builtDist — собранное приложение, каким его видит раздача.
func builtDist() fs.FS {
return fstest.MapFS{
"index.html": {Data: []byte(testMarkup)},
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
"favicon.ico": {Data: []byte("значок")},
}
}
// testEnv — собранное окружение одной проверки. Каталог данных свой у каждой: // testEnv — собранное окружение одной проверки. Каталог данных свой у каждой:
// рабочий каталог процесса проверки не трогают. // рабочий каталог процесса проверки не трогают.
type testEnv struct { type testEnv struct {
mux http.Handler mux http.Handler
handler *AppHandler handler *AppHandler
app core.App db *sqliterepo.DB
store *sqliterepo.Store
users *sqliterepo.UserRepository
files *sqliterepo.FileRepository
journal *journalBuffer journal *journalBuffer
// login — логин у провайдера, которым доверенный источник называет // login — логин у провайдера, которым доверенный источник называет
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их // пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
// по существу, обязана назваться ровно так же, как это делает прокси. // по существу, обязана назваться ровно так же, как это делает прокси.
login string login string
// account — учётная запись, которой принадлежит этот логин. // account — учётная запись, которой принадлежит этот логин.
account *core.Record account *contract.UserAccount
} }
// Доверенный источник проверок. // Доверенный источник проверок.
@@ -93,10 +108,68 @@ const (
func testTrustedNetworks(t *testing.T) []netip.Prefix { func testTrustedNetworks(t *testing.T) []netip.Prefix {
t.Helper() t.Helper()
prefix, err := netip.ParsePrefix("10.0.0.0/8") // Подсетей две: своя для запросов через `httptest.NewRecorder` и петлевая
// для тех проверок, которым нужен **настоящий** HTTP-запрос через поднятую
// цепочку слоёв — у них адрес пира ставит ядро, и он петлевой.
networks := make([]netip.Prefix, 0, 2)
for _, raw := range []string{"10.0.0.0/8", "127.0.0.0/8"} {
prefix, err := netip.ParsePrefix(raw)
require.NoError(t, err)
networks = append(networks, prefix)
}
return networks
}
// liveServer поднимает настоящий HTTP-сервер на этой поверхности.
//
// Нужен там, где проверка судит поведение, которого у вызова обработчика
// напрямую не бывает: разбор заголовка диапазона идёт по настоящему запросу, а
// адрес пира ставит ядро.
func (e *testEnv) liveServer(t *testing.T) *httptest.Server {
t.Helper()
server := httptest.NewServer(e.mux)
t.Cleanup(server.Close)
return server
}
// liveResponse — готовый ответ настоящего запроса: тело прочитано и закрыто, и
// проверке остаётся судить значения.
type liveResponse struct {
StatusCode int
Header http.Header
ContentLength int64
Body []byte
}
// liveRequest шлёт настоящий запрос от имени учётной записи проверки.
func (e *testEnv) liveRequest(
t *testing.T, server *httptest.Server, path string, headers map[string]string,
) liveResponse {
t.Helper()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+path, nil)
require.NoError(t, err)
req.Header.Set(LoginHeader, e.login)
for name, value := range headers {
req.Header.Set(name, value)
}
res, err := server.Client().Do(req)
require.NoError(t, err)
defer func() { require.NoError(t, res.Body.Close()) }()
body, err := io.ReadAll(res.Body)
require.NoError(t, err) require.NoError(t, err)
return []netip.Prefix{prefix} return liveResponse{
StatusCode: res.StatusCode,
Header: res.Header,
ContentLength: res.ContentLength,
Body: body,
}
} }
// asUser делает запрос запросом названного человека: ставит заголовок и адрес // asUser делает запрос запросом названного человека: ставит заголовок и адрес
@@ -113,30 +186,23 @@ func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
e.mux.ServeHTTP(w, asUser(req, e.login)) e.mux.ServeHTTP(w, asUser(req, e.login))
} }
// newTestAccount заводит учётную запись с известным логином. // get шлёт запрос **неузнанным**: заголовка на нём нет вовсе.
// func (e *testEnv) get(path string) *httptest.ResponseRecorder {
// Запись создаётся прямым сохранением, а не запросом к API: заводить её w := httptest.NewRecorder()
// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
// правил доступа так же, как идёт заведение записи первым обращением. return w
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) { }
t.Helper()
const login = "person" // getAs шлёт запрос от имени названного логина.
func (e *testEnv) getAs(login, path string) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
e.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, path, nil), login))
return w
}
users, err := app.FindCollectionByNameOrId("users") // getOwn шлёт запрос от имени учётной записи проверки.
require.NoError(t, err) func (e *testEnv) getOwn(path string) *httptest.ResponseRecorder {
return e.getAs(e.login, path)
record := core.NewRecord(users)
record.Set(migrations.ProviderLoginField, login)
record.Set("email", "person@example.com")
record.Set("verified", true)
// Случайный пароль ставит и сам сервис, когда заводит запись первым
// обращением: запись auth-коллекции без пароля не сохраняется, а войти по
// нему всё равно нельзя — парольный вход выключен шагом схемы.
record.SetRandomPassword()
require.NoError(t, app.Save(record))
return record, login
} }
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на // journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
@@ -162,45 +228,72 @@ func (b *journalBuffer) String() string {
return b.text.String() return b.text.String()
} }
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему — // newTestStorage поднимает базу на пустом каталоге и накатывает схему — ровно
// ровно тем же путём, каким это делает сервис при старте. // тем же путём, каким это делает сервис при старте.
func newTestStorage(t *testing.T) core.App { func newTestStorage(t *testing.T) (*sqliterepo.DB, *sqliterepo.Store, string) {
t.Helper() t.Helper()
app, err := pbrepo.New(t.TempDir()) dir := t.TempDir()
db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil { if err := db.Close(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err) t.Logf("не удалось закрыть базу: %v", err)
} }
}) })
return app require.NoError(t, sqliterepo.Migrate(
context.Background(), db, dir, slog.New(slog.DiscardHandler),
))
return db, sqliterepo.NewStore(dir), dir
}
// envOptions — чем одна проверка отличается от другой.
type envOptions struct {
metaviewer contract.AudioMetaViewer
dist fs.FS
built bool
} }
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv { func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
app := newTestStorage(t) return setupEnv(t, envOptions{metaviewer: metaviewer, dist: builtDist(), built: true})
}
pbrepo.BindPanelRules(app) // setupEnv собирает поверхность сервиса тем же способом, что и точка входа:
// адреса порождает перечень адресного пространства, слои одевает та же цепочка.
// Собери проверка поверхность иначе — и судила бы она не то.
func setupEnv(t *testing.T, opts envOptions) *testEnv {
t.Helper()
db, store, _ := newTestStorage(t)
recordRepo := sqliterepo.NewAudioRecordRepository(db)
textRepo := sqliterepo.NewTextRepository(db)
structureRepo := sqliterepo.NewStructureRepository(db)
fileRepo := sqliterepo.NewFileRepository(db, store)
users := sqliterepo.NewUserRepository(db)
recordRepo := pbrepo.NewAudioRecordRepository(app)
textRepo := pbrepo.NewTextRepository(app)
repos := service.Repositories{ repos := service.Repositories{
Records: recordRepo, Records: recordRepo,
Files: pbrepo.NewFileRepository(app), Files: fileRepo,
Texts: textRepo, Texts: textRepo,
Structures: pbrepo.NewStructureRepository(app), Structures: structureRepo,
Recognitions: pbrepo.NewRecognitionRepository(app), Recognitions: sqliterepo.NewRecognitionRepository(db, store),
Events: pbrepo.NewRecordEventRepository(app), Events: sqliterepo.NewRecordEventRepository(db),
} }
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на // Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки // имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
// отказа по-прежнему не попадают на экран и не размывают признак, по // отказа по-прежнему не попадают на экран.
// которому отличают новый красный шаг гейта от объявленного долга.
journal := &journalBuffer{} journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil)) logger := slog.New(slog.NewTextHandler(journal, nil))
metaviewer := opts.metaviewer
if metaviewer == nil {
metaviewer = readableMetaViewer()
}
trsService := service.NewTranscribeService( trsService := service.NewTranscribeService(
repos, repos,
metaviewer, metaviewer,
@@ -210,29 +303,31 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
logger, logger,
) )
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger) handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается mounts := ServiceMounts(
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку, AppChain(handler.Routes(), users, testTrustedNetworks(t), logger),
// что и прод. Собери роутер иначе — и проверка судила бы не то. http.NotFoundHandler(),
r, err := apis.NewRouter(app) )
webapp := NewWebappHandler(opts.dist, opts.built, logger)
mux := BuildHandler(mounts, webapp, logger)
account, _, err := users.EnsureUser(contract.Identity{
Login: "person",
Name: "Человек",
Email: "person@example.com",
})
require.NoError(t, err) require.NoError(t, err)
mounts := ServiceMounts(handler, http.NotFoundHandler())
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
handler.Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
account, login := newTestAccount(t, app)
return &testEnv{ return &testEnv{
mux: mux, mux: mux,
handler: handler, handler: handler,
app: app, db: db,
store: store,
users: users,
files: fileRepo,
journal: journal, journal: journal,
login: login, login: "person",
account: account, account: account,
} }
} }
@@ -285,65 +380,115 @@ func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
return items[0] return items[0]
} }
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище. // storedFileNames отдаёт имена, под которыми копии легли в каталог данных.
func storedFileNames(t *testing.T, env *testEnv) []string { func storedFileNames(t *testing.T, env *testEnv) []string {
records, err := env.app.FindAllRecords(migrations.FilesCollection) t.Helper()
rows, err := env.db.Reader().QueryContext(context.Background(), "SELECT file_name FROM files")
require.NoError(t, err) require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var names []string var names []string
for _, record := range records { for rows.Next() {
names = append(names, record.GetStringSlice("file")...) var name string
require.NoError(t, rows.Scan(&name))
names = append(names, name)
} }
require.NoError(t, rows.Err())
return names return names
} }
// countFiles считает записи о файлах. // countRows считает строки названной таблицы.
func countRows(t *testing.T, env *testEnv, table string) int {
t.Helper()
var total int
require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&total))
return total
}
// countFiles считает строки о копиях записей.
func countFiles(t *testing.T, env *testEnv) int { func countFiles(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(migrations.FilesCollection) return countRows(t, env, "files")
require.NoError(t, err)
return len(records)
} }
// countJobs считает заведённые аудиозаписи. // countJobs считает заведённые аудиозаписи.
func countJobs(t *testing.T, env *testEnv) int { func countJobs(t *testing.T, env *testEnv) int {
records, err := env.app.FindAllRecords(migrations.RecordsCollection) return countRows(t, env, "audio_records")
require.NoError(t, err)
return len(records)
} }
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна // jobWithFile заводит запись вместе с её принятой копией: без ссылки на файл
// схемой, потому что без неё задача не пройдёт ни одного шага. // запись не прошла бы ни одного шага.
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord { func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
t.Helper() t.Helper()
repo := pbrepo.NewFileRepository(env.app) return jobWithFileOf(t, env, env.account.ID)
work, err := repo.Stage(".mp3", strings.NewReader("запись")) }
// jobWithFileOf — то же, но от имени названного владельца.
func jobWithFileOf(t *testing.T, env *testEnv, ownerID string) *entity.AudioRecord {
t.Helper()
recordID := ident.New()
work, err := env.files.Stage(".mp3", strings.NewReader("запись"))
require.NoError(t, err) require.NoError(t, err)
defer func() { require.NoError(t, work.Close()) }() defer func() { require.NoError(t, work.Close()) }()
// Владелец — учётная запись проверки: задача, пришедшая из веба, без // Владелец — учётная запись проверки: ничьей записи в хранилище не бывает, и
// владельца больше не заводится, и фикстура без него описывала бы состояние, // фикстура без владельца описывала бы состояние, которого в проде нет.
// которого в проде не бывает. file, err := env.files.Create(
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id) recordID, "sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID,
)
require.NoError(t, err) require.NoError(t, err)
record := &entity.AudioRecord{ record := &entity.AudioRecord{
Id: recordID,
State: entity.StateUploaded, State: entity.StateUploaded,
StateEnteredAt: clock.Now(), StateEnteredAt: clock.Now(),
Source: entity.SourceApi, OwnerID: ownerID,
OwnerID: env.account.Id,
OriginalFileID: &file.Id, OriginalFileID: &file.Id,
} }
require.NoError(t, env.handler.recordRepo.Create(record)) require.NoError(t, env.handler.recordRepo.Create(record))
return record return record
} }
// storedContent читает содержимое файла из хранилища. // newTopic заводит тему в словаре названного владельца.
func storedContent(t *testing.T, env *testEnv, fileID string) []byte { //
repo := pbrepo.NewFileRepository(env.app) // Запросом к базе, а не адресом сервиса: адреса, которым заводят тему, у сервиса
reader, err := repo.Open(fileID) // нет — словарь пишет задача языковой модели, которой ещё нет. Проверка обязана
// пройти тот же путь разрешения тем, каким пойдёт она.
func newTopic(t *testing.T, env *testEnv, ownerID, name string) string {
t.Helper()
id := ident.New()
now := clock.Now().UTC().Format("2006-01-02T15:04:05Z")
_, err := env.db.Writer().ExecContext(context.Background(),
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
id, ownerID, name, now, now,
)
require.NoError(t, err) require.NoError(t, err)
defer reader.Close()
return id
}
// attachTopic связывает тему с записью.
func attachTopic(t *testing.T, env *testEnv, recordID, topicID string) {
t.Helper()
_, err := env.db.Writer().ExecContext(context.Background(),
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", recordID, topicID,
)
require.NoError(t, err)
}
// storedContent читает содержимое копии из каталога данных.
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
t.Helper()
reader, err := env.files.Open(fileID)
require.NoError(t, err)
defer func() { require.NoError(t, reader.Close()) }()
var buf bytes.Buffer var buf bytes.Buffer
_, err = buf.ReadFrom(reader) _, err = buf.ReadFrom(reader)
@@ -387,7 +532,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
// отправитель получит идентификатор записи, которой не будет никогда. // отправитель получит идентификатор записи, которой не будет никогда.
require.Equal(t, 1, countJobs(t, env)) require.Equal(t, 1, countJobs(t, env))
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id) job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, entity.StateUploaded, job.State) assert.Equal(t, entity.StateUploaded, job.State)
require.NotNil(t, job.OriginalFileID) require.NotNil(t, job.OriginalFileID)
@@ -529,9 +674,20 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
require.Len(t, names, 1) require.Len(t, names, 1)
assert.NotContains(t, names[0], "секретное-слово", assert.NotContains(t, names[0], "секретное-слово",
"имя, данное отправителем, в хранилище не попадает") "имя, данное отправителем, в имя файла не попадает")
assert.True(t, strings.HasSuffix(names[0], ".mp3"), assert.True(t, strings.HasSuffix(names[0], ".mp3"),
"расширение при этом сохраняется: %q", names[0]) "расширение при этом сохраняется: %q", names[0])
// И в **путь** к файлу оно не попадает тоже: подкаталог назван
// идентификатором записи, а не именем отправителя.
var recordID, storedRecord string
require.NoError(t, env.db.Reader().
QueryRow("SELECT id FROM audio_records").Scan(&recordID))
require.NoError(t, env.db.Reader().
QueryRow("SELECT record_id FROM files").Scan(&storedRecord))
assert.Equal(t, recordID, storedRecord, "подкаталог копии назван не записью")
assert.NotContains(t, storedRecord, "секретное-слово",
"имя, данное отправителем, попало в путь к файлу")
} }
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) { func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
@@ -659,7 +815,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
response := intakeItemOf(t, w) response := intakeItemOf(t, w)
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id) job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, job.OriginalFileID) require.NotNil(t, job.OriginalFileID)
@@ -821,7 +977,5 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String()) require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection) assert.Equal(t, 1, countJobs(t, env), "задача заведена, несмотря на ушедшего отправителя")
require.NoError(t, err)
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
} }
+44 -197
View File
@@ -9,29 +9,6 @@ import (
"path" "path"
"strconv" "strconv"
"strings" "strings"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
// Корни адресного пространства и отдельные адреса наблюдения.
//
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
// панели владельца. Поменять их нельзя, это литералы библиотеки.
//
// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и
// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают
// тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
// стоит, ничем не отличается от любого другого свободного, а второй перечень
// «когда-то занятых корней» разошёлся бы с первым молча.
const (
StorageRoot = "/api"
PanelRoot = "/_"
HealthPath = "/health"
MetricsPath = "/metrics"
) )
// assetsDir — каталог, который наполняет сборщик приложения. // assetsDir — каталог, который наполняет сборщик приложения.
@@ -45,104 +22,6 @@ const assetsDir = "assets"
// Срок хранения ресурса сборщика — год. // Срок хранения ресурса сборщика — год.
const assetMaxAgeSeconds = 31536000 const assetMaxAgeSeconds = 31536000
// Mount — часть адресного пространства, принадлежащая сервису.
//
// Перечень этих частей — **единственное** описание того, что сервису
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень
// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень,
// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ
// контракта.
type Mount struct {
// Path — корень либо точный адрес.
Path string
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
// только сам себя, а `/app` — всё, что под ним.
Exact bool
// Bind вешает обработчики этой части. Пусто у того, что вешает библиотека.
Bind func(r *router.Router[*core.RequestEvent])
}
// Covers говорит, принадлежит ли путь этой части адресного пространства.
//
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы
// никому и уехал бы разметкой приложения.
func (m Mount) Covers(requestPath string) bool {
if m.Exact {
return requestPath == m.Path
}
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
}
// ServiceMounts перечисляет адресное пространство сервиса целиком.
func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount {
return []Mount{
{Path: StorageRoot},
{Path: PanelRoot},
{Path: AppRoot, Bind: appHandler.Register},
{Path: HealthPath, Exact: true, Bind: bindHealth},
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
}
}
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) {
for _, mount := range mounts {
if mount.Bind != nil {
mount.Bind(r)
}
}
}
// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью.
func IsServiceAddress(mounts []Mount, requestPath string) bool {
for _, mount := range mounts {
if mount.Covers(requestPath) {
return true
}
}
return false
}
// IsObservationAddress говорит, что путь — адрес наблюдения.
//
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
// адресов разошлось бы с первым молча.
func IsObservationAddress(mounts []Mount, requestPath string) bool {
for _, mount := range mounts {
if mount.Exact && mount.Covers(requestPath) {
return true
}
}
return false
}
func bindHealth(r *router.Router[*core.RequestEvent]) {
r.GET(HealthPath, func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"status": "ok",
"message": "Transcriber service is running",
})
})
}
func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) {
return func(r *router.Router[*core.RequestEvent]) {
r.GET(MetricsPath, func(e *core.RequestEvent) error {
handler.ServeHTTP(e.Response, e.Request)
return nil
})
}
}
// notBuiltPage — что видит человек у бинарника без собранного приложения. // notBuiltPage — что видит человек у бинарника без собранного приложения.
// //
// Состояние возможно только у собранного мимо набора проверок: и набор // Состояние возможно только у собранного мимо набора проверок: и набор
@@ -164,25 +43,10 @@ const (
OutcomeFailure = "failure" OutcomeFailure = "failure"
) )
// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала.
const journalOutcomeKey = "transcriberWebappOutcome"
// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если
// запрос до неё не дошёл.
func WebappOutcome(e *core.RequestEvent) string {
outcome, ok := e.Get(journalOutcomeKey).(string)
if !ok {
return ""
}
return outcome
}
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути. // WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
type WebappHandler struct { type WebappHandler struct {
dist fs.FS dist fs.FS
built bool built bool
mounts []Mount
fingerprint string fingerprint string
logger *slog.Logger logger *slog.Logger
} }
@@ -191,14 +55,14 @@ type WebappHandler struct {
// //
// Файловая система приходит параметром, а не тянется пакетом: так тест // Файловая система приходит параметром, а не тянется пакетом: так тест
// подставляет свою сборку, не собирая приложение. // подставляет свою сборку, не собирая приложение.
func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler { func NewWebappHandler(dist fs.FS, built bool, logger *slog.Logger) *WebappHandler {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
if !built { if !built {
logger.Warn("Webapp is not built, service will answer with a placeholder page") logger.Warn("Webapp is not built, service will answer with a placeholder page")
return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger} return &WebappHandler{dist: dist, built: built, logger: logger}
} }
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от // Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
@@ -211,17 +75,12 @@ func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logge
return &WebappHandler{ return &WebappHandler{
dist: dist, dist: dist,
built: built, built: built,
mounts: mounts,
fingerprint: fingerprint, fingerprint: fingerprint,
logger: logger, logger: logger,
} }
} }
// buildFingerprint — короткий отпечаток разметки вшитой сборки. // buildFingerprint — короткий отпечаток разметки вшитой сборки.
//
// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл
// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами
// ресурсов, то есть при всякой пересборке с изменениями.
func buildFingerprint(dist fs.FS) string { func buildFingerprint(dist fs.FS) string {
markup, err := fs.ReadFile(dist, "index.html") markup, err := fs.ReadFile(dist, "index.html")
if err != nil { if err != nil {
@@ -235,54 +94,41 @@ func buildFingerprint(dist fs.FS) string {
// Register вешает раздачу на корень. // Register вешает раздачу на корень.
// //
// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама // Путь, принадлежащий корню сервиса, до неё не доходит вовсе: его забирает
// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и // маршрутизатор, у которого корень объявлен своим образцом. Отказ контракта
// второй всепокрывающий образец рядом с ним спорил бы с ним за путь. // поэтому остаётся отказом контракта, а не проваливается в разметку.
func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) { func (h *WebappHandler) Register(mux *http.ServeMux) {
// Успешная раздача в журнал хранилища не пишется. mux.HandleFunc("/", h.Serve)
//
// Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека
// кладёт путь запроса целиком вместе с адресом отправителя, храня строки
// пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое
// открытие приложения оставляло бы там его текст и его адрес. Готовая
// раздача статики ставит тот же признак первой строкой; своя написана мимо
// неё, и признак перенесён руками.
//
// Отказы записываются по-прежнему: признак снимает только успех.
r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog())
} }
// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном. // Serve отдаёт приложение либо отказ.
func (h *WebappHandler) Serve(e *core.RequestEvent) error { func (h *WebappHandler) Serve(w http.ResponseWriter, r *http.Request) {
requestPath := e.Request.URL.Path // Открывающими страницу считаются `GET` и `HEAD`, и только они.
if r.Method != http.MethodGet && r.Method != http.MethodHead {
// Путь принадлежит сервису — значит под этим корнем такого адреса просто noteWebappOutcome(r, OutcomeFailure)
// нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень http.Error(w, "", http.StatusMethodNotAllowed)
// отвечает и сегодня: своя форма здесь была бы второй. return
if IsServiceAddress(h.mounts, requestPath) {
e.Set(journalOutcomeKey, OutcomeFailure)
return router.NewNotFoundError("", nil)
}
if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
e.Set(journalOutcomeKey, OutcomeFailure)
return router.NewApiError(http.StatusMethodNotAllowed, "", nil)
} }
if !h.built { if !h.built {
e.Set(journalOutcomeKey, OutcomeFailure) noteWebappOutcome(r, OutcomeFailure)
return e.HTML(http.StatusServiceUnavailable, notBuiltPage) w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(notBuiltPage))
return
} }
name := strings.TrimPrefix(path.Clean(requestPath), "/") name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if name == "" || name == "." { if name == "" || name == "." {
return h.serveIndex(e) h.serveIndex(w, r)
return
} }
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() { if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
e.Set(journalOutcomeKey, OutcomeAsset) noteWebappOutcome(r, OutcomeAsset)
h.setCacheHeader(e, name) h.setCacheHeader(w, name)
return e.FileFS(h.dist, name) http.ServeFileFS(w, r, h.dist, name)
return
} }
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их // Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
@@ -290,33 +136,29 @@ func (h *WebappHandler) Serve(e *core.RequestEvent) error {
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса // типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
// не останется ничего. // не останется ничего.
if underAssets(name) { if underAssets(name) {
e.Set(journalOutcomeKey, OutcomeFailure) noteWebappOutcome(r, OutcomeFailure)
return router.NewNotFoundError("", nil) http.NotFound(w, r)
return
} }
return h.serveIndex(e) h.serveIndex(w, r)
} }
func (h *WebappHandler) serveIndex(e *core.RequestEvent) error { func (h *WebappHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
e.Set(journalOutcomeKey, OutcomeMarkup) noteWebappOutcome(r, OutcomeMarkup)
h.setCacheHeader(e, "index.html") h.setCacheHeader(w, "index.html")
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку — // Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени // «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо // правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
// подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо. // подтверждения.
if h.fingerprint != "" { if h.fingerprint != "" {
e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`) w.Header().Set("ETag", `"`+h.fingerprint+`"`)
} }
return e.FileFS(h.dist, "index.html") http.ServeFileFS(w, r, h.dist, "index.html")
} }
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
//
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
// отзывается со стороны сервиса ничем. Запросов он больше не увидит.
// underAssets говорит, ведёт ли путь в каталог сборщика. // underAssets говорит, ведёт ли путь в каталог сборщика.
// //
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам // Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
@@ -327,14 +169,19 @@ func underAssets(name string) bool {
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/") return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
} }
func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) { // setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
//
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
// отзывается со стороны сервиса ничем.
func (h *WebappHandler) setCacheHeader(w http.ResponseWriter, name string) {
if underAssets(name) { if underAssets(name) {
e.Response.Header().Set( w.Header().Set(
"Cache-Control", "Cache-Control",
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable", "public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
) )
return return
} }
e.Response.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
} }
+39 -74
View File
@@ -2,88 +2,25 @@ package http
import ( import (
"io/fs" "io/fs"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"testing/fstest" "testing/fstest"
"time"
"github.com/pocketbase/pocketbase/apis"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
"git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/service"
) )
const testMarkup = `<!doctype html><html lang="ru"><body>приложение</body></html>` // webappEnv — окружение проверок раздачи. Поверхность собирается тем же
// способом, что и боевая: маршруты порождает перечень адресного пространства, а
// builtDist — собранное приложение, каким его видит раздача. // не перечисление в проверке.
func builtDist() fs.FS {
return fstest.MapFS{
"index.html": {Data: []byte(testMarkup)},
"assets/index-abc123.js": {Data: []byte("console.log(1)")},
"favicon.ico": {Data: []byte("значок")},
}
}
// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом,
// что и боевой: маршруты порождает перечень, а не перечисление в проверке.
type webappEnv struct { type webappEnv struct {
mux http.Handler env *testEnv
journal *journalBuffer
login string
} }
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv { func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
t.Helper() t.Helper()
app := newTestStorage(t) return &webappEnv{env: setupEnv(t, envOptions{dist: dist, built: built})}
pbrepo.BindPanelRules(app)
recordRepo := pbrepo.NewAudioRecordRepository(app)
textRepo := pbrepo.NewTextRepository(app)
structureRepo := pbrepo.NewStructureRepository(app)
journal := &journalBuffer{}
logger := slog.New(slog.NewTextHandler(journal, nil))
trsService := service.NewTranscribeService(
service.Repositories{
Records: recordRepo,
Files: pbrepo.NewFileRepository(app),
Texts: textRepo,
Structures: structureRepo,
Recognitions: pbrepo.NewRecognitionRepository(app),
Events: pbrepo.NewRecordEventRepository(app),
},
readableMetaViewer(),
&stubConverter{},
&recognizer.MemoryAudioRecognizer{},
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
logger,
)
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
r, err := apis.NewRouter(app)
require.NoError(t, err)
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
RegisterServiceRoutes(r, mounts)
NewWebappHandler(dist, built, mounts, logger).Register(r)
mux, err := r.BuildMux()
require.NoError(t, err)
_, login := newTestAccount(t, app)
return &webappEnv{mux: mux, journal: journal, login: login}
} }
func (e *webappEnv) get(path string) *httptest.ResponseRecorder { func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
@@ -93,15 +30,20 @@ func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder { func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, nil) req := httptest.NewRequest(method, path, nil)
if identified { if identified {
asUser(req, e.login) asUser(req, e.env.login)
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
e.mux.ServeHTTP(rec, req) e.env.mux.ServeHTTP(rec, req)
return rec return rec
} }
// journal отдаёт перехваченный журнал окружения.
func (e *webappEnv) journal() string {
return e.env.journal.String()
}
// Обновление страницы посреди приложения открывает тот же экран: адреса у // Обновление страницы посреди приложения открывает тот же экран: адреса у
// приложения обычные, а не после решётки, и сервер обязан отдать разметку. // приложения обычные, а не после решётки, и сервер обязан отдать разметку.
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) { func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
@@ -117,16 +59,33 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
// Путь внутри корня сервиса в приложение не проваливается никогда: иначе // Путь внутри корня сервиса в приложение не проваливается никогда: иначе
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ. // программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
//
// Корень у сервиса остался один — корень приложения; `/api` и `/_` ушли вместе
// со встроенным хранилищем, и пути под ними стали обычными путями вне корней.
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) { func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
env := setupWebappEnv(t, builtDist(), true) env := setupWebappEnv(t, builtDist(), true)
for _, path := range []string{"/api/nope", "/_/nope"} { for _, path := range []string{"/app/nope", "/app"} {
res := env.get(path) res := env.get(path)
assert.NotContains(t, res.Body.String(), "приложение", path) assert.NotContains(t, res.Body.String(), "приложение", path)
} }
} }
// Прежние корни хранилища и панели отвечают разметкой — тем же, чем отвечает
// всякий путь вне корней. Знак, записанный своим кодом, попадает в то же
// правило: правило одно, и особого случая у него нет.
func TestWebappServesFormerStorageAndPanelPaths(t *testing.T) {
env := setupWebappEnv(t, builtDist(), true)
for _, path := range []string{"/api/nope", "/_/", "/%5f/"} {
res := env.get(path)
assert.Equal(t, http.StatusOK, res.Code, path)
assert.Contains(t, res.Body.String(), "приложение", path)
}
}
// Под корнем приложения отказ уходит его собственной формой, и код отвечает // Под корнем приложения отказ уходит его собственной формой, и код отвечает
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет». // причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) { func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
@@ -147,8 +106,14 @@ func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) { func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
env := setupWebappEnv(t, builtDist(), true) env := setupWebappEnv(t, builtDist(), true)
bare := env.get(StorageRoot) bare := env.do(http.MethodGet, AppRoot, true)
assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется") assert.Equal(t, http.StatusNotFound, bare.Code, "голый корень разметкой не подменяется")
assert.NotContains(t, bare.Body.String(), "приложение")
bareAnonymous := env.get(AppRoot)
assert.Equal(t, http.StatusUnauthorized, bareAnonymous.Code,
"голый корень неузнанному отвечает как прочие адреса под ним")
assert.NotContains(t, bareAnonymous.Body.String(), "приложение")
neighbour := env.get(AppRoot + "le") neighbour := env.get(AppRoot + "le")
assert.Equal(t, http.StatusOK, neighbour.Code) assert.Equal(t, http.StatusOK, neighbour.Code)
@@ -245,5 +210,5 @@ func TestWebappNotBuilt(t *testing.T) {
// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека, // Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
// открывшего страницу. // открывшего страницу.
assert.Contains(t, env.journal.String(), "Webapp is not built") assert.Contains(t, env.journal(), "Webapp is not built")
} }
+1 -25
View File
@@ -35,15 +35,6 @@ const (
HaltReasonStuck = "stuck" HaltReasonStuck = "stuck"
) )
const (
SourceUnknown = "unknown"
SourceApi = "api"
// SourceTelegram — историческое значение. Вход Telegram убран, новых записей
// с этим источником не появляется, а константа остаётся: на неё ссылается
// применённый шаг схемы `202608140002`, а применённый шаг не переписывается.
SourceTelegram = "telegram"
)
// AudioRecord — аудиозапись, центральная сущность сервиса. // AudioRecord — аудиозапись, центральная сущность сервиса.
// //
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и // Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
@@ -57,7 +48,6 @@ type AudioRecord struct {
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не // хранилище не бывает. Назначается один раз, при приёме, и конвейером не
// меняется. // меняется.
OwnerID string OwnerID string
Source string
// Title и Brief читаются вместе со списком, сотней штук разом, и потому // Title и Brief читаются вместе со списком, сотней штук разом, и потому
// лежат колонками записи, а не строками `texts`. // лежат колонками записи, а не строками `texts`.
@@ -142,7 +132,7 @@ const MaxOriginalFilenameLen = 255
// //
// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём, // Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём,
// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что // и правило чистки обязано быть одно. Управляющие знаки убираются потому, что
// иначе доезжают до экрана и до панели владельца; резка идёт **после** уборки, // иначе доезжают до экрана; резка идёт **после** уборки,
// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет. // иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет.
// //
// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка // Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка
@@ -162,20 +152,6 @@ func SanitizeOriginalFilename(name string) string {
return string(runes) return string(runes)
} }
// AllStates — закрытый перечень рубежей для схемы хранилища.
func AllStates() []string {
out := make([]string, 0, len(stages))
for _, s := range stages {
out = append(out, s.Name)
}
return out
}
// AllHaltReasons — закрытый перечень причин остановки для схемы хранилища.
func AllHaltReasons() []string {
return []string{HaltReasonStepFailed, HaltReasonAttempts, HaltReasonStuck}
}
// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого. // MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
// //
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания. // Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
+10 -17
View File
@@ -4,21 +4,13 @@ import (
"time" "time"
) )
// Где лежит копия файла. Поле названо `location`, а не `storage`: последним
// словом зовут само хранилище, и третий смысл у одного слова развёл бы по
// разным вещам запись о файле и хранилище, в котором она лежит.
const (
LocationLocal = "local"
LocationS3 = "s3"
)
// MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного // MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного
// потолка записи в шесть часов с запасом на видео, а не из замера. // потолка записи в шесть часов с запасом на видео, а не из замера.
// //
// Число нужно назвать **явно** в двух местах сразу: у поля файла в хранилище // Число называется **явно** везде, где иначе действует умолчание: у тела запроса
// нулевой потолок значит не «без предела», а умолчание библиотеки в 5 МиБ, а у // приёма умолчание отсекало бы запись раньше, чем она дойдёт до обработчика — и
// тела запроса приёма умолчание роутера отсекало бы запись раньше, чем она // без строки в журнале приёма. Умолчания здесь не «без предела», а величины на
// дойдёт до обработчика — без строки в журнале приёма. // два-три порядка меньше нужного.
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
// File — одна физическая копия записи. Их ровно две: принятая и приведённая к // File — одна физическая копия записи. Их ровно две: принятая и приведённая к
@@ -27,11 +19,12 @@ const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
// живёт в строке попытки распознавания. // живёт в строке попытки распознавания.
type File struct { type File struct {
Id string Id string
Location string // RecordID — запись, которой копия принадлежит. Копии одной записи лежат её
// FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса // подкаталогом, названным этим идентификатором: так они лежат вместе, а
// хранилище к заданному имени не дописывает: суффикс появляется только у // запись убирается целиком одним движением.
// имён, которые оно строит само из имени отправителя, а это умолчание не RecordID string
// применяется. // FileName — имя, под которым файл лежит: имя задаёт сервис. Имя, данное
// отправителем, в него не попадает — от него взято только расширение.
FileName string FileName string
Size int64 Size int64
// Format — расширение без точки, приведённое к нижнему регистру. Наружу оно // Format — расширение без точки, приведённое к нижнему регистру. Наружу оно
+102
View File
@@ -0,0 +1,102 @@
package entity
import (
"net/mail"
"strings"
"unicode"
"unicode/utf8"
)
// MaxProviderLoginLength — предел длины логина у провайдера.
//
// Значение приходит заголовком, то есть целиком задаётся тем, кто шлёт запрос, и
// без предела в колонку уехало бы столько, сколько влезет в заголовки. Число то
// же, что у имени: длиннее имени логин не бывает, а два разных предела на
// соседних колонках одной записи разошлись бы молча.
const MaxProviderLoginLength = 255
// MaxDisplayNameLength — предел длины имени, пригодного к показу.
const MaxDisplayNameLength = 255
// AcceptProviderLogin приводит пришедшее значение к годному логину либо
// отвергает его.
//
// Отвергается пустое, состоящее из одних пробельных знаков, длиннее предела и
// несущее управляющие знаки. Пустое значение — не крайний случай: обратный
// прокси штатно шлёт заголовок пустым там, где никого не назвал, и без этой
// проверки все неназванные собрались бы в одну учётную запись с общим архивом.
//
// Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него
// называет одного человека, а две записи о нём разошлись бы молча. Сравнение при
// поиске остаётся точным, знак в знак: приведение регистра завело бы правило,
// которого у провайдера нет.
//
// Живёт в домене, а не в транспорте и не в хранилище: правило одно на все
// способы представиться, а уложенное куском в слой оно разошлось бы двумя
// копиями при первом же втором способе.
func AcceptProviderLogin(value string) (string, bool) {
login := strings.TrimSpace(value)
// Предел считается в **знаках**, а не в байтах: колонка считает знаки, и два
// предела в разных единицах разошлись бы вдвое на любой кириллице.
if login == "" || utf8.RuneCountInString(login) > MaxProviderLoginLength {
return "", false
}
for _, r := range login {
if unicode.IsControl(r) {
return "", false
}
}
return login, true
}
// AcceptDisplayName приводит имя к годному для колонки значению.
//
// Обрезается по пределу и чистится от управляющих знаков — тем же приёмом, каким
// приём записи чистит имя файла отправителя. Пустое значение законно: имени у
// человека может не быть вовсе, и негодное значение необязательного поля не
// вправе отменять заведение учётной записи.
func AcceptDisplayName(value string) string {
name := strings.TrimSpace(stripControls(value))
runes := []rune(name)
if len(runes) > MaxDisplayNameLength {
return string(runes[:MaxDisplayNameLength])
}
return name
}
// AcceptEmail отдаёт адрес почты, если он вообще похож на адрес.
//
// Негодный отбрасывается здесь, а не отказом сохранения: иначе опечатка в
// заголовке кончалась бы либо отказом сервиса, либо — что хуже — ветвью «почта
// занята», и владелец искал бы общий ящик там, где сломан контур.
func AcceptEmail(value string) (string, bool) {
email := strings.TrimSpace(value)
if email == "" {
return "", false
}
// Разбор адреса принимает и форму «Имя <адрес>»: такую строку заголовок
// приносить не должен, и адресом она не считается.
parsed, err := mail.ParseAddress(email)
if err != nil || parsed.Name != "" || parsed.Address != email {
return "", false
}
return email, true
}
// stripControls убирает управляющие знаки: они приезжают заголовком и в колонке
// им делать нечего.
func stripControls(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsControl(r) {
return -1
}
return r
}, value)
}
+6 -5
View File
@@ -87,10 +87,11 @@ func (r *RecognitionResult) GetError() string {
// Другой провайдер её не потребует, и смена провайдера не трогает доменную // Другой провайдер её не потребует, и смена провайдера не трогает доменную
// сущность вовсе. // сущность вовсе.
// //
// Сырой ответ хранится вложением, а не колонкой этой строки: шаг опроса читает // Сохранённый ответ провайдера лежит **третьим файлом в подкаталоге записи**, а
// её раз в несколько секунд, а хранилище читает запись целиком — ответ на // не колонкой этой строки: шаг опроса читает её раз в несколько секунд, а
// многочасовую запись ехал бы в память при каждом опросе. Хранится он потому, // репозиторий читает строку целиком — ответ на многочасовую запись ехал бы в
// что результат операции у провайдера не переспрашивается. // память при каждом опросе. Хранится он потому, что результат операции у
// провайдера не переспрашивается.
type Recognition struct { type Recognition struct {
Id string Id string
RecordID string RecordID string
@@ -115,6 +116,6 @@ type RecognitionOutcome struct {
Replicas []Replica Replicas []Replica
// PlainText — плоский текст расшифровки. // PlainText — плоский текст расшифровки.
PlainText string PlainText string
// Raw — ответ провайдера целиком, как он пришёл, на хранение вложением. // Raw — ответ провайдера целиком, как он пришёл, на хранение отдельным файлом.
Raw []byte Raw []byte
} }
-10
View File
@@ -17,16 +17,6 @@ const (
EventOutcomeResumed = "resumed" EventOutcomeResumed = "resumed"
) )
// AllEventOrigins — закрытый перечень источников для схемы хранилища.
func AllEventOrigins() []string {
return []string{EventOriginPipeline, EventOriginHuman}
}
// AllEventOutcomes — закрытый перечень исходов для схемы хранилища.
func AllEventOutcomes() []string {
return []string{EventOutcomeDone, EventOutcomeFailed, EventOutcomeHalted, EventOutcomeResumed}
}
// RecordEvent — строка журнала событий одной записи. // RecordEvent — строка журнала событий одной записи.
// //
// Пишется на смену рубежа, на остановку и на снятие остановки — не на каждое // Пишется на смену рубежа, на остановку и на снятие остановки — не на каждое
-22
View File
@@ -1,22 +0,0 @@
package entity
// Состояния прежней модели. **Частью модели они не являются** и в перечень
// рубежей не входят: цепочку рубежей объявляет `stage.go`, а закрытый перечень
// для схемы — `AllStates()`.
//
// Живут они здесь по одной причине: шаг схемы `202608110001_init.go` заводил
// прежнюю коллекцию задач этими значениями, а **применённый шаг схемы не
// переписывается** — хранилище считает применённое по имени файла, и правка
// сделала бы его другим шагом под прежним именем. Шаг ссылается на эти
// константы, значит они обязаны существовать, пока существует он.
//
// Коллекцию, которую он заводил, удаляет шаг `202608140002`. Ни один живой путь
// сервиса этих значений не читает и не пишет; `StateDone` в этом списке нет —
// то же слово осталось именем конечного рубежа новой модели.
const (
StateCreated = "created"
StateConverted = "converted"
StateTranscribe = "transcribe"
StateFailed = "failed"
StateDead = "dead"
)
-5
View File
@@ -42,11 +42,6 @@ func IsKnownTextView(view string) bool {
return false return false
} }
// AllTextKinds — закрытый перечень видов текста для схемы хранилища.
func AllTextKinds() []string {
return []string{TextKindTranscript, TextKindLiterary}
}
// Text — один вид текста одной записи. Пара «запись и вид» уникальна: повтор // Text — один вид текста одной записи. Пара «запись и вид» уникальна: повтор
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст // прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
// отдавать человеку» стал бы вопросом порядка записи, а не состояния. // отдавать человеку» стал бы вопросом порядка записи, а не состояния.
+192
View File
@@ -0,0 +1,192 @@
// Package ident — единая точка выдачи идентификаторов строк.
//
// Идентификатор — ULID в нижнем регистре: 48 бит времени в миллисекундах плюс
// 80 бит случайности, записанные алфавитом Crockford base32. Так требует
// конвенция проекта [docs/conventions/database.md]; прежде идентификаторы
// выдавало встроенное хранилище своим алфавитом, и точки выдачи у приложения не
// было вовсе.
//
// Почему ULID, а не UUID: ширина записи постоянная, а старшие разряды несут
// время — лента записей упорядочивается парой «время заведения и ключ», и ключ
// в этой паре не спорит с временем, а продолжает его.
//
// Регистр нижний, и это тоже правило конвенции: сравнение строк в SQLite
// побайтово, поэтому канонический вид обязан быть один. Пришедший снаружи
// идентификатор приводится к нему разбором на границе — Parse.
package ident
import (
"crypto/rand"
"strings"
"sync"
"time"
"git.vakhrushev.me/av/transcriber/internal/clock"
)
// alphabet — Crockford base32 в нижнем регистре. Из него исключены `i`, `l`,
// `o` и `u`: первые три неотличимы от цифр в наборах без засечек, последняя
// исключена, чтобы случайная строка не складывалась в бранное слово.
const alphabet = "0123456789abcdefghjkmnpqrstvwxyz"
// Len — длина записи ULID: 10 знаков времени и 16 знаков случайности.
const Len = 26
// decode — обратная таблица алфавита. Заполняется один раз: разбор идёт на
// каждом запросе с идентификатором в пути, и собирать таблицу по месту значило
// бы платить за неё столько же раз.
var decode = func() [256]int8 {
var table [256]int8
for i := range table {
table[i] = -1
}
for i, r := range alphabet {
table[byte(r)] = int8(i)
// Заглавный знак принимается разбором наравне со строчным: алфавит
// Crockford к регистру нечувствителен, и человек, скопировавший
// идентификатор из чужого письма, не обязан знать про канонический вид.
table[byte(strings.ToUpper(string(r))[0])] = int8(i)
}
return table
}()
// Состояние выдачи: последняя метка времени и последняя случайная часть.
//
// Нужно ради **монотонности внутри миллисекунды**. Колонка времени несёт
// секунды, и порядок записей, заведённых в одну секунду, задаёт ключ: случайная
// часть, выданная заново, поставила бы их в произвольном порядке — «новые
// сверху» стало бы «как повезёт», а страница ленты читалась бы через раз.
var (
mu sync.Mutex
lastMs uint64
lastRand [10]byte
)
// New выдаёт новый идентификатор.
//
// Время берётся единой точкой чтения времени, а не `time.Now`: запрет держит
// линтер, и обойти его здесь значило бы завести вторые часы у ключей.
//
// Случайность берётся у `crypto/rand`. Он не отказывает: с Go 1.24 чтение из
// него не возвращает ошибки вовсе, а невозможность получить случайность — отказ
// такого рода, из которого не стартуют.
//
// В пределах одной миллисекунды случайная часть **растёт на единицу**, а не
// выдаётся заново: два идентификатора одной миллисекунды обязаны идти в порядке
// выдачи. Переполнение прибавляет миллисекунду — исход недостижимый на любой
// мыслимой нагрузке, но названный, потому что молчаливый откат назад испортил бы
// порядок сильнее любой случайности.
func New() string {
ms := uint64(clock.Now().UnixMilli())
mu.Lock()
switch {
case ms > lastMs:
lastMs = ms
// Ошибку `rand.Read` не проверяем сознательно: с Go 1.24 он её не
// возвращает, а `errcheck` довольствуется явным присваиванием в
// пустышку.
_, _ = rand.Read(lastRand[:])
default:
// Часы могли и отступить назад: метка тогда остаётся прежней, а порядок
// держит растущая случайная часть.
if !increment(&lastRand) {
lastMs++
_, _ = rand.Read(lastRand[:])
}
}
ms, entropy := lastMs, lastRand
mu.Unlock()
var raw [16]byte
raw[0] = byte(ms >> 40)
raw[1] = byte(ms >> 32)
raw[2] = byte(ms >> 24)
raw[3] = byte(ms >> 16)
raw[4] = byte(ms >> 8)
raw[5] = byte(ms)
copy(raw[6:], entropy[:])
return encode(raw)
}
// increment прибавляет единицу к случайной части. Ложь означает переполнение —
// все восемьдесят разрядов были заняты.
func increment(value *[10]byte) bool {
for i := len(value) - 1; i >= 0; i-- {
value[i]++
if value[i] != 0 {
return true
}
}
return false
}
// encode переводит шестнадцать байт в двадцать шесть знаков алфавита.
//
// Разрядов у записи 130, а байт — 128, поэтому старший знак несёт только два
// младших бита первого байта; остальные знаки идут ровными пятёрками бит.
func encode(raw [16]byte) string {
var n [130]byte
for i := range 128 {
n[i+2] = raw[i/8] >> (7 - i%8) & 1
}
out := make([]byte, Len)
for i := range out {
var v byte
for j := range 5 {
v = v<<1 | n[i*5+j]
}
out[i] = alphabet[v]
}
return string(out)
}
// Parse разбирает идентификатор, пришедший снаружи: проверяет вид и приводит
// регистр. Второе значение ложно у всего, что видом не совпало.
//
// Разбор стоит на границе, а не в запросе к базе: строка приходит от
// спрашивающего, а сравнение в базе побайтово — идентификатор в верхнем
// регистре не совпал бы ни с одной строкой, и «моя запись пропала» читалось бы
// как отказ разграничения.
func Parse(value string) (string, bool) {
if len(value) != Len {
return "", false
}
out := make([]byte, Len)
for i := range Len {
v := decode[value[i]]
if v < 0 {
return "", false
}
out[i] = alphabet[v]
}
// Старший знак несёт всего два бита времени: запись, у которой он больше
// семёрки, описывает время за пределами разрядности и годным ULID не
// является.
if decode[out[0]] > 7 {
return "", false
}
return string(out), true
}
// Timestamp отдаёт время, зашитое в идентификатор. Нужен проверкам: по нему
// видно, что ключ и колонка времени идут в одну сторону.
func Timestamp(id string) (time.Time, bool) {
parsed, ok := Parse(id)
if !ok {
return time.Time{}, false
}
var ms uint64
for i := range 10 {
ms = ms<<5 | uint64(decode[parsed[i]])
}
return time.UnixMilli(int64(ms)).UTC(), true
}
+101
View File
@@ -0,0 +1,101 @@
package ident
import (
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Идентификатор — ULID в нижнем регистре: постоянная ширина, алфавит Crockford,
// и разбор своего же значения его не меняет.
func TestNewIsCanonical(t *testing.T) {
id := New()
assert.Len(t, id, Len)
assert.Equal(t, strings.ToLower(id), id, "идентификатор выдан не в нижнем регистре")
parsed, ok := Parse(id)
require.True(t, ok, "свой же идентификатор не разобрался")
assert.Equal(t, id, parsed, "разбор изменил канонический вид")
}
// Порядок выдачи и порядок сравнения строк совпадают — в том числе внутри одной
// миллисекунды: колонка времени несёт секунды, и «новые сверху» держит ключ.
func TestNewIsMonotonic(t *testing.T) {
previous := New()
for range 10000 {
next := New()
require.Less(t, previous, next, "идентификатор выдан не по возрастанию")
previous = next
}
}
// Выдача идёт из нескольких потоков разом: два одинаковых ключа означали бы
// отказ вставки на живой базе.
func TestNewIsUniqueUnderConcurrency(t *testing.T) {
const goroutines = 8
const each = 500
var (
mu sync.Mutex
all = map[string]bool{}
)
var wg sync.WaitGroup
for range goroutines {
wg.Add(1)
go func() {
defer wg.Done()
own := make([]string, 0, each)
for range each {
own = append(own, New())
}
mu.Lock()
defer mu.Unlock()
for _, id := range own {
all[id] = true
}
}()
}
wg.Wait()
assert.Len(t, all, goroutines*each, "выданы одинаковые идентификаторы")
}
// Разбор проверяет вид и приводит регистр: значение приходит от спрашивающего, а
// сравнение в базе побайтово.
func TestParse(t *testing.T) {
id := New()
upper, ok := Parse(strings.ToUpper(id))
require.True(t, ok, "верхний регистр не принят: алфавит к нему нечувствителен")
assert.Equal(t, id, upper, "разбор не привёл регистр к каноническому")
for name, value := range map[string]string{
"пусто": "",
"короче": id[:Len-1],
"длиннее": id + "0",
"знак вне алфавита": strings.Repeat("u", Len),
"время за пределом": "8" + id[1:],
"не латиница": strings.Repeat("я", Len),
} {
t.Run(name, func(t *testing.T) {
_, ok := Parse(value)
assert.False(t, ok, "негодное значение принято разбором")
})
}
}
// Время, зашитое в идентификатор, идёт в ту же сторону, что и колонка времени.
func TestTimestampGrowsWithIssue(t *testing.T) {
first, ok := Timestamp(New())
require.True(t, ok)
second, ok := Timestamp(New())
require.True(t, ok)
assert.False(t, second.Before(first), "время в ключе пошло назад")
}
+6 -6
View File
@@ -18,11 +18,11 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
first, err := env.service.CreateJobFromApi(t.Context(), first, err := env.service.CreateJobFromApi(t.Context(),
strings.NewReader("первая"), "one.mp3", newOwner(t, env.app)) strings.NewReader("первая"), "one.mp3", newOwner(t, env))
require.NoError(t, err) require.NoError(t, err)
second, err := env.service.CreateJobFromApi(t.Context(), second, err := env.service.CreateJobFromApi(t.Context(),
strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app)) strings.NewReader("вторая"), "two.mp3", newOwner(t, env))
require.NoError(t, err) require.NoError(t, err)
// Третья — ещё одного владельца: воркер не сужается ни одним из них. // Третья — ещё одного владельца: воркер не сужается ни одним из них.
@@ -52,7 +52,7 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
func TestAcquireReturnsIdentifierAndHolder(t *testing.T) { func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
owner := newOwner(t, env.app) owner := newOwner(t, env)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err) require.NoError(t, err)
@@ -74,7 +74,7 @@ func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
func TestPipelineStepKeepsOwner(t *testing.T) { func TestPipelineStepKeepsOwner(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
owner := newOwner(t, env.app) owner := newOwner(t, env)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err) require.NoError(t, err)
@@ -102,8 +102,8 @@ func TestCreateJobFromApiRequiresOwner(t *testing.T) {
func TestGetByIDHidesForeignRecords(t *testing.T) { func TestGetByIDHidesForeignRecords(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
owner := newOwner(t, env.app) owner := newOwner(t, env)
stranger := newOwner(t, env.app) stranger := newOwner(t, env)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
require.NoError(t, err) require.NoError(t, err)
+85 -85
View File
@@ -12,19 +12,21 @@ import (
"testing" "testing"
"time" "time"
"github.com/google/uuid"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer" "git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "git.vakhrushev.me/av/transcriber/internal/entity"
"git.vakhrushev.me/av/transcriber/internal/ident"
) )
// timeLayout — вид времени в колонках базы. Проверки, двигающие сроки, пишут его
// тем же видом, каким пишет хранилище: сравнение там побайтово.
const timeLayout = "2006-01-02T15:04:05Z"
// Проверки конвейера идут против настоящего хранилища: захват, число отказов и // Проверки конвейера идут против настоящего хранилища: захват, число отказов и
// остановка держатся на запросе, и подставной репозиторий проверял бы // остановка держатся на запросе, и подставной репозиторий проверял бы
// собственную заглушку, а не то, что делает база. // собственную заглушку, а не то, что делает база.
@@ -61,11 +63,13 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
} }
type pipelineEnv struct { type pipelineEnv struct {
app core.App db *sqliterepo.DB
store *sqliterepo.Store
users *sqliterepo.UserRepository
service *TranscribeService service *TranscribeService
repos Repositories repos Repositories
recordRepo *pbrepo.AudioRecordRepository recordRepo *sqliterepo.AudioRecordRepository
fileRepo *pbrepo.FileRepository fileRepo *sqliterepo.FileRepository
} }
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания // testLimits — пределы простоя проверок. Числа боевые; проверка застревания
@@ -98,33 +102,34 @@ func newPipelineEnvWithLogger(
) *pipelineEnv { ) *pipelineEnv {
t.Helper() t.Helper()
app, err := pbrepo.New(t.TempDir()) dir := t.TempDir()
db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { t.Cleanup(func() {
if err := app.ResetBootstrapState(); err != nil { if err := db.Close(); err != nil {
t.Logf("не удалось закрыть хранилище: %v", err) t.Logf("не удалось закрыть базу: %v", err)
} }
}) })
// Правила панели вешаются и здесь: конфигурация под проверкой обязана require.NoError(t, sqliterepo.Migrate(t.Context(), db, dir, slog.New(slog.DiscardHandler)))
// совпадать с боевой, иначе утверждения говорят про прод то, чего в проде
// нет.
pbrepo.BindPanelRules(app)
recordRepo := pbrepo.NewAudioRecordRepository(app) store := sqliterepo.NewStore(dir)
fileRepo := pbrepo.NewFileRepository(app) recordRepo := sqliterepo.NewAudioRecordRepository(db)
fileRepo := sqliterepo.NewFileRepository(db, store)
repos := Repositories{ repos := Repositories{
Records: recordRepo, Records: recordRepo,
Files: fileRepo, Files: fileRepo,
Texts: pbrepo.NewTextRepository(app), Texts: sqliterepo.NewTextRepository(db),
Structures: pbrepo.NewStructureRepository(app), Structures: sqliterepo.NewStructureRepository(db),
Recognitions: pbrepo.NewRecognitionRepository(app), Recognitions: sqliterepo.NewRecognitionRepository(db, store),
Events: pbrepo.NewRecordEventRepository(app), Events: sqliterepo.NewRecordEventRepository(db),
} }
svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger) svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger)
return &pipelineEnv{ return &pipelineEnv{
app: app, db: db,
store: store,
users: sqliterepo.NewUserRepository(db),
service: svc, service: svc,
repos: repos, repos: repos,
recordRepo: recordRepo, recordRepo: recordRepo,
@@ -132,13 +137,22 @@ func newPipelineEnvWithLogger(
} }
} }
// exec выполняет запрос к базе от имени проверки: фикстуры двигают колонки
// напрямую там, где домен такого перехода не делает.
func (e *pipelineEnv) exec(t *testing.T, query string, args ...any) {
t.Helper()
_, err := e.db.Writer().ExecContext(context.Background(), query, args...)
require.NoError(t, err)
}
// newRecord заводит запись — так, как её заводит приём по HTTP: от имени // newRecord заводит запись — так, как её заводит приём по HTTP: от имени
// вошедшего, потому что ничьей записи в хранилище не бывает. // вошедшего, потому что ничьей записи в хранилище не бывает.
func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord { func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
t.Helper() t.Helper()
record, err := env.service.CreateJobFromApi( record, err := env.service.CreateJobFromApi(
t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app)) t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env))
require.NoError(t, err) require.NoError(t, err)
return record return record
} }
@@ -147,10 +161,7 @@ func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
func clearDelay(t *testing.T, env *pipelineEnv, recordID string) { func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) env.exec(t, "UPDATE audio_records SET delay_time = NULL WHERE id = ?", recordID)
require.NoError(t, err)
record.Set("delay_time", "")
require.NoError(t, env.app.Save(record))
} }
// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда // enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
@@ -158,13 +169,8 @@ func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) { func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) env.exec(t, "UPDATE audio_records SET state_entered_at = ? WHERE id = ?",
require.NoError(t, err) moment.UTC().Format(timeLayout), recordID)
record.Set("state_entered_at", types.DateTime{}.Add(0))
stamp, err := types.ParseDateTime(moment)
require.NoError(t, err)
record.Set("state_entered_at", stamp)
require.NoError(t, env.app.Save(record))
} }
// setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал // setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал
@@ -172,10 +178,7 @@ func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time
func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) { func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) env.exec(t, "UPDATE audio_records SET attempts = ? WHERE id = ?", attempts, recordID)
require.NoError(t, err)
record.Set("attempts", attempts)
require.NoError(t, env.app.Save(record))
} }
// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они // drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
@@ -253,10 +256,8 @@ func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) { func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
t.Helper() t.Helper()
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID) env.exec(t, "UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
require.NoError(t, err) clock.Now().Add(-time.Hour).Format(timeLayout), recordID)
record.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
require.NoError(t, env.app.Save(record))
} }
// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием // Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
@@ -508,11 +509,10 @@ func TestEveryHaltReasonRecordsItsCause(t *testing.T) {
events := recordEvents(t, env, record.Id) events := recordEvents(t, env, record.Id)
require.Len(t, events, 1, "остановка оставила строку журнала событий") require.Len(t, events, 1, "остановка оставила строку журнала событий")
outcome := events[0].GetString("outcome")
assert.Contains(t, assert.Contains(t,
[]string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, outcome, []string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, events[0].Outcome,
"строка журнала называет исход остановкой") "строка журнала называет исход остановкой")
assert.NotEmpty(t, events[0].GetString("outcome_text"), "и несёт причину") assert.NotEmpty(t, events[0].OutcomeText, "и несёт причину")
}) })
} }
} }
@@ -536,9 +536,9 @@ func TestRepeatedStoreKeepsSingleText(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "второй разбор", stored.Contents) assert.Equal(t, "второй разбор", stored.Contents)
count, err := env.app.CountRecords(migrations.TextsCollection) var count int
require.NoError(t, err) require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
assert.Equal(t, int64(1), count, "второго комплекта строк не завелось") assert.Equal(t, 1, count, "второго комплекта строк не завелось")
} }
// Пауза растёт с числом отказов и упирается в потолок. // Пауза растёт с числом отказов и упирается в потолок.
@@ -559,19 +559,12 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
// Ссылка переставляется на запись о файле без содержимого: шаг отказывает на // Ссылка переставляется на запись о файле без содержимого: шаг отказывает на
// получении рабочей копии — то есть отказом, а не приговором записи. // получении рабочей копии — то есть отказом, а не приговором записи.
files, err := env.app.FindCollectionByNameOrId(migrations.FilesCollection) emptyID := ident.New()
require.NoError(t, err)
empty := core.NewRecord(files)
empty.Set("location", entity.LocationLocal)
empty.Set("size", 1)
// Владелец обязателен и у файла: схема ничьих не принимает. // Владелец обязателен и у файла: схема ничьих не принимает.
empty.Set("owner", record.OwnerID) env.exec(t, `INSERT INTO files (id, owner_id, record_id, file_name, size_bytes, created_at)
require.NoError(t, env.app.Save(empty)) VALUES (?, ?, ?, ?, ?, ?)`,
emptyID, record.OwnerID, record.Id, "missing.mp3", 1, clock.Now().Format(timeLayout))
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id) env.exec(t, "UPDATE audio_records SET original_file_id = ? WHERE id = ?", emptyID, record.Id)
require.NoError(t, err)
stored.Set("original_file", empty.Id)
require.NoError(t, env.app.Save(stored))
require.Error(t, env.service.RunStep(t.Context())) require.Error(t, env.service.RunStep(t.Context()))
@@ -596,7 +589,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app)) _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env))
require.Error(t, err, "отказ источника метаданных роняет приём") require.Error(t, err, "отказ источника метаданных роняет приём")
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -612,7 +605,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app)) _, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env))
require.NoError(t, err) require.NoError(t, err)
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*")) leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
@@ -666,7 +659,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
content := strings.Repeat("запись ", 1000) content := strings.Repeat("запись ", 1000)
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app)) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, record.OriginalFileID) require.NotNil(t, record.OriginalFileID)
@@ -689,7 +682,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
func TestLocalizeGivesReadableCopy(t *testing.T) { func TestLocalizeGivesReadableCopy(t *testing.T) {
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{}) env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app)) record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env))
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, record.OriginalFileID) require.NotNil(t, record.OriginalFileID)
@@ -707,24 +700,17 @@ func TestLocalizeGivesReadableCopy(t *testing.T) {
// newOwner заводит учётную запись и отдаёт её идентификатор. // newOwner заводит учётную запись и отдаёт её идентификатор.
// //
// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая // Владелец — связь с таблицей учётных записей, и база проверяет, что такая
// запись есть: выдуманный идентификатор запись завести не даст. // строка есть: выдуманный идентификатор запись завести не даст.
func newOwner(t *testing.T, app core.App) string { func newOwner(t *testing.T, env *pipelineEnv) string {
t.Helper() t.Helper()
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection) // Логин у провайдера — ключ учётной записи, и он уникален: две записи с
// одним ключом схема не примет.
account, _, err := env.users.EnsureUser(contract.Identity{Login: ident.New()})
require.NoError(t, err) require.NoError(t, err)
record := core.NewRecord(users) return account.ID
// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
// пустым ключом схема не примет.
record.Set(migrations.ProviderLoginField, uuid.NewString())
record.Set("email", uuid.NewString()+"@example.test")
record.Set("verified", true)
record.Set("password", uuid.NewString())
require.NoError(t, app.Save(record))
return record.Id
} }
// Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в // Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в
@@ -752,7 +738,7 @@ func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) {
events := recordEvents(t, env, record.Id) events := recordEvents(t, env, record.Id)
require.Len(t, events, 1, "одна строка журнала, а не две") require.Len(t, events, 1, "одна строка журнала, а не две")
assert.Equal(t, entity.EventOutcomeFailed, events[0].GetString("outcome"), assert.Equal(t, entity.EventOutcomeFailed, events[0].Outcome,
"исход назван приговором, а не сделанной работой") "исход назван приговором, а не сделанной работой")
} }
@@ -780,18 +766,32 @@ func TestPostponeWritesNoEvent(t *testing.T) {
"пять откладываний не оставили в журнале ни строки") "пять откладываний не оставили в журнале ни строки")
} }
// recordEventRow — строка журнала событий записи, какой её видит проверка.
type recordEventRow struct {
Origin string
Step string
Outcome string
OutcomeText string
}
// recordEvents читает журнал событий одной записи в порядке заведения. // recordEvents читает журнал событий одной записи в порядке заведения.
func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []*core.Record { func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []recordEventRow {
t.Helper() t.Helper()
all, err := env.app.FindAllRecords(migrations.RecordEventsCollection) rows, err := env.db.Reader().QueryContext(context.Background(),
"SELECT origin, step, outcome, outcome_text FROM record_events WHERE record_id = ? ORDER BY id",
recordID,
)
require.NoError(t, err) require.NoError(t, err)
defer func() { require.NoError(t, rows.Close()) }()
var own []*core.Record var own []recordEventRow
for _, event := range all { for rows.Next() {
if event.GetString("record") == recordID { var event recordEventRow
require.NoError(t, rows.Scan(&event.Origin, &event.Step, &event.Outcome, &event.OutcomeText))
own = append(own, event) own = append(own, event)
} }
} require.NoError(t, rows.Err())
return own return own
} }
+15 -13
View File
@@ -11,11 +11,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/google/uuid"
"git.vakhrushev.me/av/transcriber/internal/clock" "git.vakhrushev.me/av/transcriber/internal/clock"
"git.vakhrushev.me/av/transcriber/internal/contract" "git.vakhrushev.me/av/transcriber/internal/contract"
"git.vakhrushev.me/av/transcriber/internal/entity" "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/metrics"
) )
@@ -155,9 +154,12 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader
return nil, contract.ErrOwnerRequired return nil, contract.ErrOwnerRequired
} }
// Идентификатор назначается здесь, до укладки файла: копии записи лежат её
// подкаталогом, названным этим идентификатором, и знать его надо раньше, чем
// класть первую копию.
record := &entity.AudioRecord{ record := &entity.AudioRecord{
Id: ident.New(),
State: entity.StateUploaded, State: entity.StateUploaded,
Source: entity.SourceApi,
OwnerID: ownerID, OwnerID: ownerID,
} }
@@ -176,10 +178,10 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
ext = fmt.Sprintf(".%s", defaultAudioExt) ext = fmt.Sprintf(".%s", defaultAudioExt)
} }
// Собственное имя записи: идентификатор с расширением. Имя, данное // Собственное имя копии: идентификатор с расширением. Имя, данное
// отправителем, в хранилище не попадает — от него взято только расширение. // отправителем, в каталог данных не попадает — от него взято только
fileId := uuid.NewString() // расширение.
storageFileName := fmt.Sprintf("%s%s", fileId, ext) storageFileName := fmt.Sprintf("%s%s", ident.New(), ext)
// Содержимое ложится в рабочую копию потоком: в память запись целиком не // Содержимое ложится в рабочую копию потоком: в память запись целиком не
// читается, расчётный потолок — шесть часов. // читается, расчётный потолок — шесть часов.
@@ -217,7 +219,7 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
DurationMs: int64(info.Seconds) * 1000, DurationMs: int64(info.Seconds) * 1000,
} }
fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, r.OwnerID) fileRecord, err := s.repos.Files.Create(r.Id, storageFileName, work, meta, r.OwnerID)
if err != nil { if err != nil {
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext) s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
return nil, err return nil, err
@@ -474,9 +476,9 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize)) metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg") destFileName := fmt.Sprintf("%s%s", ident.New(), ".ogg")
destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs} destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs}
destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, r.OwnerID) destFileRecord, err := s.repos.Files.Create(r.Id, destFileName, dest, destMeta, r.OwnerID)
if err != nil { if err != nil {
s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id) s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id)
return outcomeDone, err return outcomeDone, err
@@ -780,8 +782,8 @@ func (s *TranscribeService) storeOutcome(r *entity.AudioRecord, outcome *entity.
} }
// finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки // finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки
// ответа отправителю у сервиса нет, и свой исход отправитель узнаёт опросом // ответа отправителю у сервиса нет, и свой исход владелец записи узнаёт её
// готовности. // карточкой.
func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) { func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
r.MoveToState(entity.StateDone) r.MoveToState(entity.StateDone)
if err := s.repos.Records.Save(r, holder); err != nil { if err := s.repos.Records.Save(r, holder); err != nil {
@@ -806,7 +808,7 @@ func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string,
// событий. // событий.
// //
// Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не // Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не
// теряется молча» держится теперь опросом готовности — остановка видна там // теряется молча» держится теперь карточкой записи — остановка видна там
// признаком — и журналом владельца, где у неё стоит причина. // признаком — и журналом владельца, где у неё стоит причина.
// //
// Счётчик растит **сама остановка**, а не воркер, и это не стилистика. // Счётчик растит **сама остановка**, а не воркер, и это не стилистика.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-22

Some files were not shown because too many files have changed in this diff Show More