- колонка `owner` связью с `users` в обеих коллекциях новым шагом схемы `202608140001`; чтение задачи сужено владельцем, и чужая, ничья и несуществующая дают один ответ; правило просмотра файлов сужено им же - приём по HTTP берёт владельца из сессии, а предъявителя без учётной записи пользователя отвергает до чтения тела: позже пришлось бы убирать уложенный файл, а уборки файлов сервис не умеет. Выборка воркера владельцем не сужается - удаление учётной записи с записями отвергается стражем, и вешает его сама сборка хранилища: сборка, забывшая его позвать, теряла защиту молча
81 lines
5.0 KiB
Go
81 lines
5.0 KiB
Go
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()
|
||
})
|
||
}
|
||
|
||
// countOwned считает всё, что принадлежит учётной записи, — по обеим коллекциям
|
||
// с колонкой владельца. Перечень живёт здесь одним списком: разойдясь с шагом
|
||
// схемы, он оставил бы половину архива без защиты молча.
|
||
func countOwned(app core.App, ownerID string) (int64, error) {
|
||
var total int64
|
||
|
||
for _, collection := range []string{migrations.JobsCollection, migrations.FilesCollection} {
|
||
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
|
||
}
|