Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6, 5.1–5.4 (Details in AUSBAUSTUFEN.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
523 lines
18 KiB
Go
523 lines
18 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"munin/internal/auth"
|
|
"munin/internal/store"
|
|
"munin/internal/workspace"
|
|
)
|
|
|
|
// requireSession prüft den Session-Cookie und legt die Sitzung im Context ab.
|
|
//
|
|
// Reihenfolge der Prüfungen:
|
|
//
|
|
// 1. Signatur und Ablaufdatum des Cookies
|
|
// 2. Gibt es den zugehörigen Key noch, und ist er nicht zurückgezogen?
|
|
// 3. Bei schreibenden Requests: stimmt das CSRF-Token?
|
|
//
|
|
// Schritt 2 ist der Grund, warum ein zurückgezogener Key sofort wirkt: Die
|
|
// Sitzung trägt nur die Key-ID, die Gültigkeit wird bei jedem Request frisch in
|
|
// der Datenbank nachgesehen.
|
|
func (s *Server) requireSession(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie(auth.SessionCookieName)
|
|
if err != nil {
|
|
s.redirectToLogin(w, r)
|
|
return
|
|
}
|
|
|
|
subject, ok := s.signer.VerifySession(cookie.Value)
|
|
if !ok {
|
|
s.redirectToLogin(w, r)
|
|
return
|
|
}
|
|
|
|
sess, ok := s.sessionFromSubject(r, subject)
|
|
if !ok {
|
|
s.redirectToLogin(w, r)
|
|
return
|
|
}
|
|
|
|
if !s.checkCSRF(w, r, subject) {
|
|
return
|
|
}
|
|
|
|
ctx := withSession(r.Context(), sess)
|
|
|
|
// Ohne gewählten Workspace (Plattform-Admin vor der Workspace-Auswahl)
|
|
// gibt es nichts zu öffnen — solche Sitzungen dürfen ausschließlich die
|
|
// Workspace-Auswahl aufrufen, siehe requireWorkspacePicked.
|
|
if sess.WorkspaceID != "" {
|
|
ws, err := s.workspaces.Get(sess.WorkspaceID)
|
|
if err != nil {
|
|
s.serverError(w, "Workspace konnte nicht geöffnet werden", err)
|
|
return
|
|
}
|
|
ctx = withWorkspace(ctx, ws)
|
|
}
|
|
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// sessionFromSubject baut aus dem Session-Subject die vollständige Sitzung.
|
|
//
|
|
// Ein Subject hat eine von drei Formen:
|
|
//
|
|
// - "platform" — Plattform-Admin, noch kein Workspace gewählt.
|
|
// - "platform:<workspace-id>" — Plattform-Admin, mit gewähltem Workspace.
|
|
// - "key:<workspace-id>:<key-id>" — Projekt-Zugang, immer an genau einen
|
|
// Workspace gebunden (den seines Keys).
|
|
//
|
|
// Die Workspace-ID steht direkt im (signierten, also fälschungssicheren)
|
|
// Subject — so lässt sich jede Anfrage ohne Umweg über die Kontroll-Datenbank
|
|
// der richtigen Workspace-Datei zuordnen.
|
|
func (s *Server) sessionFromSubject(r *http.Request, subject string) (Session, bool) {
|
|
if subject == auth.SubjectPlatformAdmin {
|
|
return Session{
|
|
Subject: subject,
|
|
IsPlatformAdmin: true,
|
|
Role: auth.RoleAdmin,
|
|
}, true
|
|
}
|
|
|
|
if rest, ok := strings.CutPrefix(subject, auth.SubjectPlatformAdmin+":"); ok {
|
|
ws, err := s.control.WorkspaceByID(r.Context(), rest)
|
|
if err != nil {
|
|
return Session{}, false
|
|
}
|
|
return Session{
|
|
Subject: subject,
|
|
IsPlatformAdmin: true,
|
|
WorkspaceID: rest,
|
|
WorkspaceName: ws.Name,
|
|
Role: auth.RoleAdmin,
|
|
}, true
|
|
}
|
|
|
|
rest, ok := auth.KeyIDFromSubject(subject)
|
|
if !ok {
|
|
return Session{}, false
|
|
}
|
|
workspaceID, keyID, ok := strings.Cut(rest, ":")
|
|
if !ok {
|
|
return Session{}, false
|
|
}
|
|
|
|
ws, err := s.workspaces.Get(workspaceID)
|
|
if err != nil {
|
|
return Session{}, false
|
|
}
|
|
|
|
resolved, err := ws.Store.APIKeyByID(r.Context(), keyID)
|
|
if err != nil || resolved.Revoked() {
|
|
return Session{}, false
|
|
}
|
|
|
|
project, err := ws.Store.ProjectByID(r.Context(), resolved.ProjectID)
|
|
if err != nil {
|
|
return Session{}, false
|
|
}
|
|
|
|
return Session{
|
|
Subject: subject,
|
|
WorkspaceID: workspaceID,
|
|
Key: resolved,
|
|
Project: project,
|
|
Role: auth.Role(resolved.Role),
|
|
}, true
|
|
}
|
|
|
|
// checkCSRF prüft bei schreibenden Requests das CSRF-Token.
|
|
//
|
|
// GET, HEAD und OPTIONS gelten als sichere Methoden — sie sollen nichts
|
|
// verändern und brauchen kein Token. Alles andere muss eines mitschicken.
|
|
func (s *Server) checkCSRF(w http.ResponseWriter, r *http.Request, subject string) bool {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
return true
|
|
}
|
|
|
|
if !s.signer.ValidCSRFToken(auth.CSRFTokenFromRequest(r), subject) {
|
|
http.Error(w, "Ungültiges oder fehlendes CSRF-Token — bitte die Seite neu laden und erneut versuchen.",
|
|
http.StatusForbidden)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// redirectToLogin schickt den Browser zum Anmeldeformular.
|
|
//
|
|
// Bei einem schreibenden Request wäre eine Weiterleitung irreführend: Der Browser
|
|
// würde das POST als GET wiederholen und der Nutzer stünde ohne Erklärung vor dem
|
|
// Formular. Deshalb dort ein klarer Statuscode.
|
|
func (s *Server) redirectToLogin(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead:
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
default:
|
|
http.Error(w, "Sitzung abgelaufen — bitte neu anmelden.", http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
// requireProject löst den Projekt-Bezeichner aus der URL auf und prüft den Zugriff.
|
|
//
|
|
// Alle Projektseiten liegen unter /p/{project}/… — dieser eine Ort entscheidet
|
|
// damit für sämtliche Projektrouten, ob jemand hineindarf.
|
|
func (s *Server) requireProject(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := SessionFrom(r)
|
|
if !ok {
|
|
s.redirectToLogin(w, r)
|
|
return
|
|
}
|
|
|
|
ws, ok := WorkspaceFrom(r)
|
|
if !ok {
|
|
// Plattform-Admin ohne gewählten Workspace: Projektseiten gibt es
|
|
// erst, nachdem einer gewählt wurde.
|
|
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
slug := r.PathValue("project")
|
|
project, err := ws.Store.ProjectBySlug(r.Context(), sess.WorkspaceID, slug)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.serverError(w, "Projekt konnte nicht geladen werden", err)
|
|
return
|
|
}
|
|
|
|
// Ein fremdes Projekt wird als "nicht vorhanden" beantwortet, nicht als
|
|
// "verboten". Sonst ließe sich über die Statuscodes herausfinden, welche
|
|
// Projekte es sonst noch gibt.
|
|
if !sess.CanAccessProject(project.ID) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r.WithContext(withProject(r.Context(), project)))
|
|
})
|
|
}
|
|
|
|
// requireWorkspace lässt nur Sitzungen mit gewähltem Workspace durch.
|
|
//
|
|
// Betrifft nur den Plattform-Admin: Eine Projekt-Sitzung hat ihren Workspace
|
|
// immer schon über ihren Key (siehe sessionFromSubject). Der Plattform-Admin
|
|
// dagegen kann sich frisch anmelden, ohne dass klar ist, welche Abteilung er
|
|
// verwalten will — dafür ist /admin/workspaces da.
|
|
func (s *Server) requireWorkspace(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := WorkspaceFrom(r); !ok {
|
|
http.Redirect(w, r, "/admin/workspaces", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requirePlatformAdmin lässt nur den Plattform-Admin durch.
|
|
func (s *Server) requirePlatformAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := SessionFrom(r)
|
|
if !ok || !sess.IsPlatformAdmin {
|
|
http.Error(w, "Nur für den Plattform-Admin verfügbar", http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requireWrite lässt nur Sitzungen durch, die Einträge verändern dürfen.
|
|
func (s *Server) requireWrite(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := SessionFrom(r)
|
|
if !ok || !sess.CanWrite() {
|
|
http.Error(w, "Dieser Zugang darf nur lesen.", http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// requireKeyManagement lässt nur Sitzungen durch, die Keys verwalten dürfen.
|
|
func (s *Server) requireKeyManagement(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
sess, ok := SessionFrom(r)
|
|
if !ok || !sess.CanManageKeys() {
|
|
http.Error(w, "Für die Key-Verwaltung wird die Rolle \"admin\" benötigt.", http.StatusForbidden)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// ── Allgemeine Schutzschichten ───────────────────────────────────────────────
|
|
|
|
// SecurityHeaders setzt bei jeder Antwort die Schutz-Header des Browsers.
|
|
//
|
|
// Diese Header sind Anweisungen an den Browser: Der Server kann damit Angriffe
|
|
// entschärfen, die er selbst gar nicht sieht, weil sie erst im Browser des
|
|
// Besuchers stattfinden.
|
|
func SecurityHeaders(next http.Handler, enableHSTS bool) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
|
|
// Die Content-Security-Policy ist der wichtigste dieser Header: Sie legt
|
|
// fest, woher der Browser überhaupt etwas laden und ausführen darf.
|
|
//
|
|
// Für uns ist sie die zweite Verteidigungslinie gegen eingeschleustes
|
|
// JavaScript. Die erste ist, dass goldmark kein Roh-HTML durchlässt
|
|
// (siehe internal/entry). Sollte das je gelockert werden, verhindert
|
|
// diese Regel trotzdem die Ausführung.
|
|
//
|
|
// default-src 'self' alles nur von der eigenen Adresse
|
|
// img-src … data: https: Bilder dürfen auch extern eingebunden werden
|
|
// object-src 'none' keine Plugin-Einbettungen
|
|
// base-uri 'none' verbietet <base href>, das sonst alle
|
|
// relativen Links der Seite umbiegen könnte
|
|
// form-action 'self' Formulare können nur an uns selbst senden
|
|
// frame-ancestors 'none' die Seite darf in keinem fremden Rahmen
|
|
// stecken (Schutz vor Clickjacking)
|
|
h.Set("Content-Security-Policy",
|
|
"default-src 'self'; img-src 'self' data: https:; style-src 'self'; "+
|
|
"script-src 'self'; object-src 'none'; base-uri 'none'; "+
|
|
"form-action 'self'; frame-ancestors 'none'")
|
|
|
|
// Verbietet dem Browser, den Inhaltstyp zu erraten. Ohne diesen Header
|
|
// könnte eine als Text ausgelieferte Datei, die zufällig wie HTML
|
|
// aussieht, als HTML ausgeführt werden.
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
|
|
// Schutz vor Clickjacking für Browser, die frame-ancestors noch nicht
|
|
// kennen. Doppelt hält besser und kostet nichts.
|
|
h.Set("X-Frame-Options", "DENY")
|
|
|
|
// Beim Klick auf einen externen Link soll die vollständige interne URL
|
|
// nicht als Herkunft mitgehen — Projekt- und Eintragsnamen können
|
|
// verraten, woran gearbeitet wird.
|
|
h.Set("Referrer-Policy", "same-origin")
|
|
|
|
// Der Browser soll gar nicht erst nach Kamera, Mikrofon und Standort fragen.
|
|
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
|
|
// HSTS weist den Browser an, diese Adresse künftig nur noch über HTTPS
|
|
// aufzurufen. Der Header darf nur gesetzt werden, wenn HTTPS wirklich
|
|
// funktioniert: Ein Browser, der ihn einmal gesehen hat, weigert sich für
|
|
// die angegebene Dauer, die Seite über http zu laden — auf einer Adresse
|
|
// ohne Zertifikat sperrt man sich damit selbst aus.
|
|
if enableHSTS {
|
|
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// LimitBody begrenzt, wie viele Bytes ein Request-Body haben darf.
|
|
//
|
|
// Ohne Begrenzung kann ein einzelner Aufruf beliebig viel Speicher belegen. Das
|
|
// muss nicht einmal böswillig sein — ein Agent in einer Schleife oder eine
|
|
// versehentlich falsche Datei reichen aus.
|
|
//
|
|
// http.MaxBytesReader ist genau dafür gebaut: Er ersetzt den Body durch eine
|
|
// Variante, die nach maxBytes einen Fehler liefert statt weiterzulesen.
|
|
func LimitBody(next http.Handler, maxBytes int64) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Body != nil {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// ── API-Zugang (MCP) ─────────────────────────────────────────────────────────
|
|
|
|
// APIAuth prüft den Bearer-Key von MCP- und API-Aufrufen und legt die Sitzung im
|
|
// Context ab.
|
|
//
|
|
// Wichtig ist die Reihenfolge: erst den Key prüfen, dann die IP-Sperre.
|
|
// Andersherum wäre naheliegender, hätte aber einen unangenehmen Nebeneffekt —
|
|
// Deploy-Skript, MCP-Client und Browser eines Büros teilen sich nach außen oft
|
|
// eine Adresse, und ein einziger falsch konfigurierter Client würde alle anderen
|
|
// mitsperren. Gegen das Durchprobieren hilft die Reihenfolge trotzdem: Ein
|
|
// Angreifer hat per Definition keinen gültigen Key und landet im unteren Zweig.
|
|
func (s *Server) APIAuth(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ip := auth.ClientIP(r)
|
|
|
|
key := ""
|
|
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
|
|
key = strings.TrimPrefix(header, "Bearer ")
|
|
}
|
|
|
|
if auth.LooksLikeKey(key) {
|
|
hash := auth.HashKey(key)
|
|
|
|
// Erster Schritt: welcher Workspace? Erst danach lässt sich die
|
|
// richtige Datei überhaupt ansprechen (siehe ControlStore in
|
|
// internal/store/control.go und AUSBAUSTUFEN.md 4.4).
|
|
workspaceID, err := s.control.ResolveKeyWorkspace(r.Context(), hash)
|
|
if err == nil {
|
|
var ws *workspace.Workspace
|
|
ws, err = s.workspaces.Get(workspaceID)
|
|
if err == nil {
|
|
var resolved store.ResolvedKey
|
|
resolved, err = ws.Store.ResolveKeyHash(r.Context(), hash)
|
|
if err == nil {
|
|
s.limiter.Reset(ip)
|
|
|
|
// Nebenbei vermerken, dass der Key benutzt wurde — damit man
|
|
// in der Verwaltung sieht, welche Keys noch aktiv sind. Ein
|
|
// Fehler dabei ist unerheblich und darf den Request nicht
|
|
// scheitern lassen.
|
|
_ = ws.Store.TouchAPIKey(r.Context(), resolved.Key.ID, resolved.Key.LastUsedAt)
|
|
|
|
sess := Session{
|
|
Subject: auth.SubjectKeyPrefix + workspaceID + ":" + resolved.Key.ID,
|
|
WorkspaceID: workspaceID,
|
|
Key: resolved.Key,
|
|
Project: resolved.Project,
|
|
Role: auth.Role(resolved.Key.Role),
|
|
}
|
|
|
|
ctx := withSession(r.Context(), sess)
|
|
ctx = withProject(ctx, resolved.Project)
|
|
ctx = withWorkspace(ctx, ws)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if allowed, retryAfter := s.limiter.Allow(ip); !allowed {
|
|
sekunden := int(retryAfter.Seconds()) + 1
|
|
w.Header().Set("Retry-After", strconv.Itoa(sekunden))
|
|
http.Error(w, fmt.Sprintf("Zu viele fehlgeschlagene Versuche. Bitte in %d Sekunden erneut versuchen.", sekunden),
|
|
http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
s.limiter.RecordFailure(ip)
|
|
|
|
// Ein ungültiger Key lässt sich keinem Workspace zuordnen — es gibt
|
|
// also keine passende Datei für einen Audit-Eintrag. Das Protokoll
|
|
// verzeichnet nur Vorgänge innerhalb eines Workspace; ein anonymer
|
|
// Fehlversuch gehört ins allgemeine Server-Log.
|
|
log.Printf("Ungültiger API-Key von %s", ip)
|
|
|
|
http.Error(w, "Ungültiger oder fehlender API-Key", http.StatusUnauthorized)
|
|
})
|
|
}
|
|
|
|
// ── Zugriffsprotokoll ────────────────────────────────────────────────────────
|
|
|
|
// statusSchreiber merkt sich den Statuscode einer Antwort.
|
|
//
|
|
// http.ResponseWriter verrät ihn nicht — man kann ihn setzen, aber nicht lesen.
|
|
// Deshalb dieser dünne Mantel, der WriteHeader abfängt.
|
|
type statusSchreiber struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
}
|
|
|
|
func (w *statusSchreiber) WriteHeader(code int) {
|
|
w.status = code
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *statusSchreiber) Write(b []byte) (int, error) {
|
|
// Wer direkt schreibt, ohne WriteHeader aufzurufen, erzeugt implizit ein 200.
|
|
if w.status == 0 {
|
|
w.status = http.StatusOK
|
|
}
|
|
n, err := w.ResponseWriter.Write(b)
|
|
w.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
// Unwrap gibt den darunterliegenden ResponseWriter zurück.
|
|
//
|
|
// http.ResponseController nutzt diese Methode, um an Fähigkeiten des
|
|
// eingewickelten Schreibers heranzukommen. Der MCP-Transport geht diesen Weg für
|
|
// seine gestreamten Antworten — ohne Unwrap bliebe dort alles im Puffer hängen.
|
|
func (w *statusSchreiber) Unwrap() http.ResponseWriter {
|
|
return w.ResponseWriter
|
|
}
|
|
|
|
// Flush reicht das Leeren des Puffers durch.
|
|
//
|
|
// Unwrap deckt nur Aufrufer ab, die http.ResponseController verwenden. Die
|
|
// ältere und weiter verbreitete Schreibweise ist eine direkte Typzusicherung:
|
|
//
|
|
// if f, ok := w.(http.Flusher); ok { f.Flush() }
|
|
//
|
|
// Die schlägt bei einem eingewickelten Schreiber fehl, wenn er die Methode nicht
|
|
// selbst anbietet — und zwar lautlos: Der Aufrufer nimmt an, dass Leeren eben
|
|
// nicht unterstützt wird, und schreibt weiter in den Puffer. Solche Fehler
|
|
// bemerkt man erst im Betrieb, deshalb hier ausdrücklich.
|
|
func (w *statusSchreiber) Flush() {
|
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
// LogRequests schreibt zu jeder Anfrage eine Zeile ins Serverlog.
|
|
//
|
|
// # Warum das nötig ist
|
|
//
|
|
// Das Audit-Log hält fest, wer etwas geändert hat — es sagt aber nichts über
|
|
// Lesezugriffe, Fehlerantworten oder Antwortzeiten. Ohne Zugriffsprotokoll sieht
|
|
// man weder, dass ein Agent in einer Schleife hängt, noch dass eine Seite
|
|
// langsam geworden ist, noch dass jemand Pfade abklappert.
|
|
//
|
|
// # Was bewusst nicht protokolliert wird
|
|
//
|
|
// - Die Query-Zeichenkette. Dort stehen Suchbegriffe, und die sind Inhalt,
|
|
// kein Betriebsdatum. Der Pfad allein reicht, um zu sehen, was aufgerufen wurde.
|
|
// - Statische Dateien und der Healthcheck. Beides erzeugt nur Rauschen; der
|
|
// Healthcheck läuft alle 30 Sekunden.
|
|
func LogRequests(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/static/") || r.URL.Path == "/healthz" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
schreiber := &statusSchreiber{ResponseWriter: w}
|
|
|
|
// Halter für den Namen des Zugangs mitgeben. Die Anmelde-Middleware
|
|
// weiter innen trägt ihn ein — siehe aktorHalter in context.go.
|
|
halter := &aktorHalter{}
|
|
r = r.WithContext(withAktorHalter(r.Context(), halter))
|
|
|
|
next.ServeHTTP(schreiber, r)
|
|
|
|
wer := halter.name
|
|
if wer == "" {
|
|
wer = "—" // nicht angemeldet oder abgewiesen
|
|
}
|
|
|
|
status := schreiber.status
|
|
if status == 0 {
|
|
status = http.StatusOK // Handler hat nichts geschrieben
|
|
}
|
|
|
|
log.Printf("%d %-6s %-44s %8s %s",
|
|
status, r.Method, r.URL.Path,
|
|
time.Since(start).Round(time.Microsecond), wer)
|
|
})
|
|
}
|