- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
244 lines
12 KiB
Go
244 lines
12 KiB
Go
package http
|
||
|
||
import (
|
||
"errors"
|
||
"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"
|
||
)
|
||
|
||
// Машиночитаемые коды отказа. Перечень закрыт и объявлен одним местом: код
|
||
// HTTP не различает «файл негоден», «поля записи нет» и «неизвестный вид» — все
|
||
// три `400`, — а приложению надо решать, предлагать ли повтор и что показать
|
||
// человеку. Разбор русской фразы был бы единственным оставшимся путём.
|
||
const (
|
||
CodeUnauthorized = "unauthorized"
|
||
CodeForbidden = "forbidden"
|
||
CodeNotFound = "not_found"
|
||
CodeBadRequest = "bad_request"
|
||
CodeTooLarge = "too_large"
|
||
CodeNotReady = "not_ready"
|
||
CodeTooManyRequests = "too_many_requests"
|
||
CodeInternal = "internal"
|
||
)
|
||
|
||
// ErrorBody — единая форма тела отказа на всех адресах приложения.
|
||
//
|
||
// Два поля, а не одно: код разбирает программа, сообщение читает человек. Сырой
|
||
// текст ошибки сюда не попадает — ни `err.Error()`, ни детали устройства: имена
|
||
// внешних сервисов, пути на диске, ключи файлов. Полная ошибка остаётся в
|
||
// журнале владельца сервиса.
|
||
//
|
||
// Limit заполняется только у отказа по размеру: экран обязан показать предел
|
||
// числом, а не пересказать его словами.
|
||
type ErrorBody struct {
|
||
Code string `json:"error_code"`
|
||
Message string `json:"message"`
|
||
Limit *int64 `json:"limit,omitempty"`
|
||
}
|
||
|
||
// mapDomainError — **единственная** точка, где доменная ошибка становится кодом
|
||
// ответа и сообщением. Прежде такой точки не было вовсе, и каждый обработчик
|
||
// решал сам: опрос отвечал «записи нет» на упавшую базу, а приём — «внутренняя
|
||
// ошибка» на негодный файл. Человек читал первое как «моя запись пропала», а
|
||
// второе не говорило ему ничего.
|
||
//
|
||
// Ветвь по умолчанию определена намеренно: новая штатная ветвь отказа заводится
|
||
// добавлением сюда, а не строкой в обработчике. Иначе обычный конфликт уезжает в
|
||
// `internal`, и владелец сервиса видит в журнале аварию там, где её нет.
|
||
func mapDomainError(err error) (int, ErrorBody) {
|
||
switch {
|
||
case errors.Is(err, contract.ErrBadRequest):
|
||
// Причина у всех негодных вводов одна, а сказать человеку надо разное:
|
||
// «размер страницы отрицательный» и «неизвестный вид текста» ведут к
|
||
// разным действиям. Свой текст приезжает обёрткой; его нет — говорим
|
||
// общее. Сырой `err.Error()` наружу при этом не идёт: сообщение пишем мы,
|
||
// а не библиотека.
|
||
message := "Запрос составлен неверно"
|
||
var owned *messagedError
|
||
if errors.As(err, &owned) {
|
||
message = owned.message
|
||
}
|
||
return http.StatusBadRequest, ErrorBody{Code: CodeBadRequest, Message: message}
|
||
|
||
case errors.Is(err, contract.ErrRecordUnreadable):
|
||
return http.StatusBadRequest, ErrorBody{
|
||
Code: CodeBadRequest,
|
||
Message: "Не удалось прочитать запись: формат не распознан или файл повреждён",
|
||
}
|
||
|
||
case errors.Is(err, contract.ErrRecordTooLarge):
|
||
limit := entity.MaxRecordSize
|
||
return http.StatusRequestEntityTooLarge, ErrorBody{
|
||
Code: CodeTooLarge,
|
||
Message: "Запись больше допустимого размера",
|
||
Limit: &limit,
|
||
}
|
||
|
||
case errors.Is(err, contract.ErrTextNotReady):
|
||
return http.StatusConflict, ErrorBody{
|
||
Code: CodeNotReady,
|
||
Message: "Текст этого вида для записи ещё не готов",
|
||
}
|
||
|
||
case errors.Is(err, contract.ErrNotFound):
|
||
message := "Адрес не найден"
|
||
var owned *messagedError
|
||
if errors.As(err, &owned) {
|
||
message = owned.message
|
||
}
|
||
return http.StatusNotFound, ErrorBody{Code: CodeNotFound, Message: message}
|
||
|
||
case errors.Is(err, contract.ErrUnauthorized):
|
||
// Не «требуется вход»: своего входа у сервиса нет, и уводить человека
|
||
// некуда. Сообщение называет то, что произошло на самом деле, — сервис
|
||
// не узнал пришедшего, — и приложение показывает его как есть, своего
|
||
// словаря текстов под коды ответа не заводя.
|
||
return http.StatusUnauthorized, ErrorBody{
|
||
Code: CodeUnauthorized,
|
||
Message: "Сервис вас не узнал",
|
||
}
|
||
|
||
case errors.Is(err, contract.ErrOwnerRequired):
|
||
return http.StatusForbidden, ErrorBody{
|
||
Code: CodeForbidden,
|
||
Message: "У предъявителя нет учётной записи пользователя",
|
||
}
|
||
}
|
||
|
||
// Чужая запись, ничья и несуществующая отвечают одним и тем же: по разнице
|
||
// ответов иначе перебирается список заведённых записей.
|
||
var notFound *contract.JobNotFoundError
|
||
if errors.As(err, ¬Found) {
|
||
return http.StatusNotFound, ErrorBody{
|
||
Code: CodeNotFound,
|
||
Message: "Запись не найдена",
|
||
}
|
||
}
|
||
|
||
return http.StatusInternalServerError, ErrorBody{
|
||
Code: CodeInternal,
|
||
Message: "Внутренняя ошибка сервиса",
|
||
}
|
||
}
|
||
|
||
// fail отвечает отказом по доменной ошибке — единственный способ, которым отказ
|
||
// уходит наружу с адресов приложения.
|
||
func fail(e *core.RequestEvent, err error) error {
|
||
status, body := mapDomainError(err)
|
||
return e.JSON(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)
|
||
},
|
||
}
|
||
}
|
||
|
||
// 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: "Внутренняя ошибка сервиса",
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Узнан он всё же узнан, а учётной записи пользователя у него нет:
|
||
// код здесь другой не по оплошности. `401` значит «предъяви себя», а
|
||
// предъявитель себя предъявил.
|
||
if e.Auth.Collection().Name != usersCollection {
|
||
return fail(e, contract.ErrOwnerRequired)
|
||
}
|
||
|
||
return e.Next()
|
||
},
|
||
}
|
||
}
|