- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
253 lines
13 KiB
Go
253 lines
13 KiB
Go
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 }
|