package api import ( "context" "log/slog" "net/http" "strings" "github.com/ponzischeme89/memby/server/internal/store" ) // requestIdentity is the answer to "who did this, from where, on what build" — the // context every log line from a request needs and no single layer holds. The middleware // knows the route and the client headers before the session exists; [Server.authed] // learns the viewer and the television afterwards. // // It is carried by pointer through the request context so the outermost middleware can // still read what an inner layer filled in. A request is served on one goroutine and the // handler has returned by the time the middleware reads this, so no lock is needed. type requestIdentity struct { component string user string device string client string protocol string } type identityKey struct{} // withRequestIdentity installs an empty identity for this request and returns it. func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) { identity := &requestIdentity{ component: componentFor(r.URL.Path), client: clientVersion(r), protocol: clientProtocol(r), } return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity)), identity } func identityFrom(ctx context.Context) *requestIdentity { identity, _ := ctx.Value(identityKey{}).(*requestIdentity) return identity } // identify records who the request turned out to belong to, so every later line — the // handler's own events and the request line the middleware writes at the end — names the // viewer and the television rather than a token. func identify(ctx context.Context, sess store.Session) { identity := identityFrom(ctx) if identity == nil { return } if sess.Username != "" { identity.user = sess.Username } if sess.DeviceName != "" { identity.device = sess.DeviceName } else if sess.DeviceID != "" { identity.device = sess.DeviceID } if sess.ClientVersion != "" { identity.client = sess.ClientVersion } if sess.ClientProtocol != "" { identity.protocol = sess.ClientProtocol } } func (i *requestIdentity) attrs() []any { if i == nil { return nil } attrs := make([]any, 0, 8) if i.component != "" { attrs = append(attrs, "component", i.component) } attrs = append(attrs, i.viewerAttrs()...) if i.client != "" { attrs = append(attrs, "client", i.client) } return attrs } // viewerAttrs names the person and the television, and only when they are known: an // unauthenticated probe has neither, and "user=unknown" on every health check is noise. func (i *requestIdentity) viewerAttrs() []any { if i == nil { return nil } attrs := make([]any, 0, 4) if i.user != "" { attrs = append(attrs, "user", i.user) } if i.device != "" { attrs = append(attrs, "device", i.device) } return attrs } // loggerFor returns the request's logger: the server logger with the viewer, television, // app build and area of the app already attached. Handlers use it so an event only has // to say what happened, and every event from one request is attributable without the // reader correlating lines by hand. // // Outside a request — a scheduled sync, a health probe — it degrades to the plain server // logger rather than refusing to log. func (s *Server) loggerFor(ctx context.Context) *slog.Logger { attrs := identityFrom(ctx).attrs() if len(attrs) == 0 { return s.log } return s.log.With(attrs...) } // componentFor names the part of the app a request came from. // // It is derived from the route rather than declared by the client: the TV would have to // thread a surface name through every repository call to report one, and the route // already identifies the screen unambiguously — /v1/home is the launcher, an image is // artwork for something already on screen, /v1/items/{id}/playback is the player asking // what to play. Deriving it also means an old APK's traffic is attributed correctly. func componentFor(path string) string { switch { case path == "/healthz", path == "/readyz": return "health" case path == "/v1/status": return "status" case path == "/v1/update", strings.HasPrefix(path, "/updates/"): return "updates" case strings.HasPrefix(path, "/v1/auth/devices"): return "devices" case strings.HasPrefix(path, "/v1/auth/"): return "auth" case path == "/v1/home", path == "/v1/features": return "home" case path == "/v1/preferences", path == "/v1/theme": return "settings" case path == "/v1/screensaver", path == "/v1/preroll": return "screensaver" case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"): return "search" case strings.HasPrefix(path, "/v1/requests"): return "requests" case strings.HasPrefix(path, "/v1/recommendations"), path == "/v1/for-you": return "recommendations" case strings.HasPrefix(path, "/v1/my-shows"), strings.HasPrefix(path, "/v1/notifications"): return "my-shows" case strings.HasPrefix(path, "/v1/playback/"), isPlaybackItemPath(path): return "playback" case strings.HasPrefix(path, "/v1/images/"): return "artwork" case strings.HasPrefix(path, "/v1/items/"): return "details" case strings.HasPrefix(path, "/v1/analytics/"): return "analytics" case strings.HasPrefix(path, "/admin"): return "admin" case strings.HasPrefix(path, "/hooks/"): return "webhooks" case strings.HasPrefix(path, "/install"), path == "/": return "installer" default: return "api" } } // The player's own calls hang off an item, so they are told apart from the detail page by // their trailing segment rather than their prefix. func isPlaybackItemPath(path string) bool { if !strings.HasPrefix(path, "/v1/items/") { return false } // Fetching a subtitle is two segments deep rather than one, and matching its trailing // "search" on its own would claim any future per-item search as playback. A seek // preview is the same shape: the frame number is the last segment, not the word. if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") { return true } switch path[strings.LastIndex(path, "/")+1:] { case "playback", "next", "trailer": return true } return false }