288 lines
8.4 KiB
Go
288 lines
8.4 KiB
Go
// Package api exposes the gateway's HTTP surface.
|
|||
|
|
//
|
||
|
|
// The API is shaped for one TV screen at a time rather than mirroring Emby: /v1/home
|
||
|
|
// returns everything the launcher renders in a single round trip, which is the whole
|
||
|
|
// point of putting a gateway in front of Emby.
|
||
|
|
package api
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"crypto/subtle"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/config"
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/emby"
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Server struct {
|
||
|
|
cfg config.Config
|
||
|
|
emby *emby.Client
|
||
|
|
store *store.Store
|
||
|
|
cache *cache.Cache
|
||
|
|
recommender *recommend.Engine
|
||
|
|
syncer syncerHandle
|
||
|
|
log *slog.Logger
|
||
|
|
|
||
|
|
recommendationBuilds recommendationBuilds
|
||
|
|
maintenance maintenanceState
|
||
|
|
}
|
||
|
|
|
||
|
|
// Deps are the collaborators the API needs. A struct rather than positional arguments:
|
||
|
|
// this list has grown three times already.
|
||
|
|
type Deps struct {
|
||
|
|
Emby *emby.Client
|
||
|
|
Store *store.Store
|
||
|
|
Cache *cache.Cache
|
||
|
|
Recommender *recommend.Engine
|
||
|
|
Syncer syncerHandle
|
||
|
|
Log *slog.Logger
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(cfg config.Config, deps Deps) *Server {
|
||
|
|
return &Server{
|
||
|
|
cfg: cfg,
|
||
|
|
emby: deps.Emby,
|
||
|
|
store: deps.Store,
|
||
|
|
cache: deps.Cache,
|
||
|
|
recommender: deps.Recommender,
|
||
|
|
syncer: deps.Syncer,
|
||
|
|
log: deps.Log,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) Routes() http.Handler {
|
||
|
|
// The client API lives on its own mux so maintenance mode can gate all of it at
|
||
|
|
// once, without the gate ever touching health checks or the admin page.
|
||
|
|
v1 := http.NewServeMux()
|
||
|
|
|
||
|
|
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
|
||
|
|
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
|
||
|
|
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
|
||
|
|
|
||
|
|
v1.Handle("GET /v1/home", s.authed(s.handleHome))
|
||
|
|
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
|
||
|
|
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
|
||
|
|
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
|
||
|
|
|
||
|
|
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
|
||
|
|
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
|
||
|
|
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
|
||
|
|
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
|
||
|
|
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
|
||
|
|
|
||
|
|
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
|
||
|
|
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
|
||
|
|
|
||
|
|
v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage))
|
||
|
|
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||
|
|
mux.HandleFunc("GET /readyz", s.handleReady)
|
||
|
|
mux.Handle("/v1/", s.maintenanceGate(v1))
|
||
|
|
mux.Handle("/admin/", s.adminRoutes())
|
||
|
|
|
||
|
|
return s.withLogging(mux)
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- middleware -------------------------------------------------------------
|
||
|
|
|
||
|
|
type authedFunc func(http.ResponseWriter, *http.Request, store.Session)
|
||
|
|
|
||
|
|
// authed resolves the bearer token to a session before running h.
|
||
|
|
//
|
||
|
|
// Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the
|
||
|
|
// repository's helpers and cannot attach headers to them.
|
||
|
|
func (s *Server) authed(h authedFunc) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
token := bearerToken(r)
|
||
|
|
if token == "" {
|
||
|
|
writeError(w, http.StatusUnauthorized, "missing token")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
sess, err := s.sessionFor(r.Context(), token)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, store.ErrNotFound) {
|
||
|
|
writeError(w, http.StatusUnauthorized, "invalid token")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
s.log.Error("session lookup failed", "error", err)
|
||
|
|
writeError(w, http.StatusInternalServerError, "session lookup failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
h(w, r, sess)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) withLogging(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
start := time.Now()
|
||
|
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||
|
|
next.ServeHTTP(rec, r)
|
||
|
|
// Path only: query strings can carry image tokens.
|
||
|
|
s.log.Info("request",
|
||
|
|
"method", r.Method,
|
||
|
|
"path", r.URL.Path,
|
||
|
|
"status", rec.status,
|
||
|
|
"ms", time.Since(start).Milliseconds(),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
type statusRecorder struct {
|
||
|
|
http.ResponseWriter
|
||
|
|
status int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *statusRecorder) WriteHeader(code int) {
|
||
|
|
r.status = code
|
||
|
|
r.ResponseWriter.WriteHeader(code)
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- sessions ---------------------------------------------------------------
|
||
|
|
|
||
|
|
func bearerToken(r *http.Request) string {
|
||
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||
|
|
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||
|
|
}
|
||
|
|
if h := r.Header.Get("X-Memby-Token"); h != "" {
|
||
|
|
return strings.TrimSpace(h)
|
||
|
|
}
|
||
|
|
return strings.TrimSpace(r.URL.Query().Get("t"))
|
||
|
|
}
|
||
|
|
|
||
|
|
func hashToken(token string) []byte {
|
||
|
|
sum := sha256.Sum256([]byte(token))
|
||
|
|
return sum[:]
|
||
|
|
}
|
||
|
|
|
||
|
|
func newToken() (string, error) {
|
||
|
|
buf := make([]byte, 32)
|
||
|
|
if _, err := rand.Read(buf); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type cachedSession struct {
|
||
|
|
EmbyUserID string `json:"u"`
|
||
|
|
EmbyToken string `json:"t"`
|
||
|
|
Username string `json:"n"`
|
||
|
|
ServerID string `json:"s"`
|
||
|
|
DeviceID string `json:"d"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// sessionFor resolves a token, using Redis to keep the hot path off Postgres.
|
||
|
|
func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, error) {
|
||
|
|
hash := hashToken(token)
|
||
|
|
key := cache.SessionKey(hex.EncodeToString(hash))
|
||
|
|
|
||
|
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||
|
|
var cs cachedSession
|
||
|
|
if json.Unmarshal(raw, &cs) == nil {
|
||
|
|
return store.Session{
|
||
|
|
TokenHash: hash,
|
||
|
|
EmbyUserID: cs.EmbyUserID,
|
||
|
|
EmbyToken: cs.EmbyToken,
|
||
|
|
Username: cs.Username,
|
||
|
|
ServerID: cs.ServerID,
|
||
|
|
DeviceID: cs.DeviceID,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
sess, err := s.store.SessionByTokenHash(ctx, hash)
|
||
|
|
if err != nil {
|
||
|
|
return store.Session{}, err
|
||
|
|
}
|
||
|
|
// Constant-time confirmation that the stored hash matches the presented token.
|
||
|
|
if subtle.ConstantTimeCompare(sess.TokenHash, hash) != 1 {
|
||
|
|
return store.Session{}, store.ErrNotFound
|
||
|
|
}
|
||
|
|
|
||
|
|
if raw, err := json.Marshal(cachedSession{
|
||
|
|
EmbyUserID: sess.EmbyUserID,
|
||
|
|
EmbyToken: sess.EmbyToken,
|
||
|
|
Username: sess.Username,
|
||
|
|
ServerID: sess.ServerID,
|
||
|
|
DeviceID: sess.DeviceID,
|
||
|
|
}); err == nil {
|
||
|
|
_ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL)
|
||
|
|
}
|
||
|
|
// Best-effort activity stamp; a failure here must not fail the request.
|
||
|
|
if err := s.store.Touch(ctx, hash); err != nil {
|
||
|
|
s.log.Warn("touch session failed", "error", err)
|
||
|
|
}
|
||
|
|
return sess, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func credentials(sess store.Session) emby.Credentials {
|
||
|
|
return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- responses --------------------------------------------------------------
|
||
|
|
|
||
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||
|
|
// Headers are already out; nothing useful left to do but stop.
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeRaw(w http.ResponseWriter, status int, body []byte) {
|
||
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
|
|
w.WriteHeader(status)
|
||
|
|
_, _ = w.Write(body)
|
||
|
|
}
|
||
|
|
|
||
|
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||
|
|
writeJSON(w, status, map[string]string{"error": message})
|
||
|
|
}
|
||
|
|
|
||
|
|
// writeUpstreamError mirrors Emby's status so the TV can tell "signed out" (401) from
|
||
|
|
// "server is unwell" (5xx) without parsing strings.
|
||
|
|
func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message string) {
|
||
|
|
var apiErr *emby.APIError
|
||
|
|
if errors.As(err, &apiErr) {
|
||
|
|
switch {
|
||
|
|
case apiErr.StatusCode == http.StatusUnauthorized, apiErr.StatusCode == http.StatusForbidden:
|
||
|
|
writeError(w, http.StatusUnauthorized, "emby rejected the session")
|
||
|
|
return
|
||
|
|
case apiErr.StatusCode == http.StatusNotFound:
|
||
|
|
writeError(w, http.StatusNotFound, "not found on the emby server")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
s.log.Error(message, "error", err)
|
||
|
|
writeError(w, http.StatusBadGateway, message)
|
||
|
|
}
|
||
|
|
|
||
|
|
func queryInt(r *http.Request, key string, fallback, max int) int {
|
||
|
|
raw := r.URL.Query().Get(key)
|
||
|
|
if raw == "" {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
v, err := strconv.Atoi(raw)
|
||
|
|
if err != nil || v <= 0 {
|
||
|
|
return fallback
|
||
|
|
}
|
||
|
|
if v > max {
|
||
|
|
return max
|
||
|
|
}
|
||
|
|
return v
|
||
|
|
}
|