c129cb99fc
Single Go binary serving the API and an embedded keyboard-first UI for promoting fafflix titles into the cheeztv kids tree via hardlinks. - internal/library: hardlink sync, idempotent re-runs, drift reporting, strict single-path-element name validation as the traversal guard - internal/arr: minimal sonarr/radarr v3 client with a 60s list cache and a key-brokered poster proxy - internal/auth: server-side Authentik group enforcement on every route - internal/server: library JSON API, art proxy, health probes, SPA - ui: two-tile landing page, fuzzy-filtered title list, detail panel - Makefile, Dockerfile, .woodpecker pipelines, pre-commit config
262 lines
8.1 KiB
Go
262 lines
8.1 KiB
Go
// 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 mux
|
|
}
|
|
|
|
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})
|
|
}
|