package server import ( "encoding/json" "errors" "log/slog" "net/http" "glchat/internal/auth" "glchat/internal/httpx" "glchat/internal/permissions" "glchat/internal/store" ) // apiError — доменная ошибка с кодом для клиента (AGENT.md 8.5). type apiError struct { Status int `json:"-"` Code string `json:"code"` Message string `json:"message"` cause error } func (e apiError) Error() string { return e.Code + ": " + e.Message } func (e apiError) Unwrap() error { return e.cause } // newAPIError подбирает код и статус по доменной ошибке. func newAPIError(err error) apiError { candidate := apiError{Status: http.StatusInternalServerError, Code: "internal.error", Message: "internal error", cause: err} switch { case errors.Is(err, auth.ErrInvalidCredentials): candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.invalid_credentials", "invalid credentials" case errors.Is(err, auth.ErrTOTPRequired): candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.2fa_required", "two-factor code required" case errors.Is(err, auth.ErrTOTPInvalid): candidate.Status, candidate.Code, candidate.Message = http.StatusBadRequest, "auth.totp_invalid", "invalid two-factor code" case errors.Is(err, auth.ErrInstanceAdminTOTP): candidate.Status, candidate.Code = http.StatusForbidden, "auth.2fa_enrollment_required" candidate.Message = "instance administrator must enable two-factor authentication: run `glchat totp-setup --email ` on the server" case errors.Is(err, auth.ErrUserBanned): candidate.Status, candidate.Code = http.StatusForbidden, "user.banned" candidate.Message = "account is banned on this instance" case errors.Is(err, auth.ErrSessionExpired): candidate.Status, candidate.Code, candidate.Message = http.StatusUnauthorized, "auth.session_expired", "session expired" case errors.Is(err, auth.ErrStepUpRequired): candidate.Status, candidate.Code = http.StatusForbidden, "auth.step_up_required" candidate.Message = "step-up authentication required" case errors.Is(err, auth.ErrUsernameTaken): candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.username_taken", "username is already taken" case errors.Is(err, auth.ErrEmailTaken): candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "auth.email_taken", "email is already registered" case errors.Is(err, auth.ErrRegistrationOff): candidate.Status, candidate.Code = http.StatusForbidden, "auth.registration_disabled" candidate.Message = "registration is disabled" case errors.Is(err, auth.ErrWeakPassword): candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.weak_password", err.Error() case errors.Is(err, auth.ErrInvalidUsername): candidate.Status, candidate.Code, candidate.Message = http.StatusUnprocessableEntity, "auth.invalid_username", err.Error() case errors.Is(err, auth.ErrTOTPAlreadyEnabled): candidate.Status, candidate.Code = http.StatusConflict, "auth.2fa_already_enabled" candidate.Message = "two-factor authentication is already enabled" case errors.Is(err, auth.ErrNoTOTPSecret): candidate.Status, candidate.Code = http.StatusBadRequest, "auth.2fa_not_configured" candidate.Message = "two-factor authentication is not configured" // Passkeys (WebAuthn), Фаза 7: клиент различает «не поддерживается», // «церемония истекла» и «подпись не сошлась» (AGENT.md 8.5). case errors.Is(err, auth.ErrPasskeysDisabled): candidate.Status, candidate.Code = http.StatusNotImplemented, "auth.passkey_unsupported" candidate.Message = "passkeys are not available on this instance" case errors.Is(err, auth.ErrPasskeyCeremony): candidate.Status, candidate.Code = http.StatusBadRequest, "auth.passkey_challenge_invalid" candidate.Message = "passkey challenge is unknown, expired or already used" case errors.Is(err, auth.ErrPasskeyVerification): candidate.Status, candidate.Code = http.StatusUnauthorized, "auth.passkey_verification_failed" candidate.Message = "passkey verification failed" case errors.Is(err, auth.ErrPasskeyUnknown): candidate.Status, candidate.Code = http.StatusNotFound, "auth.passkey_unknown" candidate.Message = "passkey is not registered for this account" case errors.Is(err, auth.ErrPasskeyNameInvalid): candidate.Status, candidate.Code = http.StatusUnprocessableEntity, "auth.passkey_name_invalid" candidate.Message = "passkey name must be 1-64 characters" case errors.Is(err, auth.ErrPasskeyLimit): candidate.Status, candidate.Code = http.StatusConflict, "auth.passkey_limit" candidate.Message = "passkey limit reached for this account" case errors.Is(err, store.ErrNotFound): candidate.Status, candidate.Code, candidate.Message = http.StatusNotFound, "not_found", "resource not found" case errors.Is(err, store.ErrConflict): candidate.Status, candidate.Code, candidate.Message = http.StatusConflict, "conflict", "resource already exists" case errors.Is(err, permissions.ErrDenied): candidate.Status, candidate.Code, candidate.Message = http.StatusForbidden, "perm.denied", "permission denied" } return candidate } // writeAPIError отдаёт ошибку в едином формате с машиночитаемым кодом. func writeAPIError(w http.ResponseWriter, err error) { apiErr := newAPIError(err) if apiErr.Status >= http.StatusInternalServerError { // Неожиданную ошибку пишем в лог: клиент видит «internal error», а // разобраться без текста ошибки невозможно (AGENT.md 9.4). slog.Error("unexpected API error", slog.String("code", apiErr.Code), slog.Any("error", err)) apiErr.Message = "internal error" } httpx.WriteJSON(w, apiErr.Status, map[string]any{ "error": map[string]any{ "code": apiErr.Code, "message": apiErr.Message, }, }) } // writeHumaAPIError отдаёт ошибку chi-ручки: huma-ошибки уже несут код и // статус, доменные ошибки переводит writeAPIError (AGENT.md 8.5). func writeHumaAPIError(w http.ResponseWriter, err error) { var apiErr *humaAPIError if errors.As(err, &apiErr) { httpx.WriteJSON(w, apiErr.status, map[string]any{ "error": map[string]any{"code": apiErr.code, "message": apiErr.message}, }) return } writeAPIError(w, err) } // writeJSON пишет успешный ответ (все текущие ручки возвращают 200). func writeJSON(w http.ResponseWriter, body any) { httpx.WriteJSON(w, http.StatusOK, body) } // decodeBody читает JSON-тело с ограничением размера. func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool { if r.Body == nil { writeAPIError(w, errors.New("empty request body")) return false } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) decoder.DisallowUnknownFields() if err := decoder.Decode(dst); err != nil { httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": map[string]any{"code": "request.bad", "message": "malformed json body"}, }) return false } return true }