Add the initial mediamark app
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
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
// 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})
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.unkin.net/unkin/mediamark/internal/arr"
|
||||
"git.unkin.net/unkin/mediamark/internal/config"
|
||||
"git.unkin.net/unkin/mediamark/internal/library"
|
||||
)
|
||||
|
||||
const upstreamKey = "upstream-secret-key"
|
||||
|
||||
// testEnv is a fully wired server over a temp media root and a fake *arr.
|
||||
type testEnv struct {
|
||||
h http.Handler
|
||||
root string
|
||||
lib *library.Library
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T, arrHandler http.HandlerFunc) *testEnv {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, tree := range []string{library.SourceTree, library.KidsTree} {
|
||||
for _, s := range library.Sections {
|
||||
if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
arrs := map[library.Section]*arr.Client{}
|
||||
if arrHandler != nil {
|
||||
srv := httptest.NewServer(arrHandler)
|
||||
t.Cleanup(srv.Close)
|
||||
key := func(string) (string, bool) { return upstreamKey, true }
|
||||
sonarr := arr.New(arr.KindSonarr, srv.URL, key)
|
||||
radarr := arr.New(arr.KindRadarr, srv.URL, key)
|
||||
sonarr.SetCacheTTL(0)
|
||||
radarr.SetCacheTTL(0)
|
||||
arrs[library.SectionTVSeries] = sonarr
|
||||
arrs[library.SectionMovies] = radarr
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
MediaRoot: root,
|
||||
GroupsHeader: "X-Forwarded-Groups",
|
||||
AllowedGroups: []string{"akP-mediamark-user"},
|
||||
}
|
||||
lib := library.New(root)
|
||||
assets := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte("<html>mediamark</html>")},
|
||||
"app.js": &fstest.MapFile{Data: []byte("// js")},
|
||||
}
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
return &testEnv{h: New(cfg, lib, arrs, assets, log).Handler(), root: root, lib: lib}
|
||||
}
|
||||
|
||||
// do issues an authorized request.
|
||||
func (e *testEnv) do(t *testing.T, method, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("X-Forwarded-Groups", "akP-other,akP-mediamark-user")
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func (e *testEnv) writeTitle(t *testing.T, section library.Section, name, rel, content string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(e.root, library.SourceTree, string(section), name, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthProbesAreUngated(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
for _, p := range []string{"/livez", "/readyz"} {
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s = %d, want 200", p, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyzFailsWhenMediaRootMissing(t *testing.T) {
|
||||
cfg := &config.Config{MediaRoot: "/definitely/not/here", GroupsHeader: "X-G", AllowedGroups: []string{"g"}}
|
||||
h := New(cfg, library.New(cfg.MediaRoot), nil, fstest.MapFS{}, slog.New(slog.NewTextHandler(io.Discard, nil))).Handler()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("readyz = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Every gated surface — API, mutations, art, and the page shell — must 403 for
|
||||
// a caller outside the allowed group.
|
||||
func TestUnauthorizedGroupIsRejectedEverywhere(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
paths := []struct{ method, path string }{
|
||||
{http.MethodGet, "/"},
|
||||
{http.MethodGet, "/movies"},
|
||||
{http.MethodGet, "/api/library/movies"},
|
||||
{http.MethodPost, "/api/library/movies/Nemo/mark"},
|
||||
{http.MethodPost, "/api/library/movies/Nemo/unmark"},
|
||||
{http.MethodGet, "/api/art/movies/1"},
|
||||
}
|
||||
for _, p := range paths {
|
||||
for _, groups := range []string{"", "akP-somethingelse"} {
|
||||
req := httptest.NewRequest(p.method, p.path, nil)
|
||||
if groups != "" {
|
||||
req.Header.Set("X-Forwarded-Groups", groups)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s (groups=%q) = %d, want 403", p.method, p.path, groups, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListJSONContract(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Api-Key") != upstreamKey {
|
||||
t.Errorf("upstream request missing key")
|
||||
}
|
||||
_, _ = io.WriteString(w, `[
|
||||
{"id":9,"title":"Finding Nemo","year":2003,"overview":"fish","status":"released","path":"/media/fafflix/movies/Finding Nemo (2003)"}
|
||||
]`)
|
||||
})
|
||||
e.writeTitle(t, library.SectionMovies, "Finding Nemo (2003)", "movie.mkv", "0123456789")
|
||||
e.writeTitle(t, library.SectionMovies, "Unknown Film", "movie.mkv", "ab")
|
||||
|
||||
rec := e.do(t, http.MethodGet, "/api/library/movies")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body %s", rec.Code, rec.Body)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
var body struct {
|
||||
Section string `json:"section"`
|
||||
Titles []struct {
|
||||
Name string `json:"name"`
|
||||
Marked bool `json:"marked"`
|
||||
NeedsSync bool `json:"needsSync"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
FileCount int `json:"fileCount"`
|
||||
ArtURL string `json:"artUrl"`
|
||||
Arr *struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
} `json:"arr"`
|
||||
} `json:"titles"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Section != "movies" || len(body.Titles) != 2 {
|
||||
t.Fatalf("body = %+v", body)
|
||||
}
|
||||
// Sorted case-insensitively: "Finding Nemo (2003)" then "Unknown Film".
|
||||
matched, unmatched := body.Titles[0], body.Titles[1]
|
||||
if matched.Arr == nil || matched.Arr.ID != 9 || matched.Arr.Year != 2003 {
|
||||
t.Fatalf("matched title missing arr metadata: %+v", matched)
|
||||
}
|
||||
if matched.ArtURL != "/api/art/movies/9" {
|
||||
t.Errorf("artUrl = %q", matched.ArtURL)
|
||||
}
|
||||
if matched.SizeBytes != 10 || matched.FileCount != 1 || matched.Marked {
|
||||
t.Errorf("matched stats = %+v", matched)
|
||||
}
|
||||
if unmatched.Arr != nil || unmatched.ArtURL != "" {
|
||||
t.Errorf("unmatched title should carry no arr metadata: %+v", unmatched)
|
||||
}
|
||||
// The upstream key must never surface in the response.
|
||||
if strings.Contains(rec.Body.String(), upstreamKey) {
|
||||
t.Fatal("list response leaked the upstream api key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDegradesWhenArrIsDown(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "boom "+upstreamKey, http.StatusInternalServerError)
|
||||
})
|
||||
e.writeTitle(t, library.SectionMovies, "Nemo", "movie.mkv", "x")
|
||||
|
||||
rec := e.do(t, http.MethodGet, "/api/library/movies")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 with degraded metadata", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"name":"Nemo"`) {
|
||||
t.Fatalf("title missing from degraded list: %s", rec.Body)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), upstreamKey) {
|
||||
t.Fatal("degraded list leaked the upstream api key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkAndUnmarkRoundTrip(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
e.writeTitle(t, library.SectionTVSeries, "Bluey", "s01/e01.mkv", "one")
|
||||
e.writeTitle(t, library.SectionTVSeries, "Bluey", "s01/e02.mkv", "two")
|
||||
|
||||
rec := e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/mark")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("mark = %d body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var marked struct {
|
||||
Title library.Title `json:"title"`
|
||||
Sync library.SyncResult `json:"sync"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &marked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !marked.Title.Marked || marked.Title.NeedsSync || marked.Sync.Linked != 2 {
|
||||
t.Fatalf("mark result = %+v", marked)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "tvseries", "Bluey", "s01", "e01.mkv")); err != nil {
|
||||
t.Fatalf("hardlink missing: %v", err)
|
||||
}
|
||||
|
||||
// Marking again is idempotent.
|
||||
rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/mark")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("second mark = %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/unmark")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("unmark = %d body %s", rec.Code, rec.Body)
|
||||
}
|
||||
var unmarked struct {
|
||||
Title library.Title `json:"title"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &unmarked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unmarked.Title.Marked {
|
||||
t.Fatalf("still marked after unmark: %+v", unmarked)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "tvseries", "Bluey")); !os.IsNotExist(err) {
|
||||
t.Fatal("kids directory survived unmark")
|
||||
}
|
||||
// Unmarking again is idempotent.
|
||||
if rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/unmark"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("second unmark = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationRejectsBadSectionAndName(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
cases := []struct {
|
||||
path string
|
||||
want int
|
||||
}{
|
||||
{"/api/library/music/Nemo/mark", http.StatusNotFound},
|
||||
{"/api/library/movies/Ghost/mark", http.StatusNotFound},
|
||||
{"/api/library/movies/..%2f..%2fetc/mark", http.StatusBadRequest},
|
||||
{`/api/library/movies/..\..\etc/mark`, http.StatusBadRequest},
|
||||
// A literal ../ never reaches a handler: net/http's mux normalises the
|
||||
// path and redirects, so no mutation runs.
|
||||
{"/api/library/movies/../mark", http.StatusTemporaryRedirect},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := e.do(t, http.MethodPost, c.path)
|
||||
if rec.Code != c.want {
|
||||
t.Errorf("POST %s = %d, want %d (%s)", c.path, rec.Code, c.want, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtProxyStreamsAndHidesTheKey(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v3/mediacover/9/poster-250.jpg" {
|
||||
t.Errorf("art path = %q", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Api-Key") != upstreamKey {
|
||||
t.Error("art request missing the api key")
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("JPEGDATA"))
|
||||
})
|
||||
|
||||
rec := e.do(t, http.MethodGet, "/api/art/movies/9")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("art = %d body %s", rec.Code, rec.Body)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
|
||||
t.Errorf("Content-Type = %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=86400" {
|
||||
t.Errorf("Cache-Control = %q", got)
|
||||
}
|
||||
if rec.Body.String() != "JPEGDATA" {
|
||||
t.Errorf("body = %q", rec.Body)
|
||||
}
|
||||
for k, vs := range rec.Header() {
|
||||
for _, v := range vs {
|
||||
if strings.Contains(v, upstreamKey) {
|
||||
t.Fatalf("header %s leaked the api key", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtProxyErrorsAreOpaque(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "upstream said "+upstreamKey, http.StatusInternalServerError)
|
||||
})
|
||||
rec := e.do(t, http.MethodGet, "/api/art/movies/9")
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("art error status = %d, want 502", rec.Code)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), upstreamKey) {
|
||||
t.Fatalf("art error leaked the api key: %s", rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtRejectsBadIDAndUnknownSection(t *testing.T) {
|
||||
e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("x")) })
|
||||
for path, want := range map[string]int{
|
||||
"/api/art/movies/0": http.StatusBadRequest,
|
||||
"/api/art/movies/abc": http.StatusBadRequest,
|
||||
"/api/art/music/1": http.StatusNotFound,
|
||||
} {
|
||||
if rec := e.do(t, http.MethodGet, path); rec.Code != want {
|
||||
t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAFallbackAndAssets(t *testing.T) {
|
||||
e := newEnv(t, nil)
|
||||
if rec := e.do(t, http.MethodGet, "/app.js"); rec.Code != http.StatusOK || rec.Body.String() != "// js" {
|
||||
t.Fatalf("asset = %d %q", rec.Code, rec.Body)
|
||||
}
|
||||
for _, p := range []string{"/", "/movies", "/tvseries/Bluey"} {
|
||||
rec := e.do(t, http.MethodGet, p)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "mediamark") {
|
||||
t.Errorf("SPA fallback for %s = %d %q", p, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
// Unknown API routes must 404 as JSON rather than falling back to the SPA.
|
||||
rec := e.do(t, http.MethodGet, "/api/nope")
|
||||
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html>") {
|
||||
t.Errorf("unknown api route = %d %q", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user