package pocketbase import ( "errors" "fmt" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/migrations" "git.vakhrushev.me/av/transcriber/internal/entity" ) // Схема заводится версионированными шагами, и применённый шаг не переписывается // — только новым шагом. Инвариант проекта перенесён дословно: хранилище считает // применённое по имени файла шага. // // Шаг регистрируется в списке приложения при загрузке пакета, а накатывает его // `apis.Serve` прежде, чем поднять сервер. func init() { migrations.Register(up202608110001, down202608110001, "202608110001_init.go") migrations.Register(up202608120001, down202608120001, "202608120001_oidc_login.go") } 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 } // SessionDuration — сколько живёт сессия вошедшего, семь суток. Число выбрано // решением владельца от 2026-08-12; умолчание библиотеки в пять суток не // применяется, потому что оно никем не выбрано. // // Применяется оно не шагом схемы, а при каждом подъёме — вместе с настройками // провайдера. Причина та же: применённый шаг не переписывается, и число, // положенное туда, разошлось бы со сроком жизни куки при первой же правке — // браузер получил бы новый срок, а хранилище продолжило выдавать прежний. const SessionDuration = 7 * 24 * 60 * 60 // 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 } func ptr[T any](v T) *T { return &v }