1package middleware
2
3import (
4 "net"
5 "net/http"
6 "net/url"
7 "strings"
8 "time"
9
10 "github.com/bnema/zerowrap"
11
12 "github.com/bnema/gordon/internal/adapters/in/http/httphelper"
13 out "github.com/bnema/gordon/internal/boundaries/out"
14)
15
16// AccessLogger is a middleware that writes one access-log entry per HTTP request
17// to the provided writer. It runs alongside (not instead of) RequestLogger.
18//
19// When excludeHealthChecks is true, requests from the Gordon health prober
20// (UA prefix out.HealthCheckUserAgentPrefix) or from loopback IPs are not logged.
21//
22// Write failures are reported as warnings through the application logger and
23// never fail the HTTP response.
24func AccessLogger(writer out.AccessLogWriter, excludeHealthChecks bool, log zerowrap.Logger, trustedNets []*net.IPNet) func(http.Handler) http.Handler {
25 return func(next http.Handler) http.Handler {
26 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
27 start := time.Now()
28
29 // Ensure a request ID exists before calling inner handlers so both
30 // this middleware and RequestLogger share the same value.
31 requestID, r := ensureRequestID(w, r)
32
33 // Wrap the response writer to capture status code and bytes written.
34 rw := NewResponseWriter(w)
35
36 // Resolve client IP before calling the inner handler so we see the
37 // original remote address before any mutation.
38 clientIP := GetClientIP(r, trustedNets)
39
40 next.ServeHTTP(rw, r)
41
42 // Apply health-check exclusion after the response so the request
43 // always completes — we just skip writing the log entry.
44 // UA check uses the shared domain constant so the prober and this
45 // filter can never drift. Loopback check uses the shared helper list.
46 if excludeHealthChecks {
47 if strings.HasPrefix(r.UserAgent(), out.HealthCheckUserAgentPrefix) ||
48 httphelper.IsTrustedProxy(clientIP, httphelper.LocalhostNets) {
49 return
50 }
51 }
52
53 // Capture end time once; use it for both duration and entry timestamp
54 // so the two values are derived from the same instant.
55 end := time.Now()
56 durationMS := float64(end.Sub(start).Microseconds()) / 1000.0
57
58 entry := out.AccessLogEntry{
59 Time: end.UTC(),
60 ClientIP: clientIP,
61 Method: r.Method,
62 Host: r.Host,
63 Path: r.URL.Path,
64 Query: sanitizeLoggedQuery(r.URL.RawQuery),
65 Status: rw.StatusCode(),
66 BytesSent: rw.BytesWritten(),
67 DurationMS: durationMS,
68 UserAgent: r.UserAgent(),
69 Referer: sanitizeLoggedReferer(r.Referer()),
70 RequestID: requestID,
71 Proto: r.Proto,
72 }
73
74 if err := writer.Write(entry); err != nil {
75 log.Warn().
76 Str(zerowrap.FieldLayer, "adapter").
77 Str(zerowrap.FieldAdapter, "http").
78 Str(zerowrap.FieldRequestID, requestID).
79 Str(zerowrap.FieldMethod, r.Method).
80 Str(zerowrap.FieldPath, r.URL.Path).
81 Int(zerowrap.FieldStatus, rw.StatusCode()).
82 Str(zerowrap.FieldClientIP, clientIP).
83 Err(err).
84 Msg("access log write failed")
85 }
86 })
87 }
88}
89
90func sanitizeLoggedReferer(referer string) string {
91 if referer == "" {
92 return ""
93 }
94
95 parsed, err := url.Parse(referer)
96 if err != nil {
97 return "[INVALID_REFERER]"
98 }
99 parsed.User = nil
100 parsed.RawQuery = sanitizeLoggedQuery(parsed.RawQuery)
101 parsed.Fragment = ""
102 return parsed.String()
103}
104
105func sanitizeLoggedQuery(rawQuery string) string {
106 if rawQuery == "" {
107 return ""
108 }
109
110 parts := strings.Split(rawQuery, "&")
111 for i, part := range parts {
112 if part == "" {
113 continue
114 }
115
116 key, _, found := strings.Cut(part, "=")
117 decodedKey, err := url.QueryUnescape(key)
118 if err != nil {
119 if found {
120 parts[i] = key + "=[REDACTED]"
121 }
122 continue
123 }
124
125 if !isSensitiveQueryKey(decodedKey) {
126 continue
127 }
128
129 if found {
130 parts[i] = key + "=[REDACTED]"
131 } else {
132 parts[i] = key
133 }
134 }
135
136 return strings.Join(parts, "&")
137}
138
139func isSensitiveQueryKey(key string) bool {
140 switch strings.ToLower(key) {
141 case "access_token", "api_key", "apikey", "auth", "bearer", "code", "credential", "credentials", "key", "password", "private_key", "refresh_token", "reset", "secret", "session", "session_id", "sig", "signature", "token":
142 return true
143 default:
144 return false
145 }
146}