An unmatched request logged its path. A token route reached with another method (405) or with a trailing slash (404) is answered by ServeMux before any route handler records the pattern, so the share or invitation token in the path reached the log. The record now carries method, pattern, status and duration only.
124 lines
4.1 KiB
Go
124 lines
4.1 KiB
Go
package surf
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
)
|
|
|
|
// accessLogKey is the config key that turns the request log on.
|
|
const accessLogKey = "http.access_log"
|
|
|
|
// accessLogMessage is the message of every request log record.
|
|
const accessLogMessage = "http request"
|
|
|
|
// accessEntry is the request-scoped slot the matched route writes its
|
|
// pattern into. A pointer travels in the context, so the pattern reaches
|
|
// the access log even when stripLocalePrefix hands ServeMux a copied
|
|
// request whose Pattern the outer middleware never sees.
|
|
type accessEntry struct {
|
|
pattern string
|
|
}
|
|
|
|
type accessEntryKey struct{}
|
|
|
|
// statusClientClosedRequest is nginx's 499 "client closed request": the
|
|
// status the access log records for a request whose client went away before
|
|
// the handler answered.
|
|
const statusClientClosedRequest = 499
|
|
|
|
// accessLogEnabled reports whether http.access_log is true.
|
|
func accessLogEnabled(app *backpack.App) bool {
|
|
return app != nil && app.Config != nil && app.Config.Bool(accessLogKey)
|
|
}
|
|
|
|
// accessLog logs one Info record "http request" per request after it is
|
|
// served, with method, pattern (the matched route, "" when none matched),
|
|
// status and duration_ms (whole milliseconds). The URL path is never
|
|
// logged, not even for a request that matched no route: a ServeMux 405 for
|
|
// a token route reached with another method, or a 404 for its trailing-slash
|
|
// variant, would otherwise carry the token, because share and invitation
|
|
// tokens travel in paths. The query string, headers and the remote address
|
|
// are never logged either. A 5xx written after the client cancelled the
|
|
// request (its context ends with context.Canceled) is logged as 499: the
|
|
// error came from the cancellation, and no client received it. The logger
|
|
// is the app's published *slog.Logger, else slog.Default (which the
|
|
// generated main wraps in the redacting sunscreen handler).
|
|
func accessLog(app *backpack.App, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
start := time.Now()
|
|
entry := &accessEntry{}
|
|
rec := &statusRecorder{ResponseWriter: w}
|
|
next.ServeHTTP(rec, req.WithContext(context.WithValue(req.Context(), accessEntryKey{}, entry)))
|
|
status := rec.status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
if status >= http.StatusInternalServerError && errors.Is(req.Context().Err(), context.Canceled) {
|
|
status = statusClientClosedRequest
|
|
}
|
|
attrs := []slog.Attr{
|
|
slog.String("method", req.Method),
|
|
slog.String("pattern", entry.pattern),
|
|
slog.Int("status", status),
|
|
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
|
|
}
|
|
accessLogger(app).LogAttrs(req.Context(), slog.LevelInfo, accessLogMessage, attrs...)
|
|
})
|
|
}
|
|
|
|
// accessLogger is the app's published logger, else slog.Default.
|
|
func accessLogger(app *backpack.App) *slog.Logger {
|
|
if app != nil {
|
|
if log, ok := app.Lookup[*slog.Logger](); ok && log != nil {
|
|
return log
|
|
}
|
|
}
|
|
return slog.Default()
|
|
}
|
|
|
|
// recordPattern stores pattern in the request's access log entry, when the
|
|
// access log put one into the context, before serving the route.
|
|
func recordPattern(pattern string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
if entry, ok := req.Context().Value(accessEntryKey{}).(*accessEntry); ok && entry != nil {
|
|
entry.pattern = pattern
|
|
}
|
|
next.ServeHTTP(w, req)
|
|
})
|
|
}
|
|
|
|
// statusRecorder remembers the first status written; a Write without a
|
|
// WriteHeader is a 200. Flush and Unwrap keep http.ResponseController
|
|
// working through it.
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (w *statusRecorder) WriteHeader(status int) {
|
|
if w.status == 0 {
|
|
w.status = status
|
|
}
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (w *statusRecorder) Write(p []byte) (int, error) {
|
|
if w.status == 0 {
|
|
w.status = http.StatusOK
|
|
}
|
|
return w.ResponseWriter.Write(p)
|
|
}
|
|
|
|
func (w *statusRecorder) Flush() {
|
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|