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