хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -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, "ничей файл в хранилище не ложится")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package sqlite — хранилище сервиса: база на своей схеме и файлы записей своим
|
||||
// каталогом.
|
||||
//
|
||||
// Пакет назван по драйверу, а не по роли: соседи в `internal/adapter` названы
|
||||
// тем же способом — `converter`, `metaviewer`, `recognizer`, — и «repo/sqlite»
|
||||
// читается как «репозитории поверх SQLite» без знания кода.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
// Драйвер регистрируется загрузкой пакета. CGO ему не нужен — этим он и
|
||||
// выбран: сборка бинарника остаётся без компилятора C.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// driverName — имя, под которым драйвер регистрируется в `database/sql`.
|
||||
const driverName = "sqlite"
|
||||
|
||||
// DatabaseFile — имя файла базы в каталоге данных. Рядом с ним драйвер кладёт
|
||||
// журнал упреждающей записи и его указатель, поэтому каталог данных занят базой
|
||||
// целиком, а не одним файлом.
|
||||
const DatabaseFile = "transcriber.db"
|
||||
|
||||
// Settings — числа, которыми настраивается база. Оба приходят настройкой, а не
|
||||
// константой кода: крутят их при одном и том же отказе — «база занята» под
|
||||
// несколькими воркерами, — и подбор ответа на такой отказ не должен требовать
|
||||
// пересборки образа.
|
||||
type Settings struct {
|
||||
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
|
||||
BusyTimeoutMs int
|
||||
// ReadConnections — сколько соединений держит читающий пул.
|
||||
ReadConnections int
|
||||
}
|
||||
|
||||
// Validate проверяет числа базы. Ноль и отрицательное — опечатка, а не режим:
|
||||
// нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
|
||||
// чтения означает пул без предела, то есть настройку, которой не управляют.
|
||||
func (s Settings) Validate() error {
|
||||
if s.BusyTimeoutMs <= 0 {
|
||||
return errors.New("storage: ожидание занятой базы задаётся положительным числом миллисекунд")
|
||||
}
|
||||
if s.ReadConnections <= 0 {
|
||||
return errors.New("storage: число соединений читающего пула задаётся положительным числом")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Обращения к базе идут с **собственным** контекстом, а не с контекстом
|
||||
// запроса, и это решение, а не недосмотр. Репозитории отменять нечего: операции
|
||||
// местные и короткие, а единственное ожидание — занятая база — задано числом. За
|
||||
// отмену при этом платили бы дважды: шаг, прерванный остановкой сервиса,
|
||||
// перестал бы освобождать захват и писать причину остановки — то есть отмена
|
||||
// ломала бы ровно ту уборку, ради которой она и делается.
|
||||
//
|
||||
// Отмена, которой сервис распоряжается по-настоящему, доходит туда, где она
|
||||
// стоит денег и времени: до `ffmpeg` и до платного распознавания.
|
||||
|
||||
// DB — база сервиса двумя пулами.
|
||||
//
|
||||
// Пишущий пул держит **одно** соединение: драйвер пишет единственным
|
||||
// соединением, и несколько воркеров, пришедших писать разом мимо этого правила,
|
||||
// получают отказ по занятости — на записи результата шага, то есть после
|
||||
// оплаченной работы. Пул с одним соединением обращает их в очередь.
|
||||
//
|
||||
// Читающий пул отдельный: в журнале упреждающей записи читатели не мешают
|
||||
// писателю, и список записей не ждёт, пока конвейер сохранит свой шаг.
|
||||
type DB struct {
|
||||
// writer — единственное пишущее соединение. Через него идёт всякая
|
||||
// операция, которая читает состояние и следом его пишет: транзакцию,
|
||||
// начатую на читающем соединении, SQLite до пишущей не повышает и отвечает
|
||||
// отказом по занятости немедленно — заданное числом ожидание такой отказ не
|
||||
// лечит, ждать там нечего.
|
||||
writer *sql.DB
|
||||
// reader — пул чтения.
|
||||
reader *sql.DB
|
||||
}
|
||||
|
||||
// Writer отдаёт пишущее соединение.
|
||||
func (db *DB) Writer() *sql.DB { return db.writer }
|
||||
|
||||
// Reader отдаёт читающий пул.
|
||||
func (db *DB) Reader() *sql.DB { return db.reader }
|
||||
|
||||
// Open открывает базу в каталоге данных, заводя каталог, если его ещё нет.
|
||||
//
|
||||
// Настройки соединения задаются **строкой подключения обоих пулов**, а не
|
||||
// запросом после открытия. Соблюдение внешних ключей в SQLite — настройка
|
||||
// соединения, а не базы, и по умолчанию она выключена; пул раздаёт соединения и
|
||||
// заводит новые по мере надобности, поэтому запрос, выполненный один раз,
|
||||
// настроил бы одно соединение из многих, а остальные остались бы с умолчанием —
|
||||
// молча.
|
||||
func Open(dataDir string, settings Settings) (*DB, error) {
|
||||
if err := settings.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dataDir, DatabaseFile)
|
||||
|
||||
// Пишущее соединение начинает транзакцию сразу пишущей (`immediate`):
|
||||
// операция, которая читает и следом пишет, иначе взяла бы читающую
|
||||
// транзакцию и упёрлась бы в отказ при первой же записи.
|
||||
writer, err := open(path, settings, "immediate")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writer.SetMaxOpenConns(1)
|
||||
writer.SetMaxIdleConns(1)
|
||||
|
||||
reader, err := open(path, settings, "deferred")
|
||||
if err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("failed to open read pool: %w", err), writer.Close())
|
||||
}
|
||||
reader.SetMaxOpenConns(settings.ReadConnections)
|
||||
reader.SetMaxIdleConns(settings.ReadConnections)
|
||||
|
||||
db := &DB{writer: writer, reader: reader}
|
||||
|
||||
// Пробное обращение делается сразу: `sql.Open` соединения не открывает, и
|
||||
// негодная строка подключения вылезла бы не на старте, а на первом запросе —
|
||||
// то есть отказом каждого запроса вместо одной строки о причине.
|
||||
if err := writer.PingContext(context.Background()); err != nil {
|
||||
return nil, errors.Join(fmt.Errorf("failed to open database: %w", err), db.Close())
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// open заводит один пул с общими настройками соединения.
|
||||
func open(path string, settings Settings, txlock string) (*sql.DB, error) {
|
||||
query := url.Values{}
|
||||
query.Add("_pragma", "busy_timeout("+strconv.Itoa(settings.BusyTimeoutMs)+")")
|
||||
query.Add("_pragma", "journal_mode(WAL)")
|
||||
query.Add("_pragma", "foreign_keys(1)")
|
||||
query.Set("_txlock", txlock)
|
||||
|
||||
db, err := sql.Open(driverName, "file:"+path+"?"+query.Encode())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Close закрывает оба пула. Повторный вызов паники не даёт: закрытие уже
|
||||
// закрытого пула отказом не считается.
|
||||
func (db *DB) Close() error {
|
||||
var errs []error
|
||||
if db.reader != nil {
|
||||
if err := db.reader.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close read pool: %w", err))
|
||||
}
|
||||
db.reader = nil
|
||||
}
|
||||
if db.writer != nil {
|
||||
if err := db.writer.Close(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to close write pool: %w", err))
|
||||
}
|
||||
db.writer = nil
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// testSettings — числа базы под проверками: те же по смыслу, что и умолчания
|
||||
// конфига.
|
||||
func testSettings() Settings {
|
||||
return Settings{BusyTimeoutMs: 5000, ReadConnections: 4}
|
||||
}
|
||||
|
||||
// newTestDB поднимает базу на пустом каталоге и накатывает схему — ровно тем же
|
||||
// путём, каким это делает сервис при старте.
|
||||
func newTestDB(t *testing.T) (*DB, *Store, string) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
db, err := Open(dir, testSettings())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Logf("не удалось закрыть базу: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
|
||||
|
||||
return db, NewStore(dir), dir
|
||||
}
|
||||
|
||||
// newOwner заводит учётную запись и отдаёт её идентификатор.
|
||||
func newOwner(t *testing.T, db *DB) string {
|
||||
t.Helper()
|
||||
|
||||
account, _, err := NewUserRepository(db).EnsureUser(contract.Identity{Login: ident.New()})
|
||||
require.NoError(t, err)
|
||||
|
||||
return account.ID
|
||||
}
|
||||
|
||||
// Настройки соединения задаются строкой подключения **обоих** пулов: соблюдение
|
||||
// внешних ключей в SQLite принадлежит соединению, а не базе, и запрос, сделанный
|
||||
// один раз после открытия, настроил бы одно соединение из многих.
|
||||
func TestSettingsApplyToEveryConnection(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
var mode string
|
||||
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA journal_mode").Scan(&mode))
|
||||
assert.Equal(t, "wal", mode, "журнал упреждающей записи выключен")
|
||||
|
||||
var busy int
|
||||
require.NoError(t, db.Writer().QueryRowContext(context.Background(), "PRAGMA busy_timeout").Scan(&busy))
|
||||
assert.Equal(t, testSettings().BusyTimeoutMs, busy, "ожидание занятой базы осталось умолчанием драйвера")
|
||||
|
||||
// Читающий пул раздаёт соединения по мере надобности, поэтому спрашиваем
|
||||
// **несколько** разом: одно настроенное соединение из четырёх — ровно та
|
||||
// поломка, ради которой настройка уехала в строку подключения.
|
||||
var wg sync.WaitGroup
|
||||
answers := make([]int, testSettings().ReadConnections)
|
||||
start := make(chan struct{})
|
||||
for i := range answers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
conn, err := db.Reader().Conn(context.Background())
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
defer func() { assert.NoError(t, conn.Close()) }()
|
||||
|
||||
assert.NoError(t,
|
||||
conn.QueryRowContext(context.Background(), "PRAGMA foreign_keys").Scan(&answers[i]))
|
||||
// Соединение придерживается, пока спрашивают остальные: иначе пул
|
||||
// раздал бы всем одно и то же и правило проверило бы одну настройку
|
||||
// вместо четырёх.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, answer := range answers {
|
||||
assert.Equal(t, 1, answer, "соединение %d читающего пула не соблюдает внешние ключи", i)
|
||||
}
|
||||
|
||||
// И держатся внешние ключи **на деле**, а не только настройкой: вставка с
|
||||
// несуществующим владельцем отвергается обоими пулами.
|
||||
now := clock.Now().Format(timeLayout)
|
||||
insert := `INSERT INTO audio_records
|
||||
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
|
||||
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`
|
||||
|
||||
_, err := db.Writer().ExecContext(context.Background(), insert,
|
||||
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
|
||||
require.Error(t, err, "пишущее соединение приняло запись с несуществующим владельцем")
|
||||
|
||||
_, err = db.Reader().ExecContext(context.Background(), insert,
|
||||
ident.New(), ident.New(), entity.StateUploaded, now, now, now)
|
||||
require.Error(t, err, "читающее соединение приняло запись с несуществующим владельцем")
|
||||
}
|
||||
|
||||
// Настройки проверяются на старте: ноль и отрицательное — опечатка, а не режим.
|
||||
func TestSettingsAreValidated(t *testing.T) {
|
||||
for name, settings := range map[string]Settings{
|
||||
"нулевое ожидание": {BusyTimeoutMs: 0, ReadConnections: 4},
|
||||
"нулевой пул чтения": {BusyTimeoutMs: 5000, ReadConnections: 0},
|
||||
"отрицательный пул": {BusyTimeoutMs: 5000, ReadConnections: -1},
|
||||
"отрицательный срок": {BusyTimeoutMs: -1, ReadConnections: 4},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Open(t.TempDir(), settings)
|
||||
assert.Error(t, err, "старт на негодном числе прошёл молча")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Повторный запуск на заведённом каталоге схему второй раз не заводит и прежних
|
||||
// записей не теряет.
|
||||
func TestMigrateIsIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(dir, testSettings())
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
|
||||
|
||||
owner := newOwner(t, db)
|
||||
|
||||
require.NoError(t, Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler)))
|
||||
|
||||
var login string
|
||||
require.NoError(t, db.Reader().
|
||||
QueryRowContext(context.Background(),
|
||||
"SELECT provider_login FROM users WHERE id = ?", owner).Scan(&login))
|
||||
assert.NotEmpty(t, login, "повторный накат потерял прежние строки")
|
||||
|
||||
var applied int
|
||||
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM goose_db_version").Scan(&applied))
|
||||
assert.Equal(t, 2, applied, "шаг отмечен дважды: накат не идемпотентен")
|
||||
}
|
||||
|
||||
// Накат держится исключающей блокировкой каталога данных: второй накат ждёт
|
||||
// освобождения, а не применяет шаги параллельно.
|
||||
//
|
||||
// Библиотека шагов под SQLite блокировки не поставляет вовсе — её запиратели
|
||||
// объявлены только для PostgreSQL, — поэтому замок наш, и проверка сторожит
|
||||
// именно его.
|
||||
func TestMigrationLockSerializesRuns(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
inside int
|
||||
overlap bool
|
||||
)
|
||||
|
||||
hold := func() error {
|
||||
mu.Lock()
|
||||
inside++
|
||||
if inside > 1 {
|
||||
overlap = true
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
inside--
|
||||
mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 3 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
assert.NoError(t, withMigrationLock(dir, hold))
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.False(t, overlap, "два наката шли одновременно: замок не держит")
|
||||
}
|
||||
|
||||
// Отказ шага роняет накат и называет шаг: сервис, поднявшийся на неприведённой
|
||||
// схеме, отвечал бы отказом на каждый запрос.
|
||||
func TestMigrateFailsLoudly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := Open(dir, testSettings())
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
// Таблица уже занята чужой строкой: начальный шаг на такой базе не
|
||||
// применяется.
|
||||
_, err = db.Writer().ExecContext(context.Background(), "CREATE TABLE users (id TEXT)")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = Migrate(context.Background(), db, dir, slog.New(slog.DiscardHandler))
|
||||
|
||||
require.Error(t, err, "отказ шага прошёл молча")
|
||||
assert.Contains(t, err.Error(), "202608220002", "отказ не называет шаг")
|
||||
|
||||
// **Шаг и отметка о нём идут одной транзакцией**, поэтому отказавший шаг не
|
||||
// оставляет за собой ни отметки, ни половины схемы. Полуприменённое
|
||||
// состояние — то самое, из-за которого следующий запуск применил бы шаг
|
||||
// второй раз и упал бы на заведённой таблице.
|
||||
var version int
|
||||
err = db.Reader().QueryRowContext(context.Background(),
|
||||
"SELECT COUNT(*) FROM goose_db_version WHERE version_id = 202608220002").Scan(&version)
|
||||
if err == nil {
|
||||
assert.Equal(t, 0, version, "отказавший шаг отмечен применённым")
|
||||
}
|
||||
|
||||
var tables int
|
||||
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'audio_records'").Scan(&tables))
|
||||
assert.Equal(t, 0, tables, "отказавший шаг оставил за собой половину схемы")
|
||||
}
|
||||
|
||||
// Все колонки времени объявлены одним типом и без умолчания: умолчание схемы
|
||||
// писало бы свой вид времени, а вставка, забывшая проставить время, при нём
|
||||
// прошла бы молча.
|
||||
func TestSchemaHasOneTimeShapeWithoutDefaults(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
tables := []string{
|
||||
"users", "files", "topics", "audio_records",
|
||||
"texts", "structures", "recognitions", "record_events",
|
||||
}
|
||||
|
||||
seen := 0
|
||||
for _, table := range tables {
|
||||
rows, err := db.Reader().QueryContext(context.Background(),
|
||||
"SELECT name, type, dflt_value FROM pragma_table_info(?)", table)
|
||||
require.NoError(t, err)
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
name string
|
||||
columnType string
|
||||
dflt any
|
||||
)
|
||||
require.NoError(t, rows.Scan(&name, &columnType, &dflt))
|
||||
|
||||
if !isTimeColumn(name) {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
assert.Equal(t, "TEXT", columnType, "колонка %s.%s несёт время не текстом", table, name)
|
||||
assert.Nil(t, dflt, "у колонки %s.%s есть умолчание времени", table, name)
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
closeRows(t, rows)
|
||||
}
|
||||
|
||||
require.Positive(t, seen, "колонок времени не найдено: правило потеряло предмет")
|
||||
}
|
||||
|
||||
// closeRows закрывает выборку. Отдельной функцией, потому что закрывается она в
|
||||
// цикле по таблицам: отложенное закрытие копилось бы до конца проверки.
|
||||
func closeRows(t *testing.T, rows *sql.Rows) {
|
||||
t.Helper()
|
||||
require.NoError(t, rows.Close())
|
||||
}
|
||||
|
||||
func isTimeColumn(name string) bool {
|
||||
return strings.HasSuffix(name, "_at") || name == "delay_time"
|
||||
}
|
||||
|
||||
// Строка, заведённая приёмом, и строка, заведённая запросом к базе, попадают в
|
||||
// отбор захвата одинаково: вид времени в схеме один.
|
||||
func TestHandwrittenRecordIsAcquiredToo(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
byService := &entity.AudioRecord{
|
||||
Id: ident.New(),
|
||||
OwnerID: owner,
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
}
|
||||
require.NoError(t, records.Create(byService))
|
||||
|
||||
byHand := ident.New()
|
||||
now := clock.Now().Format(timeLayout)
|
||||
_, err := db.Writer().ExecContext(context.Background(),
|
||||
`INSERT INTO audio_records
|
||||
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
|
||||
VALUES (?, ?, 0, 0, ?, ?, ?, ?)`,
|
||||
byHand, owner, entity.StateUploaded, now, now, now,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
acquired := map[string]bool{}
|
||||
for range 2 {
|
||||
got, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
acquired[got.ID] = true
|
||||
}
|
||||
|
||||
assert.True(t, acquired[byService.Id], "запись приёма захвату не досталась")
|
||||
assert.True(t, acquired[byHand], "запись, заведённая запросом к базе, захвату не досталась")
|
||||
}
|
||||
|
||||
// Горячие выборки опираются на индекс: полного сканирования таблицы аудиозаписей
|
||||
// не показывает ни отбор захвата, ни список, сужаемый владельцем и страницей.
|
||||
func TestHotQueriesUseIndexes(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
acquire := explain(t, db, `
|
||||
SELECT id FROM audio_records
|
||||
WHERE state IN (?, ?)
|
||||
AND halted_at IS NULL
|
||||
AND (delay_time IS NULL OR delay_time < ?)
|
||||
AND (acquisition_id IS NULL OR acquire_expires_at IS NULL OR acquire_expires_at < ?)
|
||||
ORDER BY created_at, id
|
||||
LIMIT 1`,
|
||||
entity.StateUploaded, entity.StateNormalized, "now", "now")
|
||||
|
||||
list := explain(t, db, `
|
||||
SELECT id FROM audio_records
|
||||
WHERE owner_id = ?
|
||||
AND (created_at < ? OR (created_at = ? AND id < ?))
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 31`,
|
||||
"owner", "now", "now", "id")
|
||||
|
||||
for name, plan := range map[string]string{"отбор захвата": acquire, "список": list} {
|
||||
assert.NotContains(t, plan, "SCAN audio_records",
|
||||
"%s идёт полным сканированием таблицы аудиозаписей: %s", name, plan)
|
||||
assert.Contains(t, plan, "USING", "%s не опирается на индекс: %s", name, plan)
|
||||
assert.Contains(t, plan, "INDEX", "%s не опирается на индекс: %s", name, plan)
|
||||
}
|
||||
}
|
||||
|
||||
func explain(t *testing.T, db *DB, query string, args ...any) string {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.Reader().QueryContext(context.Background(), "EXPLAIN QUERY PLAN "+query, args...)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, rows.Close()) }()
|
||||
|
||||
var plan strings.Builder
|
||||
for rows.Next() {
|
||||
var id, parent, notUsed int
|
||||
var detail string
|
||||
require.NoError(t, rows.Scan(&id, &parent, ¬Used, &detail))
|
||||
plan.WriteString(detail)
|
||||
plan.WriteString("; ")
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
|
||||
return plan.String()
|
||||
}
|
||||
|
||||
// Мягкая остановка закрывает то же, что открыл подъём, и повторная остановка не
|
||||
// даёт паники.
|
||||
func TestCloseIsIdempotent(t *testing.T) {
|
||||
db, err := Open(t.TempDir(), testSettings())
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.Close())
|
||||
assert.NoError(t, db.Close(), "повторное закрытие отказало")
|
||||
}
|
||||
|
||||
// Укладка атомарна: источник, отдавший отказ на середине потока, не оставляет ни
|
||||
// файла под рабочим именем, ни временного имени в подкаталоге записи.
|
||||
func TestStorePutIsAtomic(t *testing.T) {
|
||||
_, store, dir := newTestDB(t)
|
||||
|
||||
recordID := ident.New()
|
||||
|
||||
_, err := store.Put(recordID, "voice.mp3", &brokenReader{})
|
||||
require.Error(t, err, "отказ источника прошёл молча")
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, recordsDir, recordID, "voice.mp3"))
|
||||
assert.True(t, os.IsNotExist(err), "рабочее имя появилось при оборванном потоке")
|
||||
|
||||
temporary, err := store.HasTemporary(recordID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, temporary, "временное имя осталось в подкаталоге записи")
|
||||
}
|
||||
|
||||
// brokenReader отдаёт часть потока и обрывается — так выглядит отправитель,
|
||||
// закрывший соединение на середине.
|
||||
type brokenReader struct {
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (r *brokenReader) Read(p []byte) (int, error) {
|
||||
if !r.sent {
|
||||
r.sent = true
|
||||
copy(p, strings.Repeat("a", min(len(p), 64)))
|
||||
return min(len(p), 64), nil
|
||||
}
|
||||
return 0, errors.New("источник оборвался")
|
||||
}
|
||||
|
||||
// Отказ укладки несёт причину и не несёт пути.
|
||||
//
|
||||
// Обе половины — одно требование, и порознь они друг друга отменяют. Причина
|
||||
// нужна владельцу: исчерпание места, отсутствие прав и негодная раскладка
|
||||
// каталога требуют трёх разных действий, а отказ укладки — единственная
|
||||
// поверхность, на которой он их видит. Путь не нужен: он ведёт внутрь каталога
|
||||
// данных, а отказ кончается в журнале, откуда строку потом не убрать.
|
||||
func TestStoreFailureCarriesCauseWithoutPath(t *testing.T) {
|
||||
_, store, dir := newTestDB(t)
|
||||
|
||||
// noPath судит вторую половину: ни каталога данных, ни временной приставки
|
||||
// в цепочке отказа быть не должно.
|
||||
noPath := func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
require.Error(t, err)
|
||||
assert.NotContains(t, err.Error(), dir, "путь внутри каталога данных уехал в отказ")
|
||||
assert.NotContains(t, err.Error(), tempPrefix, "временное имя укладки уехало в отказ")
|
||||
}
|
||||
|
||||
t.Run("места на диске нет", func(t *testing.T) {
|
||||
recordID := ident.New()
|
||||
_, err := store.Put(recordID, "voice.mp3", &diskFullReader{
|
||||
path: filepath.Join(dir, recordsDir, recordID, tempPrefix+"whatever"),
|
||||
})
|
||||
noPath(t, err)
|
||||
assert.ErrorIs(t, err, syscall.ENOSPC, "причина отказа отброшена: место на диске неотличимо от прочего")
|
||||
})
|
||||
|
||||
t.Run("прав на подкаталог записи нет", func(t *testing.T) {
|
||||
recordID := ident.New()
|
||||
recordDir := filepath.Join(dir, recordsDir, recordID)
|
||||
require.NoError(t, os.MkdirAll(recordDir, 0o750))
|
||||
require.NoError(t, os.Chmod(recordDir, 0o500))
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chmod(recordDir, 0o750); err != nil {
|
||||
t.Logf("не удалось вернуть права подкаталогу записи: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
|
||||
noPath(t, err)
|
||||
assert.ErrorIs(t, err, fs.ErrPermission, "причина отказа отброшена: отсутствие прав неотличимо от прочего")
|
||||
})
|
||||
|
||||
t.Run("подкаталогом записи занято не то", func(t *testing.T) {
|
||||
recordID := ident.New()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, recordsDir), 0o750))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, recordsDir, recordID), []byte("не каталог"), 0o600))
|
||||
|
||||
_, err := store.Put(recordID, "voice.mp3", strings.NewReader("данные"))
|
||||
noPath(t, err)
|
||||
assert.ErrorIs(t, err, syscall.ENOTDIR, "причина отказа отброшена: негодная раскладка неотличима от прочего")
|
||||
})
|
||||
|
||||
t.Run("копии нет", func(t *testing.T) {
|
||||
_, err := store.Open(ident.New(), "voice.mp3")
|
||||
noPath(t, err)
|
||||
assert.ErrorIs(t, err, fs.ErrNotExist, "причина отказа отброшена: «файла нет» неотличимо от прочего")
|
||||
})
|
||||
}
|
||||
|
||||
// diskFullReader отказывает так, как отказывает диск: причина приходит обёрткой
|
||||
// пакета `os`, и путь лежит в ней. Настоящим источником укладки служит `*os.File`
|
||||
// рабочей копии, и его отказ приходит ровно этой формой.
|
||||
type diskFullReader struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (r *diskFullReader) Read([]byte) (int, error) {
|
||||
return 0, &os.PathError{Op: "write", Path: r.path, Err: syscall.ENOSPC}
|
||||
}
|
||||
|
||||
// Копии одной записи лежат вместе — под её идентификатором, — и второго места,
|
||||
// где лежит что-то из них, нет.
|
||||
func TestCopiesOfRecordLiveTogether(t *testing.T) {
|
||||
db, store, dir := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
files := NewFileRepository(db, store)
|
||||
recordID := ident.New()
|
||||
|
||||
for _, name := range []string{"original.mp3", "normalized.ogg"} {
|
||||
work, err := files.Stage(filepath.Ext(name), strings.NewReader("содержимое "+name))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = files.Create(recordID, name, work, contract.FileMeta{Format: "mp3"}, owner)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, work.Close())
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(dir, recordsDir, recordID))
|
||||
require.NoError(t, err)
|
||||
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"original.mp3", "normalized.ogg"}, names)
|
||||
|
||||
records, err := os.ReadDir(filepath.Join(dir, recordsDir))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, records, 1, "второго места для копий записи не появляется")
|
||||
}
|
||||
|
||||
// Содержимое читается потоком с перемоткой: отдача по диапазону берёт кусок, а
|
||||
// не файл целиком.
|
||||
func TestOpenGivesSeekableStream(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
files := NewFileRepository(db, store)
|
||||
recordID := ident.New()
|
||||
|
||||
work, err := files.Stage(".mp3", strings.NewReader("0123456789"))
|
||||
require.NoError(t, err)
|
||||
file, err := files.Create(recordID, "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, work.Close())
|
||||
|
||||
reader, err := files.Open(file.Id)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, reader.Close()) }()
|
||||
|
||||
_, err = reader.Seek(4, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
|
||||
slice := make([]byte, 3)
|
||||
_, err = io.ReadFull(reader, slice)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "456", string(slice))
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// workFile — рабочая копия файла на диске. Живёт во временном каталоге системы,
|
||||
// а не в каталоге данных: последний смонтирован на сервере, и временному там не
|
||||
// место.
|
||||
type workFile struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (w *workFile) Path() string { return w.path }
|
||||
|
||||
func (w *workFile) Size() (int64, error) {
|
||||
info, err := os.Stat(w.path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to stat work file: %w", err)
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// Close убирает копию. Отсутствие файла отказом не считается: шаг мог не дойти
|
||||
// до его создания, и повторный Close тоже законен.
|
||||
func (w *workFile) Close() error {
|
||||
if err := os.Remove(w.path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove work file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileRepository — копии записей: строка в базе и содержимое в каталоге данных.
|
||||
type FileRepository struct {
|
||||
db *DB
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewFileRepository(db *DB, store *Store) *FileRepository {
|
||||
return &FileRepository{db: db, store: store}
|
||||
}
|
||||
|
||||
// newWorkFile заводит пустую копию во временном каталоге. Расширение сохраняется
|
||||
// в имени: `ffprobe` и `ffmpeg` по нему выбирают разбор.
|
||||
func newWorkFile(ext string) (*workFile, error) {
|
||||
f, err := os.CreateTemp("", "transcriber-*"+ext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create work file: %w", err)
|
||||
}
|
||||
path := f.Name()
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return nil, fmt.Errorf("failed to close work file: %w", err)
|
||||
}
|
||||
return &workFile{path: path}, nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) StageEmpty(ext string) (contract.WorkFile, error) {
|
||||
return newWorkFile(ext)
|
||||
}
|
||||
|
||||
func (repo *FileRepository) Stage(ext string, content io.Reader) (contract.WorkFile, error) {
|
||||
work, err := newWorkFile(ext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := writeTo(work.path, content); err != nil {
|
||||
// Отказ уборки не подменяет отказ записи, но и не теряется.
|
||||
return nil, errors.Join(err, work.Close())
|
||||
}
|
||||
|
||||
return work, nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) Localize(fileID string) (contract.WorkFile, error) {
|
||||
file, err := repo.GetByID(fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
work, err := newWorkFile(filepath.Ext(file.FileName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
src, err := repo.store.Open(file.RecordID, file.FileName)
|
||||
if err != nil {
|
||||
return nil, errors.Join(err, work.Close())
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
if err := writeTo(work.path, src); err != nil {
|
||||
return nil, errors.Join(err, work.Close())
|
||||
}
|
||||
|
||||
return work, nil
|
||||
}
|
||||
|
||||
// Create кладёт рабочую копию в каталог данных и заводит строку о файле.
|
||||
//
|
||||
// Порядок один: строка заводится **после** того, как содержимое лежит целиком
|
||||
// под рабочим именем. Обратный порядок оставлял бы в базе строку, указывающую на
|
||||
// файл, которого ещё нет или который короче принятого.
|
||||
//
|
||||
// Отсюда и уборка: содержимое легло, а строка не сохранилась — уложенный файл
|
||||
// убирается, и следа от него не остаётся. Файл, переживший свою строку, —
|
||||
// штатное состояние только у приведённой копии, которую заводит шаг конвейера; у
|
||||
// принятой это мусор, на который не ссылается ничто и о котором узнать неоткуда.
|
||||
//
|
||||
// Владелец обязателен и лежит своей колонкой: пустой отвергает схема — колонка
|
||||
// объявлена связью с учётной записью, и пустое значение ей не отвечает.
|
||||
func (repo *FileRepository) Create(
|
||||
recordID, name string,
|
||||
work contract.WorkFile,
|
||||
meta contract.FileMeta,
|
||||
ownerID string,
|
||||
) (*entity.File, error) {
|
||||
source, err := os.Open(work.Path())
|
||||
if err != nil {
|
||||
// Причина сохраняется, путь снимается: он ведёт к рабочей копии чужого
|
||||
// аудио, а отказ кончается в журнале.
|
||||
return nil, fmt.Errorf("failed to read work file: %w", causeOf(err))
|
||||
}
|
||||
|
||||
size, putErr := repo.store.Put(recordID, name, source)
|
||||
closeErr := source.Close()
|
||||
if err := errors.Join(putErr, closeErr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file := &entity.File{
|
||||
Id: ident.New(),
|
||||
RecordID: recordID,
|
||||
FileName: name,
|
||||
Size: size,
|
||||
Format: meta.Format,
|
||||
DurationMs: meta.DurationMs,
|
||||
CreatedAt: clock.Now(),
|
||||
}
|
||||
|
||||
query, args := insertSQL("files", map[string]any{
|
||||
"id": file.Id,
|
||||
"owner_id": ownerID,
|
||||
"record_id": file.RecordID,
|
||||
"file_name": file.FileName,
|
||||
"size_bytes": file.Size,
|
||||
"format": file.Format,
|
||||
"duration_ms": file.DurationMs,
|
||||
"created_at": formatTime(file.CreatedAt),
|
||||
})
|
||||
|
||||
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
|
||||
// Уложенное содержимое убирается: строки о нём не будет, и ссылаться на
|
||||
// него нечему. Имя файла в отказ не идёт — оно часть пути к чужому аудио.
|
||||
return nil, errors.Join(
|
||||
fmt.Errorf("failed to store the file row of record %s: %w", recordID, err),
|
||||
repo.store.Remove(recordID, name),
|
||||
)
|
||||
}
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (repo *FileRepository) GetByID(id string) (*entity.File, error) {
|
||||
file := &entity.File{}
|
||||
var (
|
||||
createdAt string
|
||||
recordID string
|
||||
fileName string
|
||||
size int64
|
||||
format string
|
||||
durationMs int64
|
||||
)
|
||||
|
||||
err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||
`SELECT record_id, file_name, size_bytes, format, duration_ms, created_at
|
||||
FROM files WHERE id = ?`, id,
|
||||
).Scan(&recordID, &fileName, &size, &format, &durationMs, &createdAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("file %s is not found", id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get file %s: %w", id, err)
|
||||
}
|
||||
|
||||
file.Id = id
|
||||
file.RecordID = recordID
|
||||
file.FileName = fileName
|
||||
file.Size = size
|
||||
file.Format = format
|
||||
file.DurationMs = durationMs
|
||||
file.CreatedAt = requiredTimeOf(createdAt)
|
||||
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// Open отдаёт содержимое хранимой копии потоком с перемоткой: отдача по
|
||||
// диапазону читает кусок, а не файл целиком.
|
||||
func (repo *FileRepository) Open(fileID string) (io.ReadSeekCloser, error) {
|
||||
file, err := repo.GetByID(fileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repo.store.Open(file.RecordID, file.FileName)
|
||||
}
|
||||
|
||||
// writeTo переливает содержимое в файл потоком. В память запись целиком не
|
||||
// читается: расчётный потолок — шесть часов.
|
||||
func writeTo(path string, content io.Reader) error {
|
||||
dst, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open work file: %w", err)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(dst, content); err != nil {
|
||||
_ = dst.Close()
|
||||
return fmt.Errorf("failed to write work file: %w", err)
|
||||
}
|
||||
|
||||
if err := dst.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close work file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// UserRepository — учётные записи сервиса.
|
||||
type UserRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
// EnsureUser находит учётную запись по логину у провайдера, а не найдя — заводит
|
||||
// её.
|
||||
//
|
||||
// **Дом правила один, и он здесь, а не в транспорте.** Второй способ
|
||||
// представиться — личные токены — возьмёт этот же метод; правило, уложенное
|
||||
// куском в слой транспорта, пришлось бы тогда либо дублировать вторым куском,
|
||||
// либо вытаскивать задним числом.
|
||||
//
|
||||
// Найденную запись метод **не переписывает**. Иначе всякий запрос был бы записью
|
||||
// в базу, а правка имени у провайдера меняла бы карточку человека молча, посреди
|
||||
// его работы.
|
||||
//
|
||||
// **Поиск идёт читающим пулом, и пишущая транзакция открывается только тогда,
|
||||
// когда запись не нашлась.** Узнавание одето на весь корень приложения, поэтому
|
||||
// пишущая транзакция, взятая до поиска, доставалась бы всему узнанному потоку —
|
||||
// опросу карточки и каждому запросу диапазона при проигрывании, — и вставала бы
|
||||
// в очередь к единственному пишущему соединению. Ждать там нечего: заводится
|
||||
// учётная запись один раз за жизнь человека.
|
||||
//
|
||||
// **Окно между двумя соединениями закрыто повторным поиском внутри
|
||||
// транзакции.** Между поиском читающим пулом и открытием пишущей транзакции
|
||||
// запись успевает завести сосед; ветвь ниже находит её и берёт заведённую, а
|
||||
// уникальность ключа держит схема — не порядок обращений.
|
||||
func (repo *UserRepository) EnsureUser(identity contract.Identity) (*contract.UserAccount, bool, error) {
|
||||
login, ok := entity.AcceptProviderLogin(identity.Login)
|
||||
if !ok {
|
||||
return nil, false, contract.ErrLoginNotAcceptable
|
||||
}
|
||||
|
||||
account, err := findUserByLogin(repo.db.Reader(), login)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if account != nil {
|
||||
return account, false, nil
|
||||
}
|
||||
|
||||
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to open a transaction for the user account: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Повторный поиск закрывает окно между читающим пулом и пишущей
|
||||
// транзакцией: пока её ждали, запись мог завести сосед.
|
||||
account, err = findUserByLogin(tx, login)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if account != nil {
|
||||
return account, false, commitAccount(tx, account)
|
||||
}
|
||||
|
||||
name := entity.AcceptDisplayName(identity.Name)
|
||||
email, _ := entity.AcceptEmail(identity.Email)
|
||||
|
||||
account, err = insertUser(tx, login, name, email)
|
||||
switch {
|
||||
case err == nil:
|
||||
return account, true, commitAccount(tx, account)
|
||||
case !isUniqueViolation(err):
|
||||
return nil, false, fmt.Errorf("failed to create user account: %w", err)
|
||||
}
|
||||
|
||||
// **Два отказа уникальности различаются, и исход у них разный**, а какая
|
||||
// колонка не сошлась, код отказа не называет. Различает их повторный поиск
|
||||
// по ключу: нашёлся — это гонка двух первых обращений одним логином, и надо
|
||||
// просто взять заведённую соседом запись.
|
||||
account, err = findUserByLogin(tx, login)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if account != nil {
|
||||
return account, false, commitAccount(tx, account)
|
||||
}
|
||||
|
||||
// Не нашёлся — значит не сошлась другая колонка: адрес почты, пришедший от
|
||||
// провайдера, занят другой учётной записью (общий ящик, семья, группа).
|
||||
// Запись заводится **без почты**: она необязательна и ключом не служит. Без
|
||||
// этого разреза второй человек с общим адресом не завёлся бы никогда —
|
||||
// повторный поиск по логину снова ничего не находит.
|
||||
account, err = insertUser(tx, login, name, "")
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to create user account without email: %w", err)
|
||||
}
|
||||
|
||||
return account, true, commitAccount(tx, account)
|
||||
}
|
||||
|
||||
func commitAccount(tx *sql.Tx, account *contract.UserAccount) error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("failed to commit the user account %s: %w", account.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertUser(tx *sql.Tx, login, name, email string) (*contract.UserAccount, error) {
|
||||
id := ident.New()
|
||||
now := formatTime(clock.Now())
|
||||
|
||||
_, err := tx.ExecContext(context.Background(),
|
||||
`INSERT INTO users (id, provider_login, name, email, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
id, login, name, email, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &contract.UserAccount{ID: id, Name: name}, nil
|
||||
}
|
||||
|
||||
// rowQuerier — то общее, чем поиск учётной записи пользуется у читающего пула и
|
||||
// у пишущей транзакции. Оба поиска — до транзакции и внутри неё — идут одним
|
||||
// запросом: второй его копией они разошлись бы молча.
|
||||
type rowQuerier interface {
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// findUserByLogin ищет учётную запись по ключу. Значение уходит базе
|
||||
// **параметром** запроса, а не подстановкой в текст: строка приходит снаружи, и
|
||||
// подставленная в текст она правила бы сам запрос, а не только его аргумент.
|
||||
func findUserByLogin(q rowQuerier, login string) (*contract.UserAccount, error) {
|
||||
account := &contract.UserAccount{}
|
||||
err := q.QueryRowContext(context.Background(),
|
||||
"SELECT id, name FROM users WHERE provider_login = ?", login,
|
||||
).Scan(&account.ID, &account.Name)
|
||||
switch {
|
||||
case err == nil:
|
||||
return account, nil
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("failed to look up user account: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite/migrations"
|
||||
)
|
||||
|
||||
// migrationLockFile — файл, на котором берётся замок наката. Лежит в каталоге
|
||||
// данных рядом с базой: замок принадлежит каталогу, а не машине.
|
||||
const migrationLockFile = "migrate.lock"
|
||||
|
||||
// Migrate приводит схему к последнему шагу.
|
||||
//
|
||||
// # Порядок
|
||||
//
|
||||
// Накат идёт **до подъёма входов и до старта воркеров**, а его отказ роняет
|
||||
// старт. Сервис, поднявшийся на неприведённой схеме, отвечает отказом на каждый
|
||||
// запрос и на каждый прогон воркера — вместо одной строки о причине их
|
||||
// становятся сотни, и первопричина в них теряется.
|
||||
//
|
||||
// # Чем держится неделимость
|
||||
//
|
||||
// Шаг и отметка о нём идут одной транзакцией: библиотека открывает её на том же
|
||||
// соединении и внутри выполняет и сам шаг, и вставку версии в таблицу учёта.
|
||||
// Отменяет это только пометка `NO TRANSACTION` у самого шага, и мы её не ставим.
|
||||
//
|
||||
// Порядок шагов детерминирован и выводится из версии шага, а не из порядка
|
||||
// чтения каталога: собранные шаги сортируются по версии, а две одинаковых версии
|
||||
// дают отказ сбора, а не молчаливый выбор одного.
|
||||
//
|
||||
// # Почему замок наш
|
||||
//
|
||||
// Исключающей блокировки наката библиотека под SQLite не даёт вовсе: её
|
||||
// запиратели объявлены только для PostgreSQL, а провайдер без запирателя
|
||||
// накатывает без всякой блокировки. Замок поэтому берём сами — на файле в
|
||||
// каталоге данных. С умершим процессом его снимает ядро, поэтому просроченного
|
||||
// замка, который надо чистить руками, не остаётся.
|
||||
//
|
||||
// Накат идёт по **пишущему** соединению: он читает таблицу учёта и следом в неё
|
||||
// пишет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
|
||||
// повышает.
|
||||
func Migrate(ctx context.Context, db *DB, dataDir string, logger *slog.Logger) error {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
provider, err := goose.NewProvider(
|
||||
goose.DialectSQLite3,
|
||||
db.Writer(),
|
||||
nil,
|
||||
goose.WithGoMigrations(migrations.All()...),
|
||||
// Глобальный список библиотеки не читается: перечень шагов приходит
|
||||
// доводом, и два провайдера в одном процессе за общее состояние не
|
||||
// спорят.
|
||||
goose.WithDisableGlobalRegistry(true),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare schema migrations: %w", err)
|
||||
}
|
||||
|
||||
return withMigrationLock(dataDir, func() error {
|
||||
results, err := provider.Up(ctx)
|
||||
if err != nil {
|
||||
// Отказ называет шаг: библиотека кладёт версию в текст отказа, и
|
||||
// владелец сервиса по ней находит файл шага.
|
||||
return fmt.Errorf("failed to apply schema migration: %w", err)
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
logger.Info("Schema migration applied",
|
||||
"migration_version", result.Source.Version,
|
||||
"duration_ms", result.Duration.Milliseconds())
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// withMigrationLock берёт исключающий замок каталога данных на всё время наката.
|
||||
//
|
||||
// Замок блокирующий: второй процесс, поднятый на том же каталоге, ждёт его
|
||||
// освобождения, а не применяет шаги параллельно. Два наката, разошедшихся на
|
||||
// одном шаге, оставили бы схему в состоянии, которого не описывает ни один шаг.
|
||||
func withMigrationLock(dataDir string, run func() error) error {
|
||||
path := filepath.Join(dataDir, migrationLockFile)
|
||||
|
||||
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open migration lock: %w", err)
|
||||
}
|
||||
// Замок снимается **закрытием дескриптора**, и отдельного снятия не нужно:
|
||||
// он принадлежит открытому файлу, а не процессу. С умершим процессом его
|
||||
// снимает ядро тем же движением — просроченного замка, который надо чистить
|
||||
// руками, не остаётся.
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil {
|
||||
return fmt.Errorf("failed to lock the data directory for migration: %w", err)
|
||||
}
|
||||
|
||||
return run()
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// up202608220002 заводит схему сервиса целиком.
|
||||
//
|
||||
// Шаг один, и он начальный: прежние шаги встроенного хранилища удалены вместе с
|
||||
// ним — разовое снятие инварианта «применённая миграция не переписывается»
|
||||
// решением владельца от 2026-08-22. Причина названа прямо: стадия проекта —
|
||||
// стройка, на сервере данных нет, сервис остановлен, а новая база ведёт учёт
|
||||
// применённого своей таблицей, которой отметки прежнего каталога не годятся
|
||||
// вовсе. Снятие кончается этим шагом: уехав на сервер, он подпадает под
|
||||
// инвариант как всякий прежний.
|
||||
//
|
||||
// Порядок заведения задан связями: сперва учётные записи, потом всё, что на них
|
||||
// ссылается, и только потом обратные ссылки записи на её приложения.
|
||||
//
|
||||
// **Времени умолчанием схема не ставит.** Вид времени один на все колонки —
|
||||
// `TEXT` в RFC 3339, UTC, секундная точность, — и ставит его приложение единой
|
||||
// точкой. `CURRENT_TIMESTAMP` писал бы свой вид, отличный от объявленного, а
|
||||
// вставка, забывшая проставить время, при умолчании прошла бы молча.
|
||||
//
|
||||
// **Перечни значений держит код, а не схема.** Прежде рубеж, причина остановки
|
||||
// и вид текста были закрыты схемой, потому что панель владельца правила запись
|
||||
// руками и вправе была завести значение, которого сервис не знает. Панели нет,
|
||||
// правка идёт только нашим кодом, и `CHECK` остался бы ценой — новое значение
|
||||
// стоило бы нового шага схемы — без покупателя.
|
||||
func up202608220002(ctx context.Context, tx *sql.Tx) error {
|
||||
for _, statement := range initStatements() {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("failed to apply initial schema: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// down202608220002 сносит схему целиком. Порядок обратный порядку заведения:
|
||||
// приложения ссылаются на запись, запись — на учётную запись.
|
||||
func down202608220002(ctx context.Context, tx *sql.Tx) error {
|
||||
tables := []string{
|
||||
"record_events",
|
||||
"recognitions",
|
||||
"structures",
|
||||
"texts",
|
||||
"record_topics",
|
||||
"audio_records",
|
||||
"topics",
|
||||
"files",
|
||||
"users",
|
||||
}
|
||||
for _, table := range tables {
|
||||
if _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table); err != nil {
|
||||
return fmt.Errorf("failed to drop %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initStatements — шаг по одному оператору на элемент.
|
||||
//
|
||||
// Россыпью, а не одной строкой с разделителями: тело триггера само несёт точку с
|
||||
// запятой, и разбиение общей строки резало бы его пополам.
|
||||
func initStatements() []string {
|
||||
return []string{
|
||||
// Учётная запись. Ключ — логин у провайдера: его приносит заголовок
|
||||
// доверенного источника, и по нему запись находится при каждом
|
||||
// обращении. Адрес почты необязателен и ключом не служит — он меняется,
|
||||
// и первое обращение с чужим адресом досталось бы чужой записи.
|
||||
`CREATE TABLE users (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
provider_login TEXT NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX idx_users_provider_login ON users (provider_login)`,
|
||||
// Уникальность почты частичная: пустая почта законна и не спорит с
|
||||
// другой пустой. Индекс нужен затем, чтобы занятый адрес отвергался
|
||||
// схемой — по этому отказу заведение переходит на ветвь «запись без
|
||||
// почты», а не отдаёт чужую учётную запись.
|
||||
`CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email <> ''`,
|
||||
|
||||
// Копия записи на диске. Владелец лежит своей колонкой, а не выводится
|
||||
// через запись: файл переживает свою запись — шаг заводит его до
|
||||
// сохранения, — и заведённый до неё остаётся с владельцем и без ссылки.
|
||||
//
|
||||
// Ссылки на аудиозапись внешним ключом нет намеренно, и `record_id`
|
||||
// здесь — имя подкаталога, где копия лежит. Приём заводит файл **до**
|
||||
// самой записи, и обязательная связь отвергала бы первую же принятую
|
||||
// запись.
|
||||
`CREATE TABLE files (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users (id),
|
||||
record_id TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
format TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX idx_files_owner ON files (owner_id)`,
|
||||
|
||||
// Словарь тем. Своя таблица, а не набор строк в записи: перечень тем
|
||||
// человека нужен целиком перед каждым обращением к модели, а собрать его
|
||||
// из наборов строк можно только перебором всех его записей.
|
||||
`CREATE TABLE topics (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users (id),
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX idx_topics_owner_name ON topics (owner_id, name)`,
|
||||
|
||||
// Аудиозапись — центральная сущность. Поля очереди соседствуют с
|
||||
// доменом, но не с содержимым: расшифровка лежит строкой `texts`, и
|
||||
// чтение очереди её не тянет.
|
||||
//
|
||||
// Колонка владельца обязательна и объявлена внешним ключом: ничьей
|
||||
// записи не бывает, и держит это схема, а не проверка вызывающего.
|
||||
// Пустое значение внешнему ключу не отвечает — идентификаторы у учётных
|
||||
// записей непустые, — поэтому ничью запись отвергает та же связь.
|
||||
//
|
||||
// `duration_ms` и `size_bytes` обязательны и различать «неизвестно» и
|
||||
// «ноль» не обязаны: обе величины ставит приём и ставит всегда — запись,
|
||||
// метаданные которой прочитать не удалось, отвергается отказом и не
|
||||
// заводится вовсе. Решение владельца 2026-08-15.
|
||||
`CREATE TABLE audio_records (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL REFERENCES users (id),
|
||||
title TEXT,
|
||||
brief TEXT,
|
||||
original_filename TEXT,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
state_entered_at TEXT NOT NULL,
|
||||
halted_at TEXT,
|
||||
halt_reason TEXT,
|
||||
error_text TEXT,
|
||||
acquisition_id TEXT,
|
||||
acquire_expires_at TEXT,
|
||||
delay_time TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
original_file_id TEXT REFERENCES files (id),
|
||||
normalized_file_id TEXT REFERENCES files (id),
|
||||
transcript_text_id TEXT,
|
||||
literary_text_id TEXT,
|
||||
structure_id TEXT,
|
||||
recognition_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
// Отбор захвата идёт по рубежу, признаку остановки и порядку ленты.
|
||||
// Индекс заводится здесь, а не потом: применённый шаг схемы не
|
||||
// переписывается, и добавление индекса стоило бы отдельного шага.
|
||||
`CREATE INDEX idx_audio_records_acquire
|
||||
ON audio_records (state, halted_at, created_at, id)`,
|
||||
// Страница списка сужается владельцем и режется полным ключом
|
||||
// сортировки — парой «время заведения и ключ записи».
|
||||
`CREATE INDEX idx_audio_records_owner_page
|
||||
ON audio_records (owner_id, created_at, id)`,
|
||||
|
||||
// Темы записи. Отдельной таблицей связи, а не колонкой-перечнем: у
|
||||
// набора строк в колонке нет ни связи, ни потолка.
|
||||
`CREATE TABLE record_topics (
|
||||
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||
topic_id TEXT NOT NULL REFERENCES topics (id),
|
||||
PRIMARY KEY (record_id, topic_id)
|
||||
)`,
|
||||
`CREATE INDEX idx_record_topics_topic ON record_topics (topic_id)`,
|
||||
// Потолок числа тем держит схема: без него часовой разговор даёт два
|
||||
// десятка тем, и словарь распухает за неделю. Число берётся у домена —
|
||||
// то же самое, которое сервис объявляет приложению.
|
||||
`CREATE TRIGGER trg_record_topics_limit
|
||||
BEFORE INSERT ON record_topics
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'record has too many topics')
|
||||
WHERE (
|
||||
SELECT COUNT(*) FROM record_topics WHERE record_id = NEW.record_id
|
||||
) >= ` + strconv.Itoa(entity.MaxTopicsPerRecord) + `;
|
||||
END`,
|
||||
|
||||
// Тексты записи. Пара «запись и вид» уникальна: повтор прерванного шага
|
||||
// иначе завёл бы второй комплект строк, и вопрос «какой текст отдавать
|
||||
// человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
`CREATE TABLE texts (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||
kind TEXT NOT NULL,
|
||||
contents TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX idx_texts_record_kind ON texts (record_id, kind)`,
|
||||
|
||||
// Структура реплик. Номер версии нужен потому, что разбор сохранённого
|
||||
// ответа изменится раньше, чем архив пересчитают.
|
||||
`CREATE TABLE structures (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||
version INTEGER NOT NULL,
|
||||
contents TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX idx_structures_record_version ON structures (record_id, version)`,
|
||||
|
||||
// Попытка распознавания у внешнего провайдера.
|
||||
//
|
||||
// Сохранённый ответ лежит **третьим файлом в подкаталоге записи**, а
|
||||
// здесь стоит только его имя: шаг опроса читает эту строку раз в
|
||||
// несколько секунд, и ответ на многочасовую запись, положенный колонкой,
|
||||
// ехал бы в память при каждом опросе.
|
||||
`CREATE TABLE recognitions (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
source_uri TEXT NOT NULL DEFAULT '',
|
||||
payload_file TEXT NOT NULL DEFAULT '',
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX idx_recognitions_record ON recognitions (record_id)`,
|
||||
|
||||
// Журнал событий записи. Колонка текста отказа зовётся `outcome_text`, а
|
||||
// не `error_text`: последнее имя названо поимённо инвариантом проекта о
|
||||
// секрете, и две колонки с этим именем сделали бы инвариант
|
||||
// двусмысленным.
|
||||
`CREATE TABLE record_events (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
record_id TEXT NOT NULL REFERENCES audio_records (id),
|
||||
origin TEXT NOT NULL,
|
||||
step TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL,
|
||||
outcome_text TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX idx_record_events_record ON record_events (record_id)`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Package migrations — шаги схемы базы.
|
||||
//
|
||||
// Шаг лежит своим файлом, имя файла начинается версией, и **применённый шаг не
|
||||
// переписывается** — только новым файлом. Инвариант проекта держится так же, как
|
||||
// держался прежде: изменение схемы это новый шаг, а не правка уехавшего.
|
||||
//
|
||||
// Шаги лежат отдельным каталогом, а не файлом внутри пакета хранилища, по
|
||||
// внешней причине: сверка документов ловит изменённый шаг схемы при нетронутом
|
||||
// `docs/database.md` по префиксу пути (`.av-dev.toml`, ключ `migrations` секции
|
||||
// `[docs]`), а префикс наводится только на каталог.
|
||||
//
|
||||
// Регистрация идёт **перечнем**, а не глобальным списком библиотеки: провайдер
|
||||
// заводится в точке входа и получает этот перечень доводом, поэтому два
|
||||
// провайдера в одном процессе — например, сервис и проверка — не спорят за общее
|
||||
// состояние.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// All — шаги схемы в порядке версий.
|
||||
//
|
||||
// Порядок исхода от порядка этого перечня не зависит: библиотека сортирует шаги
|
||||
// по версии сама. Перечень собран ради того, чтобы шаг, добавленный файлом и
|
||||
// забытый здесь, не оказался незамеченным: незарегистрированный шаг не
|
||||
// накатывается вовсе.
|
||||
func All() []*goose.Migration {
|
||||
return []*goose.Migration{
|
||||
goose.NewGoMigration(
|
||||
202608220002,
|
||||
&goose.GoFunc{RunTx: up202608220002},
|
||||
&goose.GoFunc{RunTx: down202608220002},
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// payloadSuffix — окончание имени файла, под которым лежит сохранённый ответ
|
||||
// провайдера. Имя задаёт сервис, как и у копий аудио.
|
||||
const payloadSuffix = ".payload"
|
||||
|
||||
type RecognitionRepository struct {
|
||||
db *DB
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewRecognitionRepository(db *DB, store *Store) *RecognitionRepository {
|
||||
return &RecognitionRepository{db: db, store: store}
|
||||
}
|
||||
|
||||
// Create заводит строку попытки **до** обращения к провайдеру.
|
||||
//
|
||||
// Порядок здесь несущий: окно между ответом провайдера и записью идентификатора
|
||||
// операции — то место, где теряется оплаченное. Заведённая заранее строка даёт
|
||||
// повторному шагу, чем проверить сделанное прежде, чем платить второй раз.
|
||||
func (repo *RecognitionRepository) Create(r *entity.Recognition) error {
|
||||
started := clock.Now()
|
||||
if r.Id == "" {
|
||||
r.Id = ident.New()
|
||||
}
|
||||
|
||||
now := formatTime(started)
|
||||
_, err := repo.db.Writer().ExecContext(context.Background(),
|
||||
`INSERT INTO recognitions
|
||||
(id, record_id, provider, model, external_id, source_uri, started_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.Id, r.RecordID, r.Provider, r.Model, r.ExternalID, r.SourceURI, now, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create recognition attempt for record %s: %w", r.RecordID, err)
|
||||
}
|
||||
|
||||
r.StartedAt = &started
|
||||
return nil
|
||||
}
|
||||
|
||||
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
|
||||
// последнему повторный шаг узнаёт, что за эту запись уже заплачено, и второй раз
|
||||
// наружу не платит.
|
||||
func (repo *RecognitionRepository) Submitted(id, sourceURI, externalID string) error {
|
||||
_, err := repo.db.Writer().ExecContext(context.Background(),
|
||||
"UPDATE recognitions SET source_uri = ?, external_id = ?, updated_at = ? WHERE id = ?",
|
||||
sourceURI, externalID, formatTime(clock.Now()), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store operation id of attempt %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Finish кладёт сохранённый ответ провайдера **третьим файлом в подкаталоге
|
||||
// записи** и отмечает завершение попытки.
|
||||
//
|
||||
// Файлом, а не колонкой: шаг опроса читает эту строку раз в несколько секунд, и
|
||||
// ответ на многочасовую запись, положенный колонкой, ехал бы в память при каждом
|
||||
// опросе. Хранится он потому, что результат операции у провайдера не
|
||||
// переспрашивается.
|
||||
//
|
||||
// Пустой ответ поверх сохранённого не кладётся — тем же доводом, что и у текста:
|
||||
// повторный опрос вправе вернуть пустое, и безусловная замена стёрла бы
|
||||
// сохранённое без возврата.
|
||||
func (repo *RecognitionRepository) Finish(id string, raw []byte) error {
|
||||
attempt, err := repo.GetByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := id + payloadSuffix
|
||||
if len(raw) > 0 {
|
||||
if _, err := repo.store.Put(attempt.RecordID, name, bytesReader(raw)); err != nil {
|
||||
// Путь к сохранённому ответу наружу не идёт: отказ называет попытку
|
||||
// её идентификатором.
|
||||
return errors.Join(fmt.Errorf("failed to store provider payload of attempt %s", id), err)
|
||||
}
|
||||
}
|
||||
|
||||
finished := formatTime(clock.Now())
|
||||
if len(raw) > 0 {
|
||||
_, err = repo.db.Writer().ExecContext(context.Background(),
|
||||
"UPDATE recognitions SET payload_file = ?, finished_at = ?, updated_at = ? WHERE id = ?",
|
||||
name, finished, finished, id,
|
||||
)
|
||||
} else {
|
||||
_, err = repo.db.Writer().ExecContext(context.Background(),
|
||||
"UPDATE recognitions SET finished_at = ?, updated_at = ? WHERE id = ?",
|
||||
finished, finished, id,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store provider payload of attempt %s", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (repo *RecognitionRepository) GetByID(id string) (*entity.Recognition, error) {
|
||||
attempt := &entity.Recognition{Id: id}
|
||||
var startedAt, finishedAt, payloadFile nullString
|
||||
err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||
`SELECT record_id, provider, model, external_id, source_uri, payload_file, started_at, finished_at
|
||||
FROM recognitions WHERE id = ?`, id,
|
||||
).Scan(
|
||||
&attempt.RecordID, &attempt.Provider, &attempt.Model,
|
||||
&attempt.ExternalID, &attempt.SourceURI, &payloadFile,
|
||||
&startedAt, &finishedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
|
||||
}
|
||||
|
||||
attempt.StartedAt = timeOf(startedAt.NullString)
|
||||
attempt.FinishedAt = timeOf(finishedAt.NullString)
|
||||
|
||||
return attempt, nil
|
||||
}
|
||||
|
||||
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда ответ
|
||||
// нужен: шаг опроса читает строку попытки без него.
|
||||
func (repo *RecognitionRepository) ReadRaw(id string) ([]byte, error) {
|
||||
attempt, err := repo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payloadFile string
|
||||
if err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||
"SELECT payload_file FROM recognitions WHERE id = ?", id,
|
||||
).Scan(&payloadFile); err != nil {
|
||||
return nil, fmt.Errorf("failed to get recognition attempt %s: %w", id, err)
|
||||
}
|
||||
if payloadFile == "" {
|
||||
return nil, fmt.Errorf("recognition attempt %s has no stored payload", id)
|
||||
}
|
||||
|
||||
file, err := repo.store.Open(attempt.RecordID, payloadFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read stored payload of attempt %s", id)
|
||||
}
|
||||
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// nullString — обёртка ради читаемости выборок: колонка, допускающая пустое
|
||||
// значение, читается в неё, а домену отдаётся указателем.
|
||||
type nullString struct {
|
||||
sql.NullString
|
||||
}
|
||||
|
||||
// bytesReader отдаёт содержимое в памяти потоком: сохранённый ответ провайдера
|
||||
// приходит целиком байтами, а укладка принимает поток.
|
||||
func bytesReader(raw []byte) io.Reader {
|
||||
return bytes.NewReader(raw)
|
||||
}
|
||||
|
||||
type RecordEventRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
func NewRecordEventRepository(db *DB) *RecordEventRepository {
|
||||
return &RecordEventRepository{db: db}
|
||||
}
|
||||
|
||||
// Append пишет строку журнала событий записи.
|
||||
//
|
||||
// Журнал пишется на смену рубежа, на остановку и на возврат в работу, а не на
|
||||
// каждое откладывание опроса: часовая запись дала бы сотни строк ни о чём. Ни
|
||||
// один шаг конвейера его не читает, чтобы решить, что делать дальше: решение
|
||||
// принимается по рубежу записи, и второй источник решения разошёлся бы с первым
|
||||
// молча.
|
||||
//
|
||||
// Содержимое записи сюда не попадает — инвариант приватности действует здесь
|
||||
// наравне с журналом сервиса.
|
||||
func (repo *RecordEventRepository) Append(event *entity.RecordEvent) error {
|
||||
if event.Id == "" {
|
||||
event.Id = ident.New()
|
||||
}
|
||||
|
||||
_, err := repo.db.Writer().ExecContext(context.Background(),
|
||||
`INSERT INTO record_events
|
||||
(id, record_id, origin, step, outcome, outcome_text, duration_ms, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
event.Id, event.RecordID, event.Origin, event.Step,
|
||||
event.Outcome, event.OutcomeText, event.DurationMs, formatTime(clock.Now()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to append event of record %s: %w", event.RecordID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+136
-77
@@ -1,12 +1,10 @@
|
||||
package pocketbase
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -47,69 +45,86 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor
|
||||
q.Limit = defaultListLimit
|
||||
}
|
||||
|
||||
collection, err := findCollection(repo.app, migrations.RecordsCollection)
|
||||
conditions := []string{"owner_id = ?"}
|
||||
args := []any{q.OwnerID}
|
||||
|
||||
state, stateArgs, err := stateCondition(q.Filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filter := dbx.HashExp{"owner": q.OwnerID}
|
||||
conditions := []dbx.Expression{filter}
|
||||
|
||||
state, err := stateCondition(q.Filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state != nil {
|
||||
if state != "" {
|
||||
conditions = append(conditions, state)
|
||||
args = append(args, stateArgs...)
|
||||
}
|
||||
|
||||
total, err := repo.countRecords(conditions)
|
||||
total, err := repo.countRecords(conditions, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pageConditions := conditions
|
||||
pageArgs := args
|
||||
// Курсор режет ленту по паре: строго раньше по времени, а при равном времени
|
||||
// — строго меньше по идентификатору. Идентификаторы хранилища монотонны в
|
||||
// пределах одной миллисекунды не всегда, но сравнение по ним устойчиво, и
|
||||
// этого довольно: задача ключа — не пропустить и не повторить.
|
||||
// — строго меньше по идентификатору.
|
||||
if q.Cursor != nil {
|
||||
conditions = append(conditions, dbx.Or(
|
||||
dbx.NewExp("created < {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
|
||||
dbx.And(
|
||||
dbx.NewExp("created = {:created}", dbx.Params{"created": q.Cursor.CreatedAt}),
|
||||
dbx.NewExp("id < {:id}", dbx.Params{"id": q.Cursor.ID}),
|
||||
),
|
||||
))
|
||||
pageConditions = append(append([]string{}, conditions...),
|
||||
"(created_at < ? OR (created_at = ? AND id < ?))")
|
||||
cursorTime := formatTime(q.Cursor.CreatedAt)
|
||||
pageArgs = append(append([]any{}, args...),
|
||||
cursorTime, cursorTime, q.Cursor.ID)
|
||||
}
|
||||
|
||||
row := &recordRow{}
|
||||
columns, targets := selectList(readRecordColumns(row), "")
|
||||
|
||||
// Просим на одну больше предела: лишняя запись отвечает на вопрос «есть ли
|
||||
// следующая страница» без второго запроса и без вычислений по общему числу,
|
||||
// которое к этому моменту могло измениться.
|
||||
records := []*core.Record{}
|
||||
err = repo.app.RecordQuery(collection).
|
||||
AndWhere(dbx.And(conditions...)).
|
||||
OrderBy("created DESC", "id DESC").
|
||||
Limit(int64(q.Limit) + 1).
|
||||
All(&records)
|
||||
query := "SELECT " + columns + " FROM " + recordsTable +
|
||||
" WHERE " + strings.Join(pageConditions, " AND ") +
|
||||
" ORDER BY created_at DESC, id DESC LIMIT ?"
|
||||
pageArgs = append(pageArgs, q.Limit+1)
|
||||
|
||||
rows, err := repo.db.Reader().QueryContext(context.Background(), query, pageArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list audio records: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := []*entity.AudioRecord{}
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(targets...); err != nil {
|
||||
return nil, fmt.Errorf("failed to read an audio record of the page: %w", err)
|
||||
}
|
||||
items = append(items, rowToAudioRecord(row))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read the page of audio records: %w", err)
|
||||
}
|
||||
|
||||
page := &contract.RecordPage{TotalItems: total}
|
||||
if len(records) > q.Limit {
|
||||
last := records[q.Limit-1]
|
||||
if len(items) > q.Limit {
|
||||
last := items[q.Limit-1]
|
||||
page.NextCursor = &contract.RecordCursor{
|
||||
CreatedAt: last.GetDateTime("created").String(),
|
||||
CreatedAt: last.CreatedAt,
|
||||
ID: last.Id,
|
||||
}
|
||||
records = records[:q.Limit]
|
||||
items = items[:q.Limit]
|
||||
}
|
||||
|
||||
page.Items = make([]*entity.AudioRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
page.Items = append(page.Items, recordToAudioRecord(record))
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
ids = append(ids, item.Id)
|
||||
}
|
||||
topics, err := repo.topicsOf(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
item.TopicIDs = topics[item.Id]
|
||||
}
|
||||
|
||||
page.Items = items
|
||||
return page, nil
|
||||
}
|
||||
|
||||
@@ -118,84 +133,128 @@ func (repo *AudioRecordRepository) List(q contract.RecordQuery) (*contract.Recor
|
||||
// Перечень рубежей сюда не переписывается: он приходит из дескриптора. Отбор
|
||||
// списка — очередной его потребитель, и рубеж, добавленный конвейером, иначе
|
||||
// молча поменял бы состав всех трёх состояний.
|
||||
func stateCondition(filter *entity.ListFilter) (dbx.Expression, error) {
|
||||
func stateCondition(filter *entity.ListFilter) (string, []any, error) {
|
||||
if filter == nil {
|
||||
return nil, nil
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
notHalted := dbx.NewExp("halted_at = ''")
|
||||
halted := dbx.NewExp("halted_at != ''")
|
||||
|
||||
switch *filter {
|
||||
case entity.ListFilterHalted:
|
||||
return halted, nil
|
||||
return "halted_at IS NOT NULL", nil, nil
|
||||
case entity.ListFilterWorking:
|
||||
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.WorkingStages())...)), nil
|
||||
condition, args := stateIn(entity.WorkingStages())
|
||||
return "halted_at IS NULL AND " + condition, args, nil
|
||||
case entity.ListFilterDone:
|
||||
return dbx.And(notHalted, dbx.In("state", stageNameValues(entity.TerminalStages())...)), nil
|
||||
condition, args := stateIn(entity.TerminalStages())
|
||||
return "halted_at IS NULL AND " + condition, args, nil
|
||||
}
|
||||
|
||||
// Ветвь отказа, а не молчаливое «без сужения»: значение, добавленное в
|
||||
// перечень состояний и забытое здесь, иначе вернуло бы человеку весь архив
|
||||
// под именем отбора — и заметить это было бы нечем.
|
||||
return nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
|
||||
return "", nil, fmt.Errorf("%w: unknown list filter %q", contract.ErrBadRequest, *filter)
|
||||
}
|
||||
|
||||
func stageNameValues(stages []entity.Stage) []any {
|
||||
func stateIn(stages []entity.Stage) (string, []any) {
|
||||
names := entity.StageNames(stages)
|
||||
out := make([]any, 0, len(names))
|
||||
for _, n := range names {
|
||||
out = append(out, n)
|
||||
args := make([]any, 0, len(names))
|
||||
placeholders := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
args = append(args, name)
|
||||
placeholders = append(placeholders, "?")
|
||||
}
|
||||
return out
|
||||
return "state IN (" + strings.Join(placeholders, ", ") + ")", args
|
||||
}
|
||||
|
||||
func (repo *AudioRecordRepository) countRecords(conditions []dbx.Expression) (int, error) {
|
||||
var counter struct {
|
||||
Total int `db:"total"`
|
||||
}
|
||||
err := repo.app.RecordQuery(migrations.RecordsCollection).
|
||||
Select("count(*) as total").
|
||||
AndWhere(dbx.And(conditions...)).
|
||||
One(&counter)
|
||||
if err != nil {
|
||||
func (repo *AudioRecordRepository) countRecords(conditions []string, args []any) (int, error) {
|
||||
var total int
|
||||
query := "SELECT COUNT(*) FROM " + recordsTable + " WHERE " + strings.Join(conditions, " AND ")
|
||||
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("failed to count audio records: %w", err)
|
||||
}
|
||||
return counter.Total, nil
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// topicsOf читает темы страницы **одним запросом**: страница в сотню записей
|
||||
// иначе стоила бы сотни обращений к базе.
|
||||
func (repo *AudioRecordRepository) topicsOf(recordIDs []string) (map[string][]string, error) {
|
||||
out := map[string][]string{}
|
||||
if len(recordIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
placeholders := make([]string, 0, len(recordIDs))
|
||||
args := make([]any, 0, len(recordIDs))
|
||||
for _, id := range recordIDs {
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
query := "SELECT record_id, topic_id FROM record_topics WHERE record_id IN (" +
|
||||
strings.Join(placeholders, ", ") + ") ORDER BY topic_id"
|
||||
|
||||
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read record topics: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var recordID, topicID string
|
||||
if err := rows.Scan(&recordID, &topicID); err != nil {
|
||||
return nil, fmt.Errorf("failed to read a record topic: %w", err)
|
||||
}
|
||||
out[recordID] = append(out[recordID], topicID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read record topics: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveTopicNames разрешает темы названиями **одним запросом на страницу**, а
|
||||
// не по запросу на запись: страница в сотню записей иначе стоила бы сотни
|
||||
// обращений к хранилищу.
|
||||
// не по запросу на запись.
|
||||
//
|
||||
// Названия, а не идентификаторы, потому что экран показывает названия: отдай мы
|
||||
// ссылки, форму ответа переделывала бы задача языковой модели — ровно то, ради
|
||||
// чего контракт согласуется один раз.
|
||||
//
|
||||
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека — пара
|
||||
// «владелец и название» уникальна, — и разрешение без сужения отдало бы название
|
||||
// чужой темы, как только темы начнёт писать языковая модель.
|
||||
func (repo *AudioRecordRepository) ResolveTopicNames(ownerID string, ids []string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if len(ids) == 0 || ownerID == "" {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
values := make([]any, 0, len(ids))
|
||||
placeholders := make([]string, 0, len(ids))
|
||||
args := []any{ownerID}
|
||||
for _, id := range ids {
|
||||
values = append(values, id)
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
// Сужение владельцем стоит и здесь: словарь тем свой у каждого человека —
|
||||
// пара «владелец и название» уникальна, — и разрешение без сужения отдало бы
|
||||
// название чужой темы, как только темы начнёт писать языковая модель.
|
||||
records := []*core.Record{}
|
||||
err := repo.app.RecordQuery(migrations.TopicsCollection).
|
||||
AndWhere(dbx.HashExp{"owner": ownerID}).
|
||||
AndWhere(dbx.In("id", values...)).
|
||||
All(&records)
|
||||
query := "SELECT id, name FROM topics WHERE owner_id = ? AND id IN (" +
|
||||
strings.Join(placeholders, ", ") + ")"
|
||||
|
||||
rows, err := repo.db.Reader().QueryContext(context.Background(), query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve topics: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for _, record := range records {
|
||||
out[record.Id] = record.GetString("name")
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
if err := rows.Scan(&id, &name); err != nil {
|
||||
return nil, fmt.Errorf("failed to read a topic: %w", err)
|
||||
}
|
||||
out[id] = name
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve topics: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Отображение аудиозаписи в строку базы и обратно живёт одним местом.
|
||||
//
|
||||
// Работает оно **по имени колонки**: именованные параметры запроса и место
|
||||
// назначения, найденное по имени. Причина в самой сущности — у аудиозаписи поля
|
||||
// одного типа идут длинным непрерывным рядом, и ссылки на файл, на структуру
|
||||
// реплик, на два вида текста и на попытку распознавания стоят в нём подряд.
|
||||
// Позиционный список дал бы сдвиг на одно поле, который компилируется молча и
|
||||
// кладёт идентификатор файла в колонку текста. По имени такого сдвига не
|
||||
// существует вовсе: лишнее имя или недостающее — отказ запроса, а не тихая
|
||||
// подмена значения.
|
||||
//
|
||||
// Инвариант проекта о колонках записи эта форма не снимает: колонку по-прежнему
|
||||
// можно забыть в отображении или в шаге схемы, и сверку держат правила
|
||||
// `internal/archrules`.
|
||||
|
||||
// writeOwnedByPipeline — колонки, которыми распоряжается конвейер.
|
||||
//
|
||||
// Разрез нужен потому, что шаг держит запись снимком с момента захвата и до
|
||||
// своего сохранения, а это часы. Всё, что владелец правил за это время,
|
||||
// безусловная запись снимка стёрла бы молча: ни строки в журнале, ни отказа
|
||||
// тому, кто правил. Владелец, заголовок, краткое описание, имя файла
|
||||
// отправителя, длительность, размер и темы не трогаются вовсе.
|
||||
func writeOwnedByPipeline(r *entity.AudioRecord) map[string]any {
|
||||
return map[string]any{
|
||||
"state": r.State,
|
||||
"state_entered_at": formatTime(r.StateEnteredAt),
|
||||
"halted_at": timeValue(r.HaltedAt),
|
||||
"halt_reason": stringValue(r.HaltReason),
|
||||
"error_text": stringValue(r.ErrorText),
|
||||
"acquisition_id": stringValue(r.AcquisitionID),
|
||||
"acquire_expires_at": timeValue(r.AcquireExpiresAt),
|
||||
"delay_time": timeValue(r.DelayTime),
|
||||
"attempts": r.Attempts,
|
||||
"original_file_id": stringValue(r.OriginalFileID),
|
||||
"normalized_file_id": stringValue(r.NormalizedFileID),
|
||||
"transcript_text_id": stringValue(r.TranscriptTextID),
|
||||
"literary_text_id": stringValue(r.LiteraryTextID),
|
||||
"structure_id": stringValue(r.StructureID),
|
||||
"recognition_id": stringValue(r.RecognitionID),
|
||||
"updated_at": formatTime(r.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// writeRecord — запись целиком: это заведение, и спорить за поля здесь не с кем.
|
||||
//
|
||||
// Колонки заведения перечислены той же формой, что и колонки конвейера, — картой
|
||||
// «имя колонки в значение»: сверка колонок в `internal/archrules` читает именно
|
||||
// её, и колонка, положенная присваиванием мимо карты, выпала бы из-под правила
|
||||
// молча.
|
||||
func writeRecord(r *entity.AudioRecord) map[string]any {
|
||||
values := writeOwnedByPipeline(r)
|
||||
|
||||
own := map[string]any{
|
||||
"id": r.Id,
|
||||
// Владелец кладётся только здесь, при заведении. В перечне конвейера его
|
||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок
|
||||
// шага, записанный поверх, стёр бы его молча.
|
||||
"owner_id": r.OwnerID,
|
||||
"title": stringValue(r.Title),
|
||||
"brief": stringValue(r.Brief),
|
||||
// Имя файла отправителя, длительность и размер кладёт приём и только он:
|
||||
// это снимок принятого, и конвейер его не пересчитывает.
|
||||
"original_filename": stringValue(r.OriginalFilename),
|
||||
"duration_ms": numberValue(r.DurationMs),
|
||||
"size_bytes": numberValue(r.SizeBytes),
|
||||
"created_at": formatTime(r.CreatedAt),
|
||||
}
|
||||
for name, value := range own {
|
||||
values[name] = value
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// recordRow — сырые значения одной строки аудиозаписи.
|
||||
type recordRow struct {
|
||||
id string
|
||||
ownerID string
|
||||
title sql.NullString
|
||||
brief sql.NullString
|
||||
originalFilename sql.NullString
|
||||
durationMs int64
|
||||
sizeBytes int64
|
||||
state string
|
||||
stateEnteredAt string
|
||||
haltedAt sql.NullString
|
||||
haltReason sql.NullString
|
||||
errorText sql.NullString
|
||||
acquisitionID sql.NullString
|
||||
acquireExpiresAt sql.NullString
|
||||
delayTime sql.NullString
|
||||
attempts int
|
||||
originalFileID sql.NullString
|
||||
normalizedFileID sql.NullString
|
||||
transcriptTextID sql.NullString
|
||||
literaryTextID sql.NullString
|
||||
structureID sql.NullString
|
||||
recognitionID sql.NullString
|
||||
createdAt string
|
||||
updatedAt string
|
||||
}
|
||||
|
||||
// readRecordColumns — куда кладётся каждая колонка при чтении.
|
||||
//
|
||||
// Перечень колонок выборки собирается из этой же карты, поэтому расхождению
|
||||
// между тем, что спрошено, и тем, куда оно ляжет, взяться неоткуда.
|
||||
func readRecordColumns(row *recordRow) map[string]any {
|
||||
return map[string]any{
|
||||
"id": &row.id,
|
||||
"owner_id": &row.ownerID,
|
||||
"title": &row.title,
|
||||
"brief": &row.brief,
|
||||
"original_filename": &row.originalFilename,
|
||||
"duration_ms": &row.durationMs,
|
||||
"size_bytes": &row.sizeBytes,
|
||||
"state": &row.state,
|
||||
"state_entered_at": &row.stateEnteredAt,
|
||||
"halted_at": &row.haltedAt,
|
||||
"halt_reason": &row.haltReason,
|
||||
"error_text": &row.errorText,
|
||||
"acquisition_id": &row.acquisitionID,
|
||||
"acquire_expires_at": &row.acquireExpiresAt,
|
||||
"delay_time": &row.delayTime,
|
||||
"attempts": &row.attempts,
|
||||
"original_file_id": &row.originalFileID,
|
||||
"normalized_file_id": &row.normalizedFileID,
|
||||
"transcript_text_id": &row.transcriptTextID,
|
||||
"literary_text_id": &row.literaryTextID,
|
||||
"structure_id": &row.structureID,
|
||||
"recognition_id": &row.recognitionID,
|
||||
"created_at": &row.createdAt,
|
||||
"updated_at": &row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// rowToAudioRecord собирает доменную запись из прочитанной строки.
|
||||
func rowToAudioRecord(row *recordRow) *entity.AudioRecord {
|
||||
return &entity.AudioRecord{
|
||||
Id: row.id,
|
||||
OwnerID: row.ownerID,
|
||||
Title: stringOf(row.title),
|
||||
Brief: stringOf(row.brief),
|
||||
OriginalFilename: stringOf(row.originalFilename),
|
||||
DurationMs: numberOf(row.durationMs),
|
||||
SizeBytes: numberOf(row.sizeBytes),
|
||||
State: row.state,
|
||||
StateEnteredAt: requiredTimeOf(row.stateEnteredAt),
|
||||
HaltedAt: timeOf(row.haltedAt),
|
||||
HaltReason: stringOf(row.haltReason),
|
||||
ErrorText: stringOf(row.errorText),
|
||||
AcquisitionID: stringOf(row.acquisitionID),
|
||||
AcquireExpiresAt: timeOf(row.acquireExpiresAt),
|
||||
DelayTime: timeOf(row.delayTime),
|
||||
Attempts: row.attempts,
|
||||
OriginalFileID: stringOf(row.originalFileID),
|
||||
NormalizedFileID: stringOf(row.normalizedFileID),
|
||||
TranscriptTextID: stringOf(row.transcriptTextID),
|
||||
LiteraryTextID: stringOf(row.literaryTextID),
|
||||
StructureID: stringOf(row.structureID),
|
||||
RecognitionID: stringOf(row.recognitionID),
|
||||
TopicIDs: []string{},
|
||||
CreatedAt: requiredTimeOf(row.createdAt),
|
||||
UpdatedAt: requiredTimeOf(row.updatedAt),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// recordsTable — таблица аудиозаписей.
|
||||
const recordsTable = "audio_records"
|
||||
|
||||
type AudioRecordRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
func NewAudioRecordRepository(db *DB) *AudioRecordRepository {
|
||||
return &AudioRecordRepository{db: db}
|
||||
}
|
||||
|
||||
// Create заводит аудиозапись.
|
||||
//
|
||||
// Идентификатор приходит готовым, когда его назначил вызывающий: приём знает его
|
||||
// раньше, чем кладёт файл, — копии записи лежат подкаталогом под этим самым
|
||||
// идентификатором. Пустой заполняется единой точкой выдачи.
|
||||
func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
|
||||
if r.Id == "" {
|
||||
r.Id = ident.New()
|
||||
}
|
||||
now := clock.Now()
|
||||
if r.CreatedAt.IsZero() {
|
||||
r.CreatedAt = now
|
||||
}
|
||||
r.UpdatedAt = now
|
||||
if r.StateEnteredAt.IsZero() {
|
||||
r.StateEnteredAt = now
|
||||
}
|
||||
|
||||
query, args := insertSQL(recordsTable, writeRecord(r))
|
||||
if _, err := repo.db.Writer().ExecContext(context.Background(), query, args...); err != nil {
|
||||
return fmt.Errorf("failed to insert audio record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save сохраняет запись, захват которой держит holder.
|
||||
//
|
||||
// Сверка захвата и запись идут **одним запросом**: значение признака стоит
|
||||
// условием правки, поэтому между проверкой и записью не остаётся окна. Сверяется
|
||||
// именно значение, а не занятость записи — захват, перевыданный другому по
|
||||
// протуханию срока или после того, как человек вернул запись в работу, обязан
|
||||
// обратить запись первого в отказ; условие по непустоте признака пропустило бы
|
||||
// обоих, и два шага записали бы в одну запись по очереди, портя её результат.
|
||||
//
|
||||
// Пустой holder снимает условность и в конвейере не употребляется: все его шаги
|
||||
// получают признак захвата от FindAndAcquire.
|
||||
func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) error {
|
||||
r.UpdatedAt = clock.Now()
|
||||
|
||||
where := "id = :id"
|
||||
whereArgs := []any{sql.Named("id", r.Id)}
|
||||
if holder != "" {
|
||||
where += " AND acquisition_id = :holder"
|
||||
whereArgs = append(whereArgs, sql.Named("holder", holder))
|
||||
}
|
||||
|
||||
query, args := updateSQL(recordsTable, writeOwnedByPipeline(r), where, whereArgs)
|
||||
|
||||
result, err := repo.db.Writer().ExecContext(context.Background(), query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update audio record: %w", err)
|
||||
}
|
||||
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read the outcome of an audio record update: %w", err)
|
||||
}
|
||||
if affected > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Строк не тронуто по одной из двух причин, и различить их можно только
|
||||
// чтением: записи нет вовсе либо захват достался другому. Разница несущая —
|
||||
// первая означает поломку, вторая штатный исход шага, потерявшего запись.
|
||||
if _, err := repo.Get(r.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return &contract.LostAcquisitionError{JobID: r.Id}
|
||||
}
|
||||
|
||||
// GetByID отдаёт запись, только если её владелец — ownerID.
|
||||
//
|
||||
// Чужая запись и несуществующая дают одну и ту же ошибку: по разнице ответов
|
||||
// иначе перебирается список заведённых записей, а идентификатор записи и есть
|
||||
// то, что разграничение прячет.
|
||||
//
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило не
|
||||
// стало избыточным с обязательностью колонки: схема запрещает **заводить** ничью
|
||||
// запись, а здесь запрещено **спрашивать** ничьим именем — иначе вызывающий без
|
||||
// учётной записи получил бы выборку вместо отказа.
|
||||
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
|
||||
if ownerID == "" {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
}
|
||||
|
||||
record, err := repo.read("id = ? AND owner_id = ?", id, ownerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// Get отдаёт запись без сужения владельцем: им пользуется конвейер, чья выборка
|
||||
// владельцем не сужается.
|
||||
func (repo *AudioRecordRepository) Get(id string) (*entity.AudioRecord, error) {
|
||||
return repo.read("id = ?", id)
|
||||
}
|
||||
|
||||
// read читает одну запись по условию.
|
||||
func (repo *AudioRecordRepository) read(where string, args ...any) (*entity.AudioRecord, error) {
|
||||
row := &recordRow{}
|
||||
columns, targets := selectList(readRecordColumns(row), "")
|
||||
|
||||
query := "SELECT " + columns + " FROM " + recordsTable + " WHERE " + where + " LIMIT 1"
|
||||
if err := repo.db.Reader().QueryRowContext(context.Background(), query, args...).Scan(targets...); err != nil {
|
||||
// «Такой записи нет» переводится в доменную ошибку здесь, у источника,
|
||||
// как велит конвенция об ошибках. Иначе исходы, которые разграничение
|
||||
// обязано сделать неразличимыми, разъезжаются: чужая запись даёт
|
||||
// доменную ошибку, а несуществующая — отказ базы, неотличимый от
|
||||
// настоящей аварии хранилища.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get audio record: %w", err)
|
||||
}
|
||||
|
||||
record := rowToAudioRecord(row)
|
||||
|
||||
topics, err := repo.topicsOf([]string{record.Id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record.TopicIDs = topics[record.Id]
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// FindAndAcquire забирает пригодную к работе запись одним неделимым шагом:
|
||||
// выбор подходящей и пометка её захваченной идут вместе, одним оператором с
|
||||
// возвратом.
|
||||
//
|
||||
// Возвращается **идентификатор и признак этого захвата**, а не перечень колонок.
|
||||
// Колонки шаг читает обычным чтением: иначе всякая новая колонка записи попадала
|
||||
// бы под инвариант проекта о колонках очереди, а забытая приезжала бы нулевой, и
|
||||
// первое же сохранение писало бы этот ноль поверх сохранённого значения.
|
||||
//
|
||||
// Срок протухания захвата приезжает **с рубежом**, а не с воркером: воркер не
|
||||
// привязан к шагу и не знает заранее, что вытянет. Перечень рубежей и их сроков
|
||||
// приходит одним дескриптором — перечислять их порознь нельзя: рубеж, забытый в
|
||||
// отборе, не выдаётся ни одному воркеру никогда, а пустой прогон по инварианту
|
||||
// проекта не пишется в журнал и не считается в метрику.
|
||||
//
|
||||
// Запрос идёт по **пишущему** соединению: он читает состояние, которое сам же
|
||||
// меняет, а транзакцию, начатую на читающем соединении, SQLite до пишущей не
|
||||
// повышает.
|
||||
func (repo *AudioRecordRepository) FindAndAcquire(stages []entity.Stage) (*contract.AcquiredRecord, error) {
|
||||
if len(stages) == 0 {
|
||||
return nil, &contract.JobNotFoundError{Message: "no working stages declared"}
|
||||
}
|
||||
|
||||
now := clock.Now()
|
||||
holder := ident.New()
|
||||
|
||||
args := []any{
|
||||
sql.Named("holder", holder),
|
||||
sql.Named("now", formatTime(now)),
|
||||
}
|
||||
|
||||
// Срок протухания у каждого рубежа свой, поэтому он выбирается по рубежу
|
||||
// самой записи прямо в запросе: воркер, ещё не знающий, что вытянет,
|
||||
// подставить его не может.
|
||||
var expiry strings.Builder
|
||||
expiry.WriteString("CASE state")
|
||||
states := make([]string, 0, len(stages))
|
||||
for i, stage := range stages {
|
||||
stateKey := fmt.Sprintf("state%d", i)
|
||||
expiryKey := fmt.Sprintf("expiry%d", i)
|
||||
|
||||
fmt.Fprintf(&expiry, " WHEN :%s THEN :%s", stateKey, expiryKey)
|
||||
args = append(args,
|
||||
sql.Named(stateKey, stage.Name),
|
||||
sql.Named(expiryKey, formatTime(now.Add(stage.AcquireTimeout))),
|
||||
)
|
||||
states = append(states, ":"+stateKey)
|
||||
}
|
||||
expiry.WriteString(" END")
|
||||
|
||||
// Порядок выборки определён однозначно: время заведения плюс ключ записи.
|
||||
// Сравнения по неуникальному значению для этого мало — порядок обработки
|
||||
// стал бы невоспроизводимым.
|
||||
query := `
|
||||
UPDATE ` + recordsTable + `
|
||||
SET acquisition_id = :holder,
|
||||
acquire_expires_at = ` + expiry.String() + `,
|
||||
attempts = attempts + 1,
|
||||
updated_at = :now
|
||||
WHERE id = (
|
||||
SELECT id FROM ` + recordsTable + `
|
||||
WHERE state IN (` + strings.Join(states, ", ") + `)
|
||||
AND halted_at IS NULL
|
||||
AND (delay_time IS NULL OR delay_time < :now)
|
||||
AND (acquisition_id IS NULL
|
||||
OR acquire_expires_at IS NULL
|
||||
OR acquire_expires_at < :now)
|
||||
ORDER BY created_at, id
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id`
|
||||
|
||||
var id string
|
||||
if err := repo.db.Writer().QueryRowContext(context.Background(), query, args...).Scan(&id); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, &contract.JobNotFoundError{Message: "no record is ready for work"}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to acquire an audio record: %w", err)
|
||||
}
|
||||
|
||||
return &contract.AcquiredRecord{ID: id, Holder: holder}, nil
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// newWorkingRecord заводит запись, пригодную к захвату.
|
||||
func newWorkingRecord(t *testing.T, db *DB, owner string) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record := &entity.AudioRecord{
|
||||
Id: ident.New(),
|
||||
OwnerID: owner,
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
}
|
||||
require.NoError(t, NewAudioRecordRepository(db).Create(record))
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
// Ничьей записи не бывает, и держит это схема: колонка владельца объявлена
|
||||
// связью с учётной записью и пустого значения не принимает.
|
||||
func TestRecordWithoutOwnerIsRejected(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
t.Run("пустой владелец", func(t *testing.T) {
|
||||
err := records.Create(&entity.AudioRecord{
|
||||
Id: ident.New(), State: entity.StateUploaded, StateEnteredAt: clock.Now(),
|
||||
})
|
||||
assert.Error(t, err, "запись с пустым владельцем сохранилась")
|
||||
})
|
||||
|
||||
t.Run("владельца нет среди учётных записей", func(t *testing.T) {
|
||||
err := records.Create(&entity.AudioRecord{
|
||||
Id: ident.New(), OwnerID: ident.New(),
|
||||
State: entity.StateUploaded, StateEnteredAt: clock.Now(),
|
||||
})
|
||||
assert.Error(t, err, "запись с выдуманным владельцем сохранилась")
|
||||
})
|
||||
|
||||
t.Run("запросом к базе тоже", func(t *testing.T) {
|
||||
now := clock.Now().Format(timeLayout)
|
||||
_, err := db.Writer().ExecContext(context.Background(),
|
||||
`INSERT INTO audio_records
|
||||
(id, owner_id, duration_ms, size_bytes, state, state_entered_at, created_at, updated_at)
|
||||
VALUES (?, '', 0, 0, ?, ?, ?, ?)`,
|
||||
ident.New(), entity.StateUploaded, now, now, now,
|
||||
)
|
||||
assert.Error(t, err, "ничья запись завелась запросом к базе")
|
||||
})
|
||||
}
|
||||
|
||||
// Файл без владельца не сохраняется — тем же правилом схемы.
|
||||
func TestFileWithoutOwnerIsRejected(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
files := NewFileRepository(db, store)
|
||||
|
||||
work, err := files.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, "")
|
||||
assert.Error(t, err, "файл с пустым владельцем сохранился")
|
||||
}
|
||||
|
||||
// Учётная запись, у которой остались аудиозаписи, файлы либо темы, не удаляется:
|
||||
// запрет держит схема обязательной связью, а не проверка вызывающего.
|
||||
func TestAccountWithBelongingsIsNotDeletable(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
|
||||
t.Run("с аудиозаписями", func(t *testing.T) {
|
||||
owner := newOwner(t, db)
|
||||
newWorkingRecord(t, db, owner)
|
||||
|
||||
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||
assert.Error(t, err, "учётная запись с аудиозаписями удалилась")
|
||||
})
|
||||
|
||||
t.Run("с одними файлами", func(t *testing.T) {
|
||||
owner := newOwner(t, db)
|
||||
files := NewFileRepository(db, store)
|
||||
work, err := files.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
_, err = files.Create(ident.New(), "voice.mp3", work, contract.FileMeta{Format: "mp3"}, owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||
assert.Error(t, err, "учётная запись с файлами удалилась")
|
||||
})
|
||||
|
||||
t.Run("с одними темами", func(t *testing.T) {
|
||||
owner := newOwner(t, db)
|
||||
now := clock.Now().Format(timeLayout)
|
||||
_, err := db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
ident.New(), owner, "тема", now, now,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||
assert.Error(t, err, "учётная запись с темами удалилась")
|
||||
})
|
||||
|
||||
t.Run("пустая удаляется", func(t *testing.T) {
|
||||
owner := newOwner(t, db)
|
||||
|
||||
_, err := db.Writer().ExecContext(context.Background(), "DELETE FROM users WHERE id = ?", owner)
|
||||
assert.NoError(t, err, "учётная запись без принадлежностей не удалилась")
|
||||
})
|
||||
}
|
||||
|
||||
// У записи не больше пяти тем, и держит это схема.
|
||||
func TestRecordTopicsAreCapped(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
|
||||
now := clock.Now().Format(timeLayout)
|
||||
for i := range entity.MaxTopicsPerRecord + 1 {
|
||||
topicID := ident.New()
|
||||
_, err := db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
topicID, owner, "тема-"+topicID, now, now,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", record.Id, topicID,
|
||||
)
|
||||
if i < entity.MaxTopicsPerRecord {
|
||||
require.NoError(t, err, "тема %d не назначилась", i)
|
||||
continue
|
||||
}
|
||||
assert.Error(t, err, "шестая тема назначилась")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAcquireGivesRecordToExactlyOne — **критерий приёмки**: захват неделим.
|
||||
//
|
||||
// Одна пригодная запись, несколько захватов разом: запись достаётся ровно
|
||||
// одному, остальные получают признак «работы сейчас нет».
|
||||
func TestAcquireGivesRecordToExactlyOne(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
const racers = 8
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
acquired []*contract.AcquiredRecord
|
||||
empty int
|
||||
)
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range racers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
got, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
var missing *contract.JobNotFoundError
|
||||
switch {
|
||||
case err == nil:
|
||||
acquired = append(acquired, got)
|
||||
case assert.ErrorAs(t, err, &missing):
|
||||
empty++
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
require.Len(t, acquired, 1, "запись досталась не одному захвату")
|
||||
assert.Equal(t, racers-1, empty, "остальные получили не признак «работы нет»")
|
||||
assert.Equal(t, record.Id, acquired[0].ID)
|
||||
assert.NotEmpty(t, acquired[0].Holder, "захват не отдал своего признака")
|
||||
}
|
||||
|
||||
// Результат пишет только держатель захвата, и держатель узнаётся **значением**
|
||||
// признака, а не занятостью записи.
|
||||
func TestSaveIsConditionalOnHolderValue(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
first, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
|
||||
// Захват уходит другому: срок протухает, и запись достаётся следующему.
|
||||
_, err = db.Writer().ExecContext(context.Background(),
|
||||
"UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
|
||||
clock.Now().Add(-time.Hour).Format(timeLayout), record.Id,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.Holder, second.Holder, "признак перевыданного захвата совпал с прежним")
|
||||
|
||||
// Прежний держатель пишет свой результат — и не пишет.
|
||||
stale, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
stale.MoveToState(entity.StateNormalized)
|
||||
|
||||
err = records.Save(stale, first.Holder)
|
||||
|
||||
var lost *contract.LostAcquisitionError
|
||||
require.ErrorAs(t, err, &lost, "шаг, потерявший захват, записал результат")
|
||||
|
||||
after, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "чужая запись изменила состояние")
|
||||
}
|
||||
|
||||
// Правка владельца переживает сохранение шага: конвейер пишет только те поля,
|
||||
// которыми распоряжается сам.
|
||||
func TestPipelineSaveKeepsOwnerFields(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
records := NewAudioRecordRepository(db)
|
||||
|
||||
acquired, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
|
||||
held, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Владелец за это время правит поле, которого шаг не касается.
|
||||
_, err = db.Writer().ExecContext(context.Background(), "UPDATE audio_records SET title = ? WHERE id = ?", "название", record.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
held.MoveToState(entity.StateNormalized)
|
||||
require.NoError(t, records.Save(held, acquired.Holder))
|
||||
|
||||
after, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateNormalized, after.State, "результат шага не записан")
|
||||
require.NotNil(t, after.Title)
|
||||
assert.Equal(t, "название", *after.Title, "правка владельца стёрта снимком шага")
|
||||
}
|
||||
|
||||
// Составная операция, которая читает и следом пишет, идёт по пишущему
|
||||
// соединению и по занятости базы не отказывает — сколько бы потоков её ни вело.
|
||||
func TestComposedOperationsDoNotFailOnBusyDatabase(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
texts := NewTextRepository(db)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, err := texts.Put(record.Id, entity.TextKindTranscript, "разбор")
|
||||
assert.NoError(t, err, "поток %d отказал по занятости базы", i)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
var count int
|
||||
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
|
||||
assert.Equal(t, 1, count, "восемь потоков завели больше одной строки текста")
|
||||
}
|
||||
|
||||
// Пустая замена не стирает ни сохранённый текст, ни сохранённый ответ
|
||||
// провайдера: повторный опрос вправе вернуть пустое, и безусловная замена
|
||||
// стёрла бы расшифровку живого человека без следа.
|
||||
func TestEmptyReplacementKeepsStoredResult(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
|
||||
texts := NewTextRepository(db)
|
||||
stored, err := texts.Put(record.Id, entity.TextKindTranscript, "живая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
again, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, stored.Id, again.Id, "строка та же")
|
||||
|
||||
read, err := texts.GetByID(stored.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "живая расшифровка", read.Contents, "пустое стёрло сохранённую расшифровку")
|
||||
|
||||
structures := NewStructureRepository(db)
|
||||
first, err := structures.Put(record.Id, entity.StructureVersion,
|
||||
[]entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = structures.Put(record.Id, entity.StructureVersion, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
structure, err := structures.GetByID(first.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, structure.Replicas, 1, "пустой разбор стёр сохранённые реплики")
|
||||
|
||||
recognitions := NewRecognitionRepository(db, store)
|
||||
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
|
||||
require.NoError(t, recognitions.Create(attempt))
|
||||
require.NoError(t, recognitions.Finish(attempt.Id, []byte("ответ провайдера")))
|
||||
require.NoError(t, recognitions.Finish(attempt.Id, nil))
|
||||
|
||||
raw, err := recognitions.ReadRaw(attempt.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ответ провайдера", string(raw), "пустое стёрло сохранённый ответ провайдера")
|
||||
}
|
||||
|
||||
// Сохранённый ответ провайдера лежит третьим файлом в подкаталоге записи, и шаг
|
||||
// опроса читает строку попытки без него.
|
||||
func TestProviderPayloadLivesInRecordDirectory(t *testing.T) {
|
||||
db, store, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
|
||||
recognitions := NewRecognitionRepository(db, store)
|
||||
attempt := &entity.Recognition{RecordID: record.Id, Provider: "проверка"}
|
||||
require.NoError(t, recognitions.Create(attempt))
|
||||
require.NoError(t, recognitions.Finish(attempt.Id, []byte("полный ответ провайдера")))
|
||||
|
||||
// Строка попытки читается без ответа: он не колонка.
|
||||
read, err := recognitions.GetByID(attempt.Id)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, read.FinishedAt)
|
||||
|
||||
file, err := store.Open(record.Id, attempt.Id+payloadSuffix)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, file.Close())
|
||||
}
|
||||
|
||||
// Два одновременных первых обращения одним значением дают ровно одну учётную
|
||||
// запись: уникальность держит схема, а не порядок обращений.
|
||||
func TestConcurrentFirstRequestsGiveOneAccount(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
login := ident.New()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
ids = map[string]bool{}
|
||||
)
|
||||
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
|
||||
account, _, err := users.EnsureUser(contract.Identity{Login: login})
|
||||
if assert.NoError(t, err) {
|
||||
mu.Lock()
|
||||
ids[account.ID] = true
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
assert.Len(t, ids, 1, "одновременные первые обращения дали разные учётные записи")
|
||||
|
||||
var rows int
|
||||
require.NoError(t, db.Reader().
|
||||
QueryRowContext(context.Background(),
|
||||
"SELECT COUNT(*) FROM users WHERE provider_login = ?", login).Scan(&rows))
|
||||
assert.Equal(t, 1, rows, "в таблице пользователей больше одной строки")
|
||||
}
|
||||
|
||||
// Занятая почта не мешает завести запись: она необязательна и ключом не служит.
|
||||
func TestBusyEmailDoesNotBlockAccount(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
|
||||
first, _, err := users.EnsureUser(contract.Identity{Login: "one", Email: "shared@example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, created, err := users.EnsureUser(contract.Identity{Login: "two", Email: "shared@example.com"})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
assert.NotEqual(t, first.ID, second.ID)
|
||||
|
||||
var email string
|
||||
require.NoError(t, db.Reader().
|
||||
QueryRowContext(context.Background(),
|
||||
"SELECT email FROM users WHERE id = ?", second.ID).Scan(&email))
|
||||
assert.Empty(t, email, "вторая запись завелась с чужой почтой")
|
||||
}
|
||||
|
||||
// Найденную запись повторное обращение не переписывает: иначе правка имени у
|
||||
// провайдера меняла бы карточку человека молча, посреди его работы.
|
||||
func TestSecondRequestDoesNotRewriteAccount(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
|
||||
first, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Первое имя"})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
again, created, err := users.EnsureUser(contract.Identity{Login: "person", Name: "Второе имя"})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, created)
|
||||
assert.Equal(t, first.ID, again.ID)
|
||||
assert.Equal(t, "Первое имя", again.Name, "имя переписано вторым обращением")
|
||||
}
|
||||
|
||||
// Узнавание известного не берёт пишущего соединения: поиск идёт читающим пулом,
|
||||
// и занятый писатель его не держит.
|
||||
//
|
||||
// Проверка нужна потому, что цена ошибки здесь не видна ни отказом, ни строкой в
|
||||
// журнале. Слой узнавания одет на весь корень приложения, поэтому пишущая
|
||||
// транзакция, взятая до поиска, досталась бы всему узнанному потоку — опросу
|
||||
// карточки раз в четыре секунды и каждому запросу диапазона при проигрывании, —
|
||||
// и встала бы в очередь к единственному пишущему соединению. Замером триажа
|
||||
// такое ожидание доходило до сотен миллисекунд и **отказом не кончалось**:
|
||||
// очередь к соединению ожиданием занятой базы не ограничена.
|
||||
func TestKnownAccountDoesNotTakeWriter(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
login := ident.New()
|
||||
|
||||
first, created, err := users.EnsureUser(contract.Identity{Login: login})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
// Писателя занимает открытая транзакция: соединение у пишущего пула одно,
|
||||
// поэтому пока она держится, второй транзакции не начаться.
|
||||
tx, err := db.Writer().BeginTx(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
type answer struct {
|
||||
account *contract.UserAccount
|
||||
created bool
|
||||
err error
|
||||
took time.Duration
|
||||
}
|
||||
done := make(chan answer, 1)
|
||||
go func() {
|
||||
started := time.Now()
|
||||
account, created, err := users.EnsureUser(contract.Identity{Login: login})
|
||||
done <- answer{account: account, created: created, err: err, took: time.Since(started)}
|
||||
}()
|
||||
|
||||
// Предел взят с запасом: запрос читающим пулом идёт по месту, а ждущее
|
||||
// узнавание не дождётся вовсе — транзакцию отпускают уже после проверки.
|
||||
const limit = time.Second
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
require.NoError(t, got.err)
|
||||
assert.False(t, got.created, "узнавание известного завело вторую учётную запись")
|
||||
assert.Equal(t, first.ID, got.account.ID)
|
||||
t.Logf("узнавание известного заняло %s при занятом писателе", got.took)
|
||||
case <-time.After(limit):
|
||||
t.Errorf("узнавание известного ждёт писателя дольше %s", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// Негодный логин учётной записи не заводит: пустой заголовок прокси шлёт штатно
|
||||
// там, где никого не назвал.
|
||||
func TestUnacceptableLoginCreatesNothing(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
users := NewUserRepository(db)
|
||||
|
||||
for name, login := range map[string]string{
|
||||
"пустой": "",
|
||||
"одни пробелы": " ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, err := users.EnsureUser(contract.Identity{Login: login})
|
||||
assert.ErrorIs(t, err, contract.ErrLoginNotAcceptable)
|
||||
})
|
||||
}
|
||||
|
||||
var rows int
|
||||
require.NoError(t, db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM users").Scan(&rows))
|
||||
assert.Equal(t, 0, rows, "негодный логин завёл учётную запись")
|
||||
}
|
||||
|
||||
// TestResumeReleasesEveryGuard — то, что делает подкоманда возврата в работу.
|
||||
//
|
||||
// Возврат идёт **через домен**: тот, кто его делает, называет запись, а поля
|
||||
// сбрасывает домен одним действием. Перечень назван целиком, потому что забытое
|
||||
// поле не даёт ни отказа, ни строки в журнале: оставленный признак захвата
|
||||
// держит запись занятой до протухания срока, оставленное время входа в рубеж
|
||||
// останавливает её снова первым же захватом, а оставленные отказы и пауза
|
||||
// откладывают первый прогон на накопленный срок.
|
||||
func TestResumeReleasesEveryGuard(t *testing.T) {
|
||||
db, _, _ := newTestDB(t)
|
||||
|
||||
owner := newOwner(t, db)
|
||||
record := newWorkingRecord(t, db, owner)
|
||||
records := NewAudioRecordRepository(db)
|
||||
events := NewRecordEventRepository(db)
|
||||
|
||||
// Так выглядит запись, остановленная после долгих отказов: захват на ней
|
||||
// стоит, срок его далеко впереди, отказы накоплены, пауза назначена, а в
|
||||
// рубеже она простояла дольше предела.
|
||||
_, err := db.Writer().ExecContext(context.Background(), `
|
||||
UPDATE audio_records
|
||||
SET halted_at = ?, halt_reason = ?, error_text = ?,
|
||||
acquisition_id = ?, acquire_expires_at = ?,
|
||||
delay_time = ?, attempts = 7, state_entered_at = ?
|
||||
WHERE id = ?`,
|
||||
clock.Now().Format(timeLayout), entity.HaltReasonAttempts, "исчерпаны отказы",
|
||||
ident.New(), clock.Now().Add(8*time.Hour).Format(timeLayout),
|
||||
clock.Now().Add(time.Hour).Format(timeLayout),
|
||||
clock.Now().Add(-48*time.Hour).Format(timeLayout),
|
||||
record.Id,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Остановленная запись захвату не выдаётся.
|
||||
_, err = records.FindAndAcquire(entity.WorkingStages())
|
||||
var missing *contract.JobNotFoundError
|
||||
require.ErrorAs(t, err, &missing, "остановленная запись досталась захвату")
|
||||
|
||||
halted, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
halted.Resume()
|
||||
require.NoError(t, records.Save(halted, ""))
|
||||
require.NoError(t, events.Append(&entity.RecordEvent{
|
||||
RecordID: record.Id,
|
||||
Origin: entity.EventOriginHuman,
|
||||
Step: "resume",
|
||||
Outcome: entity.EventOutcomeResumed,
|
||||
}))
|
||||
|
||||
after, err := records.Get(record.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.False(t, after.IsHalted(), "признак остановки остался")
|
||||
assert.Nil(t, after.HaltReason, "причина остановки осталась")
|
||||
assert.Nil(t, after.ErrorText, "машинный текст отказа остался")
|
||||
assert.Nil(t, after.AcquisitionID, "признак захвата остался")
|
||||
assert.Nil(t, after.AcquireExpiresAt, "срок протухания захвата остался")
|
||||
assert.Nil(t, after.DelayTime, "пауза перед повтором осталась")
|
||||
assert.Equal(t, 0, after.Attempts, "число отказов осталось")
|
||||
assert.WithinDuration(t, clock.Now(), after.StateEnteredAt, time.Minute,
|
||||
"время входа в рубеж не сброшено: запись остановится снова первым же захватом")
|
||||
assert.Equal(t, entity.StateUploaded, after.State, "рубеж не пережил возврата в работу")
|
||||
|
||||
// Ближайший захват выдаёт запись, не дожидаясь протухания прежнего срока.
|
||||
acquired, err := records.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "возвращённая в работу запись захвату не досталась")
|
||||
assert.Equal(t, record.Id, acquired.ID)
|
||||
|
||||
// И возврат виден в журнале событий записи — происхождением «человек».
|
||||
var origin, outcome string
|
||||
require.NoError(t, db.Reader().QueryRowContext(context.Background(),
|
||||
"SELECT origin, outcome FROM record_events WHERE record_id = ?", record.Id,
|
||||
).Scan(&origin, &outcome))
|
||||
assert.Equal(t, entity.EventOriginHuman, origin)
|
||||
assert.Equal(t, entity.EventOutcomeResumed, outcome)
|
||||
}
|
||||
|
||||
// Подъём на чистом каталоге данных не оставляет в журнале ни одного отказа: до
|
||||
// строки о готовности схема приведена целиком.
|
||||
func TestCleanStartLeavesNoFailureInJournal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
journal := &strings.Builder{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
db, err := Open(dir, testSettings())
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, db.Close()) }()
|
||||
|
||||
require.NoError(t, Migrate(context.Background(), db, dir, logger))
|
||||
|
||||
assert.NotContains(t, journal.String(), "level=ERROR", "подъём оставил отказ в журнале")
|
||||
assert.NotContains(t, journal.String(), "level=WARN", "подъём оставил предупреждение в журнале")
|
||||
assert.Contains(t, journal.String(), "Schema migration applied", "накат не отчитался")
|
||||
|
||||
// И хранилище готово принимать записи сразу: ручного шага между подъёмом и
|
||||
// первым приёмом нет.
|
||||
owner := newOwner(t, db)
|
||||
newWorkingRecord(t, db, owner)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// recordsDir — раздел каталога данных, в котором лежат файлы записей.
|
||||
const recordsDir = "records"
|
||||
|
||||
// tempPrefix — приставка временного имени укладки. Точка в начале уводит такие
|
||||
// имена из обычного перечисления каталога, а сама приставка отличает
|
||||
// незавершённую укладку от рабочего имени.
|
||||
const tempPrefix = ".partial-"
|
||||
|
||||
// Store — файлы записей в каталоге данных.
|
||||
//
|
||||
// Раскладка: подкаталог на запись, названный её идентификатором, и в нём копии
|
||||
// под именами, которые задаёт сервис. Так копии одной записи лежат вместе, а
|
||||
// запись убирается целиком одним движением — плоский каталог, где копии
|
||||
// различаются приставкой в имени, обращал бы уборку в перебор по маске.
|
||||
//
|
||||
// Имя, данное отправителем, в раскладку не попадает ни одной частью: ни именем
|
||||
// файла, ни именем каталога.
|
||||
type Store struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewStore заводит раздел записей в каталоге данных.
|
||||
func NewStore(dataDir string) *Store {
|
||||
return &Store{root: filepath.Join(dataDir, recordsDir)}
|
||||
}
|
||||
|
||||
// dir — подкаталог одной записи.
|
||||
func (s *Store) dir(recordID string) string {
|
||||
return filepath.Join(s.root, recordID)
|
||||
}
|
||||
|
||||
// path — путь копии. Наружу не отдаётся: путь на диске не идёт ни в журнал, ни
|
||||
// в ответ, ни в метку метрики.
|
||||
func (s *Store) path(recordID, name string) string {
|
||||
return filepath.Join(s.dir(recordID), name)
|
||||
}
|
||||
|
||||
// Put кладёт содержимое под рабочим именем **атомарно**.
|
||||
//
|
||||
// Содержимое пишется во временное имя в том же подкаталоге записи и
|
||||
// переименовывается в рабочее только после того, как поток дочитан до конца без
|
||||
// отказа. Временное имя берётся в том же каталоге потому, что переименование в
|
||||
// его пределах не копирует содержимое и не может оборваться на середине.
|
||||
//
|
||||
// Средство обнаружить усечение у сервиса одно, и оно снято намеренно: величины
|
||||
// записи со строкой файла не сверяются. Усечённая запись поэтому уехала бы в
|
||||
// конвейер, оплатила распознавание и отдала расшифровку половины как готовый
|
||||
// результат — атомарная укладка единственное, что этого не допускает.
|
||||
//
|
||||
// Отказ источника и отмена посреди потока кончаются одним исходом: временного
|
||||
// имени не остаётся, рабочего имени не появляется.
|
||||
func (s *Store) Put(recordID, name string, src io.Reader) (int64, error) {
|
||||
if err := os.MkdirAll(s.dir(recordID), 0o750); err != nil {
|
||||
return 0, fmt.Errorf("failed to create the directory of record %s: %w", recordID, causeOf(err))
|
||||
}
|
||||
|
||||
temp := s.path(recordID, tempPrefix+ident.New())
|
||||
file, err := os.OpenFile(temp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to open the incoming copy of record %s: %w", recordID, causeOf(err))
|
||||
}
|
||||
|
||||
size, copyErr := io.Copy(file, src)
|
||||
syncErr := file.Sync()
|
||||
closeErr := file.Close()
|
||||
if err := errors.Join(copyErr, syncErr, closeErr); err != nil {
|
||||
_ = os.Remove(temp)
|
||||
// Путь и имя файла в цепочку не идут, а причина идёт: отказ кончается в
|
||||
// журнале, журнал уезжает в собранные логи, откуда строку не убрать, —
|
||||
// но по причине владелец различает исчерпание места, отсутствие прав и
|
||||
// файловую систему только для чтения.
|
||||
return 0, fmt.Errorf("failed to store a copy of record %s: %w", recordID, causeOf(err))
|
||||
}
|
||||
|
||||
if err := os.Rename(temp, s.path(recordID, name)); err != nil {
|
||||
_ = os.Remove(temp)
|
||||
return 0, fmt.Errorf("failed to publish a copy of record %s: %w", recordID, causeOf(err))
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
// Open отдаёт содержимое копии потоком с возможностью перемотки: отдача файла
|
||||
// по диапазону читает кусок, а не файл целиком.
|
||||
func (s *Store) Open(recordID, name string) (*os.File, error) {
|
||||
file, err := os.Open(s.path(recordID, name))
|
||||
if err != nil {
|
||||
// Отказ называет запись её идентификатором и не несёт имени файла:
|
||||
// имя — часть пути к чужому аудио. Причина при этом остаётся: «файла
|
||||
// нет» и «прав нет» ведут владельца к разным действиям.
|
||||
return nil, fmt.Errorf("failed to read a copy of record %s: %w", recordID, causeOf(err))
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// Remove убирает копию. Отсутствие файла отказом не считается: уборка зовётся и
|
||||
// там, где укладка до него не дошла.
|
||||
func (s *Store) Remove(recordID, name string) error {
|
||||
if err := os.Remove(s.path(recordID, name)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove a copy of record %s: %w", recordID, causeOf(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasTemporary говорит, осталось ли в подкаталоге записи незавершённое имя.
|
||||
// Нужен проверкам: обещание атомарной укладки иначе судилось бы только по
|
||||
// отсутствию рабочего имени.
|
||||
func (s *Store) HasTemporary(recordID string) (bool, error) {
|
||||
entries, err := os.ReadDir(s.dir(recordID))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("failed to read the directory of record %s: %w", recordID, causeOf(err))
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if len(entry.Name()) > len(tempPrefix) && entry.Name()[:len(tempPrefix)] == tempPrefix {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// causeOf снимает с отказа файловой операции путь, оставляя причину.
|
||||
//
|
||||
// Обе половины обязательны, и порознь они друг друга отменяют. Причина нужна:
|
||||
// по ней владелец различает исчерпание места, отсутствие прав и файловую систему
|
||||
// только для чтения — три поломки, требующие трёх разных действий, а отказ
|
||||
// укладки — единственная поверхность, на которой он их видит. Путь не нужен и
|
||||
// вреден: он ведёт внутрь каталога данных, а отказ кончается в журнале, откуда
|
||||
// строку потом не убрать.
|
||||
//
|
||||
// Пакет `os` отдаёт причину обёрнутой в `*os.PathError` либо `*os.LinkError` —
|
||||
// именно там и лежит путь. Заворачивается поэтому `.Err`, а не обёртка целиком:
|
||||
// `errors.Is` до `fs.ErrPermission` и `syscall.ENOSPC` сравнивает значение под
|
||||
// обёрткой и от её снятия не страдает.
|
||||
//
|
||||
// Соединённый отказ разбирается по частям: укладка складывает отказы записи,
|
||||
// сброса и закрытия, и путь лежит в каждой из них.
|
||||
func causeOf(err error) error {
|
||||
switch typed := err.(type) { //nolint:errorlint // разбирается сам отказ, а не цепочка: обёртку и надо снять
|
||||
case *os.PathError:
|
||||
return typed.Err
|
||||
case *os.LinkError:
|
||||
return typed.Err
|
||||
case interface{ Unwrap() []error }:
|
||||
parts := typed.Unwrap()
|
||||
causes := make([]error, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
causes = append(causes, causeOf(part))
|
||||
}
|
||||
return errors.Join(causes...)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
type TextRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
func NewTextRepository(db *DB) *TextRepository {
|
||||
return &TextRepository{db: db}
|
||||
}
|
||||
|
||||
// Put кладёт текст записи, заменяя прежний того же вида.
|
||||
//
|
||||
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
|
||||
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
|
||||
// человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
//
|
||||
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
|
||||
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
|
||||
// умер, сохранение рубежа отказало, человек вернул запись в работу. Провайдер
|
||||
// при этом вправе ответить пустым потоком, отказом это не считается, и
|
||||
// безусловная замена стирала бы сохранённую расшифровку живого человека без
|
||||
// следа и без возврата. Та же защита стоит у сохранённого ответа провайдера, и
|
||||
// разное правило у двух хранителей одного результата читалось бы как недосмотр.
|
||||
//
|
||||
// **Граница транзакции — весь метод.** Он читает состояние, которое сам же
|
||||
// пишет, и идёт целиком по пишущему соединению: разорванный надвое, он завёл бы
|
||||
// вторую строку на гонке двух шагов.
|
||||
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
|
||||
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open a transaction for the text of record %s: %w", recordID, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var (
|
||||
id string
|
||||
existing string
|
||||
)
|
||||
err = tx.QueryRowContext(context.Background(),
|
||||
"SELECT id, contents FROM texts WHERE record_id = ? AND kind = ?", recordID, kind,
|
||||
).Scan(&id, &existing)
|
||||
|
||||
now := formatTime(clock.Now())
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
// Прежнее непустое содержимое пустым не заменяется: строка остаётся как
|
||||
// есть, и вызывающий получает её обратно.
|
||||
if contents == "" && existing != "" {
|
||||
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: existing}, nil
|
||||
}
|
||||
if _, err := tx.ExecContext(context.Background(),
|
||||
"UPDATE texts SET contents = ?, updated_at = ? WHERE id = ?", contents, now, id,
|
||||
); err != nil {
|
||||
// Текст расшифровки наружу не выходит даже отказом: цепочка `%w` от
|
||||
// драйвера несёт значение поля.
|
||||
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
id = ident.New()
|
||||
if _, err := tx.ExecContext(context.Background(),
|
||||
`INSERT INTO texts (id, record_id, kind, contents, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
id, recordID, kind, contents, now, now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to store text of kind %s for record %s", kind, recordID)
|
||||
}
|
||||
default:
|
||||
// Отказ базы «строкой нет» не является, и подменять его вставкой нельзя:
|
||||
// она упрётся в уникальный индекс, и наверх уедет жалоба на запись
|
||||
// вместо правды о недоступной базе.
|
||||
return nil, fmt.Errorf("failed to look up text of kind %s for record %s: %w", kind, recordID, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit the text of record %s: %w", recordID, err)
|
||||
}
|
||||
|
||||
return &entity.Text{Id: id, RecordID: recordID, Kind: kind, Contents: contents}, nil
|
||||
}
|
||||
|
||||
func (repo *TextRepository) GetByID(id string) (*entity.Text, error) {
|
||||
text := &entity.Text{Id: id}
|
||||
err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||
"SELECT record_id, kind, contents FROM texts WHERE id = ?", id,
|
||||
).Scan(&text.RecordID, &text.Kind, &text.Contents)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get text %s: %w", id, err)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
type StructureRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
func NewStructureRepository(db *DB) *StructureRepository {
|
||||
return &StructureRepository{db: db}
|
||||
}
|
||||
|
||||
// Put кладёт структуру реплик, заменяя прежнюю той же версии разбора. Довод тот
|
||||
// же, что и у текста: повтор шага не должен заводить второй строки, а пустой
|
||||
// перечень реплик поверх непустого не кладётся.
|
||||
func (repo *StructureRepository) Put(recordID string, version int, replicas []entity.Replica) (*entity.Structure, error) {
|
||||
if replicas == nil {
|
||||
replicas = []entity.Replica{}
|
||||
}
|
||||
contents, err := json.Marshal(replicas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode structure of record %s", recordID)
|
||||
}
|
||||
|
||||
tx, err := repo.db.Writer().BeginTx(context.Background(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open a transaction for the structure of record %s: %w", recordID, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var (
|
||||
id string
|
||||
existing string
|
||||
)
|
||||
err = tx.QueryRowContext(context.Background(),
|
||||
"SELECT id, contents FROM structures WHERE record_id = ? AND version = ?", recordID, version,
|
||||
).Scan(&id, &existing)
|
||||
|
||||
now := formatTime(clock.Now())
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
if len(replicas) == 0 && len(existing) > len("[]") {
|
||||
stored, decodeErr := decodeReplicas(id, existing)
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: stored}, nil
|
||||
}
|
||||
if _, err := tx.ExecContext(context.Background(),
|
||||
"UPDATE structures SET contents = ?, updated_at = ? WHERE id = ?", string(contents), now, id,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
|
||||
}
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
id = ident.New()
|
||||
if _, err := tx.ExecContext(context.Background(),
|
||||
`INSERT INTO structures (id, record_id, version, contents, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
id, recordID, version, string(contents), now, now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to store structure of record %s", recordID)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("failed to look up structure of record %s: %w", recordID, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit the structure of record %s: %w", recordID, err)
|
||||
}
|
||||
|
||||
return &entity.Structure{Id: id, RecordID: recordID, Version: version, Replicas: replicas}, nil
|
||||
}
|
||||
|
||||
func (repo *StructureRepository) GetByID(id string) (*entity.Structure, error) {
|
||||
structure := &entity.Structure{Id: id}
|
||||
var contents string
|
||||
err := repo.db.Reader().QueryRowContext(context.Background(),
|
||||
"SELECT record_id, version, contents FROM structures WHERE id = ?", id,
|
||||
).Scan(&structure.RecordID, &structure.Version, &contents)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get structure %s: %w", id, err)
|
||||
}
|
||||
|
||||
replicas, err := decodeReplicas(id, contents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
structure.Replicas = replicas
|
||||
|
||||
return structure, nil
|
||||
}
|
||||
|
||||
// decodeReplicas разбирает сохранённые реплики. Текст расшифровки наружу
|
||||
// отказом не выходит: сообщение несёт идентификатор строки, и только его.
|
||||
func decodeReplicas(id, contents string) ([]entity.Replica, error) {
|
||||
if contents == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var replicas []entity.Replica
|
||||
if err := json.Unmarshal([]byte(contents), &replicas); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode structure %s", id)
|
||||
}
|
||||
return replicas, nil
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sqlitedriver "modernc.org/sqlite"
|
||||
sqlitelib "modernc.org/sqlite/lib"
|
||||
)
|
||||
|
||||
// timeLayout — единственный вид времени в схеме: RFC 3339, UTC, суффикс `Z`,
|
||||
// секундная точность.
|
||||
//
|
||||
// Ширина такой записи постоянная, поэтому лексикографический порядок `TEXT`
|
||||
// совпадает с хронологией, и отбор по колонке времени работает без разбора
|
||||
// значения. Своего типа времени у SQLite нет: колонка хранит то, что в неё
|
||||
// положили, — колонка, заполненная то одним видом, то другим, обратила бы
|
||||
// условие срока протухания захвата в постоянную истину или ложь молча, и запись
|
||||
// не выдавалась бы ни одному воркеру никогда.
|
||||
const timeLayout = "2006-01-02T15:04:05Z"
|
||||
|
||||
// formatTime приводит метку времени к виду колонки.
|
||||
func formatTime(v time.Time) string {
|
||||
return v.UTC().Format(timeLayout)
|
||||
}
|
||||
|
||||
// timeValue кладёт время в колонку, допускающую пустое значение. Нулевое время
|
||||
// и отсутствующее — одно и то же: «времени нет».
|
||||
func timeValue(v *time.Time) any {
|
||||
if v == nil || v.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return formatTime(*v)
|
||||
}
|
||||
|
||||
// timeOf читает колонку времени. Нечитаемое значение отдаётся нулевым: колонка
|
||||
// пишется только нами, и разбор здесь — сторож, а не ветвь поведения.
|
||||
func timeOf(v sql.NullString) *time.Time {
|
||||
if !v.Valid || v.String == "" {
|
||||
return nil
|
||||
}
|
||||
parsed, err := time.Parse(timeLayout, v.String)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parsed = parsed.UTC()
|
||||
return &parsed
|
||||
}
|
||||
|
||||
// requiredTimeOf читает обязательную колонку времени.
|
||||
func requiredTimeOf(v string) time.Time {
|
||||
parsed, err := time.Parse(timeLayout, v)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return parsed.UTC()
|
||||
}
|
||||
|
||||
// stringValue кладёт необязательную строку: пустая и отсутствующая — одно и то
|
||||
// же.
|
||||
func stringValue(v *string) any {
|
||||
if v == nil || *v == "" {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// stringOf читает необязательную строку.
|
||||
func stringOf(v sql.NullString) *string {
|
||||
if !v.Valid || v.String == "" {
|
||||
return nil
|
||||
}
|
||||
out := v.String
|
||||
return &out
|
||||
}
|
||||
|
||||
// numberOf читает необязательное число.
|
||||
//
|
||||
// Указатель здесь не выражает «неизвестно»: обе величины записи ставит приём и
|
||||
// ставит всегда, а колонки объявлены обязательными. Форма осталась указателем
|
||||
// потому, что её несёт домен, а ответ приложению обязан различать поле и его
|
||||
// отсутствие.
|
||||
func numberOf(v int64) *int64 {
|
||||
out := v
|
||||
return &out
|
||||
}
|
||||
|
||||
// numberValue кладёт необязательное число нулём: колонка обязательна.
|
||||
func numberValue(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// insertSQL собирает вставку из карты «колонка → значение».
|
||||
//
|
||||
// Именованными параметрами, а не позиционным списком: у аудиозаписи поля одного
|
||||
// типа идут длинным непрерывным рядом, и позиционный сдвиг на одно поле
|
||||
// скомпилировался бы молча, положив идентификатор файла в колонку текста. По
|
||||
// имени такого сдвига не существует вовсе.
|
||||
//
|
||||
// Порядок колонок берётся сортировкой, а не порядком обхода карты: обход карты
|
||||
// в Go случаен, и текст запроса менялся бы от прогона к прогону — отладка по
|
||||
// журналу читала бы каждый раз новый запрос.
|
||||
func insertSQL(table string, values map[string]any) (string, []any) {
|
||||
names := sortedNames(values)
|
||||
|
||||
placeholders := make([]string, 0, len(names))
|
||||
args := make([]any, 0, len(names))
|
||||
for _, name := range names {
|
||||
placeholders = append(placeholders, ":"+name)
|
||||
args = append(args, sql.Named(name, values[name]))
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"INSERT INTO %s (%s) VALUES (%s)",
|
||||
table,
|
||||
strings.Join(names, ", "),
|
||||
strings.Join(placeholders, ", "),
|
||||
)
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
// updateSQL собирает правку из карты «колонка → значение» и условия.
|
||||
func updateSQL(table string, values map[string]any, where string, whereArgs []any) (string, []any) {
|
||||
names := sortedNames(values)
|
||||
|
||||
assignments := make([]string, 0, len(names))
|
||||
args := make([]any, 0, len(names)+len(whereArgs))
|
||||
for _, name := range names {
|
||||
assignments = append(assignments, name+" = :"+name)
|
||||
args = append(args, sql.Named(name, values[name]))
|
||||
}
|
||||
args = append(args, whereArgs...)
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"UPDATE %s SET %s WHERE %s",
|
||||
table,
|
||||
strings.Join(assignments, ", "),
|
||||
where,
|
||||
)
|
||||
|
||||
return query, args
|
||||
}
|
||||
|
||||
func sortedNames(values map[string]any) []string {
|
||||
names := make([]string, 0, len(values))
|
||||
for name := range values {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// selectList собирает перечень колонок для выборки из той же карты, по которой
|
||||
// потом идёт чтение. Один источник у обеих половин: колонка, забытая в перечне,
|
||||
// не имеет места назначения, и наоборот — расхождению взяться неоткуда.
|
||||
func selectList(targets map[string]any, prefix string) (string, []any) {
|
||||
names := sortedNames(targets)
|
||||
|
||||
columns := make([]string, 0, len(names))
|
||||
scan := make([]any, 0, len(names))
|
||||
for _, name := range names {
|
||||
columns = append(columns, prefix+name)
|
||||
scan = append(scan, targets[name])
|
||||
}
|
||||
|
||||
return strings.Join(columns, ", "), scan
|
||||
}
|
||||
|
||||
// isUniqueViolation говорит, отказала ли запись по уникальному индексу.
|
||||
//
|
||||
// Судится **код** отказа, а не его текст: текст у драйвера свой на каждую
|
||||
// версию, а узнавание ошибки по тексту запрещено правилом проекта. Какая именно
|
||||
// колонка не сошлась, код не называет — и это не мешает: заведение учётной
|
||||
// записи различает два отказа повторным поиском по ключу, а не разбором текста.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var sqliteErr *sqlitedriver.Error
|
||||
if !errors.As(err, &sqliteErr) {
|
||||
return false
|
||||
}
|
||||
return sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_UNIQUE ||
|
||||
sqliteErr.Code() == sqlitelib.SQLITE_CONSTRAINT_PRIMARYKEY
|
||||
}
|
||||
+152
-40
@@ -113,6 +113,31 @@ func TestТранспортыНеЗнаютДругОДруге(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Транспорт не знает адаптеров. Изъятие, разрешавшее ему знать адаптер
|
||||
// хранилища, снято вместе с предметом: HTTP-поверхность была роутером
|
||||
// встроенного хранилища, а стала своей, и правило на это направление заводится
|
||||
// впервые.
|
||||
//
|
||||
// Что оно ловит: возврат `EnsureUser`, `NewFileRepository` и прочих имён
|
||||
// адаптера в обработчики. Знание о внешнем мире приходит транспорту интерфейсом
|
||||
// `internal/contract`, а реализацию подставляет точка входа.
|
||||
func TestТранспортыНеЗнаютАдаптеров(t *testing.T) {
|
||||
for pkg, imports := range internalImports(t) {
|
||||
if !transports[pkg] {
|
||||
continue
|
||||
}
|
||||
for _, imp := range imports {
|
||||
if strings.HasPrefix(imp, adapterPrefix) {
|
||||
t.Errorf(
|
||||
"транспорт %s импортирует адаптер %s: реализацию подставляет "+
|
||||
"cmd/transcriber, а транспорт знает только internal/contract",
|
||||
pkg, imp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestАдаптерыНеЗнаютНиЯдра_НиТранспортов(t *testing.T) {
|
||||
for pkg, imports := range internalImports(t) {
|
||||
if !strings.HasPrefix(pkg, adapterPrefix) {
|
||||
@@ -173,16 +198,19 @@ func TestОшибкаНеУзнаётсяПоТексту(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Перечень колонок аудиозаписи компилятор не видит: их пишет `applyOwnedByPipeline`,
|
||||
// читает `recordToAudioRecord`, и заводит шаг схемы. Колонка, забытая в паре
|
||||
// «пишем — читаем», теряется молча: запись, прочитанная не тем путём, приезжает
|
||||
// с нулевым полем, и первое же сохранение пишет этот ноль поверх значения.
|
||||
// Перечень колонок аудиозаписи компилятор не видит: их пишет `writeOwnedByPipeline`
|
||||
// вместе с `writeRecord`, читает `readRecordColumns`, доводит до сущности
|
||||
// `rowToAudioRecord`, и заводит шаг схемы. Колонка, забытая в любом звене этой
|
||||
// цепочки, теряется молча: запись, прочитанная не тем путём, приезжает с нулевым
|
||||
// полем, и первое же сохранение пишет этот ноль поверх значения.
|
||||
//
|
||||
// Мест стало **два** вместо прежних четырёх: захват больше не перечисляет
|
||||
// колонки поимённо, а возвращает идентификатор и признак своего захвата. Правила
|
||||
// ниже держат оставшуюся пару плюс шаг схемы.
|
||||
// Отображение работает **по имени колонки** — именованные параметры запроса и
|
||||
// место назначения, найденное по имени, — поэтому правила ниже сверяют имена, а
|
||||
// не порядок полей. Ту поломку, где колонка не забыта, а перепутана местом, эта
|
||||
// форма снимает сама: позиционного списка, который сдвинулся бы на одно поле, у
|
||||
// отображения нет вовсе.
|
||||
const (
|
||||
repoPkg = "internal/adapter/repo/pocketbase"
|
||||
repoPkg = "internal/adapter/repo/sqlite"
|
||||
mappingFile = repoPkg + "/record_mapping.go"
|
||||
migrationsPath = repoPkg + "/migrations"
|
||||
stageFile = "internal/entity/stage.go"
|
||||
@@ -190,46 +218,64 @@ const (
|
||||
serviceFile = "internal/service/transcribe.go"
|
||||
)
|
||||
|
||||
// Колонки, которые заводит и заполняет само хранилище: нашего кода они не
|
||||
// касаются.
|
||||
var storageOwned = map[string]bool{"id": true, "created": true, "updated": true}
|
||||
|
||||
func TestКолонкиЗаписиПишутсяИЧитаются(t *testing.T) {
|
||||
written := writtenColumns(t)
|
||||
read := readColumns(t)
|
||||
|
||||
for col := range written {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !read[col] {
|
||||
t.Errorf(
|
||||
"колонку %q пишет отображение записи, но recordToAudioRecord её не "+
|
||||
"читает: запись приедет из хранилища без этого поля",
|
||||
"колонку %q пишет отображение записи, но readRecordColumns её не "+
|
||||
"читает: запись приедет из базы без этого поля",
|
||||
col,
|
||||
)
|
||||
}
|
||||
}
|
||||
for col := range read {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !written[col] {
|
||||
t.Errorf(
|
||||
"колонку %q читает recordToAudioRecord, но её не пишет ни "+
|
||||
"applyOwnedByPipeline, ни applyToRecord: поле не сохранится",
|
||||
"колонку %q читает readRecordColumns, но её не пишет ни "+
|
||||
"writeOwnedByPipeline, ни writeRecord: поле не сохранится",
|
||||
col,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Колонка, прочитанная в поле сырой строки, обязана доехать до сущности:
|
||||
// `readRecordColumns` называет, куда ляжет значение, а `rowToAudioRecord`
|
||||
// решает, возьмут ли его оттуда. Поле, забытое во втором, теряется молча —
|
||||
// компилятор его не видит, спрошенная колонка приезжает и остаётся лежать в
|
||||
// сырой строке, сущность получает нулевое значение, а ближайшее сохранение
|
||||
// пишет этот ноль поверх сохранённого.
|
||||
func TestПрочитанныеКолонкиДоезжаютДоСущности(t *testing.T) {
|
||||
targets := readTargets(t)
|
||||
used := rowFieldsTakenByEntity(t)
|
||||
|
||||
for field, column := range targets {
|
||||
if !used[field] {
|
||||
t.Errorf(
|
||||
"колонка %q читается в поле row.%s, но rowToAudioRecord его не берёт: "+
|
||||
"значение не доедет до сущности, а ближайшее сохранение запишет "+
|
||||
"нулевое поверх сохранённого",
|
||||
column, field,
|
||||
)
|
||||
}
|
||||
}
|
||||
for field := range used {
|
||||
if _, ok := targets[field]; !ok {
|
||||
t.Errorf(
|
||||
"rowToAudioRecord берёт поле row.%s, но readRecordColumns ни одной "+
|
||||
"колонки в него не кладёт: сущность получит нулевое значение всегда",
|
||||
field,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestКолонкиЗаписиЗаведеныШагомСхемы(t *testing.T) {
|
||||
declared := schemaFieldNames(t)
|
||||
for col := range writtenColumns(t) {
|
||||
if storageOwned[col] {
|
||||
continue
|
||||
}
|
||||
if !declared[col] {
|
||||
t.Errorf(
|
||||
"колонка %q пишется отображением записи, но ни один шаг схемы её не "+
|
||||
@@ -312,13 +358,16 @@ func TestШагиОбъявленыРубежамиДескриптора(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// writtenColumns — колонки, которые пишет отображение записи в хранилище.
|
||||
// columnKey — имя колонки в карте отображения: строковый ключ в начале строки.
|
||||
var columnKey = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":`)
|
||||
|
||||
// writtenColumns — колонки, которые пишет отображение записи в базу.
|
||||
func writtenColumns(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func applyOwnedByPipeline(") +
|
||||
funcBody(t, mappingFile, "func applyToRecord(")
|
||||
body := funcBody(t, mappingFile, "func writeOwnedByPipeline(") +
|
||||
funcBody(t, mappingFile, "func writeRecord(")
|
||||
out := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`record\.Set\("([^"]+)"`).FindAllStringSubmatch(body, -1) {
|
||||
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
@@ -327,16 +376,51 @@ func writtenColumns(t *testing.T) map[string]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// readColumns — колонки, которые читает обратное отображение.
|
||||
// readColumns — колонки, которые читает обратное отображение. Перечень выборки
|
||||
// собирается из той же карты, поэтому сверяется именно она.
|
||||
func readColumns(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func recordToAudioRecord(")
|
||||
body := funcBody(t, mappingFile, "func readRecordColumns(")
|
||||
out := map[string]bool{}
|
||||
for _, m := range regexp.MustCompile(`\.Get\w+\("([^"]+)"\)`).FindAllStringSubmatch(body, -1) {
|
||||
for _, m := range columnKey.FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("recordToAudioRecord не читает ни одной колонки: правило потеряло предмет")
|
||||
t.Fatalf("readRecordColumns не читает ни одной колонки: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readTarget — колонка чтения и поле сырой строки, куда она ложится.
|
||||
var readTarget = regexp.MustCompile(`(?m)^\s*"([a-z_0-9]+)":\s*&row\.(\w+),`)
|
||||
|
||||
// rowFieldUse — обращение к полю сырой строки при сборке сущности.
|
||||
var rowFieldUse = regexp.MustCompile(`\brow\.(\w+)\b`)
|
||||
|
||||
// readTargets — поле сырой строки в имя колонки, которая в него читается.
|
||||
func readTargets(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func readRecordColumns(")
|
||||
out := map[string]string{}
|
||||
for _, m := range readTarget.FindAllStringSubmatch(body, -1) {
|
||||
out[m[2]] = m[1]
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("readRecordColumns не кладёт ни одной колонки в поле строки: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rowFieldsTakenByEntity — поля сырой строки, которые берёт сборка сущности.
|
||||
func rowFieldsTakenByEntity(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
body := funcBody(t, mappingFile, "func rowToAudioRecord(")
|
||||
out := map[string]bool{}
|
||||
for _, m := range rowFieldUse.FindAllStringSubmatch(body, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("rowToAudioRecord не берёт ни одного поля строки: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -447,9 +531,11 @@ func funcBody(t *testing.T, file, header string) string {
|
||||
return body[start : start+end]
|
||||
}
|
||||
|
||||
// schemaFieldNames собирает имена полей, заведённых шагами схемы: `Name: "…"` в
|
||||
// любом файле каталога шагов. Перечень объединённый — колонку заводит тот шаг,
|
||||
// который её добавил, а переписывать применённый шаг нельзя.
|
||||
// schemaFieldNames собирает имена колонок аудиозаписи, заведённых шагами схемы.
|
||||
//
|
||||
// Читается объявление таблицы в любом файле каталога шагов: колонку заводит тот
|
||||
// шаг, который её добавил, а переписывать применённый шаг нельзя. Перечень
|
||||
// поэтому объединённый — по всем шагам сразу.
|
||||
func schemaFieldNames(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
dir := filepath.Join(repoRoot, migrationsPath)
|
||||
@@ -457,7 +543,8 @@ func schemaFieldNames(t *testing.T) map[string]bool {
|
||||
if err != nil {
|
||||
t.Fatalf("читаю каталог шагов схемы: %v", err)
|
||||
}
|
||||
re := regexp.MustCompile(`Name:\s*"([^"]+)"`)
|
||||
|
||||
column := regexp.MustCompile(`(?m)^\s*([a-z_0-9]+)\s+(TEXT|INTEGER)`)
|
||||
out := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||
@@ -467,16 +554,41 @@ func schemaFieldNames(t *testing.T) map[string]bool {
|
||||
if err != nil {
|
||||
t.Fatalf("читаю %s: %v", e.Name(), err)
|
||||
}
|
||||
for _, m := range re.FindAllStringSubmatch(string(body), -1) {
|
||||
out[m[1]] = true
|
||||
for _, block := range tableBlocks(string(body), recordsTable) {
|
||||
for _, m := range column.FindAllStringSubmatch(block, -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("шаги схемы не объявили ни одного поля: правило потеряло предмет")
|
||||
t.Fatalf("шаги схемы не объявили ни одной колонки аудиозаписи: правило потеряло предмет")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordsTable — имя таблицы аудиозаписей в шагах схемы.
|
||||
const recordsTable = "audio_records"
|
||||
|
||||
// tableBlocks вырезает объявления названной таблицы: от `CREATE TABLE имя (` до
|
||||
// закрывающей скобки в начале строки.
|
||||
func tableBlocks(body, table string) []string {
|
||||
var out []string
|
||||
marker := "CREATE TABLE " + table + " ("
|
||||
for {
|
||||
start := strings.Index(body, marker)
|
||||
if start < 0 {
|
||||
return out
|
||||
}
|
||||
body = body[start+len(marker):]
|
||||
end := strings.Index(body, "\n\t\t)")
|
||||
if end < 0 {
|
||||
return out
|
||||
}
|
||||
out = append(out, body[:end])
|
||||
body = body[end:]
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, rel string) string {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot, rel))
|
||||
|
||||
@@ -64,10 +64,38 @@ type ServerConfig struct {
|
||||
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
|
||||
}
|
||||
|
||||
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
|
||||
// записей. Двух путей, как было раньше, у хранилища не бывает.
|
||||
// StorageConfig — хранилище сервиса: каталог данных и числа его базы.
|
||||
//
|
||||
// Каталог единственный: под ним лежат и база, и файлы записей. Двух путей, как
|
||||
// было раньше, у хранилища не бывает.
|
||||
type StorageConfig struct {
|
||||
DataDir string `toml:"data_dir"`
|
||||
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
|
||||
//
|
||||
// Ключом, а не константой кода: крутят его при отказе «база занята» под
|
||||
// несколькими воркерами, и подбор ответа на такой отказ не должен требовать
|
||||
// пересборки образа.
|
||||
BusyTimeoutMs int `toml:"busy_timeout_ms"`
|
||||
// ReadConnections — сколько соединений держит читающий пул. Пишущее
|
||||
// соединение при этом всегда одно, и настройкой оно не делается: драйвер
|
||||
// пишет единственным соединением, и второе означало бы отказы по занятости.
|
||||
ReadConnections int `toml:"read_connections"`
|
||||
}
|
||||
|
||||
// Validate проверяет настройки хранилища. Ноль и отрицательное — опечатка, а не
|
||||
// режим: нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
|
||||
// чтения означает пул без предела, то есть настройку, которой не управляют.
|
||||
func (c StorageConfig) Validate() error {
|
||||
if strings.TrimSpace(c.DataDir) == "" {
|
||||
return errors.New("storage: не заполнен ключ data_dir: сервису негде держать базу и файлы записей")
|
||||
}
|
||||
if c.BusyTimeoutMs <= 0 {
|
||||
return errors.New("storage: busy_timeout_ms задаётся положительным числом миллисекунд")
|
||||
}
|
||||
if c.ReadConnections <= 0 {
|
||||
return errors.New("storage: read_connections задаётся положительным числом соединений")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type YandexConfig struct {
|
||||
@@ -156,6 +184,13 @@ func defaultConfig() *Config {
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
DataDir: "data",
|
||||
// Пять секунд ожидания и четыре читающих соединения: числа выведены
|
||||
// из числа воркеров по умолчанию, а не из замера. Смысл ожидания —
|
||||
// пережить чужую запись, а не чужую работу: пишет сервис короткими
|
||||
// операциями, и очередь из трёх воркеров укладывается в него с
|
||||
// запасом.
|
||||
BusyTimeoutMs: 5000,
|
||||
ReadConnections: 4,
|
||||
},
|
||||
Pipeline: PipelineConfig{
|
||||
Workers: 3,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
// ErrOwnerRequired — приём по HTTP дошёл до заведения задачи, а владельца ему не
|
||||
// назвали. Значение сентинельное: нести отказу нечего, а имя учётной записи в
|
||||
// него не кладётся никогда.
|
||||
//
|
||||
// Достижимого случая у него нет: узнавание заводит учётную запись само, и
|
||||
// предъявителя без неё под корнем приложения не бывает. Отдельной ветви ответа
|
||||
// он поэтому не получает — ветвь по умолчанию читает его как аварию сервиса,
|
||||
// каковой он и был бы.
|
||||
var ErrOwnerRequired = errors.New("owner is required to accept a record")
|
||||
|
||||
// ErrRecordUnreadable — присланную запись не удалось прочитать: источник
|
||||
@@ -31,6 +36,18 @@ var ErrRecordTooLarge = errors.New("uploaded record exceeds size limit")
|
||||
// человек увидел бы «не найдено» на своей записи, загруженной минуту назад.
|
||||
var ErrTextNotReady = errors.New("requested text view is not ready yet")
|
||||
|
||||
// ErrCopyNotReady — копии записи запрошенного вида у неё ещё нет. Состояние, а
|
||||
// не отсутствие, и код у него тот же, что у ненаписанного текста: пустой ответ
|
||||
// читался бы как пустой файл, а «не найдено» слилось бы с ответом на чужую и
|
||||
// неизвестную запись — человек увидел бы его на своей записи, загруженной
|
||||
// минуту назад.
|
||||
var ErrCopyNotReady = errors.New("requested file copy is not ready yet")
|
||||
|
||||
// ErrTooManyRequests — бюджет ограничителя частоты выбран. Признак заводится
|
||||
// затем, чтобы отказ ограничителя уходил той же формой тела, что и отказ
|
||||
// обработчика: он рождается слоем и до обработчика не доходит вовсе.
|
||||
var ErrTooManyRequests = errors.New("request rate budget is exhausted")
|
||||
|
||||
// ErrBadRequest — во входе запроса негодное значение: неизвестный вид текста,
|
||||
// нечитаемый ключ страницы, отрицательный размер. Отличается от ErrRecordUnreadable
|
||||
// тем, что негодна **просьба**, а не присланная запись.
|
||||
@@ -47,6 +64,11 @@ var ErrUnauthorized = errors.New("session is required")
|
||||
// отсутствующий адрес отсутствующей записью.
|
||||
var ErrNotFound = errors.New("address not found")
|
||||
|
||||
// ErrLoginNotAcceptable — логин негоден: пустой, из одних пробельных знаков,
|
||||
// длиннее предела или с управляющими знаками. Это не отказ хранилища, а негодный
|
||||
// ввод, и звать по нему учётную запись не надо.
|
||||
var ErrLoginNotAcceptable = errors.New("provider login is not acceptable")
|
||||
|
||||
type JobNotFoundError struct {
|
||||
State string
|
||||
Message string
|
||||
|
||||
@@ -2,6 +2,7 @@ package contract
|
||||
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -40,19 +41,24 @@ type FileRepository interface {
|
||||
StageEmpty(ext string) (WorkFile, error)
|
||||
// Localize выдаёт рабочую копию хранимого файла.
|
||||
Localize(fileID string) (WorkFile, error)
|
||||
// Create кладёт рабочую копию в хранилище под именем name и заводит запись о
|
||||
// файле. Имя задаёт сервис: умолчание хранилища, строящее его из имени
|
||||
// отправителя, не применяется.
|
||||
// Create кладёт рабочую копию в каталог данных под именем name и заводит
|
||||
// строку о файле. Имя задаёт сервис, и имя, данное отправителем, в него не
|
||||
// попадает: от него взято только расширение.
|
||||
//
|
||||
// recordID — запись, которой копия принадлежит: копии одной записи лежат её
|
||||
// подкаталогом, и имя этого подкаталога и есть идентификатор записи. Приём
|
||||
// знает его раньше, чем кладёт файл, потому что назначает сам.
|
||||
//
|
||||
// ownerID — владелец записи, которой файл принадлежит, и он обязателен:
|
||||
// колонка владельца пустого значения не принимает, пустой отвергается
|
||||
// схемой. Владелец лежит своей колонкой, а не выводится через запись: файл
|
||||
// переживает свою запись — шаг заводит его до сохранения, и потерянный
|
||||
// захват оставляет файл с владельцем и без ссылки.
|
||||
Create(name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
|
||||
Create(recordID, name string, work WorkFile, meta FileMeta, ownerID string) (*entity.File, error)
|
||||
GetByID(id string) (*entity.File, error)
|
||||
// Open отдаёт содержимое хранимого файла потоком.
|
||||
Open(fileID string) (io.ReadCloser, error)
|
||||
// Open отдаёт содержимое хранимого файла потоком с перемоткой: отдача по
|
||||
// диапазону читает запрошенный кусок, а не файл целиком.
|
||||
Open(fileID string) (io.ReadSeekCloser, error)
|
||||
}
|
||||
|
||||
// AcquiredRecord — то, что отдаёт захват: идентификатор записи и признак
|
||||
@@ -74,8 +80,12 @@ type AcquiredRecord struct {
|
||||
// RecordCursor — положение в ленте записей, заданное **полным** ключом
|
||||
// сортировки. Одного времени мало: у записей, принятых одним запросом, оно
|
||||
// совпадает, и порядок между ними иначе не определён.
|
||||
//
|
||||
// Время лежит здесь значением времени, а не строкой: вид, каким оно уходит в
|
||||
// запрос, принадлежит хранилищу — сравнение там побайтово, и вид, собранный
|
||||
// транспортом, разошёлся бы с колонкой молча, обратив условие в постоянную ложь.
|
||||
type RecordCursor struct {
|
||||
CreatedAt string
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
}
|
||||
|
||||
@@ -154,7 +164,8 @@ type RecognitionRepository interface {
|
||||
// Submitted сохраняет адрес аудио и идентификатор заведённой операции. По
|
||||
// последнему повторный шаг узнаёт, что за эту запись уже заплачено.
|
||||
Submitted(id, sourceURI, externalID string) error
|
||||
// Finish отмечает завершение операции и кладёт сырой ответ вложением.
|
||||
// Finish отмечает завершение операции и кладёт сохранённый ответ отдельным
|
||||
// файлом в подкаталоге записи.
|
||||
Finish(id string, raw []byte) error
|
||||
GetByID(id string) (*entity.Recognition, error)
|
||||
// ReadRaw отдаёт сохранённый ответ провайдера. Зовётся только тогда, когда
|
||||
@@ -166,3 +177,31 @@ type RecognitionRepository interface {
|
||||
type RecordEventRepository interface {
|
||||
Append(event *entity.RecordEvent) error
|
||||
}
|
||||
|
||||
// Identity — то, чем доверенный источник называет пришедшего.
|
||||
//
|
||||
// Логин — ключ учётной записи, остальное берётся только при её заведении.
|
||||
type Identity struct {
|
||||
Login string
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
// UserAccount — учётная запись сервиса, какой её видит транспорт: ключ и имя,
|
||||
// пригодное к показу. Логина у провайдера и адреса почты здесь нет: оба
|
||||
// принадлежат человеку, а не сервису, и наружу не выходят.
|
||||
type UserAccount struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// UserRepository — учётные записи.
|
||||
//
|
||||
// Дом правила «найти по логину, а не найдя — завести» один, и он в хранилище, а
|
||||
// не в транспорте: второй способ представиться возьмёт этот же метод.
|
||||
type UserRepository interface {
|
||||
// EnsureUser находит учётную запись по логину у провайдера, а не найдя —
|
||||
// заводит её. Второе значение истинно только у заведённой: заведение —
|
||||
// событие, и владелец обязан видеть его строкой журнала.
|
||||
EnsureUser(identity Identity) (account *UserAccount, created bool, err error)
|
||||
}
|
||||
|
||||
+272
-167
@@ -11,25 +11,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
)
|
||||
|
||||
// AppRoot — корень адресов приложения.
|
||||
//
|
||||
// Приложение живёт своим пространством, а не в общем `/api/`: последнее
|
||||
// принадлежит хранилищу, оно вешает туда собственные наборы адресов, и поменять
|
||||
// этот префикс нельзя — он литерал библиотеки, а не настройка. Свободных имён
|
||||
// сегодня хватает, но обновление библиотеки вправе занять новое имя рядом с
|
||||
// нашим, и разойдутся они молча.
|
||||
// Корень остался **один**: пространства `/api/`, принадлежавшего встроенному
|
||||
// хранилищу, и адреса панели `/_/` больше не существует — сервис их не занимает.
|
||||
// Соседство, ради которого корень был выбран, кончилось вместе с соседом.
|
||||
const AppRoot = "/app"
|
||||
|
||||
// Пределы страницы. Умолчание — столько, сколько помещается на экран телефона
|
||||
@@ -55,17 +48,23 @@ const pollBudgetShare = 8
|
||||
// задаётся своей константой: иначе приложение, честно опрашивающее карточку с
|
||||
// объявленной частотой, упирается в ограничитель сервиса — и получает отказ,
|
||||
// которого сервис сам же ему обещал избежать.
|
||||
//
|
||||
// Прежде вывод давал **весь** бюджет целиком, и запаса не оставалось ни на один
|
||||
// соседний запрос: любой второй в ту же секунду отвергался. Теперь объявленная
|
||||
// частота — доля бюджета, и неравенство «объявленное меньше применяемого»
|
||||
// выполняется с запасом.
|
||||
const PollIntervalMs = int64(appRateWindowSec * 1000 * pollBudgetShare / appRateMaxRequests)
|
||||
|
||||
// Значения параметра `copy` у адреса файла записи. Перечень закрыт, и каждое
|
||||
// значение называет ровно одну хранимую вещь: имя параметра нормативно наравне
|
||||
// со значениями — разбирает его каждый экран, и выбранное кодом оно стало бы
|
||||
// публичным контрактом молча.
|
||||
const (
|
||||
CopyParam = "copy"
|
||||
CopyOriginal = "original"
|
||||
CopyNormalized = "normalized"
|
||||
)
|
||||
|
||||
type AppHandler struct {
|
||||
recordRepo contract.AudioRecordRepository
|
||||
textRepo contract.TextRepository
|
||||
structureRepo contract.StructureRepository
|
||||
fileRepo contract.FileRepository
|
||||
trsService *service.TranscribeService
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -74,6 +73,7 @@ func NewAppHandler(
|
||||
recordRepo contract.AudioRecordRepository,
|
||||
textRepo contract.TextRepository,
|
||||
structureRepo contract.StructureRepository,
|
||||
fileRepo contract.FileRepository,
|
||||
trsService *service.TranscribeService,
|
||||
logger *slog.Logger,
|
||||
) *AppHandler {
|
||||
@@ -84,6 +84,7 @@ func NewAppHandler(
|
||||
recordRepo: recordRepo,
|
||||
textRepo: textRepo,
|
||||
structureRepo: structureRepo,
|
||||
fileRepo: fileRepo,
|
||||
trsService: trsService,
|
||||
logger: logger,
|
||||
}
|
||||
@@ -95,8 +96,11 @@ func NewAppHandler(
|
||||
//
|
||||
// Машинного текста отказа здесь нет: он принадлежит журналу владельца сервиса.
|
||||
// Причина остановки — значение из закрытого перечня, и она не он: без причины
|
||||
// признак остановки не говорит человеку, чего ждать. Русскую фразу из значения
|
||||
// делает приложение — второй словарь фраз на сервере разошёлся бы с экраном.
|
||||
// признак остановки не говорит человеку, чего ждать.
|
||||
//
|
||||
// Перечня доступных копий файла здесь нет намеренно: копий две, и каждая
|
||||
// выводится из рубежа записи, который карточка несёт и так. Второе поле
|
||||
// повторяло бы рубеж и разошлось бы с ним молча.
|
||||
type RecordView struct {
|
||||
ID string `json:"id"`
|
||||
Title *string `json:"title"`
|
||||
@@ -120,11 +124,6 @@ type RecordView struct {
|
||||
}
|
||||
|
||||
// IntakeItem — элемент ответа приёма: карточка плюс признак повторного файла.
|
||||
//
|
||||
// Место под признак заведено вперёд и заполняется другой задачей. Форма
|
||||
// согласована один раз: приём, отдающий одну запись, пришлось бы переписывать
|
||||
// вместе с приёмом нескольких файлов, а экран загрузки — переделывать под вторую
|
||||
// форму.
|
||||
type IntakeItem struct {
|
||||
RecordView
|
||||
Duplicate bool `json:"duplicate"`
|
||||
@@ -161,68 +160,134 @@ type ReplicaView struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Register вешает адреса приложения на роутер хранилища. Порт у сервиса и у
|
||||
// панели один, поэтому и роутер один.
|
||||
func (h *AppHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
app := r.Group(AppRoot)
|
||||
// Routes — адреса приложения одним обработчиком.
|
||||
//
|
||||
// Слоёв здесь нет: ограничитель частоты, узнавание и требование учётной записи
|
||||
// вешаются на **всю** цепочку корня приложения, а корень берётся из перечня
|
||||
// адресного пространства. Так область их действия выводится из объявленного
|
||||
// пространства, а не перечисляется вторым списком.
|
||||
//
|
||||
// Метод разбирается обработчиком, а не образцом маршрута: отказ маршрутизатора
|
||||
// на неверный метод ушёл бы его формой тела, а форма отказа под корнем
|
||||
// приложения одна.
|
||||
func (h *AppHandler) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Слой формы отказа стоит первым и снаружи всех: отказы, рождённые ниже —
|
||||
// предел тела, ограничитель частоты, неизвестный путь под нашим корнем, —
|
||||
// иначе ушли бы телом библиотеки, мимо единой формы.
|
||||
//
|
||||
// Слоя узнавания здесь нет: он вешается корневым, потому что накрывает ещё и
|
||||
// адрес выдачи файлового токена из пространства хранилища. Область его
|
||||
// действия при этом выводится из **этого же** перечня адресного
|
||||
// пространства — см. `underIdentifiedArea`.
|
||||
app.Bind(OneErrorForm())
|
||||
app.Bind(RequireUser(migrations.UsersCollection))
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
mux.HandleFunc(pattern, h.handlerOf(pattern))
|
||||
}
|
||||
|
||||
app.GET("/me", h.Me)
|
||||
app.GET("/config", h.Config)
|
||||
// Перехват «под нашим корнем такого адреса нет». Голый корень попадает сюда
|
||||
// же: он принадлежит корню приложения, адресом приложения не является и
|
||||
// потому отвечает как неизвестный путь под ним.
|
||||
//
|
||||
// Оба образца обязательны: без точного `/app` маршрутизатор увёл бы его
|
||||
// перенаправлением на `/app/`, а перенаправления норма не заказывала. В
|
||||
// закрытый перечень образцов они не входят: под них подходит **всё**, что
|
||||
// накрыто корнем, а значит путь под ними выбирает спрашивающий.
|
||||
mux.HandleFunc(AppRoot+"/", h.notFound)
|
||||
mux.HandleFunc(AppRoot, h.notFound)
|
||||
|
||||
// Приём стоит тем же адресом, что и список, и отличается только методом: он
|
||||
// заводит аудиозапись, а не кладёт файл.
|
||||
//
|
||||
// Умолчание роутера хранилища — 32 МиБ на тело, и оно отсекало бы запись
|
||||
// раньше обработчика, без строки в журнале приёма. Предел тела равен потолку
|
||||
// самой записи, а отказ по нему уходит нашей формой.
|
||||
app.POST("/audiorecords", h.CreateRecord).Bind(apis.BodyLimit(entity.MaxRecordSize))
|
||||
app.GET("/audiorecords", h.ListRecords)
|
||||
app.GET("/audiorecords/{id}", h.GetRecord)
|
||||
app.GET("/audiorecords/{id}/text", h.GetRecordText)
|
||||
|
||||
// Перехват «под нашим корнем такого адреса нет».
|
||||
//
|
||||
// Слой единой формы его не покрывает, и это не оплошность приоритета: отказ
|
||||
// «ничего не совпало» рождается маршрутом **корневой** группы, к которому
|
||||
// слои группы `/app` не привязаны вовсе. Без своего перехвата неизвестный
|
||||
// путь и неверный метод отвечали бы телом библиотеки — то есть форм отказа
|
||||
// под корнем приложения было бы две.
|
||||
//
|
||||
// Маршрут стоит за слоем предъявления, поэтому неизвестный путь без сессии
|
||||
// отвечает `401`, а не `404`, — ровно так же, как отвечают все прочие адреса
|
||||
// приложения, и по той же причине: сперва «кто спрашивает», потом «что».
|
||||
app.Any("/{path...}", func(e *core.RequestEvent) error {
|
||||
return fail(e, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *AppHandler) Me(e *core.RequestEvent) error {
|
||||
// Образцы адресов приложения. Перечень закрытый и **единственный**: из него
|
||||
// вешаются обработчики, и из него же берётся значение `http.route` для журнала.
|
||||
// Второй список образцов разошёлся бы с первым молча, и разошёлся бы в сторону
|
||||
// журнала — путь, не попавший в перечень, уехал бы в строку дословно.
|
||||
const (
|
||||
AppRouteMe = AppRoot + "/me"
|
||||
AppRouteConfig = AppRoot + "/config"
|
||||
AppRouteRecords = AppRoot + "/audiorecords"
|
||||
AppRouteRecord = AppRoot + "/audiorecords/{id}"
|
||||
AppRouteRecordText = AppRoot + "/audiorecords/{id}/text"
|
||||
AppRouteRecordFile = AppRoot + "/audiorecords/{id}/file"
|
||||
)
|
||||
|
||||
// AppRoutePatterns — тот самый перечень. Порядок значения не имеет:
|
||||
// маршрутизатор выбирает образец по точности, а не по месту в списке.
|
||||
var AppRoutePatterns = []string{
|
||||
AppRouteMe,
|
||||
AppRouteConfig,
|
||||
AppRouteRecords,
|
||||
AppRouteRecord,
|
||||
AppRouteRecordText,
|
||||
AppRouteRecordFile,
|
||||
}
|
||||
|
||||
// handlerOf выдаёт обработчик образца.
|
||||
//
|
||||
// Ветка на каждый образец, а не карта рядом с перечнем: недостающий образец
|
||||
// здесь — отказ на подъёме, а не тихо не заведённый адрес.
|
||||
func (h *AppHandler) handlerOf(pattern string) http.HandlerFunc {
|
||||
switch pattern {
|
||||
case AppRouteMe:
|
||||
return only(h.Me, http.MethodGet)
|
||||
case AppRouteConfig:
|
||||
return only(h.Config, http.MethodGet)
|
||||
// Приём стоит тем же адресом, что и список, и отличается только методом: он
|
||||
// заводит аудиозапись, а не кладёт файл.
|
||||
case AppRouteRecords:
|
||||
return h.records
|
||||
case AppRouteRecord:
|
||||
return only(h.GetRecord, http.MethodGet)
|
||||
case AppRouteRecordText:
|
||||
return only(h.GetRecordText, http.MethodGet)
|
||||
case AppRouteRecordFile:
|
||||
return only(h.GetRecordFile, http.MethodGet, http.MethodHead)
|
||||
}
|
||||
|
||||
panic("адрес приложения " + pattern + " объявлен перечнем, но обработчика у него нет")
|
||||
}
|
||||
|
||||
// only ограничивает адрес перечнем методов.
|
||||
//
|
||||
// Неверный метод отвечает «адреса нет»: код отказа принадлежит закрытому
|
||||
// перечню, и своего значения у «метод не тот» в нём не заведено — адрес,
|
||||
// которого нет для этого метода, и есть ненайденный адрес.
|
||||
func only(handler http.HandlerFunc, methods ...string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, method := range methods {
|
||||
if r.Method == method {
|
||||
handler(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AppHandler) notFound(w http.ResponseWriter, _ *http.Request) {
|
||||
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||
}
|
||||
|
||||
// records — приём и список одним адресом: разница только в методе.
|
||||
func (h *AppHandler) records(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.ListRecords(w, r)
|
||||
case http.MethodPost:
|
||||
h.CreateRecord(w, r)
|
||||
default:
|
||||
fail(w, errWithMessage(contract.ErrNotFound, "Адрес не найден"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AppHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
// Адрес почты в ответ не идёт: он приходит от провайдера и принадлежит
|
||||
// человеку, а не сервису. Логин у провайдера — тоже: это его имя у
|
||||
// провайдера, и правило о непечатаемых значениях запрещает ему выходить
|
||||
// наружу наравне с журналом.
|
||||
return e.JSON(http.StatusOK, MeView{
|
||||
ID: e.Auth.Id,
|
||||
Name: e.Auth.GetString("name"),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, MeView{ID: account.ID, Name: account.Name})
|
||||
}
|
||||
|
||||
func (h *AppHandler) Config(e *core.RequestEvent) error {
|
||||
func (h *AppHandler) Config(w http.ResponseWriter, _ *http.Request) {
|
||||
// Каждый предел — то же значение, которое сервис применяет, а не его копия.
|
||||
// Приложение, знающее предел своей константой, расходится с сервером молча —
|
||||
// до первого отказа на записи, которую человек уже успел отправить.
|
||||
return e.JSON(http.StatusOK, ConfigView{
|
||||
writeJSON(w, http.StatusOK, ConfigView{
|
||||
MaxRecordSizeBytes: entity.MaxRecordSize,
|
||||
MaxPageSize: MaxPageLimit,
|
||||
PollIntervalMs: PollIntervalMs,
|
||||
@@ -231,17 +296,35 @@ func (h *AppHandler) Config(e *core.RequestEvent) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
|
||||
file, header, err := e.Request.FormFile("audio")
|
||||
func (h *AppHandler) CreateRecord(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
// Предел тела назван числом: умолчания здесь не «без предела», а величины на
|
||||
// два-три порядка меньше нужного, и оставленные как есть они отвергли бы
|
||||
// штатную запись сервиса. Отказ по нему уходит нашей формой тела.
|
||||
//
|
||||
// Ловится он **дважды**, и это не избыточность. Объявленная длина судится
|
||||
// заранее: запись, за которую сервис платить не станет, не должна попасть
|
||||
// даже в память. Необъявленная и солгавшая ловятся на чтении — объявленной
|
||||
// длины у запроса с кусочной передачей нет вовсе.
|
||||
if r.ContentLength > entity.MaxRecordSize {
|
||||
fail(w, contract.ErrRecordTooLarge)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, entity.MaxRecordSize)
|
||||
|
||||
file, header, err := r.FormFile("audio")
|
||||
if err != nil {
|
||||
// Предел тела ловит объявленную длину заранее, слоем; необъявленную —
|
||||
// на чтении, уже здесь. Не различив эти два отказа, приём сказал бы
|
||||
// человеку «вы не приложили файл» о записи, которую он приложил и
|
||||
// которая просто больше потолка.
|
||||
if errors.Is(err, apis.ErrRequestEntityTooLarge) {
|
||||
return fail(e, contract.ErrRecordTooLarge)
|
||||
// Предел тела ловит запись на чтении. Не различив этот отказ и
|
||||
// отсутствующее поле, приём сказал бы человеку «вы не приложили файл» о
|
||||
// записи, которую он приложил и которая просто больше потолка.
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
fail(w, contract.ErrRecordTooLarge)
|
||||
return
|
||||
}
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Запись не приложена к запросу"))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
@@ -252,71 +335,78 @@ func (h *AppHandler) CreateRecord(e *core.RequestEvent) error {
|
||||
// Запись доехала целиком, поэтому она заводится независимо от того, дождётся
|
||||
// ли отправитель ответа: на контексте запроса приём терял бы полностью
|
||||
// загруженную запись от одного обрыва соединения, а забрать результат он
|
||||
// может и позже — карточкой записи. Значения контекста (журнал запроса,
|
||||
// сессия) при этом сохраняются, теряется только отмена.
|
||||
ctx := context.WithoutCancel(e.Request.Context())
|
||||
// может и позже — карточкой записи.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
// Владелец берётся из предъявленной сессии и ниоткуда больше: владелец,
|
||||
// пришедший полем запроса, дал бы всякому вошедшему право завести запись на
|
||||
// Владелец берётся из узнанного предъявителя и ниоткуда больше: владелец,
|
||||
// пришедший полем запроса, дал бы всякому узнанному право завести запись на
|
||||
// чужое имя.
|
||||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, e.Auth.Id)
|
||||
record, err := h.trsService.CreateJobFromApi(ctx, file, header.Filename, account.ID)
|
||||
if err != nil {
|
||||
// Второй раз отказ не логируем: приём назван конвенцией логирующей
|
||||
// границей и уже написал о нём. Транспорт переводит ошибку в ответ, и
|
||||
// делает это одним местом — по причине отказа, а не по месту.
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Ответ списком, даже когда файл в запросе один: форма согласована вперёд,
|
||||
// чтобы приём нескольких файлов и распознавание повтора её не переписывали.
|
||||
return e.JSON(http.StatusCreated, []IntakeItem{{
|
||||
writeJSON(w, http.StatusCreated, []IntakeItem{{
|
||||
// Свежая запись текстов не имеет, но поле обязано быть на проводе:
|
||||
// отсутствие поля и пустой перечень приложение не различит.
|
||||
RecordView: h.viewOf(record, nil, &[]string{}),
|
||||
}})
|
||||
}
|
||||
|
||||
func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
|
||||
q := contract.RecordQuery{OwnerID: e.Auth.Id, Limit: DefaultPageLimit}
|
||||
func (h *AppHandler) ListRecords(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
if raw := e.Request.URL.Query().Get("limit"); raw != "" {
|
||||
q := contract.RecordQuery{OwnerID: account.ID, Limit: DefaultPageLimit}
|
||||
|
||||
if raw := r.URL.Query().Get("limit"); raw != "" {
|
||||
limit, err := strconv.Atoi(raw)
|
||||
if err != nil || limit <= 0 {
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Размер страницы должен быть положительным числом"))
|
||||
return
|
||||
}
|
||||
// Сверх потолка — усечение, а не отказ: человек попросил больше, чем
|
||||
// сервис отдаёт, но просьба сама по себе не негодна.
|
||||
q.Limit = min(limit, MaxPageLimit)
|
||||
}
|
||||
|
||||
if raw := e.Request.URL.Query().Get("filter"); raw != "" {
|
||||
if raw := r.URL.Query().Get("filter"); raw != "" {
|
||||
filter, ok := entity.ParseListFilter(raw)
|
||||
if !ok {
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестное состояние отбора"))
|
||||
return
|
||||
}
|
||||
q.Filter = &filter
|
||||
}
|
||||
|
||||
if raw := e.Request.URL.Query().Get("cursor"); raw != "" {
|
||||
if raw := r.URL.Query().Get("cursor"); raw != "" {
|
||||
cursor, err := decodeCursor(raw)
|
||||
if err != nil {
|
||||
// Молчаливая отдача первой страницы вместо отказа дала бы человеку
|
||||
// архив, листающийся по кругу, и ни строки в журнале.
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Ключ страницы не читается"))
|
||||
return
|
||||
}
|
||||
q.Cursor = cursor
|
||||
}
|
||||
|
||||
page, err := h.recordRepo.List(q)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list audio records", "error", err, "owner_id", e.Auth.Id)
|
||||
return fail(e, err)
|
||||
h.logger.Error("Failed to list audio records", "error", err, "owner_id", account.ID)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
names, err := h.topicNames(e.Auth.Id, page.Items)
|
||||
names, err := h.topicNames(account.ID, page.Items)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", e.Auth.Id)
|
||||
return fail(e, err)
|
||||
h.logger.Error("Failed to resolve topics", "error", err, "owner_id", account.ID)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
view := PageView{Items: make([]RecordView, 0, len(page.Items)), TotalItems: page.TotalItems}
|
||||
@@ -331,55 +421,71 @@ func (h *AppHandler) ListRecords(e *core.RequestEvent) error {
|
||||
view.NextCursor = &encoded
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, view)
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (h *AppHandler) GetRecord(e *core.RequestEvent) error {
|
||||
record, err := h.readOwn(e)
|
||||
func (h *AppHandler) GetRecord(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
record, err := h.readOwn(r, account.ID)
|
||||
if err != nil {
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
names, err := h.topicNames(e.Auth.Id, []*entity.AudioRecord{record})
|
||||
names, err := h.topicNames(account.ID, []*entity.AudioRecord{record})
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to resolve topics", "error", err, "record_id", record.Id)
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
views := h.availableViews(record)
|
||||
return e.JSON(http.StatusOK, h.viewOf(record, names, &views))
|
||||
writeJSON(w, http.StatusOK, h.viewOf(record, names, &views))
|
||||
}
|
||||
|
||||
func (h *AppHandler) GetRecordText(e *core.RequestEvent) error {
|
||||
view := e.Request.URL.Query().Get("view")
|
||||
func (h *AppHandler) GetRecordText(w http.ResponseWriter, r *http.Request) {
|
||||
account, _ := AccountOf(r)
|
||||
|
||||
view := r.URL.Query().Get("view")
|
||||
if !entity.IsKnownTextView(view) {
|
||||
return fail(e, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
||||
fail(w, errWithMessage(contract.ErrBadRequest, "Неизвестный вид текста"))
|
||||
return
|
||||
}
|
||||
|
||||
record, err := h.readOwn(e)
|
||||
record, err := h.readOwn(r, account.ID)
|
||||
if err != nil {
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if view == entity.TextViewReplicas {
|
||||
return h.replicasOf(e, record)
|
||||
h.replicasOf(w, record)
|
||||
return
|
||||
}
|
||||
return h.plainTextOf(e, record, view)
|
||||
h.plainTextOf(w, record, view)
|
||||
}
|
||||
|
||||
// readOwn читает запись спрашивающего. Чужая, ничья и несуществующая отвечают
|
||||
// одним и тем же: по разнице ответов иначе перебирается список заведённых
|
||||
// записей.
|
||||
func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error) {
|
||||
recordID := e.Request.PathValue("id")
|
||||
// readOwn читает запись спрашивающего. Чужая, ничья, несуществующая и
|
||||
// нечитаемая по виду идентификатора отвечают одним и тем же: по разнице ответов
|
||||
// иначе перебирается список заведённых записей.
|
||||
func (h *AppHandler) readOwn(r *http.Request, ownerID string) (*entity.AudioRecord, error) {
|
||||
// Идентификатор разбирается на границе: он приходит от спрашивающего, а
|
||||
// сравнение в базе побайтово — запись в верхнем регистре не совпала бы ни с
|
||||
// одной строкой. Негодный по виду считается несуществующим и до базы не
|
||||
// доходит вовсе.
|
||||
recordID, ok := ident.Parse(r.PathValue("id"))
|
||||
if !ok {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
}
|
||||
|
||||
record, err := h.recordRepo.GetByID(recordID, e.Auth.Id)
|
||||
record, err := h.recordRepo.GetByID(recordID, ownerID)
|
||||
if err != nil {
|
||||
// Наружу ответ один на все исходы, а в журнал они идут по-разному.
|
||||
// «Записи нет» и «запись чужая» — штатная работа разграничения, о ней
|
||||
// писать нечего; всё прочее — отказ хранилища, и без этой строки он
|
||||
// приходит отправителю как «вашей записи нет», а владелец сервиса об
|
||||
// аварии не узнаёт ниоткуда.
|
||||
// писать нечего; всё прочее — отказ базы, и без этой строки он приходит
|
||||
// отправителю как «вашей записи нет», а владелец сервиса об аварии не
|
||||
// узнаёт ниоткуда.
|
||||
var notFound *contract.JobNotFoundError
|
||||
if !errors.As(err, ¬Found) {
|
||||
h.logger.Error("Failed to read audio record", "error", err, "record_id", recordID)
|
||||
@@ -389,52 +495,62 @@ func (h *AppHandler) readOwn(e *core.RequestEvent) (*entity.AudioRecord, error)
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (h *AppHandler) plainTextOf(e *core.RequestEvent, record *entity.AudioRecord, view string) error {
|
||||
func (h *AppHandler) plainTextOf(w http.ResponseWriter, record *entity.AudioRecord, view string) {
|
||||
textID := record.TranscriptTextID
|
||||
if view == entity.TextViewLiterary {
|
||||
textID = record.LiteraryTextID
|
||||
}
|
||||
|
||||
if textID == nil {
|
||||
return fail(e, contract.ErrTextNotReady)
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
text, err := h.textRepo.GetByID(*textID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read text", "error", err, "record_id", record.Id)
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if text.Contents == "" {
|
||||
return fail(e, contract.ErrTextNotReady)
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, TextView{View: view, Contents: text.Contents})
|
||||
writeJSON(w, http.StatusOK, TextView{View: view, Contents: text.Contents})
|
||||
}
|
||||
|
||||
func (h *AppHandler) replicasOf(e *core.RequestEvent, record *entity.AudioRecord) error {
|
||||
func (h *AppHandler) replicasOf(w http.ResponseWriter, record *entity.AudioRecord) {
|
||||
if record.StructureID == nil {
|
||||
return fail(e, contract.ErrTextNotReady)
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
structure, err := h.structureRepo.GetByID(*record.StructureID)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to read structure", "error", err, "record_id", record.Id)
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
if len(structure.Replicas) == 0 {
|
||||
return fail(e, contract.ErrTextNotReady)
|
||||
fail(w, contract.ErrTextNotReady)
|
||||
return
|
||||
}
|
||||
|
||||
replicas := make([]ReplicaView, 0, len(structure.Replicas))
|
||||
for _, r := range structure.Replicas {
|
||||
replicas = append(replicas, ReplicaView{StartMs: r.StartMs, EndMs: r.EndMs, Text: r.Text})
|
||||
for _, replica := range structure.Replicas {
|
||||
replicas = append(replicas, ReplicaView{
|
||||
StartMs: replica.StartMs,
|
||||
EndMs: replica.EndMs,
|
||||
Text: replica.Text,
|
||||
})
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
|
||||
writeJSON(w, http.StatusOK, TextView{View: entity.TextViewReplicas, Replicas: replicas})
|
||||
}
|
||||
|
||||
// topicNames разрешает темы всех записей страницы **одним** запросом: страница в
|
||||
// сотню записей иначе стоила бы сотни обращений к хранилищу.
|
||||
// сотню записей иначе стоила бы сотни обращений к базе.
|
||||
func (h *AppHandler) topicNames(ownerID string, records []*entity.AudioRecord) (map[string]string, error) {
|
||||
seen := map[string]bool{}
|
||||
ids := []string{}
|
||||
@@ -481,9 +597,7 @@ func (h *AppHandler) viewOf(record *entity.AudioRecord, names map[string]string,
|
||||
// Вид считается доступным по **содержимому**, а не по наличию ссылки. Ссылка
|
||||
// без содержимого — состояние штатное: пустой ответ распознавания проект признаёт
|
||||
// нормой и записывает его в журнал. Строй мы перечень по ссылкам, карточка
|
||||
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно:
|
||||
// приложение опрашивало бы его без конца, а человек видел бы завершённую запись,
|
||||
// из которой текст «вот-вот появится».
|
||||
// объявляла бы вид доступным, а адрес текста отвечал бы «ещё не готов» вечно.
|
||||
func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
||||
views := []string{}
|
||||
|
||||
@@ -501,8 +615,7 @@ func (h *AppHandler) availableViews(record *entity.AudioRecord) []string {
|
||||
|
||||
// hasText — есть ли у записи непустой текст этого вида. Отказ чтения читается
|
||||
// как «вида нет»: перечень доступных видов — подсказка приложению, и уронить
|
||||
// из-за неё карточку хуже, чем недосказать. Сам отказ виден владельцу сервиса
|
||||
// журналом, который пишет чтение текста.
|
||||
// из-за неё карточку хуже, чем недосказать.
|
||||
func (h *AppHandler) hasText(textID *string) bool {
|
||||
if textID == nil {
|
||||
return false
|
||||
@@ -531,11 +644,12 @@ func (h *AppHandler) hasReplicas(structureID *string) bool {
|
||||
// непрозрачной строкой: спрашивающему её содержимое не принадлежит, а
|
||||
// составлять ключ руками значило бы завязаться на порядок сортировки.
|
||||
//
|
||||
// Кодировка нужна и по существу: время заведения несёт пробел, и голая пара
|
||||
// разорвала бы строку запроса. Кодирование без набивки и в адресном алфавите —
|
||||
// ключ уезжает параметром, а не телом.
|
||||
// Кодирование без набивки и в адресном алфавите — ключ уезжает параметром, а не
|
||||
// телом.
|
||||
func encodeCursor(c *contract.RecordCursor) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(c.CreatedAt + "|" + c.ID))
|
||||
return base64.RawURLEncoding.EncodeToString(
|
||||
[]byte(c.CreatedAt.UTC().Format(time.RFC3339) + "|" + c.ID),
|
||||
)
|
||||
}
|
||||
|
||||
func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
||||
@@ -545,32 +659,23 @@ func decodeCursor(raw string) (*contract.RecordCursor, error) {
|
||||
}
|
||||
|
||||
createdAt, id, ok := strings.Cut(string(decoded), "|")
|
||||
if !ok || createdAt == "" || id == "" {
|
||||
if !ok {
|
||||
return nil, errors.New("malformed cursor")
|
||||
}
|
||||
|
||||
// Время разбирается, а не берётся строкой: в запрос оно уходит побайтовым
|
||||
// сравнением, и вид, разошедшийся с тем, каким пишет хранилище, молча
|
||||
// обращает условие в постоянную истину или ложь — человек получает либо
|
||||
// пустой архив при непустом счётчике, либо ленту с начала.
|
||||
parsed, err := types.ParseDateTime(createdAt)
|
||||
if err != nil || parsed.IsZero() {
|
||||
// Обе половины ключа разбираются, а не берутся строкой: время уходит в
|
||||
// запрос сравнением, а идентификатор — точным совпадением, и негодная
|
||||
// половина дала бы человеку либо пустой архив при непустом счётчике, либо
|
||||
// ленту с начала.
|
||||
parsed, err := time.Parse(time.RFC3339, createdAt)
|
||||
if err != nil {
|
||||
return nil, errors.New("cursor carries no readable time")
|
||||
}
|
||||
|
||||
return &contract.RecordCursor{CreatedAt: parsed.String(), ID: id}, nil
|
||||
}
|
||||
recordID, valid := ident.Parse(id)
|
||||
if !valid {
|
||||
return nil, errors.New("cursor carries no readable record key")
|
||||
}
|
||||
|
||||
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
||||
// всех одна, а сказать человеку надо разное.
|
||||
func errWithMessage(base error, message string) error {
|
||||
return &messagedError{base: base, message: message}
|
||||
return &contract.RecordCursor{CreatedAt: parsed.UTC(), ID: recordID}, nil
|
||||
}
|
||||
|
||||
type messagedError struct {
|
||||
base error
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *messagedError) Error() string { return e.message }
|
||||
func (e *messagedError) Unwrap() error { return e.base }
|
||||
|
||||
@@ -7,16 +7,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Проверки этого файла судят допуск: кого пускают к приёму и опросу, чем
|
||||
// Проверки этого файла судят допуск: кого пускают к адресам приложения, чем
|
||||
// называется пришедший, кому верят и какие адреса остаются открытыми.
|
||||
|
||||
// TestApiRequiresIdentity — первый критерий приёмки. Запрос неузнанного получает
|
||||
@@ -33,31 +30,22 @@ func TestApiRequiresIdentity(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "job_id")
|
||||
|
||||
// Ни файла, ни задачи: отказ наступает раньше, чем запись попадает в
|
||||
// хранилище.
|
||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, files)
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, jobs)
|
||||
// Ни файла, ни записи: отказ наступает раньше, чем запись попадает в
|
||||
// каталог данных.
|
||||
assert.Equal(t, 0, countFiles(t, env))
|
||||
assert.Equal(t, 0, countJobs(t, env))
|
||||
})
|
||||
|
||||
t.Run("карточка записи неузнанному", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/audiorecords/anything", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
env.mux.ServeHTTP(w, req)
|
||||
w := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "transcription_text")
|
||||
assert.NotContains(t, w.Body.String(), "created_at")
|
||||
})
|
||||
}
|
||||
|
||||
// TestUnknownJobIsIndistinguishableWithoutIdentity: по кодам ответа неузнанному
|
||||
// не перебирается список заведённых задач.
|
||||
// не перебирается список заведённых записей.
|
||||
func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -65,24 +53,16 @@ func TestUnknownJobIsIndistinguishableWithoutIdentity(t *testing.T) {
|
||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, jobs, 1)
|
||||
|
||||
existing := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(existing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/"+jobs[0].Id, nil))
|
||||
|
||||
missing := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/app/audiorecords/nosuchjobid", nil))
|
||||
existing := env.get("/app/audiorecords/" + intakeItemOf(t, created).ID)
|
||||
missing := env.get("/app/audiorecords/" + strings.Repeat("0", 26))
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||
assert.Equal(t, missing.Code, existing.Code)
|
||||
assert.Equal(t, missing.Body.String(), existing.Body.String())
|
||||
}
|
||||
|
||||
// TestFirstRequestCreatesAccountAndSecondReuses — **первый критерий приёмки**.
|
||||
//
|
||||
// Два запроса подряд с одним значением заголовка: учётная запись заводится
|
||||
// первым и находится вторым, а в хранилище её строка одна.
|
||||
// TestFirstRequestCreatesAccountAndSecondReuses — учётная запись заводится
|
||||
// первым обращением и находится вторым, а строка её в базе одна.
|
||||
func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -90,12 +70,10 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(first, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
first := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, first.Code, "первое обращение узнано")
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(second, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
second := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, second.Code)
|
||||
|
||||
assert.Equal(t, before+1, countAccounts(t, env),
|
||||
@@ -104,8 +82,8 @@ func TestFirstRequestCreatesAccountAndSecondReuses(t *testing.T) {
|
||||
"второе обращение попало в другую учётную запись")
|
||||
}
|
||||
|
||||
// TestUntrustedPeerIsNotIdentified — **второй критерий приёмки**. Тот же
|
||||
// заголовок с недоверенного адреса даёт отказ, а не вход под названным именем.
|
||||
// TestUntrustedPeerIsNotIdentified — тот же заголовок с недоверенного адреса
|
||||
// даёт отказ, а не вход под названным именем.
|
||||
func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -123,118 +101,75 @@ func TestUntrustedPeerIsNotIdentified(t *testing.T) {
|
||||
"заголовок с недоверенного адреса завёл учётную запись")
|
||||
}
|
||||
|
||||
// TestStorageOwnLoginAddressesGiveNothing — **третий критерий приёмки**.
|
||||
// TestStorageAddressSpaceIsGone — **критерий приёмки**: пространства хранилища
|
||||
// не существует.
|
||||
//
|
||||
// Перечень собственных адресов входа хранилища: каждый отвечает отказом и
|
||||
// учётной записи не меняет. Перечень закрыт и назван поимённо — пока хоть один
|
||||
// из них работает, узнавание по заголовку обходится двумя запросами.
|
||||
func TestStorageOwnLoginAddressesGiveNothing(t *testing.T) {
|
||||
// Прежде под корнем `/api/` жила собственная поверхность встроенного хранилища:
|
||||
// собственные входы, перечисление коллекции пользователей, правка своей строки —
|
||||
// то есть путь захвата чужого имени. Хранилище ушло целиком, и адресов этих нет:
|
||||
// они отвечают тем же, чем отвечает всякий путь вне корней сервиса.
|
||||
//
|
||||
// Проверка судит **и то, что ответ прежний, и то, что ничего не произошло**:
|
||||
// число учётных записей после обхода то же самое.
|
||||
func TestStorageAddressSpaceIsGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const usersRoot = "/api/collections/users"
|
||||
before := countAccounts(t, env)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "завести учётную запись самому",
|
||||
path: usersRoot + "/records",
|
||||
body: `{"email":"intruder@example.com","password":"12345678901","passwordConfirm":"12345678901"}`,
|
||||
},
|
||||
{
|
||||
name: "вход по паролю",
|
||||
path: usersRoot + "/auth-with-password",
|
||||
body: `{"identity":"person@example.com","password":"whatever"}`,
|
||||
},
|
||||
{
|
||||
name: "обмен кода у внешнего провайдера",
|
||||
path: usersRoot + "/auth-with-oauth2",
|
||||
body: `{"provider":"oidc","code":"whatever","codeVerifier":"whatever","redirectURL":"https://example.com/"}`,
|
||||
},
|
||||
{
|
||||
name: "вход по одноразовому коду",
|
||||
path: usersRoot + "/auth-with-otp",
|
||||
body: `{"otpId":"whatever","password":"whatever"}`,
|
||||
},
|
||||
{
|
||||
name: "запрос одноразового кода",
|
||||
path: usersRoot + "/request-otp",
|
||||
body: `{"email":"person@example.com"}`,
|
||||
},
|
||||
{
|
||||
name: "восстановление пароля",
|
||||
path: usersRoot + "/request-password-reset",
|
||||
body: `{"email":"person@example.com"}`,
|
||||
},
|
||||
{
|
||||
name: "продление сессии",
|
||||
path: usersRoot + "/auth-refresh",
|
||||
body: `{}`,
|
||||
},
|
||||
// Эталон: путь вне корней сервиса, за которым не стояло ничего никогда.
|
||||
reference := env.get("/nothing-was-ever-here")
|
||||
require.Equal(t, http.StatusOK, reference.Code)
|
||||
|
||||
cases := map[string]string{
|
||||
"перечисление учётных записей": "/api/collections/users/records",
|
||||
"вход по паролю": "/api/collections/users/auth-with-password",
|
||||
"обмен кода у провайдера": "/api/collections/users/auth-with-oauth2",
|
||||
"выдача токена файла": "/api/files/token",
|
||||
"адрес панели": "/_/",
|
||||
"адрес панели знаком кода": "/%5f/",
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
before := countAccounts(t, env)
|
||||
for name, path := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
w := env.getOwn(path)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, c.path, strings.NewReader(c.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest,
|
||||
"адрес %s ответил успехом: собственный вход хранилища открыт", c.path)
|
||||
assert.Equal(t, reference.Code, w.Code,
|
||||
"адрес %s отвечает не как всякий неизвестный путь", path)
|
||||
assert.Equal(t, reference.Body.String(), w.Body.String(),
|
||||
"адрес %s отвечает не тем же телом, что всякий неизвестный путь", path)
|
||||
assert.NotContains(t, w.Body.String(), `"token"`,
|
||||
"адрес %s выдал значение доступа", c.path)
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"адрес %s изменил число учётных записей", c.path)
|
||||
"адрес %s выдал значение доступа", path)
|
||||
})
|
||||
}
|
||||
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"обход прежнего пространства хранилища изменил число учётных записей")
|
||||
}
|
||||
|
||||
// TestUserRecordCannotBeEditedFromOutside — путь захвата чужого имени закрыт.
|
||||
// TestAccountKeyHasNoEditAddress: ключ учётной записи не правится ничем, кроме
|
||||
// заведения самим сервисом.
|
||||
//
|
||||
// Ключ учётной записи лежит обычной колонкой, а умолчание библиотеки открывает
|
||||
// владельцу записи правку собственной строки. Пока узнавание жило под корнем
|
||||
// приложения, до этой поверхности браузер не дотягивался вовсе; теперь она
|
||||
// достижима, и правка своей записи была бы захватом чужого имени: поставил себе
|
||||
// чужой логин — и первое обращение настоящего его владельца попало бы в твою
|
||||
// запись вместе со всем архивом.
|
||||
func TestUserRecordCannotBeEditedFromOutside(t *testing.T) {
|
||||
// Переписанный ключ отдаёт архив следующему, кто придёт с этим именем, а вернуть
|
||||
// его будет нечем. Держится это тем, что адреса правки учётной записи у сервиса
|
||||
// нет вовсе — своих экранов профиля он не заводит, а поверхности хранилища,
|
||||
// правившей запись библиотечным правилом, не осталось.
|
||||
func TestAccountKeyHasNoEditAddress(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
body := strings.NewReader(`{"` + migrations.ProviderLoginField + `":"victim"}`)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.Id, body)
|
||||
body := strings.NewReader(`{"provider_login":"victim"}`)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/collections/users/records/"+env.account.ID, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest, "правка своей учётной записи прошла")
|
||||
|
||||
after, err := env.app.FindRecordById(migrations.UsersCollection, env.account.Id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.login, after.GetString(migrations.ProviderLoginField),
|
||||
// Метод правки до раздачи приложения не доходит: она открывает страницу
|
||||
// только на `GET` и `HEAD`.
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
|
||||
assert.Equal(t, env.login, accountLogin(t, env, env.account.ID),
|
||||
"ключ учётной записи переписан снаружи")
|
||||
}
|
||||
|
||||
// TestUserRecordsCannotBeListed: перечисление коллекции пользователей закрыто.
|
||||
// Открытое, оно отдавало бы узнанному логины всех остальных — то есть ровно те
|
||||
// значения, которыми довольно назваться.
|
||||
func TestUserRecordsCannotBeListed(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest(http.MethodGet, "/api/collections/users/records", nil), env.login))
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusBadRequest)
|
||||
assert.NotContains(t, w.Body.String(), env.login)
|
||||
}
|
||||
|
||||
// TestDegenerateHeaderIdentifiesNobody: вырожденное значение никого не узнаёт и
|
||||
// ничего не заводит.
|
||||
//
|
||||
@@ -248,7 +183,7 @@ func TestDegenerateHeaderIdentifiesNobody(t *testing.T) {
|
||||
"пустое значение": "",
|
||||
"одни пробелы": " ",
|
||||
"управляющий знак": "ali\x00ce",
|
||||
"длиннее предела": strings.Repeat("a", pbrepo.MaxProviderLoginLength+1),
|
||||
"длиннее предела": strings.Repeat("a", entity.MaxProviderLoginLength+1),
|
||||
}
|
||||
|
||||
for name, value := range cases {
|
||||
@@ -292,104 +227,66 @@ func TestTwoLoginHeadersIdentifyNobody(t *testing.T) {
|
||||
assert.Equal(t, before, countAccounts(t, env))
|
||||
}
|
||||
|
||||
// TestStorageTokenBeatsHeader: годный собственный токен хранилища побеждает
|
||||
// заголовок, а протухший узнаванию не мешает.
|
||||
// TestPresentedValueIsNotAccepted: предъявленного значения сервис не признаёт.
|
||||
//
|
||||
// Первая половина защищает владельца панели: подмена его учётной записью
|
||||
// пользователя отобрала бы у него панель посреди работы. Вторая — обычного
|
||||
// человека: негодный токен, оставшийся в браузере, не должен запирать его
|
||||
// снаружи.
|
||||
func TestStorageTokenBeatsHeader(t *testing.T) {
|
||||
// Собственных токенов у него не существует — ни выдаваемых, ни принимаемых, — и
|
||||
// пришедшим считается названный заголовком. Прежде такое значение било заголовок:
|
||||
// им пользовался владелец панели, а панели больше нет.
|
||||
func TestPresentedValueIsNotAccepted(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
stranger, strangerLogin := newSecondAccount(t, env.app)
|
||||
token, err := stranger.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
t.Run("годный токен побеждает", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set("Authorization", token)
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me?token=whatever", nil)
|
||||
req.Header.Set("Authorization", "Bearer whatever")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), stranger.Id,
|
||||
"заголовок победил предъявленный токен")
|
||||
_ = strangerLogin
|
||||
})
|
||||
|
||||
t.Run("протухший токен узнаванию не мешает", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.Header.Set("Authorization", "not-a-token")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(req, env.login))
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), env.account.Id)
|
||||
})
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), env.account.ID,
|
||||
"предъявленное значение победило заголовок")
|
||||
assert.NotContains(t, w.Body.String(), stranger.ID)
|
||||
}
|
||||
|
||||
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты
|
||||
// неузнанному, а заголовок на них учётной записи не заводит.
|
||||
// TestOpenAddressesDoNotIdentify: проба здоровья и метрики открыты неузнанному,
|
||||
// а заголовок на них учётной записи не заводит.
|
||||
//
|
||||
// Вторая половина важнее первой: узнавание сужено до области приложения именно
|
||||
// затем, чтобы запрос за каждой картинкой не стоил обращения к базе, а первый
|
||||
// такой запрос с новым именем — записи в неё.
|
||||
func TestOpenAddressesDoNotIdentify(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
before := countAccounts(t, env)
|
||||
|
||||
appHandler := NewAppHandler(nil, nil, nil, nil, nil)
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), nil))
|
||||
|
||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
||||
return e.String(http.StatusOK, "# metrics")
|
||||
})
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, path := range []string{HealthPath, MetricsPath} {
|
||||
anonymous := httptest.NewRecorder()
|
||||
mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
for _, path := range []string{HealthPath, "/", "/assets/index-abc123.js"} {
|
||||
anonymous := env.get(path)
|
||||
assert.Equal(t, http.StatusOK, anonymous.Code, "адрес %s обязан отвечать неузнанному", path)
|
||||
|
||||
withHeader := httptest.NewRecorder()
|
||||
mux.ServeHTTP(withHeader, asUser(httptest.NewRequest(http.MethodGet, path, nil), "passerby"))
|
||||
withHeader := env.getAs("passerby", path)
|
||||
assert.Equal(t, http.StatusOK, withHeader.Code,
|
||||
"чужой заголовок изменил ответ адреса %s: наблюдение гасится строкой в запросе", path)
|
||||
}
|
||||
|
||||
records, err := app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records, "обращение к открытому адресу завело учётную запись")
|
||||
assert.Equal(t, before, countAccounts(t, env),
|
||||
"обращение к открытому адресу завело учётную запись")
|
||||
}
|
||||
|
||||
// TestServiceIssuesNothingThatOutlivesRequest: сервис не ставит браузеру куки.
|
||||
//
|
||||
// Проверка судит именно **отсутствие**: пока сервис выдавал значение на семь
|
||||
// суток, отозванный у провайдера человек работал до его истечения. Вернувшаяся
|
||||
// кука вернула бы и это.
|
||||
// суток, отозванный у провайдера человек работал до его истечения.
|
||||
func TestServiceIssuesNothingThatOutlivesRequest(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
||||
w := env.getOwn("/app/me")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
assert.Empty(t, w.Result().Cookies(), "ответ поставил куку: значение переживёт запрос")
|
||||
}
|
||||
|
||||
// TestIdentityValuesAreNotLogged — **четвёртый критерий приёмки**: не
|
||||
// печатается ничто, что даёт доступ.
|
||||
// TestIdentityValuesAreNotLogged — не печатается ничто, что даёт доступ.
|
||||
//
|
||||
// Проверка ищет в журнале **значения**, а не имена полей: значение, уехавшее под
|
||||
// другим ключом, поиск по ключу не разбудил бы. Логин здесь наравне с почтой: им
|
||||
@@ -406,16 +303,12 @@ func TestIdentityValuesAreNotLogged(t *testing.T) {
|
||||
|
||||
assert.NotContains(t, journal, env.login,
|
||||
"логин в журнале: строкой довольно назваться, чтобы стать этим человеком")
|
||||
assert.NotContains(t, journal, env.account.Email(),
|
||||
assert.NotContains(t, journal, "person@example.com",
|
||||
"адрес почты в журнале: он приходит от провайдера и принадлежит человеку")
|
||||
}
|
||||
|
||||
// TestUntrustedPeerIsLogged: недоверенный источник виден владельцу журналом, и
|
||||
// виден **адресом пира**, а не значением заголовка.
|
||||
//
|
||||
// Без этой строки владелец, у которого никто не может войти, не отличит своей
|
||||
// поломки (перечень доверенных адресов) от поломки контура (прокси заголовка не
|
||||
// ставит) — а это разные поломки в разных местах.
|
||||
func TestUntrustedPeerIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -434,110 +327,46 @@ func TestUntrustedPeerIsLogged(t *testing.T) {
|
||||
assert.NotContains(t, journal, intruder, "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestRecordFileIsProtected: ссылка на файл перестала быть правом пройти по ней.
|
||||
func TestRecordFileIsProtected(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
files, err := app.FindCollectionByNameOrId(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
|
||||
field, ok := files.Fields.GetByName("file").(*core.FileField)
|
||||
require.True(t, ok)
|
||||
assert.True(t, field.Protected,
|
||||
"поле файла не защищено: знание ссылки снова стало бы доступом, а отзыва у неё нет")
|
||||
}
|
||||
|
||||
// TestRecordFileNeedsToken: ссылка на файл записи без токена отказывает, а
|
||||
// конвейер тот же файл по-прежнему читает — он ходит в файловую систему, а не по
|
||||
// ссылке.
|
||||
func TestRecordFileNeedsToken(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "test.mp3", []byte("audio content")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
files, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 1)
|
||||
|
||||
names := files[0].GetStringSlice("file")
|
||||
require.Len(t, names, 1)
|
||||
|
||||
link := "/api/files/" + migrations.FilesCollection + "/" + files[0].Id + "/" + names[0]
|
||||
|
||||
anonymous := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(anonymous, httptest.NewRequest(http.MethodGet, link, nil))
|
||||
// Отказ приходит кодом «не найдено»: защищённый файл не раскрывает даже
|
||||
// своего существования. До пометки поля защищённым эта же ссылка отдавала
|
||||
// содержимое кому угодно — знание ссылки и было доступом.
|
||||
assert.Equal(t, http.StatusNotFound, anonymous.Code,
|
||||
"ссылка отдала файл без токена: знание ссылки снова стало доступом")
|
||||
assert.NotContains(t, anonymous.Body.String(), "audio content")
|
||||
|
||||
// А узнанный по заголовку берёт токен и проходит: путь «узнавание → токен
|
||||
// файла → ссылка» обязан работать целиком, иначе файл записи недостижим для
|
||||
// браузера вовсе.
|
||||
withToken := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(withToken,
|
||||
httptest.NewRequest(http.MethodGet, link+"?token="+fileToken(t, env, env.login), nil))
|
||||
require.Equal(t, http.StatusOK, withToken.Code)
|
||||
assert.Equal(t, "audio content", withToken.Body.String())
|
||||
|
||||
// Конвейер читает тот же файл своим путём — из файловой системы хранилища.
|
||||
fileRepo := pbrepo.NewFileRepository(env.app)
|
||||
reader, err := fileRepo.Open(files[0].Id)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
assert.NoError(t, reader.Close())
|
||||
}()
|
||||
|
||||
content := make([]byte, len("audio content"))
|
||||
_, err = reader.Read(content)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "audio content", string(content))
|
||||
}
|
||||
|
||||
// countAccounts — сколько учётных записей лежит в хранилище. Проверки судят
|
||||
// заведение по числу строк: «запись одна» и «записи две» — разные исходы, а по
|
||||
// ответу обработчика они неразличимы.
|
||||
// countAccounts — сколько учётных записей лежит в базе. Проверки судят заведение
|
||||
// по числу строк: «запись одна» и «записи две» — разные исходы, а по ответу
|
||||
// обработчика они неразличимы.
|
||||
func countAccounts(t *testing.T, env *testEnv) int {
|
||||
t.Helper()
|
||||
|
||||
records, err := env.app.FindAllRecords(migrations.UsersCollection)
|
||||
require.NoError(t, err)
|
||||
return countRows(t, env, "users")
|
||||
}
|
||||
|
||||
return len(records)
|
||||
// accountLogin читает ключ учётной записи прямо из базы.
|
||||
func accountLogin(t *testing.T, env *testEnv, accountID string) string {
|
||||
t.Helper()
|
||||
|
||||
var login string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT provider_login FROM users WHERE id = ?", accountID).Scan(&login))
|
||||
return login
|
||||
}
|
||||
|
||||
// TestRejectedByRateLimitCreatesNoAccount — отвергнутый ограничителем частоты
|
||||
// запрос не заводит учётной записи.
|
||||
//
|
||||
// Слой узнавания читает базу, а на новом имени ещё и пишет в неё. Стоя раньше
|
||||
// ограничителя, он работал на запросах, которые тот уже отверг: сто двадцать
|
||||
// запросов выбирали бюджет, следующие пятьдесят получали отказ — и заводили
|
||||
// пятьдесят учётных записей. Убрать их потом нечем: учётная запись с записями
|
||||
// не удаляется, а мусорная растёт в той же единственной базе.
|
||||
// ограничителя, он работал бы на запросах, которые тот уже отверг: бюджет
|
||||
// выбирается, следующие запросы получают отказ — и заводят учётные записи.
|
||||
// Убрать их потом нечем: учётная запись с записями не удаляется.
|
||||
func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
// Бюджет выбирается запросами одного имени, чтобы счётчик успел упереться в
|
||||
// потолок раньше, чем начнутся новые имена.
|
||||
for range appRateMaxRequests + 5 {
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest(http.MethodGet, "/app/me", nil))
|
||||
env.getOwn("/app/me")
|
||||
}
|
||||
|
||||
rejected := 0
|
||||
for i := range 20 {
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest(http.MethodGet, "/app/me", nil),
|
||||
fmt.Sprintf("newcomer-%d", i)))
|
||||
w := env.getAs(fmt.Sprintf("newcomer-%d", i), "/app/me")
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
rejected++
|
||||
}
|
||||
@@ -549,18 +378,12 @@ func TestRejectedByRateLimitCreatesNoAccount(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestAccountCreationIsLogged — заведение учётной записи видно владельцу.
|
||||
//
|
||||
// Без строки журнала «никто не заходил» неотличимо от «завелось двадцать», а
|
||||
// прокси, пропустивший чужой заголовок, не оставляет следа вовсе. Значение
|
||||
// заголовка при этом в строку не идёт: им довольно назваться, чтобы стать этим
|
||||
// человеком.
|
||||
func TestAccountCreationIsLogged(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const login = "brand-new-person"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
w := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
@@ -570,8 +393,7 @@ func TestAccountCreationIsLogged(t *testing.T) {
|
||||
// Второе обращение новой строки не прибавляет: заводится запись однажды.
|
||||
before := strings.Count(journal, "Account created from login header")
|
||||
|
||||
again := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(again, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), login))
|
||||
again := env.getAs(login, "/app/me")
|
||||
require.Equal(t, http.StatusOK, again.Code)
|
||||
|
||||
assert.Equal(t, before, strings.Count(env.journal.String(), "Account created from login header"),
|
||||
@@ -579,10 +401,6 @@ func TestAccountCreationIsLogged(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestDuplicateLoginHeaderIsVisibleToOwner — поломка контура видна в бою.
|
||||
//
|
||||
// Два значения заголовка означают прокси, который его добавляет вместо замены,
|
||||
// — модель угроз называет это главным барьером. Отладочным уровнем такая
|
||||
// поломка в бою не видна вовсе: боевой уровень журнала информационный.
|
||||
func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -601,27 +419,23 @@ func TestDuplicateLoginHeaderIsVisibleToOwner(t *testing.T) {
|
||||
assert.NotContains(t, journal, "intruder", "значение заголовка уехало в журнал")
|
||||
}
|
||||
|
||||
// TestStorageFailureOnIdentityIsServiceFailure — отказ хранилища на пути
|
||||
// узнавания кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
||||
// TestStorageFailureOnIdentityIsServiceFailure — отказ базы на пути узнавания
|
||||
// кончается отказом сервиса, а не молчаливым проходом неузнанным.
|
||||
//
|
||||
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя
|
||||
// то, что сломано не у него.
|
||||
// Иначе человек увидел бы отказ входа там, где легла база, и чинил бы у себя то,
|
||||
// что сломано не у него.
|
||||
func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
// Колонка ключа убирается из схемы: выборка по ней перестаёт работать — так
|
||||
// же, как она перестанет работать при отказе хранилища.
|
||||
users, err := env.app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
// Колонка ключа переименовывается: выборка по ней перестаёт работать — так
|
||||
// же, как она перестанет работать при отказе базы.
|
||||
_, err := env.db.Writer().Exec("ALTER TABLE users RENAME COLUMN provider_login TO provider_login_gone")
|
||||
require.NoError(t, err)
|
||||
users.RemoveIndex("idx_users_provider_login")
|
||||
users.Fields.RemoveByName(migrations.ProviderLoginField)
|
||||
require.NoError(t, env.app.Save(users))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", nil), "somebody"))
|
||||
w := env.getAs("somebody", "/app/me")
|
||||
|
||||
assert.GreaterOrEqual(t, w.Code, http.StatusInternalServerError,
|
||||
"отказ хранилища выдан за «вас не узнали»")
|
||||
"отказ базы выдан за «вас не узнали»")
|
||||
assert.Contains(t, env.journal.String(), "Failed to resolve account by login header")
|
||||
}
|
||||
|
||||
@@ -632,7 +446,7 @@ func TestStorageFailureOnIdentityIsServiceFailure(t *testing.T) {
|
||||
// начал бы отвечать отказом контракта, и старая закладка молча сменила бы
|
||||
// поведение.
|
||||
func TestFormerAuthRootServesMarkup(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
res := env.get("/auth/login")
|
||||
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
@@ -57,7 +65,7 @@ func TestMe_CarriesAccountWithoutEmail(t *testing.T) {
|
||||
var me MeView
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &me))
|
||||
|
||||
assert.Equal(t, env.account.Id, me.ID)
|
||||
assert.Equal(t, env.account.ID, me.ID)
|
||||
assert.NotContains(t, w.Body.String(), "person@example.com",
|
||||
"адрес почты принадлежит человеку, а не сервису")
|
||||
}
|
||||
@@ -73,7 +81,7 @@ func TestUnauthorized_ExistingRecordLooksLikeUnknown(t *testing.T) {
|
||||
env.mux.ServeHTTP(existing, httptest.NewRequest("GET", "/app/audiorecords/"+record.Id, http.NoBody))
|
||||
|
||||
unknown := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/nosuchrecordid", http.NoBody))
|
||||
env.mux.ServeHTTP(unknown, httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, existing.Code)
|
||||
require.Equal(t, http.StatusUnauthorized, unknown.Code)
|
||||
@@ -97,7 +105,7 @@ func TestIntake_SenderFilenameLandsInOwnColumn(t *testing.T) {
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
@@ -128,7 +136,7 @@ func TestIntake_LongFilenameIsTrimmed(t *testing.T) {
|
||||
|
||||
item := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.Id)
|
||||
record, err := env.handler.recordRepo.GetByID(item.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record.OriginalFilename)
|
||||
|
||||
@@ -170,7 +178,9 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "записи нет",
|
||||
req: func() *http.Request { return httptest.NewRequest("GET", "/app/audiorecords/nosuch", http.NoBody) },
|
||||
req: func() *http.Request {
|
||||
return httptest.NewRequest("GET", "/app/audiorecords/"+unknownRecordID, http.NoBody)
|
||||
},
|
||||
code: CodeNotFound,
|
||||
},
|
||||
{
|
||||
@@ -209,37 +219,279 @@ func TestErrorBody_OneShapeAcrossBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Своё правило ограничителя частоты заведено под корнем приложения: правило
|
||||
// хранилища настроено на его собственный корень и наших адресов не покрывает.
|
||||
func TestRateLimitRuleCoversAppRoot(t *testing.T) {
|
||||
// Ограничитель частоты покрывает адреса приложения и **не трогает** адресов
|
||||
// наблюдения: правило своё, и настроено оно на корень приложения.
|
||||
func TestRateLimitCoversAppRootOnly(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
var found int
|
||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
||||
found++
|
||||
assert.Positive(t, rule.MaxRequests)
|
||||
assert.Positive(t, rule.Duration)
|
||||
var refused *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
refused = env.getOwn("/app/me")
|
||||
if refused.Code == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, found, "правило под корнем приложения заведено, и оно одно")
|
||||
assert.True(t, env.app.Settings().RateLimits.Enabled, "и ограничитель включён")
|
||||
require.Equal(t, http.StatusTooManyRequests, refused.Code,
|
||||
"бюджет под корнем приложения не исчерпался — проверке не на чем сработать")
|
||||
|
||||
// Правило приводится к настройке **при каждом подъёме**, то есть на каждом
|
||||
// рестарте сервиса. Без этой проверки ветвь замены не исполнялась бы ни разу,
|
||||
// и правила молча копились бы с каждой выкладкой.
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
// Проба здоровья тем же бюджетом не ограничена: она лежит вне корня
|
||||
// приложения, а слои одеты на корень.
|
||||
assert.Equal(t, http.StatusOK, env.get(HealthPath).Code,
|
||||
"ограничитель приложения закрыл наблюдение за сервисом")
|
||||
}
|
||||
|
||||
again := 0
|
||||
for _, rule := range env.app.Settings().RateLimits.Rules {
|
||||
if strings.HasPrefix(rule.Label, AppRoot+"/") {
|
||||
again++
|
||||
// Два клиентских адреса через один доверенный прокси расходуют **разные**
|
||||
// бюджеты, а заголовок пересылки с недоверенного адреса на ключ бюджета не
|
||||
// влияет.
|
||||
//
|
||||
// Обе половины закрывают свою поломку: бюджет, посчитанный по пиру, становится
|
||||
// общим на весь сервис, а вера заголовку без сверки пира отдаёт обход
|
||||
// ограничителя ровно тому, кого он ограничивает.
|
||||
func TestRateLimitKeyNamesTheClient(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
spend := func(peer, forwarded string) int {
|
||||
refused := 0
|
||||
for range appRateMaxRequests + 1 {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = peer
|
||||
if forwarded != "" {
|
||||
req.Header.Set(ForwardedForHeader, forwarded)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
return refused
|
||||
}
|
||||
|
||||
// Первый клиент выбирает свой бюджет целиком.
|
||||
require.Positive(t, spend(trustedPeer, "198.51.100.7"),
|
||||
"бюджет первого клиента не исчерпался — проверке не на чем сработать")
|
||||
|
||||
// Второй клиент за тем же прокси начинает со своего.
|
||||
firstRefusalOfSecond := 0
|
||||
for range appRateMaxRequests {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = trustedPeer
|
||||
req.Header.Set(ForwardedForHeader, "198.51.100.8")
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
firstRefusalOfSecond++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, again, "повторный подъём правило заменяет, а не добавляет второе")
|
||||
assert.Zero(t, firstRefusalOfSecond,
|
||||
"исчерпание бюджета одним клиентом отказало другому: бюджет считается по пиру")
|
||||
}
|
||||
|
||||
// Заголовок пересылки, пришедший с недоверенного адреса, на ключ бюджета не
|
||||
// влияет: иначе спрашивающий назначал бы себе ключ счётчика сам и обходил
|
||||
// ограничитель, меняя значение.
|
||||
func TestRateLimitIgnoresForwardedHeaderFromUntrustedPeer(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := 0
|
||||
for i := range appRateMaxRequests + 10 {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.RemoteAddr = untrustedPeer
|
||||
req.Header.Set(ForwardedForHeader, fmt.Sprintf("198.51.100.%d", i%200))
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"меняя заголовок пересылки, спрашивающий обошёл ограничитель")
|
||||
}
|
||||
|
||||
// spendBudget шлёт запросы под корнем приложения и считает отказы ограничителя.
|
||||
// Заголовки пересылки ставит вызывающий: ключ бюджета выводится из них, и
|
||||
// проверке нужен каждый их вид — одна строка, несколько строк, цепочка.
|
||||
func spendBudget(env *testEnv, peer string, count int, forwarded func(i int) []string) int {
|
||||
refused := 0
|
||||
for i := range count {
|
||||
req := httptest.NewRequest("GET", "/app/me", http.NoBody)
|
||||
req.Header.Set(LoginHeader, env.login)
|
||||
req.RemoteAddr = peer
|
||||
for _, value := range forwarded(i) {
|
||||
req.Header.Add(ForwardedForHeader, value)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
return refused
|
||||
}
|
||||
|
||||
// Значение, которое приписал сам спрашивающий, ключа бюджета не задаёт.
|
||||
//
|
||||
// Прокси заголовок **дописывает**, а не заменяет: слева в цепочке стоит то, что
|
||||
// прислал аноним, а справа — адрес, который приписал прокси. Ключ, взятый слева,
|
||||
// менялся бы на каждом запросе, и бюджет обходился бы с первого.
|
||||
func TestRateLimitIgnoresValuePresentedByTheClient(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
|
||||
return []string{fmt.Sprintf("198.51.100.%d, 203.0.113.7", i%200)}
|
||||
})
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"подставляя своё значение слева, спрашивающий обошёл ограничитель")
|
||||
}
|
||||
|
||||
// Цепочка законно приходит несколькими строками заголовка, и читаются они все:
|
||||
// разбор одной строки увидел бы кусок, которым распоряжается аноним.
|
||||
func TestRateLimitReadsEveryForwardedHeaderLine(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
refused := spendBudget(env, trustedPeer, appRateMaxRequests+10, func(i int) []string {
|
||||
return []string{fmt.Sprintf("198.51.100.%d", i%200), "203.0.113.8"}
|
||||
})
|
||||
|
||||
assert.Positive(t, refused,
|
||||
"вторая строка заголовка не прочитана: ключ достался присланному значению")
|
||||
}
|
||||
|
||||
// Доверенные шаги цепочки отбрасываются, и ключом становится первый недоверенный
|
||||
// справа. Два клиента за одним прокси при этом расходуют разные бюджеты.
|
||||
func TestRateLimitSkipsTrustedHopsFromTheRight(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
require.Positive(t,
|
||||
spendBudget(env, trustedPeer, appRateMaxRequests+1, func(int) []string {
|
||||
return []string{"203.0.113.11, 10.9.9.9"}
|
||||
}),
|
||||
"бюджет первого клиента не исчерпался — проверке не на чем сработать")
|
||||
|
||||
assert.Zero(t,
|
||||
spendBudget(env, trustedPeer, appRateMaxRequests, func(int) []string {
|
||||
return []string{"203.0.113.12, 10.9.9.9"}
|
||||
}),
|
||||
"исчерпание бюджета одним клиентом отказало другому: доверенный шаг стал ключом")
|
||||
}
|
||||
|
||||
// Тип содержимого ответа выбирает сервис, а не отправитель.
|
||||
//
|
||||
// Расширение приходит из имени, данное отправителем: `запись.html`, отданный
|
||||
// типом `text/html` с показом на месте, стал бы страницей в браузере. Тип
|
||||
// выводится поэтому из **закрытого** перечня известных форматов — той же единой
|
||||
// точки, что и метка метрики, — а всё прочее отдаётся `application/octet-stream`
|
||||
// на сохранение.
|
||||
func TestFileDownload_ContentTypeComesFromKnownFormats(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
fetch := func(t *testing.T, name string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, name, []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, created.Code, name)
|
||||
|
||||
return env.getOwn("/app/audiorecords/" +
|
||||
intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||
}
|
||||
|
||||
t.Run("расширение вне перечня исполняемым типом не отдаётся", func(t *testing.T) {
|
||||
for _, name := range []string{"запись.html", "запись.svg", "запись.xhtml"} {
|
||||
w := fetch(t, name)
|
||||
require.Equal(t, http.StatusOK, w.Code, name)
|
||||
header := w.Result().Header
|
||||
assert.Equal(t, unknownContentType, header.Get("Content-Type"), name)
|
||||
assert.Contains(t, header.Get("Content-Disposition"), dispositionAttachment, name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("известный формат отдаётся своим типом", func(t *testing.T) {
|
||||
for name, want := range map[string]string{
|
||||
"запись.mkv": "video/x-matroska",
|
||||
"запись.mov": "video/quicktime",
|
||||
"запись.avi": "video/x-msvideo",
|
||||
"запись.mp3": "audio/mpeg",
|
||||
} {
|
||||
w := fetch(t, name)
|
||||
require.Equal(t, http.StatusOK, w.Code, name)
|
||||
header := w.Result().Header
|
||||
assert.Equal(t, want, header.Get("Content-Type"), name)
|
||||
assert.Contains(t, header.Get("Content-Disposition"), dispositionInline, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Перечень типов содержимого сверяется с перечнем известных форматов
|
||||
// механически: формат, объявленный диалогу выбора файла и оставшийся без типа,
|
||||
// уехал бы ответом `application/octet-stream` — то есть сервис предлагал бы
|
||||
// загрузить то, что потом не умеет показать.
|
||||
func TestEveryKnownFormatHasContentType(t *testing.T) {
|
||||
formats := metrics.PublicFormats()
|
||||
require.NotEmpty(t, formats, "перечень форматов пуст: правилу не на чем сработать")
|
||||
|
||||
for _, format := range formats {
|
||||
contentType, disposition := presentationOf(format)
|
||||
assert.NotEqual(t, unknownContentType, contentType,
|
||||
"формат %q сервис объявляет диалогу выбора файла, но типа содержимого у него нет", format)
|
||||
assert.Equal(t, dispositionInline, disposition, format)
|
||||
}
|
||||
|
||||
for format := range contentTypes {
|
||||
assert.Equal(t, format, metrics.FormatLabel(format),
|
||||
"тип содержимого заведён формату %q, которого нет среди известных: ключ никогда не совпадёт", format)
|
||||
}
|
||||
}
|
||||
|
||||
// Паника обработчика отдаёт `500` нашей формой тела, а процесс живёт дальше.
|
||||
//
|
||||
// Слой восстановления — верхняя граница поверхности, и проверяется он через
|
||||
// **всю** цепочку: паника ловится снаружи журнала и маршрутизатора, поэтому
|
||||
// собранная иначе поверхность судила бы не то. Без него один паникующий запрос
|
||||
// уронил бы процесс вместе с конвейером и всеми, кто в это время что-то грузил.
|
||||
func TestPanickingHandlerAnswersOurFailureFormAndProcessLives(t *testing.T) {
|
||||
db, _, _ := newTestStorage(t)
|
||||
users := sqliterepo.NewUserRepository(db)
|
||||
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
panicking := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
panic("шаг обработчика упал")
|
||||
})
|
||||
mounts := ServiceMounts(
|
||||
AppChain(panicking, users, testTrustedNetworks(t), logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||
|
||||
account, _, err := users.EnsureUser(contract.Identity{Login: "person"})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, account.ID)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, "/app/me", http.NoBody), "person"))
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body), "тело отказа — не наша форма")
|
||||
assert.Equal(t, CodeInternal, body.Code)
|
||||
assert.NotEmpty(t, body.Message)
|
||||
assert.NotContains(t, w.Body.String(), "шаг обработчика упал",
|
||||
"значение паники ушло спрашивающему")
|
||||
|
||||
assert.Contains(t, journal.String(), "Handler panicked",
|
||||
"владелец сервиса о панике не узнал")
|
||||
|
||||
// Процесс жив: следующий запрос отвечает как ни в чём не бывало.
|
||||
alive := httptest.NewRecorder()
|
||||
mux.ServeHTTP(alive, httptest.NewRequest(http.MethodGet, HealthPath, http.NoBody))
|
||||
assert.Equal(t, http.StatusOK, alive.Code, "после паники поверхность перестала отвечать")
|
||||
}
|
||||
|
||||
// Перечень доступных видов растёт вместе с готовыми текстами, и вычитанный текст
|
||||
@@ -250,13 +502,13 @@ func TestAvailableViewsCoverEveryKind(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||
require.NoError(t, err)
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
structures := pbrepo.NewStructureRepository(env.app)
|
||||
structures := env.handler.structureRepo
|
||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{{StartMs: 0, EndMs: 10, Text: "реплика"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -318,7 +570,6 @@ func TestTooLargeOnTheRealPath(t *testing.T) {
|
||||
// частом отказе после превышения размера.
|
||||
func TestRateLimitRefusalGoesThroughOneErrorForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
require.NoError(t, ApplyAppRateLimit(env.app))
|
||||
|
||||
var last *httptest.ResponseRecorder
|
||||
for range appRateMaxRequests + 1 {
|
||||
@@ -403,7 +654,7 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
empty, err := texts.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -425,3 +676,108 @@ func TestEmptyTextIsNotAnAvailableView(t *testing.T) {
|
||||
// И адрес текста отвечает тем же: состоянием, а не обещанием.
|
||||
assert.Equal(t, http.StatusConflict, textOf(t, env, record.Id, entity.TextViewTranscript).Code)
|
||||
}
|
||||
|
||||
// TestFailuresBornOutsideHandlerShareOneForm — **критерий приёмки**: отказы,
|
||||
// рождающиеся не в обработчике, приходят той же формой, что и отказы
|
||||
// обработчика.
|
||||
//
|
||||
// Проверка идёт **настоящими** HTTP-запросами через поднятую цепочку слоёв:
|
||||
// вызовом отображателя ошибки это не проверяется — ни один из трёх отказов до
|
||||
// него не доходит. Предел тела ловит запрос слоем чтения, ограничитель частоты —
|
||||
// слоем перед узнаванием, неизвестный путь — маршрутизатором.
|
||||
func TestFailuresBornOutsideHandlerShareOneForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
server := env.liveServer(t)
|
||||
|
||||
// Перечень кодов закрыт: значение вне его приложению разбирать нечем.
|
||||
known := map[string]bool{
|
||||
CodeUnauthorized: true, CodeNotFound: true, CodeBadRequest: true,
|
||||
CodeTooLarge: true, CodeNotReady: true, CodeTooManyRequests: true,
|
||||
CodeInternal: true,
|
||||
}
|
||||
|
||||
assertOurForm := func(t *testing.T, status int, body []byte) {
|
||||
t.Helper()
|
||||
|
||||
var raw map[string]any
|
||||
require.NoError(t, json.Unmarshal(body, &raw), "тело не наше: %s", body)
|
||||
require.Contains(t, raw, "error_code")
|
||||
require.Contains(t, raw, "message")
|
||||
|
||||
code, ok := raw["error_code"].(string)
|
||||
require.True(t, ok)
|
||||
assert.True(t, known[code], "код отказа %q вне закрытого перечня", code)
|
||||
assert.NotEmpty(t, raw["message"])
|
||||
assert.NotEqual(t, http.StatusOK, status)
|
||||
}
|
||||
|
||||
t.Run("предел тела", func(t *testing.T) {
|
||||
// Запрос идёт **сырым соединением**: клиент стандартной библиотеки
|
||||
// отказывается слать объявленную длину, которой не соответствует тело, а
|
||||
// прислать восемь гигабайт на самом деле проверка не может. Сервер при
|
||||
// этом видит обычный запрос: заголовки разобраны, длина объявлена, тела
|
||||
// он не читает вовсе — отказ наступает раньше.
|
||||
status, body := rawRequest(t, server, ""+
|
||||
"POST /app/audiorecords HTTP/1.1\r\n"+
|
||||
"Host: transcriber.test\r\n"+
|
||||
LoginHeader+": "+env.login+"\r\n"+
|
||||
"Content-Type: multipart/form-data; boundary=x\r\n"+
|
||||
"Content-Length: "+strconv.FormatInt(entity.MaxRecordSize+1, 10)+"\r\n"+
|
||||
"Connection: close\r\n\r\n")
|
||||
|
||||
require.Equal(t, http.StatusRequestEntityTooLarge, status)
|
||||
assertOurForm(t, status, body)
|
||||
assert.Contains(t, string(body), `"limit"`, "предел уходит человеку числом")
|
||||
assert.Equal(t, 0, countJobs(t, env), "записи не заводится")
|
||||
})
|
||||
|
||||
t.Run("неизвестный путь под корнем приложения", func(t *testing.T) {
|
||||
res := env.liveRequest(t, server, "/app/nosuchendpoint", nil)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, res.StatusCode)
|
||||
assertOurForm(t, res.StatusCode, res.Body)
|
||||
})
|
||||
|
||||
t.Run("ограничитель частоты", func(t *testing.T) {
|
||||
var last liveResponse
|
||||
for range appRateMaxRequests + 5 {
|
||||
last = env.liveRequest(t, server, "/app/me", nil)
|
||||
if last.StatusCode == http.StatusTooManyRequests {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, last.StatusCode,
|
||||
"ограничитель не сработал — проверке не на чем сработать")
|
||||
assertOurForm(t, last.StatusCode, last.Body)
|
||||
})
|
||||
}
|
||||
|
||||
// rawRequest шлёт запрос сырым соединением и отдаёт код с телом ответа.
|
||||
//
|
||||
// Нужен там, где клиент стандартной библиотеки запрос не отправит: он судит
|
||||
// соответствие объявленной длины телу, а проверке нужна ровно объявленная.
|
||||
func rawRequest(t *testing.T, server *httptest.Server, request string) (int, []byte) {
|
||||
t.Helper()
|
||||
|
||||
address := strings.TrimPrefix(server.URL, "http://")
|
||||
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
conn, err := dialer.DialContext(context.Background(), "tcp", address)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, conn.Close()) }()
|
||||
|
||||
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
|
||||
|
||||
_, err = conn.Write([]byte(request))
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return res.StatusCode, body
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -17,9 +14,13 @@ import (
|
||||
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
||||
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
||||
//
|
||||
// Кода `forbidden` в перечне больше нет: его единственным случаем был владелец
|
||||
// панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у
|
||||
// сервиса не осталось, а узнавание по заголовку учётную запись заводит само —
|
||||
// предъявителя без неё не бывает.
|
||||
const (
|
||||
CodeUnauthorized = "unauthorized"
|
||||
CodeForbidden = "forbidden"
|
||||
CodeNotFound = "not_found"
|
||||
CodeBadRequest = "bad_request"
|
||||
CodeTooLarge = "too_large"
|
||||
@@ -32,7 +33,7 @@ const (
|
||||
//
|
||||
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
||||
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
||||
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
|
||||
// внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в
|
||||
// журнале владельца сервиса.
|
||||
//
|
||||
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
||||
@@ -46,8 +47,7 @@ type ErrorBody struct {
|
||||
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
||||
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
||||
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
||||
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
|
||||
// второе не говорило ему ничего.
|
||||
// ошибка» на негодный файл.
|
||||
//
|
||||
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
||||
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
||||
@@ -87,6 +87,18 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
Message: "Текст этого вида для записи ещё не готов",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrCopyNotReady):
|
||||
return http.StatusConflict, ErrorBody{
|
||||
Code: CodeNotReady,
|
||||
Message: "Этой копии записи ещё нет",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrTooManyRequests):
|
||||
return http.StatusTooManyRequests, ErrorBody{
|
||||
Code: CodeTooManyRequests,
|
||||
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrNotFound):
|
||||
message := "Адрес не найден"
|
||||
var owned *messagedError
|
||||
@@ -104,12 +116,6 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
Code: CodeUnauthorized,
|
||||
Message: "Сервис вас не узнал",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrOwnerRequired):
|
||||
return http.StatusForbidden, ErrorBody{
|
||||
Code: CodeForbidden,
|
||||
Message: "У предъявителя нет учётной записи пользователя",
|
||||
}
|
||||
}
|
||||
|
||||
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||||
@@ -130,114 +136,61 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
|
||||
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
||||
// уходит наружу с адресов приложения.
|
||||
func fail(e *core.RequestEvent, err error) error {
|
||||
//
|
||||
// Отказы, рождающиеся **не в обработчике** — предел тела, ограничитель частоты,
|
||||
// неизвестный путь под корнем приложения, — приходят сюда же: слои сервиса
|
||||
// написаны нами и отвечают своей доменной ошибкой, а не телом библиотеки. Второй
|
||||
// формы тела на адресах приложения не существует.
|
||||
func fail(w http.ResponseWriter, err error) {
|
||||
status, body := mapDomainError(err)
|
||||
return e.JSON(status, body)
|
||||
writeJSON(w, status, body)
|
||||
}
|
||||
|
||||
// OneErrorForm переводит отказ библиотеки в нашу форму тела.
|
||||
//
|
||||
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
|
||||
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
|
||||
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
|
||||
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
|
||||
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
|
||||
//
|
||||
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
|
||||
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
|
||||
// «внутренняя ошибка» вместо предела числом.
|
||||
//
|
||||
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
|
||||
// него, включая предел тела и ограничитель частоты.
|
||||
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberOneErrorForm",
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
err := e.Next()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Обработчик, ответивший через fail, ошибки не возвращает — его
|
||||
// форма уже ушла в ответ, и сюда доходит только чужая.
|
||||
var apiErr *router.ApiError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
status, translated := translateAPIError(apiErr)
|
||||
return e.JSON(status, translated)
|
||||
},
|
||||
}
|
||||
// writeJSON отдаёт тело ответа. Отказ записи в журнал не идёт: соединение к
|
||||
// этому моменту оборвано, и сказать о нём некому — строка о каждом закрытом
|
||||
// браузере наполняла бы журнал ничем.
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
|
||||
// названы поимённо: значение вне перечня приложению разбирать нечем.
|
||||
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
|
||||
switch apiErr.Status {
|
||||
case http.StatusRequestEntityTooLarge:
|
||||
limit := entity.MaxRecordSize
|
||||
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||||
Code: CodeTooLarge,
|
||||
Message: "Запись больше допустимого размера",
|
||||
Limit: &limit,
|
||||
}
|
||||
case http.StatusTooManyRequests:
|
||||
return http.StatusTooManyRequests, ErrorBody{
|
||||
Code: CodeTooManyRequests,
|
||||
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||
}
|
||||
case http.StatusNotFound:
|
||||
return http.StatusNotFound, ErrorBody{
|
||||
Code: CodeNotFound,
|
||||
Message: "Адрес не найден",
|
||||
}
|
||||
case http.StatusUnauthorized:
|
||||
return mapDomainError(contract.ErrUnauthorized)
|
||||
}
|
||||
|
||||
return apiErr.Status, ErrorBody{
|
||||
Code: CodeInternal,
|
||||
Message: "Внутренняя ошибка сервиса",
|
||||
}
|
||||
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
||||
// всех одна, а сказать человеку надо разное.
|
||||
func errWithMessage(base error, message string) error {
|
||||
return &messagedError{base: base, message: message}
|
||||
}
|
||||
|
||||
// RequireUser — слой предъявления адресов приложения.
|
||||
//
|
||||
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
|
||||
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
|
||||
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
|
||||
// поимённо: без имени пускается всякая учётная запись хранилища, включая
|
||||
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
|
||||
// записи он стать не может.
|
||||
//
|
||||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||
// уложенный файл — чего сервис не умеет вовсе.
|
||||
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberRequireUser",
|
||||
// Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий
|
||||
// запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за
|
||||
// ограничителем частоты — см. `TrustedHeaderIdentity`.
|
||||
//
|
||||
// Предел тела библиотеки идёт следом (−990), и это обязательно: отказ
|
||||
// неузнанному наступает **до** чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 2,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return fail(e, contract.ErrUnauthorized)
|
||||
}
|
||||
type messagedError struct {
|
||||
base error
|
||||
message string
|
||||
}
|
||||
|
||||
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
|
||||
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
|
||||
// предъявитель себя предъявил.
|
||||
if e.Auth.Collection().Name != usersCollection {
|
||||
return fail(e, contract.ErrOwnerRequired)
|
||||
}
|
||||
func (e *messagedError) Error() string { return e.message }
|
||||
func (e *messagedError) Unwrap() error { return e.base }
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
// Recover — верхняя граница обработчика: паникующий запрос отдаёт `500` нашей
|
||||
// формой тела, а процесс живёт.
|
||||
//
|
||||
// Слой свой, потому что своим стал и роутер: прежде его вешала чужая библиотека.
|
||||
// У воркеров такой границы по-прежнему нет — паника в шаге конвейера роняет
|
||||
// процесс целиком, и это осознанно.
|
||||
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.Error("Handler panicked",
|
||||
"error", recovered, "transport", "http")
|
||||
fail(w, errors.New("handler panicked"))
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,121 +1,93 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// Заголовки, которыми обратный прокси называет пришедшего.
|
||||
//
|
||||
// Имена нормативны — ровно как было нормативно имя куки сессии, и по той же
|
||||
// причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама
|
||||
// ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена
|
||||
// соседним сервисам, и настройкой они не делаются: второе место, где их можно
|
||||
// написать неверно, выгоды не даёт.
|
||||
// Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка,
|
||||
// которая сама ставит и сама читает своё имя, этого не замечает. Контур уже
|
||||
// пишет эти имена соседним сервисам, и настройкой они не делаются: второе место,
|
||||
// где их можно написать неверно, выгоды не даёт.
|
||||
const (
|
||||
LoginHeader = "Remote-User"
|
||||
NameHeader = "Remote-Name"
|
||||
EmailHeader = "Remote-Email"
|
||||
)
|
||||
|
||||
// FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла.
|
||||
//
|
||||
// Он лежит в пространстве хранилища, а не приложения, и потому назван здесь
|
||||
// поимённо: без узнавания на нём файл записи недостижим для браузера вовсе —
|
||||
// порядок «узнавание → токен файла → ссылка» обрывается на первом шаге.
|
||||
const FileTokenPath = StorageRoot + "/files/token"
|
||||
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
|
||||
// Читает его только ограничитель частоты: барьером узнавания он не служит и
|
||||
// служить не может — кто пришёл, решает адрес самого соединения.
|
||||
const ForwardedForHeader = "X-Forwarded-For"
|
||||
|
||||
// accountKey — ключ, под которым узнанная учётная запись живёт в контексте
|
||||
// запроса. Свой тип, а не строка: чужой ключ с тем же текстом иначе перезаписал
|
||||
// бы значение.
|
||||
type accountKey struct{}
|
||||
|
||||
// AccountOf отдаёт учётную запись, от имени которой идёт запрос. Второе значение
|
||||
// ложно у неузнанного.
|
||||
func AccountOf(r *http.Request) (*contract.UserAccount, bool) {
|
||||
account, ok := r.Context().Value(accountKey{}).(*contract.UserAccount)
|
||||
return account, ok
|
||||
}
|
||||
|
||||
// withAccount кладёт узнанную учётную запись в контекст запроса.
|
||||
func withAccount(r *http.Request, account *contract.UserAccount) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), accountKey{}, account))
|
||||
}
|
||||
|
||||
// TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника.
|
||||
//
|
||||
// # Область
|
||||
//
|
||||
// Слой вешается корневым — иначе к адресу выдачи файлового токена его не
|
||||
// привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но
|
||||
// узнаёт он **только на объявленной области**: корень приложения плюс этот
|
||||
// адрес. Область выводится из перечня адресного пространства, а не пишется
|
||||
// вторым списком.
|
||||
// Слой вешается на цепочку корня приложения и только на неё. Область поэтому
|
||||
// выводится из объявленного адресного пространства сервиса, а не перечисляется
|
||||
// вторым списком: корень, переехавший в перечне, уносит слой с собой.
|
||||
//
|
||||
// Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной
|
||||
// колонкой коллекции пользователей, и узнавание на всей поверхности хранилища
|
||||
// дало бы узнанному переписать себе ключ на чужое имя — а первое обращение
|
||||
// настоящего владельца этого имени попало бы в чужую запись вместе со всем
|
||||
// архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции;
|
||||
// два барьера здесь именно потому, что прежний был один и держался на
|
||||
// случайности — на том, что браузер сам не шлёт заголовка авторизации.
|
||||
//
|
||||
// Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и
|
||||
// на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к
|
||||
// базе, а первый такой запрос с новым именем — записи в неё.
|
||||
//
|
||||
// # Кто побеждает
|
||||
//
|
||||
// Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения
|
||||
// токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его
|
||||
// учётной записью пользователя отобрала бы у него панель посреди работы.
|
||||
// Протухший и негодный токен предъявленными не считаются: библиотека их не
|
||||
// прочитала, `e.Auth` пуст, и запрос узнаётся заголовком.
|
||||
// Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не
|
||||
// срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе
|
||||
// запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с
|
||||
// новым именем — записи в неё.
|
||||
//
|
||||
// # Чего слой не делает
|
||||
//
|
||||
// Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты
|
||||
// неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто
|
||||
// пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием
|
||||
// учётной записи на адресах приложения.
|
||||
//
|
||||
// Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не
|
||||
// молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где
|
||||
// легла база.
|
||||
// Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием
|
||||
// учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а
|
||||
// не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там,
|
||||
// где легла база.
|
||||
func TrustedHeaderIdentity(
|
||||
app core.App,
|
||||
mounts []Mount,
|
||||
users contract.UserRepository,
|
||||
trusted []netip.Prefix,
|
||||
logger *slog.Logger,
|
||||
) *hook.Handler[*core.RequestEvent] {
|
||||
) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberTrustedHeaderIdentity",
|
||||
// **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а
|
||||
// на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно
|
||||
// работало на запросах, которые тот уже отверг. Замер: сто двадцать
|
||||
// запросов выбирают бюджет, следующие пятьдесят с новыми именами
|
||||
// получают отказ — и заводят пятьдесят учётных записей, которые потом не
|
||||
// убираются ничем.
|
||||
//
|
||||
// Порядок целиком: чтение токена (−1020) → ограничитель (−1000) →
|
||||
// узнавание (−999) → требование учётной записи (−998) → предел тела
|
||||
// (−990). Требование стоит перед пределом тела намеренно: отказ
|
||||
// неузнанному обязан наступать до чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 1,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Более одного значения — не выбор, а отказ. Прокси, настроенный
|
||||
// добавлять заголовок вместо замены, оставляет рядом со своим
|
||||
// значением присланное анонимом, и умолчание «берём первое» отдало
|
||||
// бы вход анониму.
|
||||
values := e.Request.Header.Values(LoginHeader)
|
||||
values := r.Header.Values(LoginHeader)
|
||||
if len(values) != 1 {
|
||||
if len(values) > 1 {
|
||||
// Уровень предупреждающий: два значения означают прокси,
|
||||
// который заголовок **добавляет** вместо замены, — то есть
|
||||
// ровно ту поломку контура, которую модель угроз называет
|
||||
// главной. Отладочным уровнем она в бою не видна вовсе:
|
||||
// боевой уровень журнала информационный.
|
||||
// главной. Отладочным уровнем она в бою не видна вовсе.
|
||||
logger.Warn("Request carries more than one login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
} else {
|
||||
// Заголовка нет вовсе. В этом контуре это значит, что прокси
|
||||
@@ -123,13 +95,14 @@ func TrustedHeaderIdentity(
|
||||
// штатный — так выглядит и человек, которому провайдер
|
||||
// отказал.
|
||||
logger.Debug("Request carries no login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
return e.Next()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
peer, ok := peerAddress(e.Request.RemoteAddr)
|
||||
peer, ok := peerAddress(r.RemoteAddr)
|
||||
if !ok || !isTrusted(trusted, peer) {
|
||||
// Уровень предупреждающий, а не отладочный, и это решение о
|
||||
// цене. Заголовок с недоверенного адреса в этом контуре — не
|
||||
@@ -143,31 +116,34 @@ func TrustedHeaderIdentity(
|
||||
// видно, чья это поломка: своя (перечень) или контура (прокси
|
||||
// заголовка не ставит).
|
||||
logger.Warn("Login header came from an untrusted peer",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
return e.Next()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{
|
||||
account, created, err := users.EnsureUser(contract.Identity{
|
||||
Login: values[0],
|
||||
Name: e.Request.Header.Get(NameHeader),
|
||||
Email: e.Request.Header.Get(EmailHeader),
|
||||
Name: r.Header.Get(NameHeader),
|
||||
Email: r.Header.Get(EmailHeader),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pbrepo.ErrLoginNotAcceptable) {
|
||||
if errors.Is(err, contract.ErrLoginNotAcceptable) {
|
||||
// Негодный логин — это негодный ввод, а не поломка сервиса:
|
||||
// пустой заголовок прокси шлёт штатно там, где никого не
|
||||
// назвал. Уровень поэтому отладочный, и запрос идёт дальше
|
||||
// неузнанным.
|
||||
logger.Debug("Login header value is not acceptable",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
return e.Next()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Error("Failed to resolve account by login header",
|
||||
"error", err, "capability", "access", "transport", "http")
|
||||
return fail(e, err)
|
||||
fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if created {
|
||||
@@ -181,36 +157,35 @@ func TrustedHeaderIdentity(
|
||||
// чтобы стать этим человеком. Идут адрес пира и идентификатор
|
||||
// записи — оба выданы не спрашивающим.
|
||||
logger.Info("Account created from login header",
|
||||
"http.peer_addr", e.Request.RemoteAddr,
|
||||
"account_id", record.Id,
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"account_id", account.ID,
|
||||
"capability", "access", "transport", "http")
|
||||
}
|
||||
|
||||
e.Auth = record
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
next.ServeHTTP(w, withAccount(r, account))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// underIdentifiedArea говорит, узнаётся ли пришедший на этом пути.
|
||||
// RequireUser — слой предъявления адресов приложения.
|
||||
//
|
||||
// Область — корень приложения из перечня адресного пространства плюс адрес
|
||||
// выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень
|
||||
// объявлен единой точкой адресного пространства, и записанный здесь второй раз
|
||||
// он разошёлся бы с ней молча.
|
||||
func underIdentifiedArea(mounts []Mount, requestPath string) bool {
|
||||
if requestPath == FileTokenPath {
|
||||
return true
|
||||
}
|
||||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||
// уложенный файл — чего сервис не умеет вовсе.
|
||||
//
|
||||
// Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит
|
||||
// учётную запись само, и предъявителя без неё не бывает.
|
||||
func RequireUser() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := AccountOf(r); !ok {
|
||||
fail(w, contract.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
for _, mount := range mounts {
|
||||
if mount.Path == AppRoot {
|
||||
return mount.Covers(requestPath)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// peerAddress достаёт адрес того, кто открыл соединение.
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
)
|
||||
|
||||
// webappRoute — чем в журнале обозначается всякий путь, отданный приложению.
|
||||
const webappRoute = "<приложение>"
|
||||
|
||||
// journalNote — то, что обработчик оставляет слою журнала о своём запросе.
|
||||
// Указателем в контексте: значение кладёт слой, а заполняет обработчик ниже.
|
||||
type journalNote struct {
|
||||
outcome string
|
||||
}
|
||||
|
||||
type journalKey struct{}
|
||||
|
||||
// noteWebappOutcome оставляет исход раздачи слою журнала.
|
||||
func noteWebappOutcome(r *http.Request, outcome string) {
|
||||
if note, ok := r.Context().Value(journalKey{}).(*journalNote); ok {
|
||||
note.outcome = outcome
|
||||
}
|
||||
}
|
||||
|
||||
// Journal пишет строку о каждом входящем запросе.
|
||||
//
|
||||
// **Путь, которым распоряжается спрашивающий, в журнал не идёт** — вместо него
|
||||
// маршрут из закрытого перечня и длина: по ним видно, что происходит, а
|
||||
// дословная запись сделала бы журнал местом, куда аноним пишет свой текст
|
||||
// произвольной длины. Корень приложения от этого правила не изъят: путь под ним
|
||||
// выбирает тот же спрашивающий.
|
||||
//
|
||||
// Журнал у сервиса **один**: второй, куда чужая библиотека клала путь целиком
|
||||
// вместе с адресом отправителя, ушёл вместе с ней.
|
||||
func Journal(mounts []Mount, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := clock.Start()
|
||||
|
||||
note := &journalNote{}
|
||||
ctx := context.WithValue(r.Context(), journalKey{}, note)
|
||||
|
||||
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(recorder, r.WithContext(ctx))
|
||||
|
||||
level := slog.LevelInfo
|
||||
if IsObservationAddress(mounts, r.URL.Path) {
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт.
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
"http.method", r.Method,
|
||||
"http.route", JournalRoute(r, mounts),
|
||||
"http.status_code", recorder.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
// Длина идёт на всякую строку, а не только на раздачу
|
||||
// приложения: обобщённое значение маршрута теперь достаётся и
|
||||
// путям под корнем приложения, и без длины по строке не видно,
|
||||
// спросили короткий адрес или мегабайт текста.
|
||||
"http.path_length", len(r.URL.Path),
|
||||
"transport", "http",
|
||||
}
|
||||
|
||||
if note.outcome != "" {
|
||||
attrs = append(attrs, "webapp.outcome", note.outcome)
|
||||
}
|
||||
|
||||
logger.Log(ctx, level, "Incoming request", attrs...)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// JournalRoute готовит путь запроса к записи в журнал.
|
||||
//
|
||||
// **Дословно в журнал идёт только то, что принадлежит закрытому перечню**:
|
||||
// точные адреса наблюдения и образцы адресов приложения. Всё прочее — и путь
|
||||
// вне корней сервиса, и путь под корнем приложения, не совпавший ни с одним
|
||||
// образцом, — обозначается одним значением, а длина уходит отдельным полем.
|
||||
//
|
||||
// Прежнее правило судило по принадлежности пути сервису и оставляло зазор
|
||||
// шириной в корень приложения: `/app/<произвольный текст>` принадлежит сервису,
|
||||
// отвечает отказом неузнанному и при этом уезжал в журнал дословно. Множеством
|
||||
// значений под корнем распоряжается спрашивающий ровно так же, как и вне его, —
|
||||
// значит и правило одно на обе половины.
|
||||
//
|
||||
// Идентификатор записи из строки при этом не пропадает: его пишет обработчик
|
||||
// полем `record_id`, и пишет он тот, что прочитал, а не тот, что попросили.
|
||||
//
|
||||
// Имя файла в хранилище из журнала выводимо быть не должно, и сегодня оно туда
|
||||
// не попадает по построению: адрес копии записи назван идентификатором самой
|
||||
// записи, а имя файла на диске в путь не входит вовсе.
|
||||
func JournalRoute(r *http.Request, mounts []Mount) string {
|
||||
if exact, ok := ExactAddressOf(mounts, r.URL.Path); ok {
|
||||
return exact
|
||||
}
|
||||
|
||||
if pattern, ok := appRoutePatternOf(r); ok {
|
||||
return pattern
|
||||
}
|
||||
|
||||
return webappRoute
|
||||
}
|
||||
|
||||
// appRouteIndex — маршрутизатор, заведённый ради одного вопроса: какому образцу
|
||||
// приложения отвечает этот запрос.
|
||||
//
|
||||
// Маршрутизатор, а не свой разбор пути: образец `{id}` разбирает стандартная
|
||||
// библиотека, и второй разбор рядом с ней разошёлся бы с настоящей
|
||||
// маршрутизацией молча. Образцы берутся тем же перечнем, которым вешаются
|
||||
// обработчики.
|
||||
var appRouteIndex = newAppRouteIndex()
|
||||
|
||||
func newAppRouteIndex() *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
mux.Handle(pattern, http.NotFoundHandler())
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
// knownAppRoutes — тот же перечень множеством: ответ маршрутизатора сверяется с
|
||||
// ним. Перенаправление на очищенный путь маршрутизатор отдаёт образцом,
|
||||
// собранным из самого пути, и без сверки такой ответ уехал бы в журнал
|
||||
// дословно — то есть ровно тем, чего правило не допускает.
|
||||
var knownAppRoutes = knownAppRouteSet()
|
||||
|
||||
func knownAppRouteSet() map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(AppRoutePatterns))
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
out[pattern] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// appRoutePatternOf называет образец адреса приложения. Второе значение ложно у
|
||||
// всего, что ни одному образцу не отвечает.
|
||||
func appRoutePatternOf(r *http.Request) (string, bool) {
|
||||
_, pattern := appRouteIndex.Handler(r)
|
||||
if _, ok := knownAppRoutes[pattern]; !ok {
|
||||
return "", false
|
||||
}
|
||||
return pattern, true
|
||||
}
|
||||
|
||||
// statusRecorder запоминает код ответа: журнал пишется после обработчика, а
|
||||
// готовый код читать больше неоткуда.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
written bool
|
||||
}
|
||||
|
||||
func (w *statusRecorder) WriteHeader(status int) {
|
||||
if !w.written {
|
||||
w.status = status
|
||||
w.written = true
|
||||
}
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *statusRecorder) Write(p []byte) (int, error) {
|
||||
w.written = true
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// journalMounts — перечень адресного пространства для проверок журнала.
|
||||
// Обработчиков он здесь не вешает: журналу нужны только границы, а не то, что
|
||||
// стоит за ними.
|
||||
func journalMounts() []Mount {
|
||||
return []Mount{
|
||||
{Path: AppRoot},
|
||||
{Path: HealthPath, Exact: true},
|
||||
{Path: MetricsPath, Exact: true},
|
||||
}
|
||||
}
|
||||
|
||||
// journalRouteOf — маршрут строки журнала для названного пути.
|
||||
func journalRouteOf(path string) string {
|
||||
return JournalRoute(httptest.NewRequest(http.MethodGet, path, http.NoBody), journalMounts())
|
||||
}
|
||||
|
||||
// Путь, которым распоряжается спрашивающий, в журнал дословно не идёт — и
|
||||
// принадлежность сервису тут ничего не меняет: путь под корнем приложения
|
||||
// выбирает тот же аноним, что и путь вне корней.
|
||||
func TestJournalRouteHidesRequestedPath(t *testing.T) {
|
||||
cases := []string{
|
||||
"/",
|
||||
"/records/abc123def456ghi",
|
||||
"/" + strings.Repeat("a", 1024),
|
||||
"/api/files/files/rec0000000000000/0f7b8dd3-d1cc-424c.mp3",
|
||||
"/_/",
|
||||
// Под корнем приложения — то же самое: образца с таким хвостом нет.
|
||||
"/app",
|
||||
"/app/",
|
||||
"/app/" + strings.Repeat("b", 1024),
|
||||
"/app/audiorecords/abc123def456ghi/file/" + strings.Repeat("c", 512),
|
||||
"/app/audiorecords/abc/def/ghi",
|
||||
}
|
||||
|
||||
for _, path := range cases {
|
||||
assert.Equal(t, webappRoute, journalRouteOf(path), path)
|
||||
}
|
||||
}
|
||||
|
||||
// Дословно пишется закрытый перечень: точные адреса наблюдения и образцы
|
||||
// адресов приложения. Идентификатор записи в образец не входит — он приходит
|
||||
// строкой обработчика полем `record_id`.
|
||||
func TestJournalRouteKeepsClosedList(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/app/audiorecords": AppRouteRecords,
|
||||
"/app/audiorecords/abc123def456ghi": AppRouteRecord,
|
||||
"/app/audiorecords/abc123def456ghi/text": AppRouteRecordText,
|
||||
"/app/audiorecords/abc123def456ghi/file": AppRouteRecordFile,
|
||||
"/app/me": AppRouteMe,
|
||||
"/app/config": AppRouteConfig,
|
||||
HealthPath: HealthPath,
|
||||
MetricsPath: MetricsPath,
|
||||
}
|
||||
|
||||
for path, want := range cases {
|
||||
assert.Equal(t, want, journalRouteOf(path), path)
|
||||
}
|
||||
}
|
||||
|
||||
// Значение маршрута берётся из перечня образцов и ничего сверх него не
|
||||
// возвращает: путь, приведённый маршрутизатором к другому виду, дословно уехать
|
||||
// не может.
|
||||
func TestJournalRouteAnswersOnlyFromClosedList(t *testing.T) {
|
||||
allowed := map[string]bool{webappRoute: true, HealthPath: true, MetricsPath: true}
|
||||
for _, pattern := range AppRoutePatterns {
|
||||
allowed[pattern] = true
|
||||
}
|
||||
|
||||
cases := []string{
|
||||
"/app/me/",
|
||||
"/app/audiorecords/../me",
|
||||
"/app//me",
|
||||
"/app/audiorecords/{id}",
|
||||
"/app/me/" + strings.Repeat("d", 256),
|
||||
}
|
||||
|
||||
for _, path := range cases {
|
||||
route := journalRouteOf(path)
|
||||
assert.True(t, allowed[route], "маршрут %q не принадлежит закрытому перечню (путь %q)", route, path)
|
||||
}
|
||||
}
|
||||
|
||||
// Имя, под которым копия легла в каталог данных, в журнал не идёт: строка
|
||||
// журнала иначе стала бы бессрочным ключом к чужой записи. Сегодня оно не
|
||||
// попадает туда по построению — адрес копии назван идентификатором самой записи,
|
||||
// — и проверка сторожит именно это.
|
||||
func TestJournalCarriesNoStoredFileName(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("данные")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
recordID := intakeItemOf(t, created).ID
|
||||
require.Equal(t, http.StatusOK,
|
||||
env.getOwn("/app/audiorecords/"+recordID+"/file?"+CopyParam+"="+CopyOriginal).Code)
|
||||
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.NotContains(t, journal, names[0], "имя файла в каталоге данных уехало в журнал")
|
||||
assert.Contains(t, journal, recordID, "идентификатор записи остаётся: по нему прослеживается путь")
|
||||
}
|
||||
|
||||
// Путь, отданный приложению, журнал заменяет исходом и длиной: строка о нём не
|
||||
// растёт вместе с длиной пути.
|
||||
func TestJournalWebappOutcomeInsteadOfPath(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
long := "/" + strings.Repeat("щ", 500)
|
||||
require.Equal(t, http.StatusOK, env.get(long).Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.NotContains(t, journal, strings.Repeat("щ", 500), "путь уехал в журнал дословно")
|
||||
assert.Contains(t, journal, "webapp.outcome="+OutcomeMarkup)
|
||||
assert.Contains(t, journal, "http.path_length=")
|
||||
}
|
||||
|
||||
// Неузнанный запрос под корнем приложения журнал тоже не пишет дословно, а
|
||||
// строка о нём не растёт вместе с длиной пути. Прежде путь под корнем считался
|
||||
// принадлежащим сервису и уезжал в строку целиком — аноним писал в журнал
|
||||
// владельца свой текст произвольной длины.
|
||||
func TestJournalHidesAnonymousPathUnderAppRoot(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const filler = 4096
|
||||
long := "/app/" + strings.Repeat("щ", filler)
|
||||
require.Equal(t, http.StatusUnauthorized, env.get(long).Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.NotContains(t, journal, strings.Repeat("щ", filler), "путь уехал в журнал дословно")
|
||||
assert.Contains(t, journal, "http.route="+webappRoute)
|
||||
assert.Contains(t, journal, "http.path_length=")
|
||||
assert.Less(t, len(journal), filler,
|
||||
"строка журнала растёт вместе с длиной запрошенного пути")
|
||||
}
|
||||
@@ -9,12 +9,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
@@ -168,7 +165,7 @@ func TestList_MalformedCursorIsRejected(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "декодируется, но идентификатора нет",
|
||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15 10:00:00.000Z|")),
|
||||
cursor: base64.RawURLEncoding.EncodeToString([]byte("2026-08-15T10:00:00Z|")),
|
||||
},
|
||||
{
|
||||
name: "пара пуста целиком",
|
||||
@@ -202,25 +199,11 @@ func TestTopicsResolveToNames(t *testing.T) {
|
||||
|
||||
// Тема заводится напрямую: словарь тем свой у каждого человека, и пишет его
|
||||
// задача языковой модели, которой ещё нет.
|
||||
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection)
|
||||
require.NoError(t, err)
|
||||
topic := core.NewRecord(topics)
|
||||
topic.Set("owner", env.account.Id)
|
||||
topic.Set("name", "семейный архив")
|
||||
require.NoError(t, env.app.Save(topic))
|
||||
topicID := newTopic(t, env, env.account.ID, "семейный архив")
|
||||
attachTopic(t, env, all.Items[0].ID, topicID)
|
||||
|
||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
||||
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
||||
record, err := env.handler.recordRepo.GetByID(all.Items[0].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
record.TopicIDs = []string{topic.Id}
|
||||
require.NoError(t, repo.Save(record, ""))
|
||||
|
||||
// Правку тем конвейер не делает, поэтому кладём их тем же путём, каким это
|
||||
// сделает задача языковой модели, — прямым сохранением записи коллекции.
|
||||
raw, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
raw.Set("topics", []string{topic.Id})
|
||||
require.NoError(t, env.app.Save(raw))
|
||||
|
||||
page := pageOf(t, env, "")
|
||||
require.Len(t, page.Items, 1)
|
||||
@@ -247,14 +230,14 @@ func TestList_ThreeStatesEachRecordOnce(t *testing.T) {
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 3)
|
||||
|
||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
||||
repo := env.handler.recordRepo
|
||||
|
||||
halted, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
||||
halted, err := repo.GetByID(all.Items[0].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
halted.Halt(entity.HaltReasonStuck, "застряла")
|
||||
require.NoError(t, repo.Save(halted, ""))
|
||||
|
||||
done, err := repo.GetByID(all.Items[1].ID, env.account.Id)
|
||||
done, err := repo.GetByID(all.Items[1].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
done.MoveToState(entity.StateDone)
|
||||
require.NoError(t, repo.Save(done, ""))
|
||||
@@ -295,11 +278,11 @@ func TestList_ShowsOnlyOwnRecords(t *testing.T) {
|
||||
|
||||
acceptRecords(t, env, 2)
|
||||
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords", http.NoBody)
|
||||
asUser(req, stranger)
|
||||
asUser(req, "stranger")
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
@@ -319,12 +302,12 @@ func TestList_DoesNotReadTranscript(t *testing.T) {
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 1)
|
||||
|
||||
repo := pbrepo.NewAudioRecordRepository(env.app)
|
||||
record, err := repo.GetByID(all.Items[0].ID, env.account.Id)
|
||||
repo := env.handler.recordRepo
|
||||
record, err := repo.GetByID(all.Items[0].ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
const marker = "СОДЕРЖИМОЕ-РАСШИФРОВКИ-МАРКЕР"
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, marker)
|
||||
require.NoError(t, err)
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
@@ -413,19 +396,10 @@ func TestForeignTopicDoesNotResolve(t *testing.T) {
|
||||
all := pageOf(t, env, "")
|
||||
require.Len(t, all.Items, 1)
|
||||
|
||||
stranger, _ := newSecondAccount(t, env.app)
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
topics, err := env.app.FindCollectionByNameOrId(migrations.TopicsCollection)
|
||||
require.NoError(t, err)
|
||||
foreign := core.NewRecord(topics)
|
||||
foreign.Set("owner", stranger.Id)
|
||||
foreign.Set("name", "ЧУЖАЯ-ТЕМА-МАРКЕР")
|
||||
require.NoError(t, env.app.Save(foreign))
|
||||
|
||||
raw, err := env.app.FindRecordById(migrations.RecordsCollection, all.Items[0].ID)
|
||||
require.NoError(t, err)
|
||||
raw.Set("topics", []string{foreign.Id})
|
||||
require.NoError(t, env.app.Save(raw))
|
||||
foreign := newTopic(t, env, stranger.ID, "ЧУЖАЯ-ТЕМА-МАРКЕР")
|
||||
attachTopic(t, env, all.Items[0].ID, foreign)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.serve(w, httptest.NewRequest("GET", "/app/audiorecords", http.NoBody))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,71 +4,71 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Проверки разграничения записей по владельцу. Все идут через собранный роутер:
|
||||
// сужение живёт в хранилище, но судится по тому, что видит отправитель.
|
||||
// Проверки разграничения записей по владельцу. Все идут через собранную
|
||||
// поверхность: сужение живёт в хранилище, но судится по тому, что видит
|
||||
// отправитель.
|
||||
|
||||
// newSecondAccount заводит вторую учётную запись со своим логином. Постоянный
|
||||
// адрес почты первой занят, и повторное сохранение отвергается — адрес здесь
|
||||
// свой.
|
||||
func newSecondAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
// unknownRecordID — идентификатор годного вида, которого нет ни у одной записи.
|
||||
const unknownRecordID = "00000000000000000000000000"
|
||||
|
||||
// newSecondAccount заводит вторую учётную запись со своим логином.
|
||||
func newSecondAccount(t *testing.T, env *testEnv) *contract.UserAccount {
|
||||
t.Helper()
|
||||
|
||||
const login = "stranger"
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
account, created, err := env.users.EnsureUser(contract.Identity{
|
||||
Login: "stranger",
|
||||
Name: "Посторонний",
|
||||
Email: "stranger@example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, created)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "stranger@example.com")
|
||||
record.Set("verified", true)
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record, login
|
||||
return account
|
||||
}
|
||||
|
||||
// serveAs шлёт запрос от имени названного логина, а не логина окружения.
|
||||
func serveAs(env *testEnv, login string, w http.ResponseWriter, req *http.Request) {
|
||||
env.mux.ServeHTTP(w, asUser(req, login))
|
||||
}
|
||||
|
||||
// Чужая задача неотличима от несуществующей: тот же код и то же тело. Разница
|
||||
// ответов обратила бы опрос в перебор — по ней считывается, какие задачи
|
||||
// Чужая запись неотличима от несуществующей: тот же код и то же тело. Разница
|
||||
// ответов обратила бы чтение в перебор — по ней считывается, какие записи
|
||||
// заведены.
|
||||
func TestGetTranscribeJobStatus_ForeignJobLooksMissing(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
foreign := httptest.NewRecorder()
|
||||
serveAs(env, stranger, foreign, httptest.NewRequest("GET", "/app/audiorecords/"+job.Id, http.NoBody))
|
||||
foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id)
|
||||
unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID)
|
||||
|
||||
unknown := httptest.NewRecorder()
|
||||
serveAs(env, stranger, unknown, httptest.NewRequest("GET", "/app/audiorecords/unknown0000000000", http.NoBody))
|
||||
|
||||
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая задача не отдаётся")
|
||||
require.Equal(t, http.StatusNotFound, foreign.Code, "чужая запись не отдаётся")
|
||||
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестного идентификатора")
|
||||
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
|
||||
|
||||
// Ни состояния, ни текста расшифровки в теле нет.
|
||||
assert.NotContains(t, foreign.Body.String(), entity.StateUploaded)
|
||||
assert.NotContains(t, foreign.Body.String(), "transcription_text")
|
||||
}
|
||||
|
||||
// Владельцем принятой записи становится предъявитель сессии — и у задачи, и у
|
||||
// её файла.
|
||||
// Нечитаемый по виду идентификатор отвечает тем же, чем неизвестный: разбор идёт
|
||||
// на границе, и до базы такой запрос не доходит вовсе.
|
||||
func TestRecordCard_MalformedIDLooksLikeUnknown(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
malformed := env.getOwn("/app/audiorecords/не-идентификатор")
|
||||
unknown := env.getOwn("/app/audiorecords/" + unknownRecordID)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, malformed.Code)
|
||||
assert.JSONEq(t, unknown.Body.String(), malformed.Body.String())
|
||||
}
|
||||
|
||||
// Владельцем принятой записи становится узнанный предъявитель — и у записи, и у
|
||||
// её копии.
|
||||
func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
@@ -78,24 +78,26 @@ func TestCreateTranscribeJob_OwnerIsSession(t *testing.T) {
|
||||
|
||||
response := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"), "владелец задачи — предъявитель")
|
||||
var owner string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
|
||||
assert.Equal(t, env.account.ID, owner, "владелец записи — предъявитель")
|
||||
|
||||
fileRecord, err := env.app.FindRecordById("files", record.GetString("original_file"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, fileRecord.GetString("owner"), "владелец файла — он же")
|
||||
var fileOwner string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT owner_id FROM files WHERE record_id = ?", response.ID).Scan(&fileOwner))
|
||||
assert.Equal(t, env.account.ID, fileOwner, "владелец копии — он же")
|
||||
}
|
||||
|
||||
// Владельца не задают запросом: своё значение в форме на результат не влияет.
|
||||
func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
stranger := newSecondAccount(t, env)
|
||||
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
query := req.URL.Query()
|
||||
query.Set("owner", stranger)
|
||||
query.Set("owner", stranger.ID)
|
||||
req.URL.RawQuery = query.Encode()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -104,97 +106,198 @@ func TestCreateTranscribeJob_OwnerFieldFromRequestIgnored(t *testing.T) {
|
||||
|
||||
response := intakeItemOf(t, w)
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, response.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, env.account.Id, record.GetString("owner"))
|
||||
var owner string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT owner_id FROM audio_records WHERE id = ?", response.ID).Scan(&owner))
|
||||
assert.Equal(t, env.account.ID, owner)
|
||||
}
|
||||
|
||||
// Предъявитель, чья сессия не даёт учётной записи пользователя, получает отказ
|
||||
// до чтения тела. Владелец панели — именно такой: узнан он узнан, а записи в
|
||||
// коллекции пользователей у него нет, и владельцем записи он стать не может.
|
||||
// TestFileDownload_NarrowedByOwner — **критерий приёмки**: чужой файл недостижим
|
||||
// по прямой ссылке, и ответ на него тот же, что и на несуществующую запись.
|
||||
//
|
||||
// Отказ **до** укладки обязателен: позже пришлось бы убирать уже сохранённый
|
||||
// файл, а уборки файлов сервис не умеет вовсе.
|
||||
func TestCreateTranscribeJob_SuperuserSessionRejected(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
superusers, err := env.app.FindCollectionByNameOrId(core.CollectionNameSuperusers)
|
||||
require.NoError(t, err)
|
||||
|
||||
admin := core.NewRecord(superusers)
|
||||
admin.Set("email", "owner@example.com")
|
||||
admin.SetRandomPassword()
|
||||
require.NoError(t, env.app.Save(admin))
|
||||
|
||||
token, err := admin.NewAuthToken()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Владелец панели предъявляет **свой токен**, а не заголовок: заголовок ему
|
||||
// никто не ставит, и узнавание по нему его учётной записи не касается.
|
||||
req := createMultipartRequest(t, "sample.mp3", []byte("запись"))
|
||||
req.Header.Set("Authorization", token)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code, "узнан, но не запись коллекции пользователей")
|
||||
assert.Equal(t, 0, countJobs(t, env), "задачи не заведено")
|
||||
assert.Equal(t, 0, countFiles(t, env), "и файла тоже")
|
||||
}
|
||||
|
||||
// Чужой файл не отдаётся по ссылке, а свой отдаётся. Проверяется именно переход
|
||||
// по ссылке: токен файла хранилище выдаёт на предъявителя, а не на файл, и отказ
|
||||
// наступает на скачивании, где правило просмотра судит владельца. Проверка,
|
||||
// написанная на выдачу токена, зеленела бы, не касаясь пути, по которому аудио и
|
||||
// уходит.
|
||||
// Отказ наступает **на самом обращении за файлом**: другого места, где он мог бы
|
||||
// наступить, у сервиса не осталось — значений на предъявителя он не выдаёт.
|
||||
func TestFileDownload_NarrowedByOwner(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
_, stranger := newSecondAccount(t, env.app)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
record, err := env.app.FindRecordById("files", *job.OriginalFileID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, env.account.Id, record.GetString("owner"))
|
||||
|
||||
link := "/api/files/files/" + record.Id + "/" + record.GetString("file")
|
||||
|
||||
// Переход по ссылке идёт **без** заголовка: файл судится своим коротким
|
||||
// токеном, а не узнаванием. Так же по ней пойдёт и браузер.
|
||||
mine := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(mine,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, env.login), http.NoBody))
|
||||
|
||||
foreign := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(foreign,
|
||||
httptest.NewRequest("GET", link+"?token="+fileToken(t, env, stranger), http.NoBody))
|
||||
link := "/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyOriginal
|
||||
|
||||
mine := env.getOwn(link)
|
||||
require.Equal(t, http.StatusOK, mine.Code, "свой файл отдаётся")
|
||||
assert.Equal(t, "запись", mine.Body.String(), "и отдаётся содержимым")
|
||||
|
||||
assert.NotEqual(t, http.StatusOK, foreign.Code, "чужой файл не отдаётся")
|
||||
foreign := env.getAs("stranger", link)
|
||||
unknown := env.getAs("stranger",
|
||||
"/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"="+CopyOriginal)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, foreign.Code, "чужой файл не отдаётся")
|
||||
assert.Equal(t, unknown.Code, foreign.Code, "код тот же, что у неизвестной записи")
|
||||
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String(), "и тело то же")
|
||||
assert.NotContains(t, foreign.Body.String(), "запись", "содержимого в отказе нет")
|
||||
}
|
||||
|
||||
// fileToken берёт у хранилища токен файла для названного логина. Токен выдаётся
|
||||
// на предъявителя: о файле хранилище при выдаче не спрашивает.
|
||||
//
|
||||
// Адрес выдачи лежит в пространстве хранилища, а не приложения, и узнавание на
|
||||
// нём работает поимённо — иначе весь путь «узнавание → токен файла → ссылка»
|
||||
// обрывался бы на первом шаге, и файл записи стал бы недостижим для браузера.
|
||||
func fileToken(t *testing.T, env *testEnv, login string) string {
|
||||
t.Helper()
|
||||
// Негодное значение копии у чужой записи неотличимо от неизвестной записи:
|
||||
// владение судится **до** разбора значения копии. Разойдись эти ответы — по их
|
||||
// разнице перебирался бы список заведённых записей одним негодным параметром.
|
||||
func TestFileDownload_UnknownCopyOnForeignRecordLooksLikeUnknownRecord(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, asUser(
|
||||
httptest.NewRequest("POST", "/api/files/token", http.NoBody), login))
|
||||
require.Equal(t, http.StatusOK, w.Code, "токен файла выдаётся всякому узнанному")
|
||||
job := jobWithFile(t, env)
|
||||
newSecondAccount(t, env)
|
||||
|
||||
var body struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.NotEmpty(t, body.Token)
|
||||
foreign := env.getAs("stranger", "/app/audiorecords/"+job.Id+"/file?"+CopyParam+"=whatever")
|
||||
unknown := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file?"+CopyParam+"=whatever")
|
||||
missing := env.getAs("stranger", "/app/audiorecords/"+unknownRecordID+"/file")
|
||||
|
||||
return body.Token
|
||||
assert.Equal(t, http.StatusNotFound, foreign.Code)
|
||||
assert.Equal(t, unknown.Code, foreign.Code)
|
||||
assert.JSONEq(t, unknown.Body.String(), foreign.Body.String())
|
||||
assert.JSONEq(t, missing.Body.String(), foreign.Body.String(),
|
||||
"незаданная копия у чужой записи отвечает не как неизвестная запись")
|
||||
}
|
||||
|
||||
// Копия, которой у записи ещё нет, отвечает отказом состояния, а не «не
|
||||
// найдено»: иначе человек увидел бы «не найдено» на своей записи, загруженной
|
||||
// минуту назад.
|
||||
func TestFileDownload_MissingCopyIsConflict(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
w := env.getOwn("/app/audiorecords/" + job.Id + "/file?" + CopyParam + "=" + CopyNormalized)
|
||||
|
||||
require.Equal(t, http.StatusConflict, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeNotReady, body.Code)
|
||||
|
||||
unknown := env.getOwn("/app/audiorecords/" + unknownRecordID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||
assert.NotEqual(t, unknown.Code, w.Code, "отказ состояния слился с ответом на неизвестную запись")
|
||||
}
|
||||
|
||||
// Копия, которой сервис не знает, и незаданная копия у **своей** записи дают
|
||||
// отказ по негодному вводу.
|
||||
func TestFileDownload_UnknownCopyIsBadRequest(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
for name, query := range map[string]string{
|
||||
"копия неизвестна": "?" + CopyParam + "=whatever",
|
||||
"копия не названа": "",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
w := env.getOwn("/app/audiorecords/" + job.Id + "/file" + query)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeBadRequest, body.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Проигрыватель просит кусок записи: ответ несёт запрошенный кусок, а не файл
|
||||
// целиком.
|
||||
func TestFileDownload_RangeGivesRequestedSlice(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
const content = "0123456789abcdef"
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "sample.mp3", []byte(content)))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
server := env.liveServer(t)
|
||||
link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal
|
||||
|
||||
res := env.liveRequest(t, server, link, map[string]string{"Range": "bytes=4-9"})
|
||||
|
||||
require.Equal(t, http.StatusPartialContent, res.StatusCode)
|
||||
assert.Equal(t, "456789", string(res.Body), "ответ несёт не запрошенный кусок")
|
||||
assert.Equal(t, int64(len("456789")), res.ContentLength, "длина ответа — длина файла целиком")
|
||||
assert.Equal(t, "bytes 4-9/16", res.Header.Get("Content-Range"))
|
||||
}
|
||||
|
||||
// TestFileDownload_BadRangeIsOurFailureForm — **критерий приёмки**: негодный
|
||||
// диапазон приходит обычным отказом сервиса, а не `416` телом библиотеки.
|
||||
//
|
||||
// Проверка идёт настоящим HTTP-запросом через поднятую цепочку слоёв: вызовом
|
||||
// отображателя ошибки это не проверяется — код `416` рождается не в нём.
|
||||
func TestFileDownload_BadRangeIsOurFailureForm(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "sample.mp3", []byte("0123456789")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
server := env.liveServer(t)
|
||||
link := "/app/audiorecords/" + intakeItemOf(t, created).ID + "/file?" + CopyParam + "=" + CopyOriginal
|
||||
|
||||
cases := map[string]string{
|
||||
"неудовлетворимый диапазон": "bytes=99999999-",
|
||||
"два диапазона в запросе": "bytes=0-1,4-5",
|
||||
}
|
||||
|
||||
for name, rangeHeader := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
res := env.liveRequest(t, server, link, map[string]string{"Range": rangeHeader})
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode, "ответ не `400`")
|
||||
assert.NotContains(t, res.Header.Get("Content-Type"), "multipart",
|
||||
"сервис отдал ответ из нескольких частей")
|
||||
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(res.Body, &body))
|
||||
assert.Contains(t, body, "error_code")
|
||||
assert.Contains(t, body, "message")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Имя файла на диске в ответ не идёт: имя, предлагаемое браузеру при сохранении,
|
||||
// строится из имени, данного отправителем.
|
||||
func TestFileDownload_CarriesNoStoredName(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
created := httptest.NewRecorder()
|
||||
env.serve(created, createMultipartRequest(t, "разговор.mp3", []byte("запись")))
|
||||
require.Equal(t, http.StatusCreated, created.Code)
|
||||
|
||||
recordID := intakeItemOf(t, created).ID
|
||||
|
||||
w := env.getOwn("/app/audiorecords/" + recordID + "/file?" + CopyParam + "=" + CopyOriginal)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
names := storedFileNames(t, env)
|
||||
require.Len(t, names, 1)
|
||||
|
||||
headers := w.Result().Header
|
||||
for _, value := range headers.Values("Content-Disposition") {
|
||||
assert.NotContains(t, value, names[0], "имя файла на диске уехало в ответ")
|
||||
}
|
||||
assert.Contains(t, strings.Join(headers.Values("Content-Disposition"), " "), "%D1%80",
|
||||
"имя, данное отправителем, в ответ не попало")
|
||||
}
|
||||
|
||||
// Перечня доступных копий карточка не объявляет: копий две, и каждая выводится
|
||||
// из рубежа записи, который карточка несёт и так.
|
||||
func TestRecordCard_HasNoCopyList(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
job := jobWithFile(t, env)
|
||||
|
||||
w := env.getOwn("/app/audiorecords/" + job.Id)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var raw map[string]json.RawMessage
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &raw))
|
||||
|
||||
assert.NotContains(t, raw, "available_copies")
|
||||
assert.NotContains(t, raw, "copies")
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
)
|
||||
|
||||
// Своё правило ограничителя частоты под корнем приложения.
|
||||
//
|
||||
// Заводится потому, что правило хранилища настроено на **его** корень и наших
|
||||
// адресов больше не покрывает: приложение уехало в своё пространство, и вместе с
|
||||
// переездом ограничитель перестал бы существовать для него вовсе. Потеря тихая —
|
||||
// заметить её нечем, пока кто-нибудь не начнёт опрашивать карточку в цикле.
|
||||
// Бюджет ограничителя частоты под корнем приложения.
|
||||
//
|
||||
// Числа скромные намеренно: сервисом пользуются единицы человек, а экран
|
||||
// опрашивает карточку, пока запись идёт по конвейеру. Из них же выводится
|
||||
@@ -22,71 +22,202 @@ const (
|
||||
appRateWindowSec = 60
|
||||
)
|
||||
|
||||
// ApplyAppRateLimit ставит правило ограничителя на корень приложения.
|
||||
//
|
||||
// Правило приводится к настройке при каждом подъёме, как и настройки провайдера:
|
||||
// применённый шаг схемы не переписывается, а настройки хранилища живут в базе, и
|
||||
// правило, положенное однажды, не пережило бы ни правки числа, ни чистого
|
||||
// каталога данных.
|
||||
func ApplyAppRateLimit(app core.App) error {
|
||||
settings := app.Settings()
|
||||
// staleBudgetAge — через сколько неиспользуемый счётчик выбрасывается. Карта
|
||||
// счётчиков растёт с числом адресов, и без уборки она стала бы местом, куда
|
||||
// спрашивающий кладёт по строке на каждый свой адрес.
|
||||
const staleBudgetAge = 10 * time.Minute
|
||||
|
||||
rule := core.RateLimitRule{
|
||||
Label: AppRoot + "/",
|
||||
MaxRequests: appRateMaxRequests,
|
||||
Duration: appRateWindowSec,
|
||||
// RateLimit — свой ограничитель частоты под корнем приложения.
|
||||
//
|
||||
// Бюджет считается по адресу спрашивающего скользящим окном: столько-то запросов
|
||||
// за столько-то секунд. Отказ уходит **нашей** формой тела — слой отвечает
|
||||
// доменной ошибкой, а не телом библиотеки, и второй формы отказа на адресах
|
||||
// приложения не появляется.
|
||||
//
|
||||
// **Адрес спрашивающего берётся из заголовка пересылки — и только тогда, когда
|
||||
// соединение пришло с адреса из объявленного перечня доверенных.** Во всяком
|
||||
// другом случае адресом считается адрес пира, а пришедший заголовок на ключ
|
||||
// бюджета не влияет ничем.
|
||||
//
|
||||
// **Цепочка пересылки читается справа налево до первого недоверенного адреса.**
|
||||
// Значением слева распоряжается тот, кто шлёт запрос: прокси заголовок
|
||||
// дописывает, а не заменяет, и левое значение цепочки он берёт из присланного.
|
||||
//
|
||||
// Обе половины правила закрывают свою поломку. Сервис стоит за обратным прокси,
|
||||
// и пир у него один на всех: бюджет, посчитанный по пиру, становится общим на
|
||||
// весь сервис, и один спрашивающий исчерпывает его остальным. Обратная ошибка —
|
||||
// верить заголовку без сверки пира — отдаёт обход ограничителя ровно тому, кого
|
||||
// он ограничивает: значением пересылаемого заголовка распоряжается тот, кто шлёт
|
||||
// запрос, и меняет он его на каждом запросе.
|
||||
//
|
||||
// **Узнавание и ограничитель берут адрес разными способами, и это намеренно.**
|
||||
// Узнаванию нужен адрес самого соединения: им оно решает, верить ли заголовку
|
||||
// вообще. Ограничителю нужен адрес того, кого он ограничивает, а тот за прокси в
|
||||
// адресе соединения не виден вовсе.
|
||||
//
|
||||
// Слой стоит **перед** узнаванием: узнавание читает базу, а на новом имени ещё и
|
||||
// пишет в неё; выполненное раньше ограничителя, оно работало бы на запросах,
|
||||
// которые тот уже отверг, и поток отвергнутых обращений заводил бы учётные
|
||||
// записи, которые потом не убираются ничем.
|
||||
func RateLimit(trusted []netip.Prefix) func(http.Handler) http.Handler {
|
||||
limiter := newRateLimiter(appRateMaxRequests, appRateWindowSec*time.Second)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !limiter.allow(clientAddress(r, trusted)) {
|
||||
fail(w, contract.ErrTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// clientAddress — тот, кого ограничивают.
|
||||
func clientAddress(r *http.Request, trusted []netip.Prefix) string {
|
||||
peer, ok := peerAddress(r.RemoteAddr)
|
||||
if !ok {
|
||||
// Адрес пира не разобран — ключ берётся строкой как есть: общий бюджет
|
||||
// лучше отсутствующего.
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
replaced := false
|
||||
for i, existing := range settings.RateLimits.Rules {
|
||||
if existing.Label == rule.Label {
|
||||
settings.RateLimits.Rules[i] = rule
|
||||
replaced = true
|
||||
break
|
||||
if !isTrusted(trusted, peer) {
|
||||
return peer.String()
|
||||
}
|
||||
|
||||
if client, ok := forwardedClient(r, trusted); ok {
|
||||
return client
|
||||
}
|
||||
|
||||
// Прокси адреса не назвал либо назвал одними доверенными: счётчик падает
|
||||
// обратно на адрес пира — то есть на общий. Лучше общий, чем ключ, который
|
||||
// выбирает сам спрашивающий.
|
||||
return peer.String()
|
||||
}
|
||||
|
||||
// forwardedClient достаёт из цепочки пересылки того, кого ограничивают.
|
||||
//
|
||||
// **Цепочка читается справа налево, а доверенные адреса отбрасываются.**
|
||||
// Значение слева пишет тот, кто шлёт запрос: прокси заголовок **дописывает**, а
|
||||
// не заменяет, поэтому левым значением цепочки распоряжается аноним, и ключ
|
||||
// бюджета, взятый оттуда, меняется у него на каждом запросе — бюджет обходится
|
||||
// с первого. Справа же стоит адрес, который приписал ближайший к нам прокси, и
|
||||
// каждый следующий шаг влево тем доверен, кто его приписал. Первый недоверенный
|
||||
// справа и есть последний адрес, за который отвечает контур, а не спрашивающий.
|
||||
//
|
||||
// Заголовок читается **всеми** строками, а не одной: цепочка законно приходит
|
||||
// несколькими заголовками, и разбор одной строки увидел бы только её часть — то
|
||||
// есть снова кусок, которым распоряжается аноним.
|
||||
func forwardedClient(r *http.Request, trusted []netip.Prefix) (string, bool) {
|
||||
hops := forwardedChain(r)
|
||||
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
addr, err := netip.ParseAddr(hops[i])
|
||||
if err != nil {
|
||||
// Значение, которое не разбирается адресом, доверенным считать
|
||||
// нечем: цепочка на нём кончается.
|
||||
return "", false
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
if isTrusted(trusted, addr) {
|
||||
continue
|
||||
}
|
||||
|
||||
return addr.String(), true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// forwardedChain — цепочка пересылки одним списком: все строки заголовка по
|
||||
// порядку, каждая разобрана по запятым.
|
||||
func forwardedChain(r *http.Request) []string {
|
||||
var hops []string
|
||||
for _, value := range r.Header.Values(ForwardedForHeader) {
|
||||
for _, hop := range strings.Split(value, ",") {
|
||||
hop = strings.TrimSpace(hop)
|
||||
if hop != "" {
|
||||
hops = append(hops, hop)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
settings.RateLimits.Rules = append(settings.RateLimits.Rules, rule)
|
||||
return hops
|
||||
}
|
||||
|
||||
// rateLimiter — счётчики бюджета по ключу.
|
||||
//
|
||||
// Окно скользящее и хранится метками времени: счётчик с обнулением по границе
|
||||
// окна пропустил бы двойной бюджет на стыке двух окон.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
window time.Duration
|
||||
budgets map[string]*budget
|
||||
swept time.Time
|
||||
}
|
||||
|
||||
type budget struct {
|
||||
hits []time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(maxRequests int, window time.Duration) *rateLimiter {
|
||||
return &rateLimiter{
|
||||
max: maxRequests,
|
||||
window: window,
|
||||
budgets: map[string]*budget{},
|
||||
swept: clock.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) allow(key string) bool {
|
||||
now := clock.Now()
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
l.sweep(now)
|
||||
|
||||
b, ok := l.budgets[key]
|
||||
if !ok {
|
||||
b = &budget{}
|
||||
l.budgets[key] = b
|
||||
}
|
||||
|
||||
settings.RateLimits.Enabled = true
|
||||
b.hits = fresh(b.hits, now.Add(-l.window))
|
||||
if len(b.hits) >= l.max {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return fmt.Errorf("failed to apply app rate limit: %w", err)
|
||||
}
|
||||
return nil
|
||||
b.hits = append(b.hits, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// ApplyTrustedProxyHeaders называет хранилищу заголовок, из которого брать
|
||||
// адрес спрашивающего.
|
||||
//
|
||||
// Без этого ограничитель частоты ключует счётчик адресом **пира**, а пир с
|
||||
// переездом входа на заголовок всегда один и тот же — обратный прокси. Бюджет
|
||||
// в этом случае общий на весь сервис: восемь одновременно открытых карточек
|
||||
// выбирают его целиком, и девятый человек получает отказ, не сделав ни одного
|
||||
// запроса. Норма при этом требует обратного — бюджет считается по адресу
|
||||
// спрашивающего.
|
||||
//
|
||||
// Доверие здесь той же природы, что и к `Remote-User`, и той же ширины: адрес
|
||||
// берётся из пересылаемого заголовка, а верить пересылаемому можно ровно
|
||||
// потому, что до нас дотянулся доверенный пир. Отсюда требование к контуру,
|
||||
// записанное в модели угроз: прокси обязан `X-Forwarded-For` **перезаписывать**,
|
||||
// а не дописывать к присланному, — иначе спрашивающий назначает себе ключ
|
||||
// счётчика сам и обходит ограничитель, меняя значение.
|
||||
//
|
||||
// Барьером узнавания этот заголовок не служит и служить не может: кто пришёл,
|
||||
// по-прежнему решает адрес самого соединения.
|
||||
func ApplyTrustedProxyHeaders(app core.App) error {
|
||||
settings := app.Settings()
|
||||
// sweep выбрасывает счётчики, которыми давно не пользовались. Идёт не чаще раза
|
||||
// в окно: обход карты на каждом запросе стоил бы больше самого ограничителя.
|
||||
func (l *rateLimiter) sweep(now time.Time) {
|
||||
if now.Sub(l.swept) < l.window {
|
||||
return
|
||||
}
|
||||
l.swept = now
|
||||
|
||||
settings.TrustedProxy.Headers = []string{"X-Forwarded-For"}
|
||||
// Пустой заголовок означает, что прокси адреса не назвал; счётчик тогда
|
||||
// падает обратно на адрес пира — то есть на общий, как было. Лучше общий,
|
||||
// чем один пустой ключ на всех.
|
||||
settings.TrustedProxy.UseLeftmostIP = true
|
||||
for key, b := range l.budgets {
|
||||
b.hits = fresh(b.hits, now.Add(-staleBudgetAge))
|
||||
if len(b.hits) == 0 {
|
||||
delete(l.budgets, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Save(settings); err != nil {
|
||||
return fmt.Errorf("failed to apply trusted proxy headers: %w", err)
|
||||
// fresh отбрасывает метки старше границы. Метки лежат по возрастанию, поэтому
|
||||
// достаточно найти первую свежую.
|
||||
func fresh(hits []time.Time, edge time.Time) []time.Time {
|
||||
for i, hit := range hits {
|
||||
if hit.After(edge) {
|
||||
return hits[i:]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -8,12 +8,9 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
@@ -47,7 +44,7 @@ func TestRecordCard_CarriesNoTextButListsViews(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -120,7 +117,7 @@ func TestRecordText_TranscriptView(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
literary, err := texts.Put(record.Id, entity.TextKindLiterary, "вычитанный текст")
|
||||
@@ -149,7 +146,7 @@ func TestRecordText_ReplicasView(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
structures := pbrepo.NewStructureRepository(env.app)
|
||||
structures := env.handler.structureRepo
|
||||
structure, err := structures.Put(record.Id, 1, []entity.Replica{
|
||||
{StartMs: 0, EndMs: 1500, Text: "первая реплика"},
|
||||
{StartMs: 1500, EndMs: 3000, Text: "вторая реплика"},
|
||||
@@ -179,7 +176,7 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
@@ -204,6 +201,64 @@ func TestRecordText_NotReadyDiffersFromNotFound(t *testing.T) {
|
||||
"«ещё не готово» и «записи нет» ведут к разным действиям человека")
|
||||
}
|
||||
|
||||
// Каждая ветвь «ещё не готово» отвечает одним кодом и одним телом.
|
||||
//
|
||||
// Ветвей четыре, и достижимы все: текста нет вовсе, текст заведён пустым,
|
||||
// структуры реплик нет, структура заведена без единой реплики. Шаг завершения
|
||||
// пишет результат несколькими операциями, поэтому запись законно застаётся между
|
||||
// ними. Ветвь, отвечающая иначе, отправила бы приложение за текстом, которого
|
||||
// нет, либо показала бы «записи нет» на своей записи.
|
||||
func TestRecordText_EveryNotReadyBranchIsOneAnswer(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
notReady := func(t *testing.T, record *entity.AudioRecord, view string) {
|
||||
t.Helper()
|
||||
|
||||
w := textOf(t, env, record.Id, view)
|
||||
require.Equal(t, http.StatusConflict, w.Code, view)
|
||||
|
||||
var body ErrorBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
assert.Equal(t, CodeNotReady, body.Code, view)
|
||||
assert.NotEmpty(t, body.Message, view)
|
||||
}
|
||||
|
||||
t.Run("текста нет вовсе", func(t *testing.T) {
|
||||
record := jobWithFile(t, env)
|
||||
notReady(t, record, entity.TextViewTranscript)
|
||||
notReady(t, record, entity.TextViewLiterary)
|
||||
notReady(t, record, entity.TextViewReplicas)
|
||||
})
|
||||
|
||||
t.Run("текст заведён пустым", func(t *testing.T) {
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
transcript, err := env.handler.textRepo.Put(record.Id, entity.TextKindTranscript, "")
|
||||
require.NoError(t, err)
|
||||
record.TranscriptTextID = &transcript.Id
|
||||
|
||||
literary, err := env.handler.textRepo.Put(record.Id, entity.TextKindLiterary, "")
|
||||
require.NoError(t, err)
|
||||
record.LiteraryTextID = &literary.Id
|
||||
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
notReady(t, record, entity.TextViewTranscript)
|
||||
notReady(t, record, entity.TextViewLiterary)
|
||||
})
|
||||
|
||||
t.Run("структура заведена без единой реплики", func(t *testing.T) {
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
structure, err := env.handler.structureRepo.Put(record.Id, 1, []entity.Replica{})
|
||||
require.NoError(t, err)
|
||||
record.StructureID = &structure.Id
|
||||
require.NoError(t, env.handler.recordRepo.Save(record, ""))
|
||||
|
||||
notReady(t, record, entity.TextViewReplicas)
|
||||
})
|
||||
}
|
||||
|
||||
// Вид, которого сервис не знает, и незаданный вид дают отказ по негодному вводу:
|
||||
// умолчание сделало бы ответ функцией того, что успел записать конвейер.
|
||||
func TestRecordText_UnknownViewIsBadRequest(t *testing.T) {
|
||||
@@ -241,7 +296,7 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
|
||||
texts := pbrepo.NewTextRepository(env.app)
|
||||
texts := env.handler.textRepo
|
||||
transcript, err := texts.Put(record.Id, entity.TextKindTranscript, "сырая расшифровка")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -251,20 +306,21 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
// Обработчик пересобирается с отказывающим хранилищем текстов: остальная
|
||||
// цепочка та же, что и в проде.
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
handler := NewAppHandler(
|
||||
env.handler.recordRepo,
|
||||
&failingTextRepo{},
|
||||
pbrepo.NewStructureRepository(env.app),
|
||||
env.handler.structureRepo,
|
||||
env.handler.fileRepo,
|
||||
env.handler.trsService,
|
||||
slog.New(slog.NewTextHandler(journal, nil)),
|
||||
logger,
|
||||
)
|
||||
|
||||
r, err := apis.NewRouter(env.app)
|
||||
require.NoError(t, err)
|
||||
r.Bind(TrustedHeaderIdentity(env.app, ServiceMounts(handler, http.NotFoundHandler()), testTrustedNetworks(t), nil))
|
||||
handler.Register(r)
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
mounts := ServiceMounts(
|
||||
AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||
|
||||
req := httptest.NewRequest("GET", "/app/audiorecords/"+record.Id+"/text?view="+entity.TextViewTranscript, http.NoBody)
|
||||
asUser(req, env.login)
|
||||
@@ -280,16 +336,25 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
|
||||
// Прежние адреса приложения убраны целиком: контракт объявлен сломанным, и
|
||||
// адрес, отвечающий по-старому, означал бы два дома у одного вопроса.
|
||||
//
|
||||
// Отвечают они теперь тем же, чем отвечает всякий путь вне корней сервиса, —
|
||||
// разметкой приложения: корень `/api` перестал существовать вместе со
|
||||
// встроенным хранилищем, и резервировать имя за отказом сервис не берётся.
|
||||
func TestFormerAddressesAreGone(t *testing.T) {
|
||||
env := setupTestEnv(t, readableMetaViewer())
|
||||
|
||||
record := jobWithFile(t, env)
|
||||
before := countJobs(t, env)
|
||||
|
||||
status := httptest.NewRecorder()
|
||||
env.serve(status, httptest.NewRequest("GET", "/api/status/"+record.Id, http.NoBody))
|
||||
assert.Equal(t, http.StatusNotFound, status.Code)
|
||||
assert.Equal(t, http.StatusOK, status.Code)
|
||||
assert.Contains(t, status.Body.String(), "приложение", "прежний адрес отдал не разметку")
|
||||
assert.NotContains(t, status.Body.String(), entity.StateUploaded,
|
||||
"прежний адрес отдал данные записи")
|
||||
|
||||
intake := httptest.NewRecorder()
|
||||
env.serve(intake, createMultipartRequestAt(t, "/api/audio", "запись.mp3", []byte("данные")))
|
||||
assert.Equal(t, http.StatusNotFound, intake.Code)
|
||||
assert.Equal(t, http.StatusMethodNotAllowed, intake.Code)
|
||||
assert.Equal(t, before, countJobs(t, env), "прежний адрес приёма завёл запись")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -15,20 +17,19 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
)
|
||||
|
||||
@@ -66,19 +67,33 @@ func readableMetaViewer() *stubMetaViewer {
|
||||
return &stubMetaViewer{seconds: 42}
|
||||
}
|
||||
|
||||
const testMarkup = `<!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 — собранное окружение одной проверки. Каталог данных свой у каждой:
|
||||
// рабочий каталог процесса проверки не трогают.
|
||||
type testEnv struct {
|
||||
mux http.Handler
|
||||
handler *AppHandler
|
||||
app core.App
|
||||
db *sqliterepo.DB
|
||||
store *sqliterepo.Store
|
||||
users *sqliterepo.UserRepository
|
||||
files *sqliterepo.FileRepository
|
||||
journal *journalBuffer
|
||||
// login — логин у провайдера, которым доверенный источник называет
|
||||
// пришедшего. Адреса приложения закрыты узнаванием, и проверка, судящая их
|
||||
// по существу, обязана назваться ровно так же, как это делает прокси.
|
||||
login string
|
||||
// account — учётная запись, которой принадлежит этот логин.
|
||||
account *core.Record
|
||||
account *contract.UserAccount
|
||||
}
|
||||
|
||||
// Доверенный источник проверок.
|
||||
@@ -93,10 +108,68 @@ const (
|
||||
func testTrustedNetworks(t *testing.T) []netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
prefix, err := netip.ParsePrefix("10.0.0.0/8")
|
||||
// Подсетей две: своя для запросов через `httptest.NewRecorder` и петлевая
|
||||
// для тех проверок, которым нужен **настоящий** HTTP-запрос через поднятую
|
||||
// цепочку слоёв — у них адрес пира ставит ядро, и он петлевой.
|
||||
networks := make([]netip.Prefix, 0, 2)
|
||||
for _, raw := range []string{"10.0.0.0/8", "127.0.0.0/8"} {
|
||||
prefix, err := netip.ParsePrefix(raw)
|
||||
require.NoError(t, err)
|
||||
networks = append(networks, prefix)
|
||||
}
|
||||
|
||||
return networks
|
||||
}
|
||||
|
||||
// liveServer поднимает настоящий HTTP-сервер на этой поверхности.
|
||||
//
|
||||
// Нужен там, где проверка судит поведение, которого у вызова обработчика
|
||||
// напрямую не бывает: разбор заголовка диапазона идёт по настоящему запросу, а
|
||||
// адрес пира ставит ядро.
|
||||
func (e *testEnv) liveServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
server := httptest.NewServer(e.mux)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// liveResponse — готовый ответ настоящего запроса: тело прочитано и закрыто, и
|
||||
// проверке остаётся судить значения.
|
||||
type liveResponse struct {
|
||||
StatusCode int
|
||||
Header http.Header
|
||||
ContentLength int64
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// liveRequest шлёт настоящий запрос от имени учётной записи проверки.
|
||||
func (e *testEnv) liveRequest(
|
||||
t *testing.T, server *httptest.Server, path string, headers map[string]string,
|
||||
) liveResponse {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+path, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set(LoginHeader, e.login)
|
||||
for name, value := range headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
|
||||
res, err := server.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
|
||||
return []netip.Prefix{prefix}
|
||||
return liveResponse{
|
||||
StatusCode: res.StatusCode,
|
||||
Header: res.Header,
|
||||
ContentLength: res.ContentLength,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
// asUser делает запрос запросом названного человека: ставит заголовок и адрес
|
||||
@@ -113,30 +186,23 @@ func (e *testEnv) serve(w http.ResponseWriter, req *http.Request) {
|
||||
e.mux.ServeHTTP(w, asUser(req, e.login))
|
||||
}
|
||||
|
||||
// newTestAccount заводит учётную запись с известным логином.
|
||||
//
|
||||
// Запись создаётся прямым сохранением, а не запросом к API: заводить её
|
||||
// запросом нельзя — создание закрыто шагом схемы. Прямое сохранение идёт мимо
|
||||
// правил доступа так же, как идёт заведение записи первым обращением.
|
||||
func newTestAccount(t *testing.T, app core.App) (*core.Record, string) {
|
||||
t.Helper()
|
||||
// get шлёт запрос **неузнанным**: заголовка на нём нет вовсе.
|
||||
func (e *testEnv) get(path string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return w
|
||||
}
|
||||
|
||||
const login = "person"
|
||||
// getAs шлёт запрос от имени названного логина.
|
||||
func (e *testEnv) getAs(login, path string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(w, asUser(httptest.NewRequest(http.MethodGet, path, nil), login))
|
||||
return w
|
||||
}
|
||||
|
||||
users, err := app.FindCollectionByNameOrId("users")
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
record.Set(migrations.ProviderLoginField, login)
|
||||
record.Set("email", "person@example.com")
|
||||
record.Set("verified", true)
|
||||
// Случайный пароль ставит и сам сервис, когда заводит запись первым
|
||||
// обращением: запись auth-коллекции без пароля не сохраняется, а войти по
|
||||
// нему всё равно нельзя — парольный вход выключен шагом схемы.
|
||||
record.SetRandomPassword()
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record, login
|
||||
// getOwn шлёт запрос от имени учётной записи проверки.
|
||||
func (e *testEnv) getOwn(path string) *httptest.ResponseRecorder {
|
||||
return e.getAs(e.login, path)
|
||||
}
|
||||
|
||||
// journalBuffer — перехваченный журнал одной проверки. Свой на случай: общий на
|
||||
@@ -162,45 +228,72 @@ func (b *journalBuffer) String() string {
|
||||
return b.text.String()
|
||||
}
|
||||
|
||||
// newTestStorage поднимает хранилище на пустом каталоге и накатывает схему —
|
||||
// ровно тем же путём, каким это делает сервис при старте.
|
||||
func newTestStorage(t *testing.T) core.App {
|
||||
// newTestStorage поднимает базу на пустом каталоге и накатывает схему — ровно
|
||||
// тем же путём, каким это делает сервис при старте.
|
||||
func newTestStorage(t *testing.T) (*sqliterepo.DB, *sqliterepo.Store, string) {
|
||||
t.Helper()
|
||||
|
||||
app, err := pbrepo.New(t.TempDir())
|
||||
dir := t.TempDir()
|
||||
db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Logf("не удалось закрыть базу: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return app
|
||||
require.NoError(t, sqliterepo.Migrate(
|
||||
context.Background(), db, dir, slog.New(slog.DiscardHandler),
|
||||
))
|
||||
|
||||
return db, sqliterepo.NewStore(dir), dir
|
||||
}
|
||||
|
||||
// envOptions — чем одна проверка отличается от другой.
|
||||
type envOptions struct {
|
||||
metaviewer contract.AudioMetaViewer
|
||||
dist fs.FS
|
||||
built bool
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
app := newTestStorage(t)
|
||||
return setupEnv(t, envOptions{metaviewer: metaviewer, dist: builtDist(), built: true})
|
||||
}
|
||||
|
||||
pbrepo.BindPanelRules(app)
|
||||
// setupEnv собирает поверхность сервиса тем же способом, что и точка входа:
|
||||
// адреса порождает перечень адресного пространства, слои одевает та же цепочка.
|
||||
// Собери проверка поверхность иначе — и судила бы она не то.
|
||||
func setupEnv(t *testing.T, opts envOptions) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
db, store, _ := newTestStorage(t)
|
||||
|
||||
recordRepo := sqliterepo.NewAudioRecordRepository(db)
|
||||
textRepo := sqliterepo.NewTextRepository(db)
|
||||
structureRepo := sqliterepo.NewStructureRepository(db)
|
||||
fileRepo := sqliterepo.NewFileRepository(db, store)
|
||||
users := sqliterepo.NewUserRepository(db)
|
||||
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||
textRepo := pbrepo.NewTextRepository(app)
|
||||
repos := service.Repositories{
|
||||
Records: recordRepo,
|
||||
Files: pbrepo.NewFileRepository(app),
|
||||
Files: fileRepo,
|
||||
Texts: textRepo,
|
||||
Structures: pbrepo.NewStructureRepository(app),
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
Structures: structureRepo,
|
||||
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
|
||||
Events: sqliterepo.NewRecordEventRepository(db),
|
||||
}
|
||||
|
||||
// Журнал уходит в буфер, а не в никуда: по нему судит проверка запрета на
|
||||
// имя отправителя. Вывод прогона от этого не меняется — ERROR-строки ветки
|
||||
// отказа по-прежнему не попадают на экран и не размывают признак, по
|
||||
// которому отличают новый красный шаг гейта от объявленного долга.
|
||||
// отказа по-прежнему не попадают на экран.
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
metaviewer := opts.metaviewer
|
||||
if metaviewer == nil {
|
||||
metaviewer = readableMetaViewer()
|
||||
}
|
||||
|
||||
trsService := service.NewTranscribeService(
|
||||
repos,
|
||||
metaviewer,
|
||||
@@ -210,29 +303,31 @@ func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
logger,
|
||||
)
|
||||
|
||||
handler := NewAppHandler(recordRepo, textRepo, pbrepo.NewStructureRepository(app), trsService, logger)
|
||||
handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)
|
||||
|
||||
// Роутер собирается тем же способом, что и боевой: слой узнавания вешается
|
||||
// корневым, маршруты вешает сам обработчик, и проверка судит ту же цепочку,
|
||||
// что и прод. Собери роутер иначе — и проверка судила бы не то.
|
||||
r, err := apis.NewRouter(app)
|
||||
mounts := ServiceMounts(
|
||||
AppChain(handler.Routes(), users, testTrustedNetworks(t), logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
webapp := NewWebappHandler(opts.dist, opts.built, logger)
|
||||
mux := BuildHandler(mounts, webapp, logger)
|
||||
|
||||
account, _, err := users.EnsureUser(contract.Identity{
|
||||
Login: "person",
|
||||
Name: "Человек",
|
||||
Email: "person@example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mounts := ServiceMounts(handler, http.NotFoundHandler())
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
handler.Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
account, login := newTestAccount(t, app)
|
||||
|
||||
return &testEnv{
|
||||
mux: mux,
|
||||
handler: handler,
|
||||
app: app,
|
||||
db: db,
|
||||
store: store,
|
||||
users: users,
|
||||
files: fileRepo,
|
||||
journal: journal,
|
||||
login: login,
|
||||
login: "person",
|
||||
account: account,
|
||||
}
|
||||
}
|
||||
@@ -285,65 +380,115 @@ func intakeItemOf(t *testing.T, w *httptest.ResponseRecorder) IntakeItem {
|
||||
return items[0]
|
||||
}
|
||||
|
||||
// storedFileNames отдаёт имена, под которыми файлы легли в хранилище.
|
||||
// storedFileNames отдаёт имена, под которыми копии легли в каталог данных.
|
||||
func storedFileNames(t *testing.T, env *testEnv) []string {
|
||||
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
t.Helper()
|
||||
|
||||
rows, err := env.db.Reader().QueryContext(context.Background(), "SELECT file_name FROM files")
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, rows.Close()) }()
|
||||
|
||||
var names []string
|
||||
for _, record := range records {
|
||||
names = append(names, record.GetStringSlice("file")...)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
require.NoError(t, rows.Scan(&name))
|
||||
names = append(names, name)
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
return names
|
||||
}
|
||||
|
||||
// countFiles считает записи о файлах.
|
||||
// countRows считает строки названной таблицы.
|
||||
func countRows(t *testing.T, env *testEnv, table string) int {
|
||||
t.Helper()
|
||||
|
||||
var total int
|
||||
require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM "+table).Scan(&total))
|
||||
return total
|
||||
}
|
||||
|
||||
// countFiles считает строки о копиях записей.
|
||||
func countFiles(t *testing.T, env *testEnv) int {
|
||||
records, err := env.app.FindAllRecords(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
return len(records)
|
||||
return countRows(t, env, "files")
|
||||
}
|
||||
|
||||
// countJobs считает заведённые аудиозаписи.
|
||||
func countJobs(t *testing.T, env *testEnv) int {
|
||||
records, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
return len(records)
|
||||
return countRows(t, env, "audio_records")
|
||||
}
|
||||
|
||||
// jobWithFile заводит задачу вместе с её записью: ссылка на файл обязательна
|
||||
// схемой, потому что без неё задача не пройдёт ни одного шага.
|
||||
// jobWithFile заводит запись вместе с её принятой копией: без ссылки на файл
|
||||
// запись не прошла бы ни одного шага.
|
||||
func jobWithFile(t *testing.T, env *testEnv) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
repo := pbrepo.NewFileRepository(env.app)
|
||||
work, err := repo.Stage(".mp3", strings.NewReader("запись"))
|
||||
return jobWithFileOf(t, env, env.account.ID)
|
||||
}
|
||||
|
||||
// jobWithFileOf — то же, но от имени названного владельца.
|
||||
func jobWithFileOf(t *testing.T, env *testEnv, ownerID string) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
recordID := ident.New()
|
||||
|
||||
work, err := env.files.Stage(".mp3", strings.NewReader("запись"))
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, work.Close()) }()
|
||||
|
||||
// Владелец — учётная запись проверки: задача, пришедшая из веба, без
|
||||
// владельца больше не заводится, и фикстура без него описывала бы состояние,
|
||||
// которого в проде не бывает.
|
||||
file, err := repo.Create("sample.mp3", work, contract.FileMeta{Format: "mp3"}, env.account.Id)
|
||||
// Владелец — учётная запись проверки: ничьей записи в хранилище не бывает, и
|
||||
// фикстура без владельца описывала бы состояние, которого в проде нет.
|
||||
file, err := env.files.Create(
|
||||
recordID, "sample.mp3", work, contract.FileMeta{Format: "mp3"}, ownerID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
record := &entity.AudioRecord{
|
||||
Id: recordID,
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: env.account.Id,
|
||||
OwnerID: ownerID,
|
||||
OriginalFileID: &file.Id,
|
||||
}
|
||||
require.NoError(t, env.handler.recordRepo.Create(record))
|
||||
return record
|
||||
}
|
||||
|
||||
// storedContent читает содержимое файла из хранилища.
|
||||
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
|
||||
repo := pbrepo.NewFileRepository(env.app)
|
||||
reader, err := repo.Open(fileID)
|
||||
// newTopic заводит тему в словаре названного владельца.
|
||||
//
|
||||
// Запросом к базе, а не адресом сервиса: адреса, которым заводят тему, у сервиса
|
||||
// нет — словарь пишет задача языковой модели, которой ещё нет. Проверка обязана
|
||||
// пройти тот же путь разрешения тем, каким пойдёт она.
|
||||
func newTopic(t *testing.T, env *testEnv, ownerID, name string) string {
|
||||
t.Helper()
|
||||
|
||||
id := ident.New()
|
||||
now := clock.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
_, err := env.db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO topics (id, owner_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||
id, ownerID, name, now, now,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer reader.Close()
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// attachTopic связывает тему с записью.
|
||||
func attachTopic(t *testing.T, env *testEnv, recordID, topicID string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := env.db.Writer().ExecContext(context.Background(),
|
||||
"INSERT INTO record_topics (record_id, topic_id) VALUES (?, ?)", recordID, topicID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// storedContent читает содержимое копии из каталога данных.
|
||||
func storedContent(t *testing.T, env *testEnv, fileID string) []byte {
|
||||
t.Helper()
|
||||
|
||||
reader, err := env.files.Open(fileID)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, reader.Close()) }()
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = buf.ReadFrom(reader)
|
||||
@@ -387,7 +532,7 @@ func TestCreateTranscribeJob_Success(t *testing.T) {
|
||||
// отправитель получит идентификатор записи, которой не будет никогда.
|
||||
require.Equal(t, 1, countJobs(t, env))
|
||||
|
||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
|
||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entity.StateUploaded, job.State)
|
||||
require.NotNil(t, job.OriginalFileID)
|
||||
@@ -529,9 +674,20 @@ func TestCreateTranscribeJob_SenderFileNameNotStored(t *testing.T) {
|
||||
require.Len(t, names, 1)
|
||||
|
||||
assert.NotContains(t, names[0], "секретное-слово",
|
||||
"имя, данное отправителем, в хранилище не попадает")
|
||||
"имя, данное отправителем, в имя файла не попадает")
|
||||
assert.True(t, strings.HasSuffix(names[0], ".mp3"),
|
||||
"расширение при этом сохраняется: %q", names[0])
|
||||
|
||||
// И в **путь** к файлу оно не попадает тоже: подкаталог назван
|
||||
// идентификатором записи, а не именем отправителя.
|
||||
var recordID, storedRecord string
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT id FROM audio_records").Scan(&recordID))
|
||||
require.NoError(t, env.db.Reader().
|
||||
QueryRow("SELECT record_id FROM files").Scan(&storedRecord))
|
||||
assert.Equal(t, recordID, storedRecord, "подкаталог копии назван не записью")
|
||||
assert.NotContains(t, storedRecord, "секретное-слово",
|
||||
"имя, данное отправителем, попало в путь к файлу")
|
||||
}
|
||||
|
||||
func TestCreateTranscribeJob_MetaViewerFailure(t *testing.T) {
|
||||
@@ -659,7 +815,7 @@ func TestCreateTranscribeJob_JournalTracesRecord(t *testing.T) {
|
||||
|
||||
response := intakeItemOf(t, w)
|
||||
|
||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.Id)
|
||||
job, err := env.handler.recordRepo.GetByID(response.ID, env.account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, job.OriginalFileID)
|
||||
|
||||
@@ -821,7 +977,5 @@ func TestAcceptedRecordSurvivesSenderDisconnect(t *testing.T) {
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Result().StatusCode, "тело ответа: %s", w.Body.String())
|
||||
|
||||
jobs, err := env.app.FindAllRecords(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, jobs, 1, "задача заведена, несмотря на ушедшего отправителя")
|
||||
assert.Equal(t, 1, countJobs(t, env), "задача заведена, несмотря на ушедшего отправителя")
|
||||
}
|
||||
|
||||
@@ -9,29 +9,6 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
// Корни адресного пространства и отдельные адреса наблюдения.
|
||||
//
|
||||
// `/api` и `/_` принадлежат хранилищу: первый — его наборам адресов, второй —
|
||||
// панели владельца. Поменять их нельзя, это литералы библиотеки.
|
||||
//
|
||||
// Корня `/auth` здесь больше нет: собственного входа у сервиса не осталось, и
|
||||
// адресов под этим корнем не существует. Прежние адреса входа поэтому отвечают
|
||||
// тем же, чем отвечает всякий путь вне корней, — разметкой приложения.
|
||||
// Резервировать имя за отказом сервис не берётся: имя, за которым ничего не
|
||||
// стоит, ничем не отличается от любого другого свободного, а второй перечень
|
||||
// «когда-то занятых корней» разошёлся бы с первым молча.
|
||||
const (
|
||||
StorageRoot = "/api"
|
||||
PanelRoot = "/_"
|
||||
|
||||
HealthPath = "/health"
|
||||
MetricsPath = "/metrics"
|
||||
)
|
||||
|
||||
// assetsDir — каталог, который наполняет сборщик приложения.
|
||||
@@ -45,104 +22,6 @@ const assetsDir = "assets"
|
||||
// Срок хранения ресурса сборщика — год.
|
||||
const assetMaxAgeSeconds = 31536000
|
||||
|
||||
// Mount — часть адресного пространства, принадлежащая сервису.
|
||||
//
|
||||
// Перечень этих частей — **единственное** описание того, что сервису
|
||||
// принадлежит, и он не описывает регистрацию, а порождает её: корень,
|
||||
// заведённый мимо перечня, не получит обработчика вовсе. Прежде такой перечень
|
||||
// был бы вторым описанием таблицы маршрутов, которую ведут три места, и корень,
|
||||
// забытый в нём, молча отдавал бы разметку там, где программа ждёт отказ
|
||||
// контракта.
|
||||
type Mount struct {
|
||||
// Path — корень либо точный адрес.
|
||||
Path string
|
||||
|
||||
// Exact — путь является точным адресом, а не корнем: `/health` накрывает
|
||||
// только сам себя, а `/app` — всё, что под ним.
|
||||
Exact bool
|
||||
|
||||
// Bind вешает обработчики этой части. Пусто у того, что вешает библиотека.
|
||||
Bind func(r *router.Router[*core.RequestEvent])
|
||||
}
|
||||
|
||||
// Covers говорит, принадлежит ли путь этой части адресного пространства.
|
||||
//
|
||||
// Условий два, и оба обязательны: точное совпадение либо префикс **вместе с
|
||||
// косой чертой**. По одному префиксу корню `/app` достался бы посторонний
|
||||
// `/apple`; по одному префиксу с косой чертой голый `/api` не достался бы
|
||||
// никому и уехал бы разметкой приложения.
|
||||
func (m Mount) Covers(requestPath string) bool {
|
||||
if m.Exact {
|
||||
return requestPath == m.Path
|
||||
}
|
||||
|
||||
return requestPath == m.Path || strings.HasPrefix(requestPath, m.Path+"/")
|
||||
}
|
||||
|
||||
// ServiceMounts перечисляет адресное пространство сервиса целиком.
|
||||
func ServiceMounts(appHandler *AppHandler, metricsHandler http.Handler) []Mount {
|
||||
return []Mount{
|
||||
{Path: StorageRoot},
|
||||
{Path: PanelRoot},
|
||||
{Path: AppRoot, Bind: appHandler.Register},
|
||||
{Path: HealthPath, Exact: true, Bind: bindHealth},
|
||||
{Path: MetricsPath, Exact: true, Bind: bindMetrics(metricsHandler)},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterServiceRoutes вешает всё, что сервис вешает сам.
|
||||
func RegisterServiceRoutes(r *router.Router[*core.RequestEvent], mounts []Mount) {
|
||||
for _, mount := range mounts {
|
||||
if mount.Bind != nil {
|
||||
mount.Bind(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsServiceAddress говорит, принадлежит ли путь сервису хоть какой-то частью.
|
||||
func IsServiceAddress(mounts []Mount, requestPath string) bool {
|
||||
for _, mount := range mounts {
|
||||
if mount.Covers(requestPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsObservationAddress говорит, что путь — адрес наблюдения.
|
||||
//
|
||||
// Опрос здоровья и метрик идёт постоянно и полезного не несёт, поэтому уровень
|
||||
// журнала у него свой. Перечень при этом тот же самый: второе перечисление этих
|
||||
// адресов разошлось бы с первым молча.
|
||||
func IsObservationAddress(mounts []Mount, requestPath string) bool {
|
||||
for _, mount := range mounts {
|
||||
if mount.Exact && mount.Covers(requestPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func bindHealth(r *router.Router[*core.RequestEvent]) {
|
||||
r.GET(HealthPath, func(e *core.RequestEvent) error {
|
||||
return e.JSON(http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Transcriber service is running",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func bindMetrics(handler http.Handler) func(r *router.Router[*core.RequestEvent]) {
|
||||
return func(r *router.Router[*core.RequestEvent]) {
|
||||
r.GET(MetricsPath, func(e *core.RequestEvent) error {
|
||||
handler.ServeHTTP(e.Response, e.Request)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// notBuiltPage — что видит человек у бинарника без собранного приложения.
|
||||
//
|
||||
// Состояние возможно только у собранного мимо набора проверок: и набор
|
||||
@@ -164,25 +43,10 @@ const (
|
||||
OutcomeFailure = "failure"
|
||||
)
|
||||
|
||||
// journalOutcomeKey — под каким ключом раздача оставляет исход слою журнала.
|
||||
const journalOutcomeKey = "transcriberWebappOutcome"
|
||||
|
||||
// WebappOutcome отдаёт исход, оставленный раздачей, либо пустую строку, если
|
||||
// запрос до неё не дошёл.
|
||||
func WebappOutcome(e *core.RequestEvent) string {
|
||||
outcome, ok := e.Get(journalOutcomeKey).(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
// WebappHandler раздаёт собранное приложение и держит правило неизвестного пути.
|
||||
type WebappHandler struct {
|
||||
dist fs.FS
|
||||
built bool
|
||||
mounts []Mount
|
||||
fingerprint string
|
||||
logger *slog.Logger
|
||||
}
|
||||
@@ -191,14 +55,14 @@ type WebappHandler struct {
|
||||
//
|
||||
// Файловая система приходит параметром, а не тянется пакетом: так тест
|
||||
// подставляет свою сборку, не собирая приложение.
|
||||
func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logger) *WebappHandler {
|
||||
func NewWebappHandler(dist fs.FS, built bool, logger *slog.Logger) *WebappHandler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
if !built {
|
||||
logger.Warn("Webapp is not built, service will answer with a placeholder page")
|
||||
return &WebappHandler{dist: dist, built: built, mounts: mounts, logger: logger}
|
||||
return &WebappHandler{dist: dist, built: built, logger: logger}
|
||||
}
|
||||
|
||||
// Отпечаток вшитой сборки — единственное, чем «не та сборка» отличается от
|
||||
@@ -211,17 +75,12 @@ func NewWebappHandler(dist fs.FS, built bool, mounts []Mount, logger *slog.Logge
|
||||
return &WebappHandler{
|
||||
dist: dist,
|
||||
built: built,
|
||||
mounts: mounts,
|
||||
fingerprint: fingerprint,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// buildFingerprint — короткий отпечаток разметки вшитой сборки.
|
||||
//
|
||||
// Считается по самой разметке, а не по файлу, который положил бы сборщик: файл
|
||||
// пришлось бы заводить настройкой сборки, а разметка меняется вместе с именами
|
||||
// ресурсов, то есть при всякой пересборке с изменениями.
|
||||
func buildFingerprint(dist fs.FS) string {
|
||||
markup, err := fs.ReadFile(dist, "index.html")
|
||||
if err != nil {
|
||||
@@ -235,54 +94,41 @@ func buildFingerprint(dist fs.FS) string {
|
||||
|
||||
// Register вешает раздачу на корень.
|
||||
//
|
||||
// Маршрут стоит ровно на `/`, а не на `/{path...}`: сборка маршрутов сама
|
||||
// вешает на `/` отказ «ничего не совпало», если такого маршрута ещё нет, и
|
||||
// второй всепокрывающий образец рядом с ним спорил бы с ним за путь.
|
||||
func (h *WebappHandler) Register(r *router.Router[*core.RequestEvent]) {
|
||||
// Успешная раздача в журнал хранилища не пишется.
|
||||
//
|
||||
// Журнал хранилища — второй, помимо журнала контейнера, и в него библиотека
|
||||
// кладёт путь запроса целиком вместе с адресом отправителя, храня строки
|
||||
// пять суток. Путь здесь выбирает спрашивающий, и без этого отказа всякое
|
||||
// открытие приложения оставляло бы там его текст и его адрес. Готовая
|
||||
// раздача статики ставит тот же признак первой строкой; своя написана мимо
|
||||
// неё, и признак перенесён руками.
|
||||
//
|
||||
// Отказы записываются по-прежнему: признак снимает только успех.
|
||||
r.Any("/", h.Serve).Bind(apis.SkipSuccessActivityLog())
|
||||
// Путь, принадлежащий корню сервиса, до неё не доходит вовсе: его забирает
|
||||
// маршрутизатор, у которого корень объявлен своим образцом. Отказ контракта
|
||||
// поэтому остаётся отказом контракта, а не проваливается в разметку.
|
||||
func (h *WebappHandler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/", h.Serve)
|
||||
}
|
||||
|
||||
// Serve отдаёт приложение либо отказ — по порядку, объявленному дизайном.
|
||||
func (h *WebappHandler) Serve(e *core.RequestEvent) error {
|
||||
requestPath := e.Request.URL.Path
|
||||
|
||||
// Путь принадлежит сервису — значит под этим корнем такого адреса просто
|
||||
// нет. Отказ уходит формой библиотеки, то есть тем же, чем этот корень
|
||||
// отвечает и сегодня: своя форма здесь была бы второй.
|
||||
if IsServiceAddress(h.mounts, requestPath) {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewNotFoundError("", nil)
|
||||
}
|
||||
|
||||
if e.Request.Method != http.MethodGet && e.Request.Method != http.MethodHead {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewApiError(http.StatusMethodNotAllowed, "", nil)
|
||||
// Serve отдаёт приложение либо отказ.
|
||||
func (h *WebappHandler) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
// Открывающими страницу считаются `GET` и `HEAD`, и только они.
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
noteWebappOutcome(r, OutcomeFailure)
|
||||
http.Error(w, "", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.built {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return e.HTML(http.StatusServiceUnavailable, notBuiltPage)
|
||||
noteWebappOutcome(r, OutcomeFailure)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(notBuiltPage))
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(path.Clean(requestPath), "/")
|
||||
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if name == "" || name == "." {
|
||||
return h.serveIndex(e)
|
||||
h.serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if info, err := fs.Stat(h.dist, name); err == nil && !info.IsDir() {
|
||||
e.Set(journalOutcomeKey, OutcomeAsset)
|
||||
h.setCacheHeader(e, name)
|
||||
return e.FileFS(h.dist, name)
|
||||
noteWebappOutcome(r, OutcomeAsset)
|
||||
h.setCacheHeader(w, name)
|
||||
http.ServeFileFS(w, r, h.dist, name)
|
||||
return
|
||||
}
|
||||
|
||||
// Разметка прежней сборки называет ресурсы прежней сборки. Подменить их
|
||||
@@ -290,33 +136,29 @@ func (h *WebappHandler) Serve(e *core.RequestEvent) error {
|
||||
// типу содержимого, человек увидит пустой экран, а в кодах ответов сервиса
|
||||
// не останется ничего.
|
||||
if underAssets(name) {
|
||||
e.Set(journalOutcomeKey, OutcomeFailure)
|
||||
return router.NewNotFoundError("", nil)
|
||||
noteWebappOutcome(r, OutcomeFailure)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
return h.serveIndex(e)
|
||||
h.serveIndex(w, r)
|
||||
}
|
||||
|
||||
func (h *WebappHandler) serveIndex(e *core.RequestEvent) error {
|
||||
e.Set(journalOutcomeKey, OutcomeMarkup)
|
||||
h.setCacheHeader(e, "index.html")
|
||||
func (h *WebappHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
noteWebappOutcome(r, OutcomeMarkup)
|
||||
h.setCacheHeader(w, "index.html")
|
||||
|
||||
// Отпечаток идёт метке ответа: `no-cache` обещает дешёвую проверку —
|
||||
// «спроси заново и получи подтверждение», — а вшитый файл не несёт времени
|
||||
// правки вовсе, и без метки браузеру каждый раз отдаётся полное тело вместо
|
||||
// подтверждения. Отпечаток уже посчитан при подъёме, второго счёта не надо.
|
||||
// подтверждения.
|
||||
if h.fingerprint != "" {
|
||||
e.Response.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
||||
w.Header().Set("ETag", `"`+h.fingerprint+`"`)
|
||||
}
|
||||
|
||||
return e.FileFS(h.dist, "index.html")
|
||||
http.ServeFileFS(w, r, h.dist, "index.html")
|
||||
}
|
||||
|
||||
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
||||
//
|
||||
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
||||
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
||||
// отзывается со стороны сервиса ничем. Запросов он больше не увидит.
|
||||
// underAssets говорит, ведёт ли путь в каталог сборщика.
|
||||
//
|
||||
// Условий два, и оба обязательны — та же пара, что у принадлежности корню: сам
|
||||
@@ -327,14 +169,19 @@ func underAssets(name string) bool {
|
||||
return name == assetsDir || strings.HasPrefix(name, assetsDir+"/")
|
||||
}
|
||||
|
||||
func (h *WebappHandler) setCacheHeader(e *core.RequestEvent, name string) {
|
||||
// setCacheHeader назначает срок хранения по каталогу, а не по виду файла.
|
||||
//
|
||||
// Вид файла признака не даёт: в сборке лежат и файлы с постоянными именами —
|
||||
// иконка, манифест, — и такой файл, однажды отданный как неизменяемый, не
|
||||
// отзывается со стороны сервиса ничем.
|
||||
func (h *WebappHandler) setCacheHeader(w http.ResponseWriter, name string) {
|
||||
if underAssets(name) {
|
||||
e.Response.Header().Set(
|
||||
w.Header().Set(
|
||||
"Cache-Control",
|
||||
"public, max-age="+strconv.Itoa(assetMaxAgeSeconds)+", immutable",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
e.Response.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
|
||||
@@ -2,88 +2,25 @@ package http
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/service"
|
||||
)
|
||||
|
||||
const testMarkup = `<!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("значок")},
|
||||
}
|
||||
}
|
||||
|
||||
// webappEnv — окружение проверок раздачи. Роутер собирается тем же способом,
|
||||
// что и боевой: маршруты порождает перечень, а не перечисление в проверке.
|
||||
// webappEnv — окружение проверок раздачи. Поверхность собирается тем же
|
||||
// способом, что и боевая: маршруты порождает перечень адресного пространства, а
|
||||
// не перечисление в проверке.
|
||||
type webappEnv struct {
|
||||
mux http.Handler
|
||||
journal *journalBuffer
|
||||
login string
|
||||
env *testEnv
|
||||
}
|
||||
|
||||
func setupWebappEnv(t *testing.T, dist fs.FS, built bool) *webappEnv {
|
||||
t.Helper()
|
||||
|
||||
app := newTestStorage(t)
|
||||
pbrepo.BindPanelRules(app)
|
||||
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||
textRepo := pbrepo.NewTextRepository(app)
|
||||
structureRepo := pbrepo.NewStructureRepository(app)
|
||||
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
trsService := service.NewTranscribeService(
|
||||
service.Repositories{
|
||||
Records: recordRepo,
|
||||
Files: pbrepo.NewFileRepository(app),
|
||||
Texts: textRepo,
|
||||
Structures: structureRepo,
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
},
|
||||
readableMetaViewer(),
|
||||
&stubConverter{},
|
||||
&recognizer.MemoryAudioRecognizer{},
|
||||
entity.StuckLimits{Own: time.Hour, Foreign: 24 * time.Hour},
|
||||
logger,
|
||||
)
|
||||
|
||||
appHandler := NewAppHandler(recordRepo, textRepo, structureRepo, trsService, logger)
|
||||
|
||||
mounts := ServiceMounts(appHandler, http.NotFoundHandler())
|
||||
|
||||
r, err := apis.NewRouter(app)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.Bind(TrustedHeaderIdentity(app, mounts, testTrustedNetworks(t), logger))
|
||||
RegisterServiceRoutes(r, mounts)
|
||||
NewWebappHandler(dist, built, mounts, logger).Register(r)
|
||||
|
||||
mux, err := r.BuildMux()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, login := newTestAccount(t, app)
|
||||
|
||||
return &webappEnv{mux: mux, journal: journal, login: login}
|
||||
return &webappEnv{env: setupEnv(t, envOptions{dist: dist, built: built})}
|
||||
}
|
||||
|
||||
func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||
@@ -93,15 +30,20 @@ func (e *webappEnv) get(path string) *httptest.ResponseRecorder {
|
||||
func (e *webappEnv) do(method, path string, identified bool) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if identified {
|
||||
asUser(req, e.login)
|
||||
asUser(req, e.env.login)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
e.mux.ServeHTTP(rec, req)
|
||||
e.env.mux.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// journal отдаёт перехваченный журнал окружения.
|
||||
func (e *webappEnv) journal() string {
|
||||
return e.env.journal.String()
|
||||
}
|
||||
|
||||
// Обновление страницы посреди приложения открывает тот же экран: адреса у
|
||||
// приложения обычные, а не после решётки, и сервер обязан отдать разметку.
|
||||
func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||
@@ -117,16 +59,33 @@ func TestWebappServesMarkupOutsideServiceRoots(t *testing.T) {
|
||||
|
||||
// Путь внутри корня сервиса в приложение не проваливается никогда: иначе
|
||||
// программа, ошибшаяся адресом, приняла бы разметку с кодом 200 за ответ.
|
||||
//
|
||||
// Корень у сервиса остался один — корень приложения; `/api` и `/_` ушли вместе
|
||||
// со встроенным хранилищем, и пути под ними стали обычными путями вне корней.
|
||||
func TestWebappNeverAnswersInsideServiceRoots(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
for _, path := range []string{"/api/nope", "/_/nope"} {
|
||||
for _, path := range []string{"/app/nope", "/app"} {
|
||||
res := env.get(path)
|
||||
|
||||
assert.NotContains(t, res.Body.String(), "приложение", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Прежние корни хранилища и панели отвечают разметкой — тем же, чем отвечает
|
||||
// всякий путь вне корней. Знак, записанный своим кодом, попадает в то же
|
||||
// правило: правило одно, и особого случая у него нет.
|
||||
func TestWebappServesFormerStorageAndPanelPaths(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
for _, path := range []string{"/api/nope", "/_/", "/%5f/"} {
|
||||
res := env.get(path)
|
||||
|
||||
assert.Equal(t, http.StatusOK, res.Code, path)
|
||||
assert.Contains(t, res.Body.String(), "приложение", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Под корнем приложения отказ уходит его собственной формой, и код отвечает
|
||||
// причине: без сессии — «предъяви себя», с сессией — «такого адреса нет».
|
||||
func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
||||
@@ -147,8 +106,14 @@ func TestWebappLeavesAppRootToItsOwnFailureForm(t *testing.T) {
|
||||
func TestWebappRootMatchNeedsExactOrSlash(t *testing.T) {
|
||||
env := setupWebappEnv(t, builtDist(), true)
|
||||
|
||||
bare := env.get(StorageRoot)
|
||||
assert.NotContains(t, bare.Body.String(), "приложение", "голый корень разметкой не подменяется")
|
||||
bare := env.do(http.MethodGet, AppRoot, true)
|
||||
assert.Equal(t, http.StatusNotFound, bare.Code, "голый корень разметкой не подменяется")
|
||||
assert.NotContains(t, bare.Body.String(), "приложение")
|
||||
|
||||
bareAnonymous := env.get(AppRoot)
|
||||
assert.Equal(t, http.StatusUnauthorized, bareAnonymous.Code,
|
||||
"голый корень неузнанному отвечает как прочие адреса под ним")
|
||||
assert.NotContains(t, bareAnonymous.Body.String(), "приложение")
|
||||
|
||||
neighbour := env.get(AppRoot + "le")
|
||||
assert.Equal(t, http.StatusOK, neighbour.Code)
|
||||
@@ -245,5 +210,5 @@ func TestWebappNotBuilt(t *testing.T) {
|
||||
|
||||
// Владелец сервиса узнаёт об этом журналом подъёма, а не от человека,
|
||||
// открывшего страницу.
|
||||
assert.Contains(t, env.journal.String(), "Webapp is not built")
|
||||
assert.Contains(t, env.journal(), "Webapp is not built")
|
||||
}
|
||||
|
||||
@@ -35,15 +35,6 @@ const (
|
||||
HaltReasonStuck = "stuck"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceUnknown = "unknown"
|
||||
SourceApi = "api"
|
||||
// SourceTelegram — историческое значение. Вход Telegram убран, новых записей
|
||||
// с этим источником не появляется, а константа остаётся: на неё ссылается
|
||||
// применённый шаг схемы `202608140002`, а применённый шаг не переписывается.
|
||||
SourceTelegram = "telegram"
|
||||
)
|
||||
|
||||
// AudioRecord — аудиозапись, центральная сущность сервиса.
|
||||
//
|
||||
// Приложения к ней — файлы, тексты, структура реплик, темы, журнал событий и
|
||||
@@ -57,7 +48,6 @@ type AudioRecord struct {
|
||||
// хранилище не бывает. Назначается один раз, при приёме, и конвейером не
|
||||
// меняется.
|
||||
OwnerID string
|
||||
Source string
|
||||
|
||||
// Title и Brief читаются вместе со списком, сотней штук разом, и потому
|
||||
// лежат колонками записи, а не строками `texts`.
|
||||
@@ -142,7 +132,7 @@ const MaxOriginalFilenameLen = 255
|
||||
//
|
||||
// Живёт в домене, а не в транспорте: имя доходит до колонки записи одним путём,
|
||||
// и правило чистки обязано быть одно. Управляющие знаки убираются потому, что
|
||||
// иначе доезжают до экрана и до панели владельца; резка идёт **после** уборки,
|
||||
// иначе доезжают до экрана; резка идёт **после** уборки,
|
||||
// иначе потолок съедали бы знаки, которых в сохранённом имени всё равно не будет.
|
||||
//
|
||||
// Режется по знакам, а не по байтам: имя русское чаще, чем латинское, и обрезка
|
||||
@@ -162,20 +152,6 @@ func SanitizeOriginalFilename(name string) string {
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// AllStates — закрытый перечень рубежей для схемы хранилища.
|
||||
func AllStates() []string {
|
||||
out := make([]string, 0, len(stages))
|
||||
for _, s := range stages {
|
||||
out = append(out, s.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AllHaltReasons — закрытый перечень причин остановки для схемы хранилища.
|
||||
func AllHaltReasons() []string {
|
||||
return []string{HaltReasonStepFailed, HaltReasonAttempts, HaltReasonStuck}
|
||||
}
|
||||
|
||||
// MoveToState двигает запись на новый рубеж и чистит служебные поля прошлого.
|
||||
//
|
||||
// Время входа в рубеж ставится заново: с этой минуты идёт отсчёт застревания.
|
||||
|
||||
+11
-18
@@ -4,21 +4,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Где лежит копия файла. Поле названо `location`, а не `storage`: последним
|
||||
// словом зовут само хранилище, и третий смысл у одного слова развёл бы по
|
||||
// разным вещам запись о файле и хранилище, в котором она лежит.
|
||||
const (
|
||||
LocationLocal = "local"
|
||||
LocationS3 = "s3"
|
||||
)
|
||||
|
||||
// MaxRecordSize — потолок размера одного файла записи. Выведен из расчётного
|
||||
// потолка записи в шесть часов с запасом на видео, а не из замера.
|
||||
//
|
||||
// Число нужно назвать **явно** в двух местах сразу: у поля файла в хранилище
|
||||
// нулевой потолок значит не «без предела», а умолчание библиотеки в 5 МиБ, а у
|
||||
// тела запроса приёма умолчание роутера отсекало бы запись раньше, чем она
|
||||
// дойдёт до обработчика — без строки в журнале приёма.
|
||||
// Число называется **явно** везде, где иначе действует умолчание: у тела запроса
|
||||
// приёма умолчание отсекало бы запись раньше, чем она дойдёт до обработчика — и
|
||||
// без строки в журнале приёма. Умолчания здесь не «без предела», а величины на
|
||||
// два-три порядка меньше нужного.
|
||||
const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
||||
|
||||
// File — одна физическая копия записи. Их ровно две: принятая и приведённая к
|
||||
@@ -26,12 +18,13 @@ const MaxRecordSize int64 = 8 << 30 // 8 ГиБ
|
||||
// существует только потому, что провайдер читает аудио по адресу, и её ключ
|
||||
// живёт в строке попытки распознавания.
|
||||
type File struct {
|
||||
Id string
|
||||
Location string
|
||||
// FileName — имя, под которым файл лежит: имя задаёт сервис. Своего суффикса
|
||||
// хранилище к заданному имени не дописывает: суффикс появляется только у
|
||||
// имён, которые оно строит само из имени отправителя, а это умолчание не
|
||||
// применяется.
|
||||
Id string
|
||||
// RecordID — запись, которой копия принадлежит. Копии одной записи лежат её
|
||||
// подкаталогом, названным этим идентификатором: так они лежат вместе, а
|
||||
// запись убирается целиком одним движением.
|
||||
RecordID string
|
||||
// FileName — имя, под которым файл лежит: имя задаёт сервис. Имя, данное
|
||||
// отправителем, в него не попадает — от него взято только расширение.
|
||||
FileName string
|
||||
Size int64
|
||||
// Format — расширение без точки, приведённое к нижнему регистру. Наружу оно
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -87,10 +87,11 @@ func (r *RecognitionResult) GetError() string {
|
||||
// Другой провайдер её не потребует, и смена провайдера не трогает доменную
|
||||
// сущность вовсе.
|
||||
//
|
||||
// Сырой ответ хранится вложением, а не колонкой этой строки: шаг опроса читает
|
||||
// её раз в несколько секунд, а хранилище читает запись целиком — ответ на
|
||||
// многочасовую запись ехал бы в память при каждом опросе. Хранится он потому,
|
||||
// что результат операции у провайдера не переспрашивается.
|
||||
// Сохранённый ответ провайдера лежит **третьим файлом в подкаталоге записи**, а
|
||||
// не колонкой этой строки: шаг опроса читает её раз в несколько секунд, а
|
||||
// репозиторий читает строку целиком — ответ на многочасовую запись ехал бы в
|
||||
// память при каждом опросе. Хранится он потому, что результат операции у
|
||||
// провайдера не переспрашивается.
|
||||
type Recognition struct {
|
||||
Id string
|
||||
RecordID string
|
||||
@@ -115,6 +116,6 @@ type RecognitionOutcome struct {
|
||||
Replicas []Replica
|
||||
// PlainText — плоский текст расшифровки.
|
||||
PlainText string
|
||||
// Raw — ответ провайдера целиком, как он пришёл, на хранение вложением.
|
||||
// Raw — ответ провайдера целиком, как он пришёл, на хранение отдельным файлом.
|
||||
Raw []byte
|
||||
}
|
||||
|
||||
@@ -17,16 +17,6 @@ const (
|
||||
EventOutcomeResumed = "resumed"
|
||||
)
|
||||
|
||||
// AllEventOrigins — закрытый перечень источников для схемы хранилища.
|
||||
func AllEventOrigins() []string {
|
||||
return []string{EventOriginPipeline, EventOriginHuman}
|
||||
}
|
||||
|
||||
// AllEventOutcomes — закрытый перечень исходов для схемы хранилища.
|
||||
func AllEventOutcomes() []string {
|
||||
return []string{EventOutcomeDone, EventOutcomeFailed, EventOutcomeHalted, EventOutcomeResumed}
|
||||
}
|
||||
|
||||
// RecordEvent — строка журнала событий одной записи.
|
||||
//
|
||||
// Пишется на смену рубежа, на остановку и на снятие остановки — не на каждое
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -42,11 +42,6 @@ func IsKnownTextView(view string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// AllTextKinds — закрытый перечень видов текста для схемы хранилища.
|
||||
func AllTextKinds() []string {
|
||||
return []string{TextKindTranscript, TextKindLiterary}
|
||||
}
|
||||
|
||||
// Text — один вид текста одной записи. Пара «запись и вид» уникальна: повтор
|
||||
// прерванного шага иначе завёл бы второй комплект строк, и вопрос «какой текст
|
||||
// отдавать человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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), "время в ключе пошло назад")
|
||||
}
|
||||
@@ -18,11 +18,11 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
first, err := env.service.CreateJobFromApi(t.Context(),
|
||||
strings.NewReader("первая"), "one.mp3", newOwner(t, env.app))
|
||||
strings.NewReader("первая"), "one.mp3", newOwner(t, env))
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := env.service.CreateJobFromApi(t.Context(),
|
||||
strings.NewReader("вторая"), "two.mp3", newOwner(t, env.app))
|
||||
strings.NewReader("вторая"), "two.mp3", newOwner(t, env))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Третья — ещё одного владельца: воркер не сужается ни одним из них.
|
||||
@@ -52,7 +52,7 @@ func TestWorkerTakesRecordsOfEveryOwner(t *testing.T) {
|
||||
func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
owner := newOwner(t, env.app)
|
||||
owner := newOwner(t, env)
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestAcquireReturnsIdentifierAndHolder(t *testing.T) {
|
||||
func TestPipelineStepKeepsOwner(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
owner := newOwner(t, env.app)
|
||||
owner := newOwner(t, env)
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -102,8 +102,8 @@ func TestCreateJobFromApiRequiresOwner(t *testing.T) {
|
||||
func TestGetByIDHidesForeignRecords(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
owner := newOwner(t, env.app)
|
||||
stranger := newOwner(t, env.app)
|
||||
owner := newOwner(t, env)
|
||||
stranger := newOwner(t, env)
|
||||
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "one.mp3", owner)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -12,19 +12,21 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/recognizer"
|
||||
pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase"
|
||||
"git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations"
|
||||
sqliterepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/sqlite"
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
)
|
||||
|
||||
// timeLayout — вид времени в колонках базы. Проверки, двигающие сроки, пишут его
|
||||
// тем же видом, каким пишет хранилище: сравнение там побайтово.
|
||||
const timeLayout = "2006-01-02T15:04:05Z"
|
||||
|
||||
// Проверки конвейера идут против настоящего хранилища: захват, число отказов и
|
||||
// остановка держатся на запросе, и подставной репозиторий проверял бы
|
||||
// собственную заглушку, а не то, что делает база.
|
||||
@@ -61,11 +63,13 @@ func (m *failingMetaViewer) GetInfo(context.Context, string) (*contract.AudioInf
|
||||
}
|
||||
|
||||
type pipelineEnv struct {
|
||||
app core.App
|
||||
db *sqliterepo.DB
|
||||
store *sqliterepo.Store
|
||||
users *sqliterepo.UserRepository
|
||||
service *TranscribeService
|
||||
repos Repositories
|
||||
recordRepo *pbrepo.AudioRecordRepository
|
||||
fileRepo *pbrepo.FileRepository
|
||||
recordRepo *sqliterepo.AudioRecordRepository
|
||||
fileRepo *sqliterepo.FileRepository
|
||||
}
|
||||
|
||||
// testLimits — пределы простоя проверок. Числа боевые; проверка застревания
|
||||
@@ -98,33 +102,34 @@ func newPipelineEnvWithLogger(
|
||||
) *pipelineEnv {
|
||||
t.Helper()
|
||||
|
||||
app, err := pbrepo.New(t.TempDir())
|
||||
dir := t.TempDir()
|
||||
db, err := sqliterepo.Open(dir, sqliterepo.Settings{BusyTimeoutMs: 5000, ReadConnections: 4})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
if err := app.ResetBootstrapState(); err != nil {
|
||||
t.Logf("не удалось закрыть хранилище: %v", err)
|
||||
if err := db.Close(); err != nil {
|
||||
t.Logf("не удалось закрыть базу: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Правила панели вешаются и здесь: конфигурация под проверкой обязана
|
||||
// совпадать с боевой, иначе утверждения говорят про прод то, чего в проде
|
||||
// нет.
|
||||
pbrepo.BindPanelRules(app)
|
||||
require.NoError(t, sqliterepo.Migrate(t.Context(), db, dir, slog.New(slog.DiscardHandler)))
|
||||
|
||||
recordRepo := pbrepo.NewAudioRecordRepository(app)
|
||||
fileRepo := pbrepo.NewFileRepository(app)
|
||||
store := sqliterepo.NewStore(dir)
|
||||
recordRepo := sqliterepo.NewAudioRecordRepository(db)
|
||||
fileRepo := sqliterepo.NewFileRepository(db, store)
|
||||
repos := Repositories{
|
||||
Records: recordRepo,
|
||||
Files: fileRepo,
|
||||
Texts: pbrepo.NewTextRepository(app),
|
||||
Structures: pbrepo.NewStructureRepository(app),
|
||||
Recognitions: pbrepo.NewRecognitionRepository(app),
|
||||
Events: pbrepo.NewRecordEventRepository(app),
|
||||
Texts: sqliterepo.NewTextRepository(db),
|
||||
Structures: sqliterepo.NewStructureRepository(db),
|
||||
Recognitions: sqliterepo.NewRecognitionRepository(db, store),
|
||||
Events: sqliterepo.NewRecordEventRepository(db),
|
||||
}
|
||||
svc := NewTranscribeService(repos, metaviewer, converter, rec, testLimits, logger)
|
||||
|
||||
return &pipelineEnv{
|
||||
app: app,
|
||||
db: db,
|
||||
store: store,
|
||||
users: sqliterepo.NewUserRepository(db),
|
||||
service: svc,
|
||||
repos: repos,
|
||||
recordRepo: recordRepo,
|
||||
@@ -132,13 +137,22 @@ func newPipelineEnvWithLogger(
|
||||
}
|
||||
}
|
||||
|
||||
// exec выполняет запрос к базе от имени проверки: фикстуры двигают колонки
|
||||
// напрямую там, где домен такого перехода не делает.
|
||||
func (e *pipelineEnv) exec(t *testing.T, query string, args ...any) {
|
||||
t.Helper()
|
||||
|
||||
_, err := e.db.Writer().ExecContext(context.Background(), query, args...)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// newRecord заводит запись — так, как её заводит приём по HTTP: от имени
|
||||
// вошедшего, потому что ничьей записи в хранилище не бывает.
|
||||
func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.service.CreateJobFromApi(
|
||||
t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env.app))
|
||||
t.Context(), strings.NewReader("запись"), "voice.ogg", newOwner(t, env))
|
||||
require.NoError(t, err)
|
||||
return record
|
||||
}
|
||||
@@ -147,10 +161,7 @@ func newRecord(t *testing.T, env *pipelineEnv) *entity.AudioRecord {
|
||||
func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
record.Set("delay_time", "")
|
||||
require.NoError(t, env.app.Save(record))
|
||||
env.exec(t, "UPDATE audio_records SET delay_time = NULL WHERE id = ?", recordID)
|
||||
}
|
||||
|
||||
// enteredStateAt отодвигает время входа записи в рубеж: так это выглядит, когда
|
||||
@@ -158,13 +169,8 @@ func clearDelay(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time.Time) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
record.Set("state_entered_at", types.DateTime{}.Add(0))
|
||||
stamp, err := types.ParseDateTime(moment)
|
||||
require.NoError(t, err)
|
||||
record.Set("state_entered_at", stamp)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
env.exec(t, "UPDATE audio_records SET state_entered_at = ? WHERE id = ?",
|
||||
moment.UTC().Format(timeLayout), recordID)
|
||||
}
|
||||
|
||||
// setAttempts ставит записи число отказов: так она выглядит, когда шаг отказал
|
||||
@@ -172,10 +178,7 @@ func enteredStateAt(t *testing.T, env *pipelineEnv, recordID string, moment time
|
||||
func setAttempts(t *testing.T, env *pipelineEnv, recordID string, attempts int) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
record.Set("attempts", attempts)
|
||||
require.NoError(t, env.app.Save(record))
|
||||
env.exec(t, "UPDATE audio_records SET attempts = ? WHERE id = ?", attempts, recordID)
|
||||
}
|
||||
|
||||
// drain крутит конвейер, пока он двигает записи. Паузы опроса снимаются: они
|
||||
@@ -253,10 +256,8 @@ func TestRecordHaltsAfterAttemptLimit(t *testing.T) {
|
||||
func expireAcquisition(t *testing.T, env *pipelineEnv, recordID string) {
|
||||
t.Helper()
|
||||
|
||||
record, err := env.app.FindRecordById(migrations.RecordsCollection, recordID)
|
||||
require.NoError(t, err)
|
||||
record.Set("acquire_expires_at", types.NowDateTime().Add(-time.Hour))
|
||||
require.NoError(t, env.app.Save(record))
|
||||
env.exec(t, "UPDATE audio_records SET acquire_expires_at = ? WHERE id = ?",
|
||||
clock.Now().Add(-time.Hour).Format(timeLayout), recordID)
|
||||
}
|
||||
|
||||
// Критерий приёмки 1. Остановленная на шаге запись перезапускается снятием
|
||||
@@ -508,11 +509,10 @@ func TestEveryHaltReasonRecordsItsCause(t *testing.T) {
|
||||
events := recordEvents(t, env, record.Id)
|
||||
require.Len(t, events, 1, "остановка оставила строку журнала событий")
|
||||
|
||||
outcome := events[0].GetString("outcome")
|
||||
assert.Contains(t,
|
||||
[]string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, outcome,
|
||||
[]string{entity.EventOutcomeHalted, entity.EventOutcomeFailed}, events[0].Outcome,
|
||||
"строка журнала называет исход остановкой")
|
||||
assert.NotEmpty(t, events[0].GetString("outcome_text"), "и несёт причину")
|
||||
assert.NotEmpty(t, events[0].OutcomeText, "и несёт причину")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -536,9 +536,9 @@ func TestRepeatedStoreKeepsSingleText(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "второй разбор", stored.Contents)
|
||||
|
||||
count, err := env.app.CountRecords(migrations.TextsCollection)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), count, "второго комплекта строк не завелось")
|
||||
var count int
|
||||
require.NoError(t, env.db.Reader().QueryRowContext(context.Background(), "SELECT COUNT(*) FROM texts").Scan(&count))
|
||||
assert.Equal(t, 1, count, "второго комплекта строк не завелось")
|
||||
}
|
||||
|
||||
// Пауза растёт с числом отказов и упирается в потолок.
|
||||
@@ -559,19 +559,12 @@ func TestFailedStepSchedulesRetryWithGrowingDelay(t *testing.T) {
|
||||
|
||||
// Ссылка переставляется на запись о файле без содержимого: шаг отказывает на
|
||||
// получении рабочей копии — то есть отказом, а не приговором записи.
|
||||
files, err := env.app.FindCollectionByNameOrId(migrations.FilesCollection)
|
||||
require.NoError(t, err)
|
||||
empty := core.NewRecord(files)
|
||||
empty.Set("location", entity.LocationLocal)
|
||||
empty.Set("size", 1)
|
||||
emptyID := ident.New()
|
||||
// Владелец обязателен и у файла: схема ничьих не принимает.
|
||||
empty.Set("owner", record.OwnerID)
|
||||
require.NoError(t, env.app.Save(empty))
|
||||
|
||||
stored, err := env.app.FindRecordById(migrations.RecordsCollection, record.Id)
|
||||
require.NoError(t, err)
|
||||
stored.Set("original_file", empty.Id)
|
||||
require.NoError(t, env.app.Save(stored))
|
||||
env.exec(t, `INSERT INTO files (id, owner_id, record_id, file_name, size_bytes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
emptyID, record.OwnerID, record.Id, "missing.mp3", 1, clock.Now().Format(timeLayout))
|
||||
env.exec(t, "UPDATE audio_records SET original_file_id = ? WHERE id = ?", emptyID, record.Id)
|
||||
|
||||
require.Error(t, env.service.RunStep(t.Context()))
|
||||
|
||||
@@ -596,7 +589,7 @@ func TestWorkFileRemovedAfterIntakeFailure(t *testing.T) {
|
||||
|
||||
env := newPipelineEnv(t, &failingMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app))
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env))
|
||||
require.Error(t, err, "отказ источника метаданных роняет приём")
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
@@ -612,7 +605,7 @@ func TestWorkFileRemovedAfterSuccessfulIntake(t *testing.T) {
|
||||
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env.app))
|
||||
_, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("запись"), "sample.mp3", newOwner(t, env))
|
||||
require.NoError(t, err)
|
||||
|
||||
leftovers, err := filepath.Glob(filepath.Join(tempDir, "transcriber-*"))
|
||||
@@ -666,7 +659,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
|
||||
content := strings.Repeat("запись ", 1000)
|
||||
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env.app))
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader(content), "sample.mp3", newOwner(t, env))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record.OriginalFileID)
|
||||
|
||||
@@ -689,7 +682,7 @@ func TestStoredContentSurvivesRoundTrip(t *testing.T) {
|
||||
func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||
env := newPipelineEnv(t, &okMetaViewer{}, &failingConverter{})
|
||||
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env.app))
|
||||
record, err := env.service.CreateJobFromApi(t.Context(), strings.NewReader("содержимое"), "sample.mp3", newOwner(t, env))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record.OriginalFileID)
|
||||
|
||||
@@ -707,24 +700,17 @@ func TestLocalizeGivesReadableCopy(t *testing.T) {
|
||||
|
||||
// newOwner заводит учётную запись и отдаёт её идентификатор.
|
||||
//
|
||||
// Владелец — связь с коллекцией пользователей, и хранилище проверяет, что такая
|
||||
// запись есть: выдуманный идентификатор запись завести не даст.
|
||||
func newOwner(t *testing.T, app core.App) string {
|
||||
// Владелец — связь с таблицей учётных записей, и база проверяет, что такая
|
||||
// строка есть: выдуманный идентификатор запись завести не даст.
|
||||
func newOwner(t *testing.T, env *pipelineEnv) string {
|
||||
t.Helper()
|
||||
|
||||
users, err := app.FindCollectionByNameOrId(migrations.UsersCollection)
|
||||
// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
|
||||
// одним ключом схема не примет.
|
||||
account, _, err := env.users.EnsureUser(contract.Identity{Login: ident.New()})
|
||||
require.NoError(t, err)
|
||||
|
||||
record := core.NewRecord(users)
|
||||
// Логин у провайдера — ключ учётной записи, и он уникален: две записи с
|
||||
// пустым ключом схема не примет.
|
||||
record.Set(migrations.ProviderLoginField, uuid.NewString())
|
||||
record.Set("email", uuid.NewString()+"@example.test")
|
||||
record.Set("verified", true)
|
||||
record.Set("password", uuid.NewString())
|
||||
require.NoError(t, app.Save(record))
|
||||
|
||||
return record.Id
|
||||
return account.ID
|
||||
}
|
||||
|
||||
// Остановка приговором шага засчитывается **отказом**, а не успехом, и пишет в
|
||||
@@ -752,7 +738,7 @@ func TestHaltIsCountedAsFailureAndLoggedOnce(t *testing.T) {
|
||||
|
||||
events := recordEvents(t, env, record.Id)
|
||||
require.Len(t, events, 1, "одна строка журнала, а не две")
|
||||
assert.Equal(t, entity.EventOutcomeFailed, events[0].GetString("outcome"),
|
||||
assert.Equal(t, entity.EventOutcomeFailed, events[0].Outcome,
|
||||
"исход назван приговором, а не сделанной работой")
|
||||
}
|
||||
|
||||
@@ -780,18 +766,32 @@ func TestPostponeWritesNoEvent(t *testing.T) {
|
||||
"пять откладываний не оставили в журнале ни строки")
|
||||
}
|
||||
|
||||
// recordEventRow — строка журнала событий записи, какой её видит проверка.
|
||||
type recordEventRow struct {
|
||||
Origin string
|
||||
Step string
|
||||
Outcome string
|
||||
OutcomeText string
|
||||
}
|
||||
|
||||
// recordEvents читает журнал событий одной записи в порядке заведения.
|
||||
func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []*core.Record {
|
||||
func recordEvents(t *testing.T, env *pipelineEnv, recordID string) []recordEventRow {
|
||||
t.Helper()
|
||||
|
||||
all, err := env.app.FindAllRecords(migrations.RecordEventsCollection)
|
||||
rows, err := env.db.Reader().QueryContext(context.Background(),
|
||||
"SELECT origin, step, outcome, outcome_text FROM record_events WHERE record_id = ? ORDER BY id",
|
||||
recordID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer func() { require.NoError(t, rows.Close()) }()
|
||||
|
||||
var own []*core.Record
|
||||
for _, event := range all {
|
||||
if event.GetString("record") == recordID {
|
||||
own = append(own, event)
|
||||
}
|
||||
var own []recordEventRow
|
||||
for rows.Next() {
|
||||
var event recordEventRow
|
||||
require.NoError(t, rows.Scan(&event.Origin, &event.Step, &event.Outcome, &event.OutcomeText))
|
||||
own = append(own, event)
|
||||
}
|
||||
require.NoError(t, rows.Err())
|
||||
|
||||
return own
|
||||
}
|
||||
|
||||
@@ -11,11 +11,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/clock"
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
"git.vakhrushev.me/av/transcriber/internal/ident"
|
||||
"git.vakhrushev.me/av/transcriber/internal/metrics"
|
||||
)
|
||||
|
||||
@@ -155,9 +154,12 @@ func (s *TranscribeService) CreateJobFromApi(ctx context.Context, file io.Reader
|
||||
return nil, contract.ErrOwnerRequired
|
||||
}
|
||||
|
||||
// Идентификатор назначается здесь, до укладки файла: копии записи лежат её
|
||||
// подкаталогом, названным этим идентификатором, и знать его надо раньше, чем
|
||||
// класть первую копию.
|
||||
record := &entity.AudioRecord{
|
||||
Id: ident.New(),
|
||||
State: entity.StateUploaded,
|
||||
Source: entity.SourceApi,
|
||||
OwnerID: ownerID,
|
||||
}
|
||||
|
||||
@@ -176,10 +178,10 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
|
||||
ext = fmt.Sprintf(".%s", defaultAudioExt)
|
||||
}
|
||||
|
||||
// Собственное имя записи: идентификатор с расширением. Имя, данное
|
||||
// отправителем, в хранилище не попадает — от него взято только расширение.
|
||||
fileId := uuid.NewString()
|
||||
storageFileName := fmt.Sprintf("%s%s", fileId, ext)
|
||||
// Собственное имя копии: идентификатор с расширением. Имя, данное
|
||||
// отправителем, в каталог данных не попадает — от него взято только
|
||||
// расширение.
|
||||
storageFileName := fmt.Sprintf("%s%s", ident.New(), ext)
|
||||
|
||||
// Содержимое ложится в рабочую копию потоком: в память запись целиком не
|
||||
// читается, расчётный потолок — шесть часов.
|
||||
@@ -217,7 +219,7 @@ func (s *TranscribeService) createRecord(ctx context.Context, r *entity.AudioRec
|
||||
DurationMs: int64(info.Seconds) * 1000,
|
||||
}
|
||||
|
||||
fileRecord, err := s.repos.Files.Create(storageFileName, work, meta, r.OwnerID)
|
||||
fileRecord, err := s.repos.Files.Create(r.Id, storageFileName, work, meta, r.OwnerID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create file record", "error", err, "file_ext", ext)
|
||||
return nil, err
|
||||
@@ -474,9 +476,9 @@ func (s *TranscribeService) normalize(ctx context.Context, r *entity.AudioRecord
|
||||
|
||||
metrics.OutputFileSizeHistogram.WithLabelValues("ogg").Observe(float64(destSize))
|
||||
|
||||
destFileName := fmt.Sprintf("%s%s", uuid.NewString(), ".ogg")
|
||||
destFileName := fmt.Sprintf("%s%s", ident.New(), ".ogg")
|
||||
destMeta := contract.FileMeta{Format: "ogg", DurationMs: srcFile.DurationMs}
|
||||
destFileRecord, err := s.repos.Files.Create(destFileName, dest, destMeta, r.OwnerID)
|
||||
destFileRecord, err := s.repos.Files.Create(r.Id, destFileName, dest, destMeta, r.OwnerID)
|
||||
if err != nil {
|
||||
s.logger.Error("Failed to create normalized file record", "error", err, "record_id", r.Id)
|
||||
return outcomeDone, err
|
||||
@@ -780,8 +782,8 @@ func (s *TranscribeService) storeOutcome(r *entity.AudioRecord, outcome *entity.
|
||||
}
|
||||
|
||||
// finish доводит запись до конечного рубежа. Наружу шаг не обращается: доставки
|
||||
// ответа отправителю у сервиса нет, и свой исход отправитель узнаёт опросом
|
||||
// готовности.
|
||||
// ответа отправителю у сервиса нет, и свой исход владелец записи узнаёт её
|
||||
// карточкой.
|
||||
func (s *TranscribeService) finish(ctx context.Context, r *entity.AudioRecord, holder string) (stepOutcome, error) {
|
||||
r.MoveToState(entity.StateDone)
|
||||
if err := s.repos.Records.Save(r, holder); err != nil {
|
||||
@@ -806,7 +808,7 @@ func (s *TranscribeService) failStep(r *entity.AudioRecord, holder, step string,
|
||||
// событий.
|
||||
//
|
||||
// Отправителю отсюда ничего не уходит: инвариант проекта «Принятая запись не
|
||||
// теряется молча» держится теперь опросом готовности — остановка видна там
|
||||
// теряется молча» держится теперь карточкой записи — остановка видна там
|
||||
// признаком — и журналом владельца, где у неё стоит причина.
|
||||
//
|
||||
// Счётчик растит **сама остановка**, а не воркер, и это не стилистика.
|
||||
|
||||
Reference in New Issue
Block a user