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,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 != ""
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user