189 lines
5.4 KiB
Go
189 lines
5.4 KiB
Go
package activitylog
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/clientip"
|
|
)
|
|
|
|
// excludedPrefixes are paths that should not be logged.
|
|
var excludedPrefixes = []string{
|
|
"/api/v1/health",
|
|
"/api/v1/ready",
|
|
"/api/v1/admin/logs",
|
|
}
|
|
|
|
// streamPrefixes are paths logged only at session-start (not per-chunk).
|
|
var streamPrefixes = []string{
|
|
"/api/v1/stream/",
|
|
"/api/v1/playback/transcode/",
|
|
}
|
|
|
|
// LogContext is a mutable holder stored in context BEFORE auth middleware.
|
|
// Auth middleware populates it, and the activity log middleware reads it
|
|
// after the handler chain completes.
|
|
type LogContext struct {
|
|
UserID *int
|
|
ImpersonatorUserID *int
|
|
SessionID string
|
|
}
|
|
|
|
type logContextKey struct{}
|
|
|
|
// PlaybackLogContext is a mutable holder for playback-specific correlation.
|
|
// Handlers update it once they know the playback session ID, and both the
|
|
// request logger and activity logger read it after the handler returns.
|
|
type PlaybackLogContext struct {
|
|
PlaybackSessionID string
|
|
}
|
|
|
|
type playbackLogContextKey struct{}
|
|
|
|
// SetLogContext stores a LogContext pointer in the request context.
|
|
func SetLogContext(ctx context.Context, lc *LogContext) context.Context {
|
|
return context.WithValue(ctx, logContextKey{}, lc)
|
|
}
|
|
|
|
// GetLogContext retrieves the LogContext from the request context.
|
|
func GetLogContext(ctx context.Context) *LogContext {
|
|
lc, _ := ctx.Value(logContextKey{}).(*LogContext)
|
|
return lc
|
|
}
|
|
|
|
func SetPlaybackLogContext(ctx context.Context, lc *PlaybackLogContext) context.Context {
|
|
return context.WithValue(ctx, playbackLogContextKey{}, lc)
|
|
}
|
|
|
|
func GetPlaybackLogContext(ctx context.Context) *PlaybackLogContext {
|
|
lc, _ := ctx.Value(playbackLogContextKey{}).(*PlaybackLogContext)
|
|
return lc
|
|
}
|
|
|
|
// NewMiddleware returns chi-compatible middleware that logs every request
|
|
// to the given Writer. It wraps ResponseWriter to capture the status code.
|
|
// It stores a mutable LogContext in the request context that downstream auth
|
|
// middleware can populate with user info.
|
|
func NewMiddleware(w Writer, nodeID string) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
|
|
// Skip excluded endpoints
|
|
for _, prefix := range excludedPrefixes {
|
|
if strings.HasPrefix(path, prefix) {
|
|
next.ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Skip per-chunk stream requests (segments, manifests after start)
|
|
for _, prefix := range streamPrefixes {
|
|
if strings.HasPrefix(path, prefix) && isStreamChunk(path) {
|
|
next.ServeHTTP(rw, r)
|
|
return
|
|
}
|
|
}
|
|
|
|
start := time.Now()
|
|
wrapped := &statusWriter{ResponseWriter: rw, status: http.StatusOK}
|
|
|
|
// Reuse any existing LogContext so request logging and activity logging
|
|
// observe the same auth-populated values.
|
|
lc := GetLogContext(r.Context())
|
|
if lc == nil {
|
|
lc = &LogContext{}
|
|
ctx := SetLogContext(r.Context(), lc)
|
|
r = r.WithContext(ctx)
|
|
}
|
|
playbackLC := GetPlaybackLogContext(r.Context())
|
|
if playbackLC == nil {
|
|
playbackLC = &PlaybackLogContext{}
|
|
ctx := SetPlaybackLogContext(r.Context(), playbackLC)
|
|
r = r.WithContext(ctx)
|
|
}
|
|
|
|
next.ServeHTTP(wrapped, r)
|
|
|
|
pathPattern := path
|
|
if routeCtx := chi.RouteContext(r.Context()); routeCtx != nil {
|
|
if route := routeCtx.RoutePattern(); route != "" {
|
|
pathPattern = route
|
|
}
|
|
}
|
|
|
|
// After handler chain completes, auth middleware has populated lc
|
|
entry := LogEntry{
|
|
Timestamp: start,
|
|
ClientIP: clientip.FromContext(r.Context()),
|
|
UserID: lc.UserID,
|
|
ImpersonatorUserID: lc.ImpersonatorUserID,
|
|
SessionID: lc.SessionID,
|
|
PlaybackSessionID: playbackLC.PlaybackSessionID,
|
|
RequestID: middleware.GetReqID(r.Context()),
|
|
NodeID: nodeID,
|
|
Method: r.Method,
|
|
Path: path,
|
|
PathPattern: pathPattern,
|
|
StatusCode: wrapped.status,
|
|
UserAgent: r.UserAgent(),
|
|
DurationMs: int(time.Since(start).Milliseconds()),
|
|
}
|
|
|
|
w.Write(entry)
|
|
})
|
|
}
|
|
}
|
|
|
|
// isStreamChunk returns true if the path looks like a stream segment/manifest
|
|
// chunk rather than a session-start request.
|
|
func isStreamChunk(path string) bool {
|
|
return strings.Contains(path, "/segment/") ||
|
|
strings.Contains(path, "/master.m3u8") ||
|
|
strings.Contains(path, "/subtitles/")
|
|
}
|
|
|
|
// statusWriter wraps http.ResponseWriter to capture the status code.
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
wroteHeader bool
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
if !w.wroteHeader {
|
|
w.status = code
|
|
w.wroteHeader = true
|
|
}
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (w *statusWriter) Write(b []byte) (int, error) {
|
|
if !w.wroteHeader {
|
|
w.wroteHeader = true
|
|
}
|
|
return w.ResponseWriter.Write(b)
|
|
}
|
|
|
|
// Hijack implements http.Hijacker, required for WebSocket upgrades.
|
|
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
if hj, ok := w.ResponseWriter.(http.Hijacker); ok {
|
|
return hj.Hijack()
|
|
}
|
|
return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker")
|
|
}
|
|
|
|
// Unwrap returns the underlying ResponseWriter, preserving http.Flusher,
|
|
// http.Hijacker, etc. (Go 1.20+ ResponseController pattern).
|
|
func (w *statusWriter) Unwrap() http.ResponseWriter {
|
|
return w.ResponseWriter
|
|
}
|