- Moderationsseite und Melde-Funktion (report) komplett ausgebaut; folgt als eigenständiges Projekt - alle API-Endpunkte unter /api, dort ausschließlich JSON: auch 404, 405, Rate-Limit (429) und Panics (500) - Fehler nur über HTTP-Status; stille DB-Fehler in stats, logout und Vote-Zählern liefern jetzt 500 statt Nullen - /auth/headerbar entfernt (Frontend nutzt /api/user/info) - Frontend auf /api und statusbasierte Auswertung umgestellt - notes/api.md neu als Referenz der aktuellen API Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
171 lines
6.3 KiB
Go
171 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
)
|
|
|
|
// Fehler-Taxonomie: trennt die drei Informationen, die früher an jeder
|
|
// Fehler-Aufrufstelle verklebt waren -- wie schwer der Fehler ist (Kind),
|
|
// was genau passiert ist (Code) und was der Nutzer lesen soll (Msg).
|
|
//
|
|
// Der Sinn der Trennung: das Frontend zeigt Msg an und markiert Field, ein
|
|
// Client braucht Code, um ohne Parsen deutscher Strings reagieren zu können,
|
|
// und der HTTP-Status ergibt sich allein aus Kind.
|
|
|
|
type Kind uint8
|
|
|
|
const (
|
|
KindInternal Kind = iota // Bug oder Infrastruktur -- Ursache nie nach außen
|
|
KindInvalid // Eingabe passt nicht
|
|
KindUnauth // nicht angemeldet
|
|
KindForbidden // angemeldet, aber nicht erlaubt
|
|
KindNotFound // Objekt existiert nicht (oder soll nicht sichtbar sein)
|
|
KindConflict // Name vergeben, Doppel-Post
|
|
KindTooLarge // Upload über dem Limit
|
|
KindRateLimited // zu viele Anfragen
|
|
KindMethodNotAllowed // Route existiert, aber nicht mit dieser Methode
|
|
)
|
|
|
|
// Error ist der Fehlertyp, den die Service-Schicht nach oben gibt. Kind ist
|
|
// bewusst kein HTTP-Status: die Zuordnung passiert erst im Adapter, damit ein
|
|
// künftiger CLI- oder App-Client dieselben Fehler auf eigene Weise abbilden
|
|
// kann (Exit-Code, Dialog, Retry).
|
|
type Error struct {
|
|
Kind Kind
|
|
Code string // stabil und maschinenlesbar, Schema "<bereich>.<sache>"
|
|
Msg string // deutsche Nutzermeldung
|
|
Field string // optional: betroffenes Formularfeld
|
|
Fields map[string]string // optional: mehrere Feldfehler auf einmal
|
|
cause error // nur fürs Log, nie für den Nutzer
|
|
}
|
|
|
|
func (e *Error) Error() string { return e.Code + ": " + e.Msg }
|
|
|
|
// Unwrap gibt die eingepackte Ursache frei, damit errors.Is/As über den
|
|
// Taxonomie-Fehler hinweg bis zum ursprünglichen Fehler (etwa sql.ErrNoRows)
|
|
// durchsuchen kann.
|
|
func (e *Error) Unwrap() error { return e.cause }
|
|
|
|
func Invalid(code, msg string) *Error { return &Error{Kind: KindInvalid, Code: code, Msg: msg} }
|
|
func Unauth(code, msg string) *Error { return &Error{Kind: KindUnauth, Code: code, Msg: msg} }
|
|
func Forbidden(code, msg string) *Error { return &Error{Kind: KindForbidden, Code: code, Msg: msg} }
|
|
func NotFound(code, msg string) *Error { return &Error{Kind: KindNotFound, Code: code, Msg: msg} }
|
|
func Conflict(code, msg string) *Error { return &Error{Kind: KindConflict, Code: code, Msg: msg} }
|
|
func TooLarge(code, msg string) *Error { return &Error{Kind: KindTooLarge, Code: code, Msg: msg} }
|
|
func RateLimited(code, msg string) *Error { return &Error{Kind: KindRateLimited, Code: code, Msg: msg} }
|
|
func MethodNotAllowed(code, msg string) *Error {
|
|
return &Error{Kind: KindMethodNotAllowed, Code: code, Msg: msg}
|
|
}
|
|
|
|
// Internal verschluckt die Ursache absichtlich: der Nutzer sieht nie einen
|
|
// DB- oder Dateisystemfehler (der Interna und Pfade verraten würde), das Log
|
|
// sieht ihn immer. Deshalb hat Internal auch keine freie Msg -- eine
|
|
// nutzerseitige Unterscheidung zwischen "Datenbankfehler" und "Speichern
|
|
// fehlgeschlagen" ist bedeutungslos.
|
|
func Internal(cause error) *Error {
|
|
return &Error{
|
|
Kind: KindInternal,
|
|
Code: "internal",
|
|
Msg: "Es ist ein Fehler aufgetreten.",
|
|
cause: cause,
|
|
}
|
|
}
|
|
|
|
// At markiert das Formularfeld, an dem der Fehler hängt. Verkettbar, damit die
|
|
// Aufrufstelle einzeilig bleibt: return Invalid(...).At("content")
|
|
//
|
|
// Achtung: At und WithCause verändern den Empfänger. Auf einen frisch
|
|
// konstruierten Fehler anwenden, NICHT auf ein geteiltes Sentinel wie
|
|
// errEntryNotFound -- das würde den globalen Wert für alle Requests umschreiben.
|
|
func (e *Error) At(field string) *Error {
|
|
e.Field = field
|
|
return e
|
|
}
|
|
|
|
// WithCause hängt eine technische Ursache an einen fachlichen Fehler. Nützlich,
|
|
// wenn ein DB-Fehler fachlich eindeutig ist (UNIQUE-Verletzung -> Conflict),
|
|
// die Ursache aber trotzdem ins Log soll.
|
|
func (e *Error) WithCause(cause error) *Error {
|
|
e.cause = cause
|
|
return e
|
|
}
|
|
|
|
// asError normalisiert alles, was kein *Error ist, zu einem Internal. Damit
|
|
// müssen die Adapter keinen Sonderfall für "nackte" Fehler kennen, die aus
|
|
// Bibliotheken oder noch nicht umgestellten Stellen kommen.
|
|
func asError(err error) *Error {
|
|
var e *Error
|
|
if errors.As(err, &e) {
|
|
return e
|
|
}
|
|
return Internal(err)
|
|
}
|
|
|
|
// statusOf bildet Kind auf HTTP ab -- die einzige Stelle, an der das passiert.
|
|
func statusOf(k Kind) int {
|
|
switch k {
|
|
case KindInvalid:
|
|
return http.StatusBadRequest
|
|
case KindUnauth:
|
|
return http.StatusUnauthorized
|
|
case KindForbidden:
|
|
return http.StatusForbidden
|
|
case KindNotFound:
|
|
return http.StatusNotFound
|
|
case KindConflict:
|
|
return http.StatusConflict
|
|
case KindTooLarge:
|
|
return http.StatusRequestEntityTooLarge
|
|
case KindRateLimited:
|
|
return http.StatusTooManyRequests
|
|
case KindMethodNotAllowed:
|
|
return http.StatusMethodNotAllowed
|
|
default:
|
|
return http.StatusInternalServerError
|
|
}
|
|
}
|
|
|
|
// logInternal protokolliert nur echte Serverfehler -- Validierungs- und
|
|
// Rechtefehler sind normaler Betrieb und würden das Log zumüllen.
|
|
func logInternal(e *Error) {
|
|
if e.Kind != KindInternal {
|
|
return
|
|
}
|
|
// Die Ursache ist das Einzige, was hier wirklich interessiert; fehlt sie,
|
|
// bleibt wenigstens die Meldung.
|
|
if e.cause != nil {
|
|
log.Printf("internal: %v", e.cause)
|
|
} else {
|
|
log.Printf("internal: %s", e.Msg)
|
|
}
|
|
}
|
|
|
|
// apiErrorBody ist das Drahtformat des JSON-Adapters. Eigener Typ statt einer
|
|
// Map, damit die Feldnamen an genau einer Stelle stehen -- sie sind Teil des
|
|
// API-Vertrags, sobald ein externer Client existiert.
|
|
type apiErrorBody struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Field string `json:"field,omitempty"`
|
|
Fields map[string]string `json:"fields,omitempty"`
|
|
}
|
|
|
|
// writeAPIError ist der einzige Ausgang für Fehler im JSON-Adapter. Hier -- an
|
|
// der Adaptergrenze -- wird auch geloggt, und zwar nur einmal: die
|
|
// Service-Schicht loggt nicht, sonst steht derselbe Fehler mehrfach im Log.
|
|
func writeAPIError(w http.ResponseWriter, err error) {
|
|
e := asError(err)
|
|
logInternal(e)
|
|
|
|
writeJSON(w, statusOf(e.Kind), map[string]any{
|
|
"error": apiErrorBody{
|
|
Code: e.Code,
|
|
Message: e.Msg,
|
|
Field: e.Field,
|
|
Fields: e.Fields,
|
|
},
|
|
})
|
|
}
|