хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -1,14 +1,11 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/contract"
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
@@ -17,9 +14,13 @@ import (
|
||||
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
||||
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||||
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
||||
//
|
||||
// Кода `forbidden` в перечне больше нет: его единственным случаем был владелец
|
||||
// панели, предъявивший собственный токен хранилища. Ни панели, ни токенов у
|
||||
// сервиса не осталось, а узнавание по заголовку учётную запись заводит само —
|
||||
// предъявителя без неё не бывает.
|
||||
const (
|
||||
CodeUnauthorized = "unauthorized"
|
||||
CodeForbidden = "forbidden"
|
||||
CodeNotFound = "not_found"
|
||||
CodeBadRequest = "bad_request"
|
||||
CodeTooLarge = "too_large"
|
||||
@@ -32,7 +33,7 @@ const (
|
||||
//
|
||||
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
||||
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
||||
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
|
||||
// внешних сервисов, пути на диске, имена файлов. Полная ошибка остаётся в
|
||||
// журнале владельца сервиса.
|
||||
//
|
||||
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
||||
@@ -46,8 +47,7 @@ type ErrorBody struct {
|
||||
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
||||
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
||||
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
||||
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
|
||||
// второе не говорило ему ничего.
|
||||
// ошибка» на негодный файл.
|
||||
//
|
||||
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
||||
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
||||
@@ -87,6 +87,18 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
Message: "Текст этого вида для записи ещё не готов",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrCopyNotReady):
|
||||
return http.StatusConflict, ErrorBody{
|
||||
Code: CodeNotReady,
|
||||
Message: "Этой копии записи ещё нет",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrTooManyRequests):
|
||||
return http.StatusTooManyRequests, ErrorBody{
|
||||
Code: CodeTooManyRequests,
|
||||
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrNotFound):
|
||||
message := "Адрес не найден"
|
||||
var owned *messagedError
|
||||
@@ -104,12 +116,6 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
Code: CodeUnauthorized,
|
||||
Message: "Сервис вас не узнал",
|
||||
}
|
||||
|
||||
case errors.Is(err, contract.ErrOwnerRequired):
|
||||
return http.StatusForbidden, ErrorBody{
|
||||
Code: CodeForbidden,
|
||||
Message: "У предъявителя нет учётной записи пользователя",
|
||||
}
|
||||
}
|
||||
|
||||
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||||
@@ -130,114 +136,61 @@ func mapDomainError(err error) (int, ErrorBody) {
|
||||
|
||||
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
||||
// уходит наружу с адресов приложения.
|
||||
func fail(e *core.RequestEvent, err error) error {
|
||||
//
|
||||
// Отказы, рождающиеся **не в обработчике** — предел тела, ограничитель частоты,
|
||||
// неизвестный путь под корнем приложения, — приходят сюда же: слои сервиса
|
||||
// написаны нами и отвечают своей доменной ошибкой, а не телом библиотеки. Второй
|
||||
// формы тела на адресах приложения не существует.
|
||||
func fail(w http.ResponseWriter, err error) {
|
||||
status, body := mapDomainError(err)
|
||||
return e.JSON(status, body)
|
||||
writeJSON(w, status, body)
|
||||
}
|
||||
|
||||
// OneErrorForm переводит отказ библиотеки в нашу форму тела.
|
||||
//
|
||||
// Своей единой точки мало: часть отказов на адресах приложения рождается **не в
|
||||
// обработчике** и до `mapDomainError` не доходит вовсе. Их три, и все три частые:
|
||||
// предел тела (`413`), ограничитель частоты (`429`) и неизвестный путь под нашим
|
||||
// корнем (`404`). Каждый уходил бы телом `router.ApiError` — без машиночитаемого
|
||||
// кода, — и форм отказа на адресах приложения оказалось бы две вместо одной.
|
||||
//
|
||||
// Дороже всего первый: «запись больше потолка» — самый частый отказ у человека
|
||||
// на мобильной сети, и приложение, разобрав чужое тело, показало бы ветвь
|
||||
// «внутренняя ошибка» вместо предела числом.
|
||||
//
|
||||
// Слой стоит **самым внешним**: он обязан видеть отказ, рождённый слоями ниже
|
||||
// него, включая предел тела и ограничитель частоты.
|
||||
func OneErrorForm() *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberOneErrorForm",
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority - 100,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
err := e.Next()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Обработчик, ответивший через fail, ошибки не возвращает — его
|
||||
// форма уже ушла в ответ, и сюда доходит только чужая.
|
||||
var apiErr *router.ApiError
|
||||
if !errors.As(err, &apiErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
status, translated := translateAPIError(apiErr)
|
||||
return e.JSON(status, translated)
|
||||
},
|
||||
}
|
||||
// writeJSON отдаёт тело ответа. Отказ записи в журнал не идёт: соединение к
|
||||
// этому моменту оборвано, и сказать о нём некому — строка о каждом закрытом
|
||||
// браузере наполняла бы журнал ничем.
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
// translateAPIError переводит отказ библиотеки в перечень наших кодов. Ветви
|
||||
// названы поимённо: значение вне перечня приложению разбирать нечем.
|
||||
func translateAPIError(apiErr *router.ApiError) (int, ErrorBody) {
|
||||
switch apiErr.Status {
|
||||
case http.StatusRequestEntityTooLarge:
|
||||
limit := entity.MaxRecordSize
|
||||
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||||
Code: CodeTooLarge,
|
||||
Message: "Запись больше допустимого размера",
|
||||
Limit: &limit,
|
||||
}
|
||||
case http.StatusTooManyRequests:
|
||||
return http.StatusTooManyRequests, ErrorBody{
|
||||
Code: CodeTooManyRequests,
|
||||
Message: "Слишком много запросов подряд, попробуйте позже",
|
||||
}
|
||||
case http.StatusNotFound:
|
||||
return http.StatusNotFound, ErrorBody{
|
||||
Code: CodeNotFound,
|
||||
Message: "Адрес не найден",
|
||||
}
|
||||
case http.StatusUnauthorized:
|
||||
return mapDomainError(contract.ErrUnauthorized)
|
||||
}
|
||||
|
||||
return apiErr.Status, ErrorBody{
|
||||
Code: CodeInternal,
|
||||
Message: "Внутренняя ошибка сервиса",
|
||||
}
|
||||
// errWithMessage приклеивает к признаку негодного ввода свой текст: причина у
|
||||
// всех одна, а сказать человеку надо разное.
|
||||
func errWithMessage(base error, message string) error {
|
||||
return &messagedError{base: base, message: message}
|
||||
}
|
||||
|
||||
// RequireUser — слой предъявления адресов приложения.
|
||||
//
|
||||
// Своя проверка, а не `apis.RequireAuth`, по одной причине: отказ библиотеки
|
||||
// уходит **её** формой тела, и на адресах приложения оказалось бы две формы
|
||||
// отказа вместо одной. Проверка при этом та же самая, и коллекция названа
|
||||
// поимённо: без имени пускается всякая учётная запись хранилища, включая
|
||||
// владельца панели, — а записи в коллекции пользователей у него нет, и владельцем
|
||||
// записи он стать не может.
|
||||
//
|
||||
// Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный
|
||||
// отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже
|
||||
// уложенный файл — чего сервис не умеет вовсе.
|
||||
func RequireUser(usersCollection string) *hook.Handler[*core.RequestEvent] {
|
||||
return &hook.Handler[*core.RequestEvent]{
|
||||
Id: "transcriberRequireUser",
|
||||
// Сразу после слоя узнавания: раньше него `e.Auth` ещё пуст, и всякий
|
||||
// запрос получал бы отказ. Слой узнавания, в свою очередь, стоит за
|
||||
// ограничителем частоты — см. `TrustedHeaderIdentity`.
|
||||
//
|
||||
// Предел тела библиотеки идёт следом (−990), и это обязательно: отказ
|
||||
// неузнанному наступает **до** чтения тела.
|
||||
Priority: apis.DefaultRateLimitMiddlewarePriority + 2,
|
||||
Func: func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return fail(e, contract.ErrUnauthorized)
|
||||
}
|
||||
type messagedError struct {
|
||||
base error
|
||||
message string
|
||||
}
|
||||
|
||||
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
|
||||
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
|
||||
// предъявитель себя предъявил.
|
||||
if e.Auth.Collection().Name != usersCollection {
|
||||
return fail(e, contract.ErrOwnerRequired)
|
||||
}
|
||||
func (e *messagedError) Error() string { return e.message }
|
||||
func (e *messagedError) Unwrap() error { return e.base }
|
||||
|
||||
return e.Next()
|
||||
},
|
||||
// Recover — верхняя граница обработчика: паникующий запрос отдаёт `500` нашей
|
||||
// формой тела, а процесс живёт.
|
||||
//
|
||||
// Слой свой, потому что своим стал и роутер: прежде его вешала чужая библиотека.
|
||||
// У воркеров такой границы по-прежнему нет — паника в шаге конвейера роняет
|
||||
// процесс целиком, и это осознанно.
|
||||
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logger.Error("Handler panicked",
|
||||
"error", recovered, "transport", "http")
|
||||
fail(w, errors.New("handler panicked"))
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user