Add the initial mediamark app
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

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:
2026-08-29 21:27:09 +10:00
parent 3b63e8a7f1
commit c129cb99fc
26 changed files with 3456 additions and 1 deletions
+216
View File
@@ -0,0 +1,216 @@
// Package arr is a minimal typed client for the sonarr/radarr v3 APIs, covering
// only what mediamark needs: the series/movie list (for titles, artwork ids and
// metadata) and a poster proxy.
//
// The api key is read from a mounted secret on every call so a Vault Secrets
// Operator rotation is picked up without a restart, and it is never echoed into
// a response or an error string.
package arr
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
)
// maxListBytes caps a list response; the estate's libraries are a few MB of
// JSON at most and an unbounded read from a compromised upstream is a DoS.
const maxListBytes = 32 << 20
// maxArtBytes caps a proxied poster.
const maxArtBytes = 16 << 20
// ErrNoKey is returned when no api key is available for the app.
var ErrNoKey = errors.New("no api key available")
// Kind identifies which *arr an entry came from.
type Kind string
// The supported *arr kinds.
const (
KindSonarr Kind = "sonarr"
KindRadarr Kind = "radarr"
)
// Entry is the subset of a series/movie record mediamark shows.
type Entry struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
Overview string `json:"overview"`
Status string `json:"status"`
// Path is the *arr's on-disk directory; its basename matches the library
// directory name.
Path string `json:"path"`
}
// Dir returns the library directory name for the entry.
func (e Entry) Dir() string {
if e.Path == "" {
return ""
}
return path.Base(strings.TrimRight(strings.ReplaceAll(e.Path, `\`, "/"), "/"))
}
// KeyFunc returns the api key for an app name ("sonarr"/"radarr").
type KeyFunc func(app string) (string, bool)
// Client talks to one *arr instance.
type Client struct {
kind Kind
baseURL string
key KeyFunc
http *http.Client
mu sync.Mutex
cached []Entry
cachedAt time.Time
ttl time.Duration
}
// New builds a Client for baseURL (which already includes the instance UrlBase).
func New(kind Kind, baseURL string, key KeyFunc) *Client {
return &Client{
kind: kind,
baseURL: strings.TrimRight(baseURL, "/"),
key: key,
http: &http.Client{Timeout: 30 * time.Second},
ttl: 60 * time.Second,
}
}
// SetCacheTTL overrides the list cache lifetime (tests use a zero TTL).
func (c *Client) SetCacheTTL(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.ttl = d
}
// listPath is the collection endpoint for the kind.
func (c *Client) listPath() string {
if c.kind == KindSonarr {
return "/api/v3/series"
}
return "/api/v3/movie"
}
// List returns the instance's entries, served from a short-lived cache so list
// pages stay fast. A failed refresh does not poison the cache.
func (c *Client) List(ctx context.Context) ([]Entry, error) {
c.mu.Lock()
if c.cached != nil && c.ttl > 0 && time.Since(c.cachedAt) < c.ttl {
out := c.cached
c.mu.Unlock()
return out, nil
}
c.mu.Unlock()
entries, err := c.fetchList(ctx)
if err != nil {
return nil, err
}
c.mu.Lock()
c.cached, c.cachedAt = entries, time.Now()
c.mu.Unlock()
return entries, nil
}
func (c *Client) fetchList(ctx context.Context) ([]Entry, error) {
resp, err := c.do(ctx, c.listPath())
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s list: unexpected status %d", c.kind, resp.StatusCode)
}
out := []Entry{}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxListBytes)).Decode(&out); err != nil {
return nil, fmt.Errorf("%s list: decode: %w", c.kind, err)
}
return out, nil
}
// Art is a proxied poster response the caller must close.
type Art struct {
Body io.ReadCloser
ContentType string
}
// Poster streams the 250px poster for an entry id.
func (c *Client) Poster(ctx context.Context, id int) (*Art, error) {
resp, err := c.do(ctx, "/api/v3/mediacover/"+strconv.Itoa(id)+"/poster-250.jpg")
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
return nil, fmt.Errorf("%s poster %d: unexpected status %d", c.kind, id, resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = "image/jpeg"
}
return &Art{
Body: struct {
io.Reader
io.Closer
}{io.LimitReader(resp.Body, maxArtBytes), resp.Body},
ContentType: ct,
}, nil
}
// do issues an authenticated GET. Errors deliberately name only the app and
// path so a key can never reach a log line or an HTTP body.
func (c *Client) do(ctx context.Context, p string) (*http.Response, error) {
key, ok := c.key(string(c.kind))
if !ok {
return nil, fmt.Errorf("%s: %w", c.kind, ErrNoKey)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+p, nil)
if err != nil {
return nil, fmt.Errorf("%s %s: build request", c.kind, p)
}
req.Header.Set("X-Api-Key", key)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: request failed", c.kind, p)
}
return resp, nil
}
// FileKeys reads *arr api keys from a mounted secret directory, re-reading on
// every call so rotations land without a restart. ARR_<APP>_APIKEY overrides it
// for local development.
func FileKeys(dir string) KeyFunc {
return func(app string) (string, bool) {
env := "ARR_" + strings.ToUpper(app) + "_APIKEY"
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
return v, true
}
if dir == "" {
return "", false
}
// app is a fixed internal constant, never user input, but keep the
// read confined to the directory anyway.
if strings.ContainsAny(app, `/\`) {
return "", false
}
b, err := os.ReadFile(path.Join(dir, app))
if err != nil {
return "", false
}
key := strings.TrimSpace(string(b))
return key, key != ""
}
}
+208
View File
@@ -0,0 +1,208 @@
package arr
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
const testKey = "s3cr3t-api-key-value"
func staticKey(string) (string, bool) { return testKey, true }
func TestListSonarrAndRadarrHitTheRightEndpoint(t *testing.T) {
cases := []struct {
kind Kind
path string
body string
}{
{KindSonarr, "/base/api/v3/series", `[{"id":7,"title":"Bluey","year":2018,"overview":"dogs","status":"continuing","path":"/media/fafflix/tvseries/Bluey"}]`},
{KindRadarr, "/base/api/v3/movie", `[{"id":9,"title":"Nemo","year":2003,"overview":"fish","status":"released","path":"/media/fafflix/movies/Finding Nemo (2003)"}]`},
}
for _, c := range cases {
t.Run(string(c.kind), func(t *testing.T) {
var gotPath, gotKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotKey = r.URL.Path, r.Header.Get("X-Api-Key")
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, c.body)
}))
defer srv.Close()
cl := New(c.kind, srv.URL+"/base", staticKey)
entries, err := cl.List(context.Background())
if err != nil {
t.Fatal(err)
}
if gotPath != c.path {
t.Errorf("path = %q, want %q", gotPath, c.path)
}
if gotKey != testKey {
t.Errorf("X-Api-Key = %q", gotKey)
}
if len(entries) != 1 || entries[0].Title == "" || entries[0].Year == 0 {
t.Fatalf("entries = %+v", entries)
}
})
}
}
func TestEntryDirMatchesLibraryDirectory(t *testing.T) {
cases := []struct{ path, want string }{
{"/media/fafflix/movies/Finding Nemo (2003)", "Finding Nemo (2003)"},
{"/media/fafflix/tvseries/Bluey/", "Bluey"},
{`C:\media\movies\Cars`, "Cars"},
{"", ""},
}
for _, c := range cases {
if got := (Entry{Path: c.path}).Dir(); got != c.want {
t.Errorf("Dir(%q) = %q, want %q", c.path, got, c.want)
}
}
}
func TestListCachesAndExpires(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = io.WriteString(w, `[{"id":1,"title":"A","path":"/x/A"}]`)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
for i := 0; i < 3; i++ {
if _, err := cl.List(context.Background()); err != nil {
t.Fatal(err)
}
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (cached)", got)
}
cl.SetCacheTTL(time.Nanosecond)
time.Sleep(time.Millisecond)
if _, err := cl.List(context.Background()); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("upstream calls after expiry = %d, want 2", got)
}
}
func TestListErrorsNeverLeakTheKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Echo the key back the way a chatty upstream error page might.
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, "bad key "+testKey)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
_, err := cl.List(context.Background())
if err == nil {
t.Fatal("List succeeded on a 401")
}
if strings.Contains(err.Error(), testKey) {
t.Fatalf("error leaked the api key: %v", err)
}
}
func TestNoKeyFailsClosed(t *testing.T) {
cl := New(KindSonarr, "http://unused.invalid", func(string) (string, bool) { return "", false })
if _, err := cl.List(context.Background()); !errors.Is(err, ErrNoKey) {
t.Fatalf("List = %v, want ErrNoKey", err)
}
if _, err := cl.Poster(context.Background(), 1); !errors.Is(err, ErrNoKey) {
t.Fatalf("Poster = %v, want ErrNoKey", err)
}
}
func TestPosterStreamsWithContentType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/mediacover/42/poster-250.jpg" {
t.Errorf("poster path = %q", r.URL.Path)
}
if r.Header.Get("X-Api-Key") != testKey {
t.Error("poster request missing the api key")
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte{0x89, 'P', 'N', 'G'})
}))
defer srv.Close()
cl := New(KindRadarr, srv.URL, staticKey)
art, err := cl.Poster(context.Background(), 42)
if err != nil {
t.Fatal(err)
}
defer func() { _ = art.Body.Close() }()
if art.ContentType != "image/png" {
t.Errorf("ContentType = %q", art.ContentType)
}
b, err := io.ReadAll(art.Body)
if err != nil || len(b) != 4 {
t.Fatalf("body = %v %v", b, err)
}
}
func TestPosterMissingIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "not found "+testKey, http.StatusNotFound)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
_, err := cl.Poster(context.Background(), 1)
if err == nil {
t.Fatal("Poster succeeded on a 404")
}
if strings.Contains(err.Error(), testKey) {
t.Fatalf("error leaked the api key: %v", err)
}
}
func TestFileKeysReReadsAfterRotation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sonarr")
if err := os.WriteFile(path, []byte("first\n"), 0o600); err != nil {
t.Fatal(err)
}
kf := FileKeys(dir)
if k, ok := kf("sonarr"); !ok || k != "first" {
t.Fatalf("key = %q %v", k, ok)
}
if err := os.WriteFile(path, []byte(" second "), 0o600); err != nil {
t.Fatal(err)
}
if k, ok := kf("sonarr"); !ok || k != "second" {
t.Fatalf("rotated key = %q %v, want trimmed second", k, ok)
}
if _, ok := kf("radarr"); ok {
t.Fatal("missing key file reported ok")
}
if err := os.WriteFile(filepath.Join(dir, "empty"), []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := kf("empty"); ok {
t.Fatal("blank key file reported ok")
}
if _, ok := kf("../etc/passwd"); ok {
t.Fatal("traversal key name reported ok")
}
}
func TestFileKeysEnvOverride(t *testing.T) {
t.Setenv("ARR_SONARR_APIKEY", "from-env")
kf := FileKeys(t.TempDir())
if k, ok := kf("sonarr"); !ok || k != "from-env" {
t.Fatalf("key = %q %v, want from-env", k, ok)
}
}
+55
View File
@@ -0,0 +1,55 @@
// Package auth enforces Authentik group membership from the oauth2-proxy
// identity header. oauth2-proxy already gates the route, but mediamark mutates
// the on-disk library, so it re-checks the group server-side rather than
// trusting the front door alone.
package auth
import (
"net/http"
"git.unkin.net/unkin/mediamark/internal/config"
)
// Middleware rejects requests whose group header carries none of the allowed
// groups. header is the request header to read; allowed must be non-empty.
type Middleware struct {
header string
allowed map[string]bool
}
// New builds a Middleware. An empty allowed set denies everything, which is the
// correct fail-closed behaviour if config validation is ever bypassed.
func New(header string, allowed []string) *Middleware {
m := &Middleware{header: header, allowed: make(map[string]bool, len(allowed))}
for _, g := range allowed {
m.allowed[g] = true
}
return m
}
// Permit reports whether the request carries an allowed group.
func (m *Middleware) Permit(r *http.Request) bool {
if len(m.allowed) == 0 {
return false
}
for _, v := range r.Header.Values(m.header) {
for _, g := range config.ParseGroups(v) {
if m.allowed[g] {
return true
}
}
}
return false
}
// Wrap gates next behind Permit, answering 403 with a plain body that never
// echoes the submitted groups back to the caller.
func (m *Middleware) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !m.Permit(r) {
http.Error(w, "forbidden: missing required group", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
+91
View File
@@ -0,0 +1,91 @@
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPermit(t *testing.T) {
m := New("X-Forwarded-Groups", []string{"akP-mediamark-user", "akR-admins"})
cases := []struct {
name string
values []string
want bool
}{
{"no header", nil, false},
{"empty header", []string{""}, false},
{"exact match", []string{"akP-mediamark-user"}, true},
{"comma list containing match", []string{"akP-other,akP-mediamark-user,akP-x"}, true},
{"space separated", []string{"akP-other akR-admins"}, true},
{"padded", []string{" akP-mediamark-user "}, true},
{"repeated header lines", []string{"akP-nope", "akR-admins"}, true},
{"unrelated groups only", []string{"akP-arrstack-kids,akP-nope"}, false},
{"prefix lookalike", []string{"akP-mediamark-users"}, false},
{"substring lookalike", []string{"xakP-mediamark-user"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/api/library/movies", nil)
for _, v := range c.values {
r.Header.Add("X-Forwarded-Groups", v)
}
if got := m.Permit(r); got != c.want {
t.Fatalf("Permit = %v, want %v", got, c.want)
}
})
}
}
func TestEmptyAllowListDeniesEverything(t *testing.T) {
m := New("X-Forwarded-Groups", nil)
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-Forwarded-Groups", "anything")
if m.Permit(r) {
t.Fatal("empty allow-list permitted a request")
}
}
func TestWrapBlocksAndPasses(t *testing.T) {
m := New("X-Grp", []string{"good"})
called := false
h := m.Wrap(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusTeapot)
}))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/library/movies/x/mark", nil)
req.Header.Set("X-Grp", "bad,worse")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if called {
t.Fatal("handler ran despite a forbidden request")
}
// The rejection must not echo the submitted groups back.
if strings.Contains(rec.Body.String(), "worse") {
t.Fatalf("403 body echoed submitted groups: %q", rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/library/movies/x/mark", nil)
req.Header.Set("X-Grp", "good")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTeapot || !called {
t.Fatalf("allowed request not passed through: status %d called %v", rec.Code, called)
}
}
// The configured header is the only one trusted; a client-forged alternative
// must not grant access.
func TestOnlyConfiguredHeaderIsRead(t *testing.T) {
m := New("X-Auth-Request-Groups", []string{"good"})
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("X-Forwarded-Groups", "good")
if m.Permit(r) {
t.Fatal("a non-configured header granted access")
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package config loads mediamark runtime configuration from the environment.
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Config is the fully-resolved mediamark configuration.
type Config struct {
// Listen is the HTTP listen address, e.g. ":8080".
Listen string
// MediaRoot holds the fafflix/ (source) and cheeztv/ (kids) library trees.
MediaRoot string
// KeysDir holds per-app *arr api keys as files named "sonarr"/"radarr",
// projected by the Vault Secrets Operator and re-read on every use.
KeysDir string
// SonarrURL and RadarrURL include the *arr instance's UrlBase; the API
// lives at <url>/api/v3/...
SonarrURL string
RadarrURL string
// GroupsHeader is the oauth2-proxy header carrying Authentik group names.
GroupsHeader string
// AllowedGroups gates every page load and API call. Never empty.
AllowedGroups []string
}
// Load resolves configuration from the environment, failing closed on an empty
// allow-list (an empty list would authorize nobody or, worse, be read as
// "anyone" by a future refactor).
func Load() (*Config, error) {
c := &Config{
Listen: envOr("MEDIAMARK_LISTEN", ":8080"),
MediaRoot: envOr("MEDIAMARK_MEDIA_ROOT", "/media"),
KeysDir: envOr("MEDIAMARK_KEYS_DIR", "/etc/mediamark/keys"),
SonarrURL: envOr("MEDIAMARK_SONARR_URL", "http://sonarr.arrstack.svc.cluster.local:8989/3aa168/sonarr"),
RadarrURL: envOr("MEDIAMARK_RADARR_URL", "http://radarr.arrstack.svc.cluster.local:7878/3aa168/radarr"),
GroupsHeader: envOr("MEDIAMARK_GROUPS_HEADER", "X-Forwarded-Groups"),
AllowedGroups: ParseGroups(envOr("MEDIAMARK_ALLOWED_GROUPS", "akP-mediamark-user")),
}
if len(c.AllowedGroups) == 0 {
return nil, fmt.Errorf("MEDIAMARK_ALLOWED_GROUPS must name at least one group")
}
if strings.TrimSpace(c.GroupsHeader) == "" {
return nil, fmt.Errorf("MEDIAMARK_GROUPS_HEADER must not be empty")
}
if !filepath.IsAbs(c.MediaRoot) {
return nil, fmt.Errorf("MEDIAMARK_MEDIA_ROOT %q must be an absolute path", c.MediaRoot)
}
c.MediaRoot = filepath.Clean(c.MediaRoot)
for _, u := range []struct{ name, val string }{{"MEDIAMARK_SONARR_URL", c.SonarrURL}, {"MEDIAMARK_RADARR_URL", c.RadarrURL}} {
if !strings.HasPrefix(u.val, "http://") && !strings.HasPrefix(u.val, "https://") {
return nil, fmt.Errorf("%s %q must be an http(s) URL", u.name, u.val)
}
}
return c, nil
}
// ParseGroups splits a group list tolerating both comma and whitespace
// separation, dropping empties. oauth2-proxy emits comma-separated groups but
// deployments hand-write the allow-list.
func ParseGroups(s string) []string {
fields := strings.FieldsFunc(s, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ';'
})
out := make([]string, 0, len(fields))
for _, f := range fields {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
+87
View File
@@ -0,0 +1,87 @@
package config
import (
"reflect"
"testing"
)
func TestParseGroups(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", []string{}},
{"akP-mediamark-user", []string{"akP-mediamark-user"}},
{"a,b,c", []string{"a", "b", "c"}},
{" a , b ,, c ", []string{"a", "b", "c"}},
{"a b\tc", []string{"a", "b", "c"}},
{"a,\nb;c", []string{"a", "b", "c"}},
{",,,", []string{}},
}
for _, c := range cases {
if got := ParseGroups(c.in); !reflect.DeepEqual(got, c.want) {
t.Errorf("ParseGroups(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestLoadDefaults(t *testing.T) {
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Listen != ":8080" || cfg.MediaRoot != "/media" || cfg.KeysDir != "/etc/mediamark/keys" {
t.Fatalf("unexpected defaults: %+v", cfg)
}
if cfg.GroupsHeader != "X-Forwarded-Groups" {
t.Fatalf("GroupsHeader = %q", cfg.GroupsHeader)
}
if !reflect.DeepEqual(cfg.AllowedGroups, []string{"akP-mediamark-user"}) {
t.Fatalf("AllowedGroups = %v", cfg.AllowedGroups)
}
}
func TestLoadFailsClosed(t *testing.T) {
cases := []struct {
name string
env map[string]string
}{
{"blank allowed groups", map[string]string{"MEDIAMARK_ALLOWED_GROUPS": " , , "}},
{"relative media root", map[string]string{"MEDIAMARK_MEDIA_ROOT": "media"}},
{"non-http sonarr url", map[string]string{"MEDIAMARK_SONARR_URL": "sonarr:8989"}},
{"non-http radarr url", map[string]string{"MEDIAMARK_RADARR_URL": "ftp://radarr"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
for k, v := range c.env {
t.Setenv(k, v)
}
if _, err := Load(); err == nil {
t.Fatal("Load succeeded, want error")
}
})
}
}
func TestLoadOverrides(t *testing.T) {
t.Setenv("MEDIAMARK_LISTEN", ":9999")
t.Setenv("MEDIAMARK_MEDIA_ROOT", "/srv/media/")
t.Setenv("MEDIAMARK_ALLOWED_GROUPS", "akP-mediamark-user, akR-admins")
t.Setenv("MEDIAMARK_GROUPS_HEADER", "X-Auth-Request-Groups")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.Listen != ":9999" {
t.Errorf("Listen = %q", cfg.Listen)
}
if cfg.MediaRoot != "/srv/media" {
t.Errorf("MediaRoot = %q, want cleaned /srv/media", cfg.MediaRoot)
}
if !reflect.DeepEqual(cfg.AllowedGroups, []string{"akP-mediamark-user", "akR-admins"}) {
t.Errorf("AllowedGroups = %v", cfg.AllowedGroups)
}
if cfg.GroupsHeader != "X-Auth-Request-Groups" {
t.Errorf("GroupsHeader = %q", cfg.GroupsHeader)
}
}
+367
View File
@@ -0,0 +1,367 @@
// Package library models the two-tree media layout and the hardlink sync that
// marks a title as kids ("cheeztv") content.
//
// Layout under the media root:
//
// fafflix/{tvseries,movies}/<title>/... source library
// cheeztv/{tvseries,movies}/<title>/... kids tree (hardlinks into fafflix)
//
// Marking a title hardlinks every regular file across; unmarking removes only
// the kids-side directory, which never destroys data because the source inode
// keeps a link.
package library
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// Tree names, relative to the media root.
const (
SourceTree = "fafflix"
KidsTree = "cheeztv"
)
// Section is a library section directory shared by both trees.
type Section string
// The supported sections.
const (
SectionTVSeries Section = "tvseries"
SectionMovies Section = "movies"
)
// Sections lists every valid section in display order.
var Sections = []Section{SectionMovies, SectionTVSeries}
// ErrInvalidSection is returned for a section outside Sections.
var ErrInvalidSection = errors.New("invalid section")
// ErrInvalidName is returned when a title name is not a single, clean path
// element — the guard against path traversal out of the library roots.
var ErrInvalidName = errors.New("invalid title name")
// ErrNotFound is returned when a title has no source directory.
var ErrNotFound = errors.New("title not found")
// ParseSection validates s against the known sections.
func ParseSection(s string) (Section, error) {
for _, k := range Sections {
if string(k) == s {
return k, nil
}
}
return "", fmt.Errorf("%w: %q", ErrInvalidSection, s)
}
// ValidateName rejects anything that is not a single, clean path element:
// empties, dot entries, absolute paths, and any name containing a separator.
// This is the sole traversal guard between an HTTP path segment and the
// filesystem, so it is deliberately strict rather than sanitising.
func ValidateName(name string) error {
switch {
case name == "":
return fmt.Errorf("%w: empty", ErrInvalidName)
case name == "." || name == "..":
return fmt.Errorf("%w: dot entry", ErrInvalidName)
// A backslash is a legal byte in a Linux filename, but it is a separator on
// the platforms that write into these libraries; reject it rather than
// reason about which side produced the name.
case strings.ContainsRune(name, '/'), strings.ContainsRune(name, '\\'), strings.ContainsRune(name, os.PathSeparator):
return fmt.Errorf("%w: contains a path separator", ErrInvalidName)
case strings.ContainsRune(name, 0):
return fmt.Errorf("%w: contains NUL", ErrInvalidName)
case filepath.IsAbs(name):
return fmt.Errorf("%w: absolute path", ErrInvalidName)
case name != filepath.Clean(name):
return fmt.Errorf("%w: not a clean path element", ErrInvalidName)
}
return nil
}
// Title is the reported state of one library title.
type Title struct {
Name string `json:"name"`
// Marked is true when the kids tree has a directory of the same name.
Marked bool `json:"marked"`
// NeedsSync is true for a marked title whose source holds regular files
// that are not linked into the kids tree (new episodes, upgrades).
NeedsSync bool `json:"needsSync"`
// SizeBytes and FileCount describe the source directory.
SizeBytes int64 `json:"sizeBytes"`
FileCount int `json:"fileCount"`
// UnlinkedFiles counts the source files missing from the kids tree; zero
// for an unmarked title.
UnlinkedFiles int `json:"unlinkedFiles"`
}
// Library reads and mutates the two trees under Root.
type Library struct {
Root string
}
// New returns a Library rooted at the (cleaned) media root.
func New(root string) *Library { return &Library{Root: filepath.Clean(root)} }
// sourceRoot and kidsRoot are the per-section tree roots.
func (l *Library) sourceRoot(s Section) string { return filepath.Join(l.Root, SourceTree, string(s)) }
func (l *Library) kidsRoot(s Section) string { return filepath.Join(l.Root, KidsTree, string(s)) }
// paths resolves the source and kids directories for a validated title, and
// re-verifies containment: ValidateName already makes escape impossible, but
// the check costs nothing and keeps the invariant local to the filesystem call.
func (l *Library) paths(section Section, name string) (src, kids string, err error) {
if _, err := ParseSection(string(section)); err != nil {
return "", "", err
}
if err := ValidateName(name); err != nil {
return "", "", err
}
src = filepath.Join(l.sourceRoot(section), name)
kids = filepath.Join(l.kidsRoot(section), name)
if !under(l.sourceRoot(section), src) || !under(l.kidsRoot(section), kids) {
return "", "", fmt.Errorf("%w: escapes the library root", ErrInvalidName)
}
return src, kids, nil
}
// under reports whether path is root itself or lies beneath it.
func under(root, path string) bool {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
if err != nil {
return false
}
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}
// Ready reports whether the media root is visible, backing /readyz.
func (l *Library) Ready() error {
fi, err := os.Stat(l.Root)
if err != nil {
return err
}
if !fi.IsDir() {
return fmt.Errorf("media root %q is not a directory", l.Root)
}
return nil
}
// List reports every title in a section, sorted case-insensitively by name.
func (l *Library) List(section Section) ([]Title, error) {
if _, err := ParseSection(string(section)); err != nil {
return nil, err
}
entries, err := os.ReadDir(l.sourceRoot(section))
if err != nil {
if os.IsNotExist(err) {
return []Title{}, nil
}
return nil, err
}
out := make([]Title, 0, len(entries))
for _, e := range entries {
if !e.IsDir() || ValidateName(e.Name()) != nil {
continue
}
t, err := l.Stat(section, e.Name())
if err != nil {
continue
}
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool {
li, lj := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name)
if li == lj {
return out[i].Name < out[j].Name
}
return li < lj
})
return out, nil
}
// Stat reports one title's size, file count, marked state and link drift.
func (l *Library) Stat(section Section, name string) (Title, error) {
src, kids, err := l.paths(section, name)
if err != nil {
return Title{}, err
}
fi, err := os.Stat(src)
if err != nil {
if os.IsNotExist(err) {
return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
}
return Title{}, err
}
if !fi.IsDir() {
return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
}
t := Title{Name: name}
if kfi, err := os.Stat(kids); err == nil && kfi.IsDir() {
t.Marked = true
}
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
// A vanished file mid-walk is normal on a live library; skip it
// rather than failing the whole listing.
if os.IsNotExist(err) {
return nil
}
return err
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
t.FileCount++
t.SizeBytes += info.Size()
if !t.Marked {
return nil
}
rel, err := filepath.Rel(src, p)
if err != nil {
return nil
}
if !sameFile(p, filepath.Join(kids, rel)) {
t.UnlinkedFiles++
}
return nil
})
if err != nil {
return Title{}, err
}
t.NeedsSync = t.Marked && t.UnlinkedFiles > 0
return t, nil
}
// sameFile reports whether both paths resolve to the same inode.
func sameFile(a, b string) bool {
ai, err := os.Lstat(a)
if err != nil {
return false
}
bi, err := os.Lstat(b)
if err != nil {
return false
}
return os.SameFile(ai, bi)
}
// SyncResult reports what a Mark run changed.
type SyncResult struct {
Linked int `json:"linked"`
Replaced int `json:"replaced"`
Unchanged int `json:"unchanged"`
Skipped int `json:"skipped"`
Dirs int `json:"dirs"`
}
// Mark hardlinks every regular file of a title into the kids tree, creating the
// directory skeleton as it goes. It is idempotent: files already linked to the
// same inode are left alone, a kids-side file pointing at a different inode is
// replaced, and symlinks/devices/sockets are skipped. Re-running after new
// episodes arrive syncs only the new files.
func (l *Library) Mark(section Section, name string) (SyncResult, error) {
src, kids, err := l.paths(section, name)
if err != nil {
return SyncResult{}, err
}
fi, err := os.Stat(src)
if err != nil || !fi.IsDir() {
return SyncResult{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
}
if err := os.MkdirAll(kids, 0o755); err != nil {
return SyncResult{}, err
}
var res SyncResult
walkErr := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
dst := filepath.Join(kids, rel)
if !under(kids, dst) {
// Unreachable for a well-formed walk; belt-and-braces against a
// crafted name surviving the entry guards.
return fmt.Errorf("refusing to write outside the kids tree: %s", dst)
}
switch {
case d.IsDir():
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
res.Dirs++
return nil
case !d.Type().IsRegular():
res.Skipped++
return nil
}
return linkFile(p, dst, &res)
})
if walkErr != nil {
return res, walkErr
}
return res, nil
}
// linkFile hardlinks src to dst, replacing a stale link to a different inode.
func linkFile(src, dst string, res *SyncResult) error {
di, err := os.Lstat(dst)
switch {
case err == nil:
si, serr := os.Lstat(src)
if serr == nil && di.Mode().IsRegular() && os.SameFile(si, di) {
res.Unchanged++
return nil
}
if err := os.Remove(dst); err != nil {
return err
}
if err := os.Link(src, dst); err != nil {
return err
}
res.Replaced++
return nil
case !os.IsNotExist(err):
return err
}
if err := os.Link(src, dst); err != nil {
// A file that disappeared between the walk and the link is not fatal.
if os.IsNotExist(err) {
res.Skipped++
return nil
}
return err
}
res.Linked++
return nil
}
// Unmark removes a title's kids-tree directory. Only hardlinks live there, so
// the source library is untouched. Removing an already-absent title succeeds.
func (l *Library) Unmark(section Section, name string) error {
_, kids, err := l.paths(section, name)
if err != nil {
return err
}
return os.RemoveAll(kids)
}
+289
View File
@@ -0,0 +1,289 @@
package library
import (
"errors"
"os"
"path/filepath"
"testing"
)
// newLib builds a media root with the four tree/section dirs and returns it.
func newLib(t *testing.T) *Library {
t.Helper()
root := t.TempDir()
for _, tree := range []string{SourceTree, KidsTree} {
for _, s := range Sections {
if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil {
t.Fatal(err)
}
}
}
return New(root)
}
// writeSrc creates a source file with content, making parents as needed.
func writeSrc(t *testing.T, l *Library, s Section, rel, content string) string {
t.Helper()
p := filepath.Join(l.sourceRoot(s), 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)
}
return p
}
func TestValidateNameRejectsTraversal(t *testing.T) {
bad := []string{
"", ".", "..", "../etc", "..", "a/b", "/abs", "/etc/passwd",
"sub/../..", "./x", "x/", "a\x00b", "..\\evil",
}
for _, name := range bad {
if err := ValidateName(name); err == nil {
t.Errorf("ValidateName(%q) = nil, want error", name)
} else if !errors.Is(err, ErrInvalidName) {
t.Errorf("ValidateName(%q) error %v, want ErrInvalidName", name, err)
}
}
good := []string{"The Muppets", "Movie (2019)", "a.b.c", "...", "dot.dir", "Ünïcødé"}
for _, name := range good {
if err := ValidateName(name); err != nil {
t.Errorf("ValidateName(%q) = %v, want nil", name, err)
}
}
}
func TestPathsRejectsTraversalAndKeepsContainment(t *testing.T) {
l := newLib(t)
if _, _, err := l.paths(SectionMovies, "../../etc"); err == nil {
t.Fatal("paths accepted a traversal name")
}
src, kids, err := l.paths(SectionMovies, "Nemo")
if err != nil {
t.Fatal(err)
}
if !under(l.sourceRoot(SectionMovies), src) || !under(l.kidsRoot(SectionMovies), kids) {
t.Fatalf("resolved paths escaped roots: %s %s", src, kids)
}
}
// TestMarkOperationsCannotEscapeRoot proves a crafted name never touches a file
// outside the media root, even when the target already exists there.
func TestMarkOperationsCannotEscapeRoot(t *testing.T) {
l := newLib(t)
outside := filepath.Join(t.TempDir(), "victim")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"../../../" + filepath.Base(outside), "..", "../movies"} {
if _, err := l.Mark(SectionMovies, name); !errors.Is(err, ErrInvalidName) {
t.Errorf("Mark(%q) error = %v, want ErrInvalidName", name, err)
}
if err := l.Unmark(SectionMovies, name); !errors.Is(err, ErrInvalidName) {
t.Errorf("Unmark(%q) error = %v, want ErrInvalidName", name, err)
}
}
if _, err := os.Stat(outside); err != nil {
t.Fatalf("victim directory was disturbed: %v", err)
}
}
func TestMarkHardlinksTreeAndIsIdempotent(t *testing.T) {
l := newLib(t)
writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e01.mkv", "one")
writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e02.mkv", "two")
writeSrc(t, l, SectionTVSeries, "Bluey/poster.jpg", "art")
res, err := l.Mark(SectionTVSeries, "Bluey")
if err != nil {
t.Fatal(err)
}
if res.Linked != 3 {
t.Fatalf("Linked = %d, want 3", res.Linked)
}
// Every kids file must share an inode with its source.
srcFile := filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv")
kidsFile := filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv")
if !sameFile(srcFile, kidsFile) {
t.Fatal("kids file is not a hardlink of the source")
}
// Re-running links nothing new.
res2, err := l.Mark(SectionTVSeries, "Bluey")
if err != nil {
t.Fatal(err)
}
if res2.Linked != 0 || res2.Unchanged != 3 {
t.Fatalf("second Mark = %+v, want 0 linked / 3 unchanged", res2)
}
}
func TestMarkSyncsNewFilesAndReplacesStaleLinks(t *testing.T) {
l := newLib(t)
writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one")
if _, err := l.Mark(SectionTVSeries, "Bluey"); err != nil {
t.Fatal(err)
}
// A new episode arrives and an existing file is replaced upstream (a new
// inode with the same name, as an *arr upgrade does).
writeSrc(t, l, SectionTVSeries, "Bluey/e02.mkv", "two")
if err := os.Remove(filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/e01.mkv")); err != nil {
t.Fatal(err)
}
writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one-upgraded")
tit, err := l.Stat(SectionTVSeries, "Bluey")
if err != nil {
t.Fatal(err)
}
if !tit.NeedsSync || tit.UnlinkedFiles != 2 {
t.Fatalf("drift = %+v, want NeedsSync with 2 unlinked", tit)
}
res, err := l.Mark(SectionTVSeries, "Bluey")
if err != nil {
t.Fatal(err)
}
if res.Linked != 1 || res.Replaced != 1 {
t.Fatalf("sync = %+v, want 1 linked / 1 replaced", res)
}
tit, err = l.Stat(SectionTVSeries, "Bluey")
if err != nil {
t.Fatal(err)
}
if tit.NeedsSync || tit.UnlinkedFiles != 0 {
t.Fatalf("post-sync drift = %+v, want none", tit)
}
b, err := os.ReadFile(filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/e01.mkv"))
if err != nil || string(b) != "one-upgraded" {
t.Fatalf("stale link not replaced: %q %v", b, err)
}
}
func TestMarkSkipsSymlinks(t *testing.T) {
l := newLib(t)
writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film")
link := filepath.Join(l.sourceRoot(SectionMovies), "Nemo", "elsewhere.mkv")
if err := os.Symlink("/dev/null", link); err != nil {
t.Skipf("symlinks unsupported: %v", err)
}
res, err := l.Mark(SectionMovies, "Nemo")
if err != nil {
t.Fatal(err)
}
if res.Linked != 1 || res.Skipped != 1 {
t.Fatalf("res = %+v, want 1 linked / 1 skipped", res)
}
if _, err := os.Lstat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo", "elsewhere.mkv")); !os.IsNotExist(err) {
t.Fatal("symlink was copied into the kids tree")
}
}
func TestUnmarkLeavesSourceIntact(t *testing.T) {
l := newLib(t)
writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film")
if _, err := l.Mark(SectionMovies, "Nemo"); err != nil {
t.Fatal(err)
}
if err := l.Unmark(SectionMovies, "Nemo"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo")); !os.IsNotExist(err) {
t.Fatal("kids directory survived unmark")
}
b, err := os.ReadFile(filepath.Join(l.sourceRoot(SectionMovies), "Nemo/movie.mkv"))
if err != nil || string(b) != "film" {
t.Fatalf("source damaged by unmark: %q %v", b, err)
}
// Unmarking again is a no-op, not an error.
if err := l.Unmark(SectionMovies, "Nemo"); err != nil {
t.Fatalf("second Unmark = %v, want nil", err)
}
}
func TestStatAndListReportSizeCountAndMarks(t *testing.T) {
l := newLib(t)
writeSrc(t, l, SectionMovies, "Zootopia/movie.mkv", "0123456789")
writeSrc(t, l, SectionMovies, "Zootopia/extra.nfo", "abc")
writeSrc(t, l, SectionMovies, "aladdin/movie.mkv", "xy")
if _, err := l.Mark(SectionMovies, "Zootopia"); err != nil {
t.Fatal(err)
}
got, err := l.Stat(SectionMovies, "Zootopia")
if err != nil {
t.Fatal(err)
}
if got.FileCount != 2 || got.SizeBytes != 13 || !got.Marked || got.NeedsSync {
t.Fatalf("Stat = %+v, want 2 files / 13 bytes / marked / synced", got)
}
list, err := l.List(SectionMovies)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("List returned %d titles, want 2", len(list))
}
// Case-insensitive ordering: "aladdin" before "Zootopia".
if list[0].Name != "aladdin" || list[1].Name != "Zootopia" {
t.Fatalf("List order = %q, %q", list[0].Name, list[1].Name)
}
if list[0].Marked || !list[1].Marked {
t.Fatalf("marked flags wrong: %+v", list)
}
}
func TestListMissingSectionIsEmptyNotError(t *testing.T) {
l := New(t.TempDir())
got, err := l.List(SectionMovies)
if err != nil {
t.Fatalf("List = %v, want nil error", err)
}
if len(got) != 0 {
t.Fatalf("List = %v, want empty", got)
}
}
func TestStatMissingTitleIsNotFound(t *testing.T) {
l := newLib(t)
if _, err := l.Stat(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) {
t.Fatalf("Stat = %v, want ErrNotFound", err)
}
if _, err := l.Mark(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) {
t.Fatalf("Mark = %v, want ErrNotFound", err)
}
}
func TestParseSection(t *testing.T) {
for _, ok := range []string{"movies", "tvseries"} {
if _, err := ParseSection(ok); err != nil {
t.Errorf("ParseSection(%q) = %v", ok, err)
}
}
for _, bad := range []string{"", "Movies", "music", "../movies", "tvseries/x"} {
if _, err := ParseSection(bad); !errors.Is(err, ErrInvalidSection) {
t.Errorf("ParseSection(%q) = %v, want ErrInvalidSection", bad, err)
}
}
}
func TestReady(t *testing.T) {
l := newLib(t)
if err := l.Ready(); err != nil {
t.Fatalf("Ready = %v", err)
}
if err := New(filepath.Join(t.TempDir(), "absent")).Ready(); err == nil {
t.Fatal("Ready on a missing root = nil, want error")
}
f := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(f, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := New(f).Ready(); err == nil {
t.Fatal("Ready on a file root = nil, want error")
}
}
+261
View File
@@ -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})
}
+364
View File
@@ -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)
}
}