v2.31.0-dev6 8056b57

accesslog.go RawHistoryFolder

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:    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 sanitizeLoggedQuery(rawQuery string) string {
91	if rawQuery == "" {
92		return ""
93	}
94
95	parts := strings.Split(rawQuery, "&")
96	for i, part := range parts {
97		if part == "" {
98			continue
99		}
100
101		key, _, found := strings.Cut(part, "=")
102		decodedKey, err := url.QueryUnescape(key)
103		if err != nil {
104			decodedKey = key
105		}
106
107		if !isSensitiveQueryKey(decodedKey) {
108			continue
109		}
110
111		if found {
112			parts[i] = key + "=[REDACTED]"
113		} else {
114			parts[i] = key
115		}
116	}
117
118	return strings.Join(parts, "&")
119}
120
121func isSensitiveQueryKey(key string) bool {
122	switch strings.ToLower(key) {
123	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":
124		return true
125	default:
126		return false
127	}
128}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close