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` ещё пуст, и всякий запрос получал бы отказ. Priority: apis.DefaultLoadAuthTokenMiddlewarePriority + 1, 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() }, } }