1package accesslog
2
3import (
4 "encoding/json"
5 "fmt"
6 "strings"
7
8 out "github.com/bnema/gordon/internal/boundaries/out"
9)
10
11// jsonEntry is the stable JSON schema for access log entries.
12// Field order is controlled by the struct definition.
13type jsonEntry struct {
14 Time string `json:"time"`
15 ClientIP string `json:"client_ip"`
16 Method string `json:"method"`
17 Host string `json:"host"`
18 Path string `json:"path"`
19 Query string `json:"query"`
20 Status int `json:"status"`
21 BytesSent int `json:"bytes_sent"`
22 DurationMS float64 `json:"duration_ms"`
23 UserAgent string `json:"user_agent"`
24 Referer string `json:"referer"`
25 RequestID string `json:"request_id"`
26 Proto string `json:"proto"`
27}
28
29// clfTimestamp is the standard Common Log Format timestamp layout.
30const clfTimestamp = "02/Jan/2006:15:04:05 -0700"
31
32// jsonTimestamp is the access log JSON timestamp layout: RFC3339 with
33// millisecond precision in UTC.
34const jsonTimestamp = "2006-01-02T15:04:05.000Z07:00"
35
36// formatJSON serializes entry as a single-line JSON object.
37// Timestamps are RFC3339 with millisecond precision in UTC.
38// time.Time's default JSON marshaling is NOT used to ensure stable precision.
39func formatJSON(e out.AccessLogEntry) (string, error) {
40 je := jsonEntry{
41 Time: e.Time.UTC().Format(jsonTimestamp),
42 ClientIP: e.ClientIP,
43 Method: e.Method,
44 Host: e.Host,
45 Path: e.Path,
46 Query: e.Query,
47 Status: e.Status,
48 BytesSent: e.BytesSent,
49 DurationMS: e.DurationMS,
50 UserAgent: e.UserAgent,
51 Referer: e.Referer,
52 RequestID: e.RequestID,
53 Proto: e.Proto,
54 }
55 b, err := json.Marshal(je)
56 if err != nil {
57 return "", fmt.Errorf("formatJSON: %w", err)
58 }
59 return string(b), nil
60}
61
62// formatCLF serializes entry in Common Log Format (NCSA).
63//
64// <ip> - - [timestamp] "METHOD target PROTO" status bytes
65func formatCLF(e out.AccessLogEntry) (string, error) {
66 ts := e.Time.UTC().Format(clfTimestamp)
67 target := buildRequestTarget(e.Path, e.Query)
68 return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d`,
69 e.ClientIP,
70 ts,
71 escapeQuoted(e.Method),
72 escapeQuoted(target),
73 escapeQuoted(e.Proto),
74 e.Status,
75 e.BytesSent,
76 ), nil
77}
78
79// formatCombined serializes entry in Combined Log Format (CLF + referer + UA).
80//
81// <ip> - - [timestamp] "METHOD target PROTO" status bytes "referer" "ua"
82func formatCombined(e out.AccessLogEntry) (string, error) {
83 ts := e.Time.UTC().Format(clfTimestamp)
84 target := buildRequestTarget(e.Path, e.Query)
85 referer := e.Referer
86 if referer == "" {
87 referer = "-"
88 }
89 ua := e.UserAgent
90 if ua == "" {
91 ua = "-"
92 }
93 return fmt.Sprintf(`%s - - [%s] "%s %s %s" %d %d "%s" "%s"`,
94 e.ClientIP,
95 ts,
96 escapeQuoted(e.Method),
97 escapeQuoted(target),
98 escapeQuoted(e.Proto),
99 e.Status,
100 e.BytesSent,
101 escapeQuoted(referer),
102 escapeQuoted(ua),
103 ), nil
104}
105
106// buildRequestTarget builds the request target portion of a CLF/Combined line.
107// When query is empty only the path is used; otherwise "path?query".
108func buildRequestTarget(path, query string) string {
109 if query == "" {
110 return path
111 }
112 return path + "?" + query
113}
114
115// escapeQuoted escapes backslashes, double-quotes, and control characters inside
116// a value that will be enclosed in double-quotes in the log line.
117// All ASCII control characters (0x00-0x1F) are escaped to prevent log-injection
118// via user-controlled fields such as User-Agent or Referer.
119func escapeQuoted(s string) string {
120 var b strings.Builder
121 b.Grow(len(s))
122 for _, r := range s {
123 switch {
124 case r == '\\':
125 b.WriteString(`\\`)
126 case r == '"':
127 b.WriteString(`\"`)
128 case r == '\n':
129 b.WriteString(`\n`)
130 case r == '\r':
131 b.WriteString(`\r`)
132 case r == '\t':
133 b.WriteString(`\t`)
134 case r < 0x20:
135 fmt.Fprintf(&b, `\x%02x`, r)
136 default:
137 b.WriteRune(r)
138 }
139 }
140 return b.String()
141}