// Package server wires the mediamark HTTP surface: the library JSON API, the // key-brokered poster proxy, health probes, and the embedded SPA. package server import ( "encoding/json" "errors" "io" "io/fs" "log/slog" "net/http" "strconv" "strings" "git.unkin.net/unkin/mediamark/internal/arr" "git.unkin.net/unkin/mediamark/internal/auth" "git.unkin.net/unkin/mediamark/internal/config" "git.unkin.net/unkin/mediamark/internal/library" ) // Server holds the resolved dependencies of the app. type Server struct { cfg *config.Config lib *library.Library arrs map[library.Section]*arr.Client gate *auth.Middleware assets fs.FS log *slog.Logger } // New constructs a Server. arrs maps a section to the *arr instance that owns // it; a missing entry simply means titles in that section carry no metadata. func New(cfg *config.Config, lib *library.Library, arrs map[library.Section]*arr.Client, assets fs.FS, log *slog.Logger) *Server { if log == nil { log = slog.Default() } return &Server{ cfg: cfg, lib: lib, arrs: arrs, gate: auth.New(cfg.GroupsHeader, cfg.AllowedGroups), assets: assets, log: log, } } // Handler returns the root handler. Health probes are ungated (kubelet sends no // identity header); everything else — API and UI alike — is behind the group // gate, so an unauthorized user cannot even load the page shell. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /livez", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) mux.HandleFunc("GET /readyz", s.readyz) gated := http.NewServeMux() gated.HandleFunc("GET /api/library/{section}", s.handleList) gated.HandleFunc("POST /api/library/{section}/{name}/mark", s.handleMark) gated.HandleFunc("POST /api/library/{section}/{name}/unmark", s.handleUnmark) gated.HandleFunc("GET /api/art/{section}/{id}", s.handleArt) gated.HandleFunc("/", s.handleUI) mux.Handle("/", s.gate.Wrap(gated)) return secureHeaders(mux) } // cspPolicy locks the page to same-origin code. The SPA carries no inline // script or style, so no unsafe-inline escape hatch is needed; data: is in // img-src solely for the inline SVG favicon. const cspPolicy = "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'" // secureHeaders stamps the browser-facing hardening headers onto every // response — API, UI and probes alike — before the handler writes. func secureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", cspPolicy) h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "no-referrer") next.ServeHTTP(w, r) }) } func (s *Server) readyz(w http.ResponseWriter, _ *http.Request) { if err := s.lib.Ready(); err != nil { s.log.Warn("readyz: media root unavailable", "err", err) http.Error(w, "media root unavailable", http.StatusServiceUnavailable) return } _, _ = w.Write([]byte("ok")) } // arrMeta is the *arr metadata attached to a library title, or null. type arrMeta struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` Overview string `json:"overview"` Status string `json:"status"` } // titleView is one row of the library list API. type titleView struct { library.Title Arr *arrMeta `json:"arr"` ArtURL string `json:"artUrl,omitempty"` } func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { section, ok := s.section(w, r) if !ok { return } titles, err := s.lib.List(section) if err != nil { s.log.Error("list library", "section", section, "err", err) writeErr(w, http.StatusInternalServerError, "cannot read library") return } // *arr metadata is a nicety: a down sonarr must not blank the list, so a // lookup failure degrades to art-less rows. byDir := map[string]arr.Entry{} if c := s.arrs[section]; c != nil { entries, err := c.List(r.Context()) if err != nil { s.log.Warn("arr list failed; serving library without metadata", "section", section, "err", err) } for _, e := range entries { if d := e.Dir(); d != "" { byDir[d] = e } } } out := make([]titleView, 0, len(titles)) for _, t := range titles { v := titleView{Title: t} if e, ok := byDir[t.Name]; ok { v.Arr = &arrMeta{ID: e.ID, Title: e.Title, Year: e.Year, Overview: e.Overview, Status: e.Status} v.ArtURL = "/api/art/" + string(section) + "/" + strconv.Itoa(e.ID) } out = append(out, v) } writeJSON(w, http.StatusOK, map[string]any{"section": string(section), "titles": out}) } func (s *Server) handleMark(w http.ResponseWriter, r *http.Request) { section, ok := s.section(w, r) if !ok { return } name := r.PathValue("name") res, err := s.lib.Mark(section, name) if err != nil { s.writeLibErr(w, "mark", section, name, err) return } s.log.Info("marked title", "section", section, "name", name, "linked", res.Linked, "replaced", res.Replaced) s.writeState(w, section, name, res) } func (s *Server) handleUnmark(w http.ResponseWriter, r *http.Request) { section, ok := s.section(w, r) if !ok { return } name := r.PathValue("name") if err := s.lib.Unmark(section, name); err != nil { s.writeLibErr(w, "unmark", section, name, err) return } s.log.Info("unmarked title", "section", section, "name", name) s.writeState(w, section, name, library.SyncResult{}) } // writeState answers a mutation with the title's fresh on-disk state so the UI // never has to guess what the sync did. func (s *Server) writeState(w http.ResponseWriter, section library.Section, name string, res library.SyncResult) { t, err := s.lib.Stat(section, name) if err != nil && !errors.Is(err, library.ErrNotFound) { s.writeLibErr(w, "stat", section, name, err) return } if errors.Is(err, library.ErrNotFound) { // Unmarking a title whose source has since vanished still succeeded. t = library.Title{Name: name} } writeJSON(w, http.StatusOK, map[string]any{"title": t, "sync": res}) } func (s *Server) handleArt(w http.ResponseWriter, r *http.Request) { section, ok := s.section(w, r) if !ok { return } id, err := strconv.Atoi(r.PathValue("id")) if err != nil || id <= 0 { writeErr(w, http.StatusBadRequest, "invalid id") return } c := s.arrs[section] if c == nil { writeErr(w, http.StatusNotFound, "no metadata source for section") return } art, err := c.Poster(r.Context(), id) if err != nil { // The upstream error may name the app but never the key; still, answer // with a fixed body so nothing upstream-shaped reaches the browser. s.log.Warn("poster proxy failed", "section", section, "id", id, "err", err) writeErr(w, http.StatusBadGateway, "poster unavailable") return } defer func() { _ = art.Body.Close() }() w.Header().Set("Content-Type", art.ContentType) w.Header().Set("Cache-Control", "public, max-age=86400") if _, err := io.Copy(w, art.Body); err != nil { s.log.Warn("poster copy failed", "id", id, "err", err) } } // handleUI serves the embedded assets, falling back to index.html so client // routes like /movies deep-link correctly. func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/") if strings.HasPrefix(p, "api/") { writeErr(w, http.StatusNotFound, "not found") return } if p != "" { if st, err := fs.Stat(s.assets, p); err == nil && !st.IsDir() { http.FileServerFS(s.assets).ServeHTTP(w, r) return } } b, err := fs.ReadFile(s.assets, "index.html") if err != nil { http.Error(w, "index missing", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(b) } // section resolves and validates the {section} path value. func (s *Server) section(w http.ResponseWriter, r *http.Request) (library.Section, bool) { sec, err := library.ParseSection(r.PathValue("section")) if err != nil { writeErr(w, http.StatusNotFound, "unknown section") return "", false } return sec, true } // writeLibErr maps a library error onto a status without leaking paths. func (s *Server) writeLibErr(w http.ResponseWriter, op string, section library.Section, name string, err error) { switch { case errors.Is(err, library.ErrInvalidName), errors.Is(err, library.ErrInvalidSection): writeErr(w, http.StatusBadRequest, "invalid title") case errors.Is(err, library.ErrNotFound): writeErr(w, http.StatusNotFound, "title not found") default: s.log.Error("library operation failed", "op", op, "section", section, "name", name, "err", err) writeErr(w, http.StatusInternalServerError, "library operation failed") } } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) }