Files
transcriber/internal/adapter/repo/pocketbase/panel.go
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

132 lines
7.5 KiB
Go

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()
})
}