2026-08-06 22:33:56 +12:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-08-14 11:47:32 +12:00
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
2026-08-06 22:33:56 +12:00
|
|
|
"log/slog"
|
2026-08-12 13:08:53 +12:00
|
|
|
"net"
|
2026-08-06 22:33:56 +12:00
|
|
|
"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 {
|
2026-08-14 11:47:32 +12:00
|
|
|
component string
|
2026-08-15 09:23:26 +12:00
|
|
|
userID string
|
2026-08-14 11:47:32 +12:00
|
|
|
user string
|
|
|
|
|
device string
|
|
|
|
|
client string
|
|
|
|
|
protocol string
|
|
|
|
|
correlation string
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type identityKey struct{}
|
|
|
|
|
|
|
|
|
|
// withRequestIdentity installs an empty identity for this request and returns it.
|
|
|
|
|
func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) {
|
|
|
|
|
identity := &requestIdentity{
|
2026-08-14 11:47:32 +12:00
|
|
|
component: componentFor(r.URL.Path),
|
|
|
|
|
client: clientVersion(r),
|
|
|
|
|
protocol: clientProtocol(r),
|
|
|
|
|
correlation: requestCorrelation(r),
|
2026-08-06 22:33:56 +12:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-08-15 09:23:26 +12:00
|
|
|
identity.userID = sess.EmbyUserID
|
2026-08-06 22:33:56 +12:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-14 11:47:32 +12:00
|
|
|
if i.correlation != "" {
|
|
|
|
|
attrs = append(attrs, "correlation", i.correlation)
|
|
|
|
|
}
|
2026-08-06 22:33:56 +12:00
|
|
|
return attrs
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 11:47:32 +12:00
|
|
|
// requestCorrelation accepts a client-supplied safe identifier or creates one at the
|
|
|
|
|
// gateway boundary. The same value is attached to every log line within the exchange and
|
|
|
|
|
// returned to the client, making a playback launch traceable through gateway and Emby work.
|
|
|
|
|
func requestCorrelation(r *http.Request) string {
|
|
|
|
|
if value := strings.TrimSpace(r.Header.Get("X-Memby-Correlation")); len(value) >= 6 && len(value) <= 64 {
|
|
|
|
|
for _, rune := range value {
|
|
|
|
|
if !(rune >= 'a' && rune <= 'z' || rune >= 'A' && rune <= 'Z' || rune >= '0' && rune <= '9' || rune == '-' || rune == '_') {
|
|
|
|
|
return newCorrelation()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
return newCorrelation()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newCorrelation() string {
|
|
|
|
|
var raw [4]byte
|
|
|
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
|
|
|
return "request-unknown"
|
|
|
|
|
}
|
|
|
|
|
return "req-" + strings.ToUpper(hex.EncodeToString(raw[:]))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-06 22:33:56 +12:00
|
|
|
// 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"
|
2026-08-09 08:25:50 +12:00
|
|
|
case path == "/v1/preferences", path == "/v1/theme":
|
2026-08-06 22:33:56 +12:00
|
|
|
return "settings"
|
|
|
|
|
case path == "/v1/screensaver", path == "/v1/preroll":
|
|
|
|
|
return "screensaver"
|
2026-08-09 12:53:25 +12:00
|
|
|
case strings.HasPrefix(path, "/v1/search"), strings.HasPrefix(path, "/v1/genres/"), path == "/v1/library/items":
|
2026-08-06 22:33:56 +12:00
|
|
|
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
|
2026-08-07 10:44:17 +12:00
|
|
|
// "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.
|
2026-08-12 13:08:53 +12:00
|
|
|
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") ||
|
|
|
|
|
strings.Contains(path, "/trailers") {
|
2026-08-06 22:33:56 +12:00
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
switch path[strings.LastIndex(path, "/")+1:] {
|
|
|
|
|
case "playback", "next", "trailer":
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
2026-08-12 13:08:53 +12:00
|
|
|
|
|
|
|
|
// requestClientIP is the viewer-facing address recorded for trailer playback. The first
|
|
|
|
|
// Forwarded address is the original client when the gateway is behind its normal reverse
|
|
|
|
|
// proxy; direct deployments fall back to RemoteAddr. This value is for operational logs,
|
|
|
|
|
// never authentication or access control.
|
|
|
|
|
func requestClientIP(r *http.Request) string {
|
|
|
|
|
for _, value := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") {
|
|
|
|
|
if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil {
|
|
|
|
|
return ip.String()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil {
|
|
|
|
|
return ip.String()
|
|
|
|
|
}
|
|
|
|
|
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
|
|
|
|
if err == nil {
|
|
|
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
|
|
|
return ip.String()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ip := net.ParseIP(strings.TrimSpace(r.RemoteAddr)); ip != nil {
|
|
|
|
|
return ip.String()
|
|
|
|
|
}
|
|
|
|
|
return "unknown"
|
|
|
|
|
}
|