удалён вход Telegram, владелец записи стал обязателен в схеме
- убраны клиент бота, транспорт обновлений, отправитель сообщений, сборка входа при старте, секция настроек и зависимость go-telegram-bot-api; из конвейера ушла доставка ответа отправителю — исход виден опросом готовности. Колонки адресата и значение источника остались в схеме: применённые шаги не переписываются - шаг 202608140003 запрещает пустого владельца у аудиозаписи и у файла; существующие строки он не проверяет, и это принято сознательно — искать их надо запросом до выкладки - ревью нашло два пред-существующих дефекта, оба закрыты: пустой второй ответ распознавателя стирал сохранённую расшифровку, а пустая расшифровка перестала быть заметной вместе с убранной доставкой. Попутно поднят golang.org/x/image до v0.45.0 — красный шаг vulns, воспроизводился и на чистом master
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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
|
||||
}
|
||||
@@ -49,6 +49,7 @@ func init() {
|
||||
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")
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -42,9 +42,7 @@ func newRecordOf(t *testing.T, app core.App, ownerID string) *entity.AudioRecord
|
||||
State: entity.StateUploaded,
|
||||
StateEnteredAt: clock.Now(),
|
||||
Source: entity.SourceApi,
|
||||
}
|
||||
if ownerID != "" {
|
||||
record.OwnerID = &ownerID
|
||||
OwnerID: ownerID,
|
||||
}
|
||||
require.NoError(t, NewAudioRecordRepository(app).Create(record))
|
||||
return record
|
||||
@@ -65,8 +63,7 @@ func TestGuardOwnerDeletion(t *testing.T) {
|
||||
|
||||
after, err := NewAudioRecordRepository(app).Get(record.Id)
|
||||
require.NoError(t, err, "запись на месте")
|
||||
require.NotNil(t, after.OwnerID, "и владелец у неё прежний")
|
||||
assert.Equal(t, account.Id, *after.OwnerID)
|
||||
assert.Equal(t, account.Id, after.OwnerID, "и владелец у неё прежний")
|
||||
}
|
||||
|
||||
// Считаются все коллекции с владельцем, а не одни записи: файл переживает свою
|
||||
@@ -119,19 +116,52 @@ func TestGuardOwnerDeletionLetsEmptyAccountGo(t *testing.T) {
|
||||
require.NoError(t, app.Delete(account), "пустая учётная запись удаляется")
|
||||
}
|
||||
|
||||
// Умолчания у колонки владельца нет: запись, чей владелец не назван, не
|
||||
// достаётся никому по недосмотру схемы.
|
||||
func TestOwnerColumnHasNoDefault(t *testing.T) {
|
||||
// Колонка владельца пустого значения не принимает и умолчания не имеет:
|
||||
// ничьей записи в хранилище не бывает, и завести её нечем — ни приёмом, ни
|
||||
// конвейером, ни рукой в панели.
|
||||
func TestOwnerColumnRefusesEmptyValue(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
|
||||
records, err := app.FindCollectionByNameOrId(migrations.RecordsCollection)
|
||||
require.NoError(t, err)
|
||||
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 := records.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена")
|
||||
field := collection.Fields.GetByName("owner")
|
||||
require.NotNil(t, field, "колонка владельца заведена")
|
||||
|
||||
relation, ok := field.(*core.RelationField)
|
||||
require.True(t, ok, "владелец — связь с учётной записью, а не строка")
|
||||
assert.False(t, relation.Required, "пустое значение допустимо ради записей бота")
|
||||
assert.False(t, relation.CascadeDelete, "удаление учётной записи не уносит архив следом")
|
||||
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, "ничей файл в хранилище не ложится")
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func updateByRequest(t *testing.T, app core.App, recordID string, body map[strin
|
||||
func haltedRecord(t *testing.T, app core.App) *entity.AudioRecord {
|
||||
t.Helper()
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.MoveToState(entity.StateNormalized)
|
||||
record.Attempts = 4
|
||||
record.AcquisitionID = ptrOf("прежний-захват")
|
||||
@@ -143,7 +143,7 @@ func TestPanelResumeIsLogged(t *testing.T) {
|
||||
func TestPanelStateEditClearsGuards(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.Attempts = 4
|
||||
record.AcquisitionID = ptrOf("прежний-захват")
|
||||
record.AcquireExpiresAt = ptrOf(clock.Now().Add(8 * time.Hour))
|
||||
@@ -162,7 +162,7 @@ func TestPanelStateEditClearsGuards(t *testing.T) {
|
||||
func TestPanelKeepsGuardsOnUnrelatedEdit(t *testing.T) {
|
||||
app := newPanelStorage(t)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
record.Attempts = 3
|
||||
record.AcquisitionID = ptrOf("живой-захват")
|
||||
require.NoError(t, NewAudioRecordRepository(app).Save(record, ""))
|
||||
@@ -181,7 +181,7 @@ func TestAcquireCarriesStageDeadline(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
acquired, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
@@ -205,7 +205,7 @@ func TestAcquireHandsRecordToExactlyOne(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
newRecordOf(t, app, "")
|
||||
newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err, "первому запись досталась")
|
||||
@@ -223,7 +223,7 @@ func TestRottenAcquisitionIsHandedOutAgain(t *testing.T) {
|
||||
app := newTestStorage(t)
|
||||
repo := NewAudioRecordRepository(app)
|
||||
|
||||
record := newRecordOf(t, app, "")
|
||||
record := newRecordOf(t, app, newAccount(t, app).Id)
|
||||
|
||||
first, err := repo.FindAndAcquire(entity.WorkingStages())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -50,18 +50,16 @@ func applyToRecord(record *core.Record, r *entity.AudioRecord) {
|
||||
// Владелец кладётся только здесь, при заведении. В applyOwnedByPipeline его
|
||||
// нет намеренно: конвейер владельца не назначает и не меняет, а снимок шага,
|
||||
// записанный поверх, стёр бы его молча.
|
||||
record.Set("owner", derefString(r.OwnerID))
|
||||
record.Set("owner", r.OwnerID)
|
||||
record.Set("source", r.Source)
|
||||
record.Set("title", derefString(r.Title))
|
||||
record.Set("brief", derefString(r.Brief))
|
||||
record.Set("tg_chat_id", derefInt64(r.TgChatId))
|
||||
record.Set("tg_reply_message_id", derefInt(r.TgReplyMessageId))
|
||||
}
|
||||
|
||||
func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||||
return &entity.AudioRecord{
|
||||
Id: record.Id,
|
||||
OwnerID: nilIfEmpty(record.GetString("owner")),
|
||||
OwnerID: record.GetString("owner"),
|
||||
Source: record.GetString("source"),
|
||||
Title: nilIfEmpty(record.GetString("title")),
|
||||
Brief: nilIfEmpty(record.GetString("brief")),
|
||||
@@ -80,8 +78,6 @@ func recordToAudioRecord(record *core.Record) *entity.AudioRecord {
|
||||
LiteraryTextID: nilIfEmpty(record.GetString("literary_text")),
|
||||
StructureID: nilIfEmpty(record.GetString("structure")),
|
||||
RecognitionID: nilIfEmpty(record.GetString("recognition")),
|
||||
TgChatId: nilIfZero64(int64(record.GetInt("tg_chat_id"))),
|
||||
TgReplyMessageId: nilIfZeroInt(record.GetInt("tg_reply_message_id")),
|
||||
CreatedAt: record.GetDateTime("created").Time(),
|
||||
UpdatedAt: record.GetDateTime("updated").Time(),
|
||||
}
|
||||
@@ -94,20 +90,6 @@ func derefString(v *string) string {
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt64(v *int64) int64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func derefInt(v *int) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// dateOrEmpty отдаёт пустое значение вместо нулевой даты: пустая колонка даты в
|
||||
// хранилище это пустая строка, и она же значит «времени нет».
|
||||
func dateOrEmpty(v *time.Time) any {
|
||||
@@ -135,17 +117,3 @@ func timeOrNil(v types.DateTime) *time.Time {
|
||||
t := v.Time()
|
||||
return &t
|
||||
}
|
||||
|
||||
func nilIfZero64(v int64) *int64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func nilIfZeroInt(v int) *int {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (repo *AudioRecordRepository) Create(r *entity.AudioRecord) error {
|
||||
// перевыданный другому — по протуханию срока или после того, как человек снял
|
||||
// признак остановки в панели, — обязан обратить запись первого в отказ; условие
|
||||
// по непустоте признака пропустило бы обоих, и два шага записали бы в одну
|
||||
// запись и оба ответили бы отправителю.
|
||||
// запись по очереди, портя её результат.
|
||||
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)
|
||||
@@ -89,9 +89,10 @@ func (repo *AudioRecordRepository) Save(r *entity.AudioRecord, holder string) er
|
||||
// по разнице ответов иначе перебирается список заведённых записей, а
|
||||
// идентификатор записи и есть то, что разграничение прячет.
|
||||
//
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем: иначе
|
||||
// вызывающий без учётной записи получил бы ровно множество записей без
|
||||
// владельца, то есть все записи бота.
|
||||
// Пустой ownerID отсекается **до** чтения и не совпадает ни с чем. Правило это
|
||||
// не стало избыточным с обязательностью колонки: схема запрещает **заводить**
|
||||
// ничью запись, а здесь запрещено **спрашивать** ничьим именем — иначе
|
||||
// вызывающий без учётной записи получил бы выборку вместо отказа.
|
||||
func (repo *AudioRecordRepository) GetByID(id, ownerID string) (*entity.AudioRecord, error) {
|
||||
if ownerID == "" {
|
||||
return nil, &contract.JobNotFoundError{Message: "record not found"}
|
||||
|
||||
@@ -26,6 +26,15 @@ func NewTextRepository(app core.App) *TextRepository {
|
||||
// Замена, а не вставка: пара «запись и вид» уникальна, и повтор прерванного шага
|
||||
// иначе завёл бы второй комплект строк — тогда вопрос «какой текст отдавать
|
||||
// человеку» стал бы вопросом порядка записи, а не состояния.
|
||||
//
|
||||
// **Пустое не кладётся поверх непустого**, и это не осторожность, а защита
|
||||
// архива. Повторный опрос той же операции — обычное дело: держатель захвата
|
||||
// умер, сохранение рубежа отказало, человек снял остановку в панели. Провайдер
|
||||
// при этом вправе ответить пустым потоком, отказом это не считается, и
|
||||
// безусловная замена стирала бы сохранённую расшифровку живого человека без
|
||||
// следа и без возврата. Та же защита стоит у сырого ответа провайдера
|
||||
// (`RecognitionRepository.Finish`), и разное правило у двух хранителей одного
|
||||
// результата читалось бы как недосмотр.
|
||||
func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text, error) {
|
||||
collection, err := findCollection(repo.app, migrations.TextsCollection)
|
||||
if err != nil {
|
||||
@@ -50,6 +59,11 @@ func (repo *TextRepository) Put(recordID, kind, contents string) (*entity.Text,
|
||||
// запись вместо правды о недоступной базе.
|
||||
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 {
|
||||
@@ -106,7 +120,12 @@ func (repo *StructureRepository) Put(recordID string, version int, replicas []en
|
||||
)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user