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
+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")
}
}