From 0f4e92862259a404be5075261ebd9ff2fc73e9d1 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 11 Apr 2026 00:02:59 -0300 Subject: [PATCH] pegasus: tighten quality gate --- Dockerfile | 2 +- Jenkinsfile | 44 +- backend/app.go | 57 ++ backend/app_test.go | 214 ++++++ backend/config.go | 46 ++ backend/config_test.go | 26 + backend/handlers_auth.go | 70 ++ backend/handlers_auth_test.go | 71 ++ backend/handlers_fs.go | 275 ++++++++ backend/handlers_fs_test.go | 447 +++++++++++++ backend/internal/auth.go | 5 +- backend/internal/auth_test.go | 17 + backend/internal/debuglog.go | 5 + backend/internal/fs.go | 7 +- backend/internal/fs_test.go | 50 ++ backend/internal/jellyfin.go | 15 +- backend/internal/jellyfin_test.go | 11 + backend/internal/naming.go | 2 + backend/internal/naming_test.go | 6 + backend/internal/refresh_test.go | 22 + backend/internal/session.go | 9 +- backend/internal/session_test.go | 14 + backend/internal/usermap.go | 6 +- backend/internal/usermap_test.go | 14 + backend/main.go | 729 +-------------------- backend/main_test.go | 196 ++++++ backend/middleware.go | 90 +++ backend/middleware_test.go | 53 ++ backend/router_test.go | 526 +++++++++++++++ backend/routes.go | 71 ++ backend/upload_handler.go | 102 +++ backend/uploads.go | 136 ++++ backend/util.go | 67 ++ backend/util_test.go | 55 ++ frontend/src/Uploader.entry.test.ts | 9 + frontend/src/Uploader.helpers.test.ts | 55 +- frontend/src/Uploader.tsx | 757 +--------------------- frontend/src/UploaderView.test.tsx | 178 +++++ frontend/src/UploaderView.tsx | 327 ++++++++++ frontend/src/api.ts | 2 + frontend/src/main.test.tsx | 39 ++ frontend/src/uploader-controller.test.tsx | 348 ++++++++++ frontend/src/uploader-controller.ts | 422 ++++++++++++ frontend/src/uploader-thumb.tsx | 32 + frontend/src/uploader-utils.ts | 141 ++++ scripts/publish_test_metrics.py | 45 +- testing/__init__.py | 1 + testing/pegasus_gate.py | 270 ++++++++ 48 files changed, 4537 insertions(+), 1549 deletions(-) create mode 100644 backend/app.go create mode 100644 backend/app_test.go create mode 100644 backend/config.go create mode 100644 backend/config_test.go create mode 100644 backend/handlers_auth.go create mode 100644 backend/handlers_auth_test.go create mode 100644 backend/handlers_fs.go create mode 100644 backend/handlers_fs_test.go create mode 100644 backend/middleware.go create mode 100644 backend/middleware_test.go create mode 100644 backend/router_test.go create mode 100644 backend/routes.go create mode 100644 backend/upload_handler.go create mode 100644 backend/uploads.go create mode 100644 backend/util.go create mode 100644 backend/util_test.go create mode 100644 frontend/src/Uploader.entry.test.ts create mode 100644 frontend/src/UploaderView.test.tsx create mode 100644 frontend/src/UploaderView.tsx create mode 100644 frontend/src/main.test.tsx create mode 100644 frontend/src/uploader-controller.test.tsx create mode 100644 frontend/src/uploader-controller.ts create mode 100644 frontend/src/uploader-thumb.tsx create mode 100644 frontend/src/uploader-utils.ts create mode 100644 testing/__init__.py create mode 100644 testing/pegasus_gate.py diff --git a/Dockerfile b/Dockerfile index 2d6bca0..91675ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/go/pkg/mod go mod tidy RUN --mount=type=cache,target=/go/pkg/mod \ set -eux; \ mkdir -p /out; \ - go build -trimpath -ldflags="-s -w" -o /out/pegasus ./main.go; \ + go build -trimpath -ldflags="-s -w" -o /out/pegasus .; \ test -f /out/pegasus; \ upx -q --lzma /out/pegasus || true diff --git a/Jenkinsfile b/Jenkinsfile index 3986d9b..fb6e006 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -64,6 +64,8 @@ spec: container('go-tester') { sh ''' set -eu + export PEGASUS_SESSION_KEY=test-session-key + export PEGASUS_COOKIE_INSECURE=1 mkdir -p build cd backend go install github.com/jstemmer/go-junit-report/v2@latest @@ -108,43 +110,12 @@ spec: } } - stage('Combine coverage summary') { + stage('Quality gate report') { steps { container('publisher') { sh ''' set -eu - python - <<'PY' -import json -from pathlib import Path - -build = Path('build') -def read_float(path: Path) -> float: - if not path.exists(): - return 0.0 - try: - return float(path.read_text(encoding='utf-8').strip()) - except ValueError: - return 0.0 - -backend = read_float(build / 'coverage-backend-percent.txt') -frontend = read_float(build / 'coverage-frontend-percent.txt') -combined = (backend + frontend) / 2 if (backend or frontend) else 0.0 - -(build / 'coverage.json').write_text( - json.dumps( - { - 'summary': { - 'backend_percent': backend, - 'frontend_percent': frontend, - 'percent_covered': combined, - } - }, - indent=2, - ) + '\\n', - encoding='utf-8', -) -print(f'Combined Pegasus coverage: {combined:.2f}%') -PY + python -m testing.pegasus_gate report ''' } } @@ -166,12 +137,7 @@ PY container('publisher') { sh ''' set -eu - backend_rc="$(cat build/backend-test.rc 2>/dev/null || echo 1)" - frontend_rc="$(cat build/frontend-test.rc 2>/dev/null || echo 1)" - if [ "${backend_rc}" -ne 0 ] || [ "${frontend_rc}" -ne 0 ]; then - echo "Pegasus test suite failed (backend=${backend_rc}, frontend=${frontend_rc})" - exit 1 - fi + python -m testing.pegasus_gate enforce ''' } } diff --git a/backend/app.go b/backend/app.go new file mode 100644 index 0000000..1544a5c --- /dev/null +++ b/backend/app.go @@ -0,0 +1,57 @@ +// backend/app.go +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "sort" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +var serveHTTP = (*http.Server).ListenAndServe + +var run = runServer + +func runServer() error { + internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot) + + if os.Getenv("PEGASUS_SESSION_KEY") == "" { + return fmt.Errorf("PEGASUS_SESSION_KEY is not set") + } + + um, err := internal.LoadUserMap(userMapFile) + if err != nil { + return fmt.Errorf("load user map: %w", err) + } + if internal.Debug { + keys := make([]string, 0, len(um.Map)) + for k := range um.Map { + keys = append(keys, k) + } + sort.Strings(keys) + preview := keys + if len(preview) > 10 { + preview = preview[:10] + } + internal.Logf("user-map loaded (%d): %v%s", len(keys), preview, map[bool]string{true: " ...", false: ""}[len(keys) > len(preview)]) + } + + if err := os.MkdirAll(tusDir, 0o2775); err != nil { + return fmt.Errorf("mkdir %s: %w", tusDir, err) + } + + handler := buildRouter(um, jf) + addr := env("PEGASUS_BIND", ":8080") + log.Printf("Pegasus listening on %s", addr) + + srv := &http.Server{ + Addr: addr, + Handler: handler, + ReadTimeout: 0, + WriteTimeout: 0, + } + return serveHTTP(srv) +} diff --git a/backend/app_test.go b/backend/app_test.go new file mode 100644 index 0000000..a3a2358 --- /dev/null +++ b/backend/app_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +func TestMainDelegatesToRunApp(t *testing.T) { + origRun := run + origFatal := fatal + defer func() { + run = origRun + fatal = origFatal + }() + + called := false + run = func() error { + called = true + return http.ErrNoCookie + } + fatal = func(args ...any) { + called = true + } + + main() + + if !called { + t.Fatalf("expected main to delegate to runApp") + } +} + +func TestRunInitializesServer(t *testing.T) { + t.Setenv("PEGASUS_SESSION_KEY", "test-session-key") + origServeHTTP := serveHTTP + origMediaRoot := mediaRoot + origUserMapFile := userMapFile + origTusDir := tusDir + origDebug := internal.Debug + defer func() { + serveHTTP = origServeHTTP + mediaRoot = origMediaRoot + userMapFile = origUserMapFile + tusDir = origTusDir + internal.Debug = origDebug + }() + + workDir := t.TempDir() + mediaRoot = filepath.Join(workDir, "media") + tusDir = filepath.Join(workDir, "tus") + userMapFile = filepath.Join(workDir, "user-map.yaml") + internal.Debug = false + + if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil { + t.Fatalf("mkdir media root: %v", err) + } + mapBody := "map:\n brad: library\n" + for i := 0; i < 11; i++ { + mapBody += fmt.Sprintf(" user%02d: library\n", i) + } + if err := os.WriteFile(userMapFile, []byte(mapBody), 0o644); err != nil { + t.Fatalf("write user map: %v", err) + } + + var gotServer *http.Server + serveHTTP = func(srv *http.Server) error { + gotServer = srv + return nil + } + + if err := run(); err != nil { + t.Fatalf("run returned error: %v", err) + } + if gotServer == nil { + t.Fatalf("expected server to be created") + } + if gotServer.Addr != ":8080" { + t.Fatalf("unexpected default address %q", gotServer.Addr) + } + if gotServer.Handler == nil { + t.Fatalf("expected handler to be configured") + } + if _, err := os.Stat(tusDir); err != nil { + t.Fatalf("expected tus dir to be created: %v", err) + } +} + +func TestRunDebugLogging(t *testing.T) { + t.Setenv("PEGASUS_SESSION_KEY", "test-session-key") + origServeHTTP := serveHTTP + origMediaRoot := mediaRoot + origUserMapFile := userMapFile + origTusDir := tusDir + origDebug := internal.Debug + defer func() { + serveHTTP = origServeHTTP + mediaRoot = origMediaRoot + userMapFile = origUserMapFile + tusDir = origTusDir + internal.Debug = origDebug + }() + + workDir := t.TempDir() + mediaRoot = filepath.Join(workDir, "media") + tusDir = filepath.Join(workDir, "tus") + userMapFile = filepath.Join(workDir, "user-map.yaml") + internal.Debug = true + + if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil { + t.Fatalf("mkdir media root: %v", err) + } + mapBody := "map:\n brad: library\n" + for i := 0; i < 11; i++ { + mapBody += fmt.Sprintf(" user%02d: library\n", i) + } + if err := os.WriteFile(userMapFile, []byte(mapBody), 0o644); err != nil { + t.Fatalf("write user map: %v", err) + } + + serveHTTP = func(srv *http.Server) error { return nil } + if err := run(); err != nil { + t.Fatalf("run returned error: %v", err) + } +} + +func TestRunRejectsMissingSessionKey(t *testing.T) { + t.Setenv("PEGASUS_SESSION_KEY", "") + origServeHTTP := serveHTTP + origMediaRoot := mediaRoot + origUserMapFile := userMapFile + origTusDir := tusDir + defer func() { + serveHTTP = origServeHTTP + mediaRoot = origMediaRoot + userMapFile = origUserMapFile + tusDir = origTusDir + }() + + serveHTTP = func(srv *http.Server) error { + t.Fatalf("serveHTTP should not be called") + return nil + } + if err := run(); err == nil { + t.Fatalf("expected missing session key error") + } +} + +func TestRunRejectsMissingUserMap(t *testing.T) { + t.Setenv("PEGASUS_SESSION_KEY", "test-session-key") + origServeHTTP := serveHTTP + origMediaRoot := mediaRoot + origUserMapFile := userMapFile + origTusDir := tusDir + defer func() { + serveHTTP = origServeHTTP + mediaRoot = origMediaRoot + userMapFile = origUserMapFile + tusDir = origTusDir + }() + + workDir := t.TempDir() + mediaRoot = filepath.Join(workDir, "media") + tusDir = filepath.Join(workDir, "tus") + userMapFile = filepath.Join(workDir, "missing.yaml") + serveHTTP = func(srv *http.Server) error { + t.Fatalf("serveHTTP should not be called") + return nil + } + + if err := run(); err == nil { + t.Fatalf("expected load user map error") + } +} + +func TestRunRejectsTusDirCreationFailure(t *testing.T) { + t.Setenv("PEGASUS_SESSION_KEY", "test-session-key") + origServeHTTP := serveHTTP + origMediaRoot := mediaRoot + origUserMapFile := userMapFile + origTusDir := tusDir + origDebug := internal.Debug + defer func() { + serveHTTP = origServeHTTP + mediaRoot = origMediaRoot + userMapFile = origUserMapFile + tusDir = origTusDir + internal.Debug = origDebug + }() + + workDir := t.TempDir() + mediaRoot = filepath.Join(workDir, "media") + userMapFile = filepath.Join(workDir, "user-map.yaml") + _ = os.WriteFile(userMapFile, []byte("map:\n brad: library\n"), 0o644) + filePath := filepath.Join(workDir, "not-a-dir") + _ = os.WriteFile(filePath, []byte("file"), 0o644) + tusDir = filepath.Join(filePath, "sub") + internal.Debug = false + serveHTTP = func(srv *http.Server) error { + t.Fatalf("serveHTTP should not be called") + return nil + } + + if err := run(); err == nil { + t.Fatalf("expected tus dir creation error") + } +} + +func TestMustDoesNotExitOnNil(t *testing.T) { + must(nil, "noop") +} diff --git a/backend/config.go b/backend/config.go new file mode 100644 index 0000000..a7e2375 --- /dev/null +++ b/backend/config.go @@ -0,0 +1,46 @@ +// backend/config.go +package main + +import ( + "path/filepath" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +type appConfig struct { + mediaRoot string + userMapFile string + tusDir string + jf jellyfinClient +} + +func loadAppConfig() appConfig { + mediaRoot := env("PEGASUS_MEDIA_ROOT", "/media") + return appConfig{ + mediaRoot: mediaRoot, + userMapFile: env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml"), + tusDir: env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus")), + jf: internal.NewJellyfin(), + } +} + +var ( + mediaRoot string + userMapFile string + tusDir string + jf jellyfinClient +) + +var ( + version = "dev" + git = "" + builtAt = "" +) + +func init() { + cfg := loadAppConfig() + mediaRoot = cfg.mediaRoot + userMapFile = cfg.userMapFile + tusDir = cfg.tusDir + jf = cfg.jf +} diff --git a/backend/config_test.go b/backend/config_test.go new file mode 100644 index 0000000..bd041ab --- /dev/null +++ b/backend/config_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "path/filepath" + "testing" +) + +func TestLoadAppConfigUsesEnvironment(t *testing.T) { + t.Setenv("PEGASUS_MEDIA_ROOT", "/srv/media") + t.Setenv("PEGASUS_USER_MAP_FILE", "/cfg/user-map.yaml") + t.Setenv("PEGASUS_TUS_DIR", "") + + cfg := loadAppConfig() + if cfg.mediaRoot != "/srv/media" { + t.Fatalf("unexpected media root %q", cfg.mediaRoot) + } + if cfg.userMapFile != "/cfg/user-map.yaml" { + t.Fatalf("unexpected user map file %q", cfg.userMapFile) + } + if cfg.tusDir != filepath.Join("/srv/media", ".pegasus-tus") { + t.Fatalf("unexpected tus dir %q", cfg.tusDir) + } + if cfg.jf == nil { + t.Fatalf("expected jellyfin client") + } +} diff --git a/backend/handlers_auth.go b/backend/handlers_auth.go new file mode 100644 index 0000000..a052cd8 --- /dev/null +++ b/backend/handlers_auth.go @@ -0,0 +1,70 @@ +// backend/handlers_auth.go +package main + +import ( + "encoding/json" + "net/http" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +type jellyfinClient interface { + AuthenticateByName(username, password string) (internal.AuthResult, error) + RefreshLibrary(userToken string) +} + +func loginHandler(um *internal.UserMap, jf jellyfinClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var f struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&f); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + res, err := jf.AuthenticateByName(f.Username, f.Password) + if err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + if _, err := um.Resolve(f.Username); err != nil { + internal.Logf("login ok but map missing for %q (JF name=%q)", f.Username, res.User.Name) + http.Error(w, "no mapping", http.StatusForbidden) + return + } + if err := internal.SetSession(w, f.Username, res.AccessToken); err != nil { + http.Error(w, "session error", http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"ok": true}) + } +} + +func logoutHandler() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + internal.ClearSession(w) + writeJSON(w, map[string]any{"ok": true}) + } +} + +func whoamiHandler(um *internal.UserMap) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cl, err := internal.CurrentUser(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + dr, err := um.Resolve(cl.Username) + if err != nil { + http.Error(w, "no mapping", http.StatusForbidden) + return + } + all, _ := um.ResolveAll(cl.Username) + writeJSON(w, map[string]any{ + "username": cl.Username, + "root": dr, + "roots": all, + }) + } +} diff --git a/backend/handlers_auth_test.go b/backend/handlers_auth_test.go new file mode 100644 index 0000000..89f5d3b --- /dev/null +++ b/backend/handlers_auth_test.go @@ -0,0 +1,71 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +func TestLoginHandlerFailurePaths(t *testing.T) { + um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}} + jf := &fakeJellyfin{ + authErr: http.ErrNoCookie, + } + + handler := loginHandler(um, jf) + + t.Run("bad json", func(t *testing.T) { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/login", bytes.NewBufferString("{bad"))) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected bad json status, got %d", rr.Code) + } + }) + + t.Run("invalid credentials", func(t *testing.T) { + jf.authErr = http.ErrNoCookie + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/login", "", []byte(`{"username":"brad","password":"bad"}`))) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized status, got %d", rr.Code) + } + }) + + t.Run("missing mapping", func(t *testing.T) { + jf.authErr = nil + jf.authResult.AccessToken = "token" + jf.authResult.User.Name = "brad" + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/login", "", []byte(`{"username":"missing","password":"pw"}`))) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden status, got %d", rr.Code) + } + }) +} + +func TestWhoamiHandlerFailurePaths(t *testing.T) { + um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}} + handler := whoamiHandler(um) + + t.Run("unauthorized", func(t *testing.T) { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/whoami", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized status, got %d", rr.Code) + } + }) + + t.Run("missing mapping", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/whoami", nil) + cookie := sessionCookie(t, "missing", "token") + req.Header.Set("Cookie", cookie) + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden status, got %d", rr.Code) + } + }) +} diff --git a/backend/handlers_fs.go b/backend/handlers_fs.go new file mode 100644 index 0000000..500242c --- /dev/null +++ b/backend/handlers_fs.go @@ -0,0 +1,275 @@ +// backend/handlers_fs.go +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +type listEntry struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + Mtime int64 `json:"mtime"` +} + +func resolveLibraryRoot(um *internal.UserMap, username, requested string) (string, error) { + allRoots, _ := um.ResolveAll(username) + reqLib := sanitizeSegment(requested) + if reqLib != "" && contains(allRoots, reqLib) { + return reqLib, nil + } + return um.Resolve(username) +} + +func listHandler(um *internal.UserMap) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cl, err := internal.CurrentUser(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + rootRel, err := resolveLibraryRoot(um, cl.Username, r.URL.Query().Get("lib")) + if err != nil { + internal.Logf("list: map missing for %q", cl.Username) + http.Error(w, "no mapping", http.StatusForbidden) + return + } + + q := sanitizeSegment(r.URL.Query().Get("path")) + rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) + dirAbs := rootAbs + if q != "" { + if dirAbs, err = internal.SafeJoin(rootAbs, q); err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + } + + ents, err := os.ReadDir(dirAbs) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + out := make([]listEntry, 0, len(ents)) + for _, d := range ents { + info, _ := d.Info() + var size int64 + if info != nil && !d.IsDir() { + size = info.Size() + } + var mtime int64 + if info != nil { + mtime = info.ModTime().Unix() + } + out = append(out, listEntry{ + Name: d.Name(), + Path: filepath.Join(q, d.Name()), + IsDir: d.IsDir(), + Size: size, + Mtime: mtime, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].IsDir != out[j].IsDir { + return out[i].IsDir + } + return out[i].Name < out[j].Name + }) + writeJSON(w, out) + } +} + +func renameHandler(um *internal.UserMap, jf jellyfinClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cl, err := internal.CurrentUser(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var p struct { + Lib string `json:"lib"` + From string `json:"from"` + To string `json:"to"` + } + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) { + http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest) + return + } + + rootRel, err := resolveLibraryRoot(um, cl.Username, p.Lib) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) + fromAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.From, "/")) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + toAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.To, "/")) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + _ = os.MkdirAll(filepath.Dir(toAbs), 0o2775) + if internal.DryRun { + internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs) + } else if err := os.Rename(fromAbs, toAbs); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + jf.RefreshLibrary(cl.JFToken) + writeJSON(w, map[string]any{"ok": true}) + } +} + +func deleteHandler(um *internal.UserMap, jf jellyfinClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cl, err := internal.CurrentUser(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + rootRel, err := resolveLibraryRoot(um, cl.Username, r.URL.Query().Get("lib")) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + path := strings.TrimPrefix(r.URL.Query().Get("path"), "/") + rec := r.URL.Query().Get("recursive") == "true" + + rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) + abs, err := internal.SafeJoin(rootAbs, path) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + if internal.DryRun { + internal.Logf("[DRY] rm%s %s", map[bool]string{true: " -r", false: ""}[rec], abs) + } else if rec { + err = os.RemoveAll(abs) + } else { + err = os.Remove(abs) + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + jf.RefreshLibrary(cl.JFToken) + writeJSON(w, map[string]any{"ok": true}) + } +} + +func mkdirHandler(um *internal.UserMap, jf jellyfinClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cl, err := internal.CurrentUser(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var p struct { + Lib string `json:"lib"` + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + + rootRel, err := resolveLibraryRoot(um, cl.Username, p.Lib) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) + if fi, err := os.Stat(rootAbs); err != nil || !fi.IsDir() { + http.Error(w, "library not found", http.StatusBadRequest) + return + } + + seg := sanitizeSegment(p.Path) + if seg == "" { + http.Error(w, "folder name is empty", http.StatusBadRequest) + return + } + + abs, err := internal.SafeJoin(rootAbs, seg) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + if internal.DryRun { + internal.Logf("[DRY] mkdir -p %s", abs) + } else if err := os.MkdirAll(abs, 0o2775); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + jf.RefreshLibrary(cl.JFToken) + writeJSON(w, map[string]any{"ok": true}) + } +} + +func debugEnvHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !internal.Debug { + http.Error(w, "disabled", http.StatusForbidden) + return + } + writeJSON(w, map[string]any{ + "mediaRoot": mediaRoot, + "tusDir": tusDir, + "userMapFile": userMapFile, + }) + } +} + +func debugWriteTestHandler(um *internal.UserMap) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !internal.Debug { + http.Error(w, "disabled", http.StatusForbidden) + return + } + cl, err := internal.CurrentUser(r) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + rootRel, err := um.Resolve(cl.Username) + if err != nil { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) + test := filepath.Join(rootAbs, fmt.Sprintf("TEST.%d.txt", time.Now().Unix())) + if internal.DryRun { + internal.Logf("[DRY] write %s", test) + } else { + _ = os.WriteFile(test, []byte("ok\n"), 0o644) + } + writeJSON(w, map[string]string{"wrote": test}) + } +} diff --git a/backend/handlers_fs_test.go b/backend/handlers_fs_test.go new file mode 100644 index 0000000..c702838 --- /dev/null +++ b/backend/handlers_fs_test.go @@ -0,0 +1,447 @@ +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +func TestResolveLibraryRootFallback(t *testing.T) { + um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library", "alt"}}} + got, err := resolveLibraryRoot(um, "brad", "not-allowed") + if err != nil { + t.Fatalf("resolveLibraryRoot failed: %v", err) + } + if got != "library" { + t.Fatalf("expected default root, got %q", got) + } +} + +func TestListHandlerErrorPaths(t *testing.T) { + origMediaRoot := mediaRoot + defer func() { mediaRoot = origMediaRoot }() + mediaRoot = t.TempDir() + + um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}} + handler := listHandler(um) + + t.Run("unauthorized", func(t *testing.T) { + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/list", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized status, got %d", rr.Code) + } + }) + + t.Run("forbidden escape", func(t *testing.T) { + cookie := sessionCookie(t, "brad", "token") + req := httptest.NewRequest(http.MethodGet, "/api/list?path=../secret", nil) + req.Header.Set("Cookie", cookie) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden status, got %d", rr.Code) + } + }) +} + +func TestRenameDeleteMkdirErrorPaths(t *testing.T) { + origMediaRoot := mediaRoot + origDryRun := internal.DryRun + defer func() { + mediaRoot = origMediaRoot + internal.DryRun = origDryRun + }() + mediaRoot = t.TempDir() + internal.DryRun = true + + um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}} + jf := &fakeJellyfin{} + cookie := sessionCookie(t, "brad", "token") + + t.Run("rename bad json", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewBufferString("{bad")) + req.Header.Set("Cookie", cookie) + renameHandler(um, jf).ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected bad request, got %d", rr.Code) + } + }) + + t.Run("rename invalid name", func(t *testing.T) { + body := []byte(`{"lib":"library","from":"old.txt","to":"bad-name.txt"}`) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewReader(body)) + req.Header.Set("Cookie", cookie) + renameHandler(um, jf).ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected bad request, got %d", rr.Code) + } + }) + + t.Run("mkdir bad json and empty folder", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString("{bad")) + req.Header.Set("Cookie", cookie) + mkdirHandler(um, jf).ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected bad request, got %d", rr.Code) + } + + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":" "}`)) + req.Header.Set("Cookie", cookie) + mkdirHandler(um, jf).ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected bad request, got %d", rr.Code) + } + }) + + t.Run("delete unauthorized", func(t *testing.T) { + rr := httptest.NewRecorder() + deleteHandler(um, jf).ServeHTTP(rr, httptest.NewRequest(http.MethodDelete, "/api/file?path=a", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized, got %d", rr.Code) + } + }) +} + +func TestDebugHandlersDisabled(t *testing.T) { + origDebug := internal.Debug + defer func() { internal.Debug = origDebug }() + internal.Debug = false + + rr := httptest.NewRecorder() + debugEnvHandler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/debug/env", nil)) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden when debug disabled, got %d", rr.Code) + } + + rr = httptest.NewRecorder() + debugWriteTestHandler(&internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}).ServeHTTP( + rr, + httptest.NewRequest(http.MethodGet, "/debug/write-test", nil), + ) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden when debug disabled, got %d", rr.Code) + } +} + +func TestFilesystemHandlersMoreBranches(t *testing.T) { + origMediaRoot := mediaRoot + origDryRun := internal.DryRun + origDebug := internal.Debug + defer func() { + mediaRoot = origMediaRoot + internal.DryRun = origDryRun + internal.Debug = origDebug + }() + + root := t.TempDir() + mediaRoot = root + internal.DryRun = false + internal.Debug = true + + um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}} + jf := &fakeJellyfin{} + cookie := sessionCookie(t, "brad", "token") + handlerList := listHandler(um) + handlerRename := renameHandler(um, jf) + handlerDelete := deleteHandler(um, jf) + handlerMkdir := mkdirHandler(um, jf) + + if err := os.MkdirAll(filepath.Join(root, "library", "album"), 0o2775); err != nil { + t.Fatalf("mkdir library: %v", err) + } + if err := os.MkdirAll(filepath.Join(root, "library", "zeta"), 0o2775); err != nil { + t.Fatalf("mkdir zeta: %v", err) + } + if err := os.WriteFile(filepath.Join(root, "library", "keep.txt"), []byte("keep"), 0o644); err != nil { + t.Fatalf("write keep file: %v", err) + } + + t.Run("list same-type sorting", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library", nil) + req.Header.Set("Cookie", cookie) + handlerList.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String()) + } + }) + + t.Run("list nested path", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library&path=album", nil) + req.Header.Set("Cookie", cookie) + handlerList.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String()) + } + }) + + t.Run("list missing directory", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library&path=missing", nil) + req.Header.Set("Cookie", cookie) + handlerList.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected internal error, got %d", rr.Code) + } + }) + + t.Run("list missing mapping", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library", nil) + req.Header.Set("Cookie", sessionCookie(t, "nobody", "token")) + handlerList.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden for missing mapping, got %d", rr.Code) + } + }) + + t.Run("rename unauthorized and escape", func(t *testing.T) { + rr := httptest.NewRecorder() + handlerRename.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`))) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized rename, got %d", rr.Code) + } + + rr = httptest.NewRecorder() + body := bytes.NewBufferString(`{"lib":"library","from":"../escape.txt","to":"2026.01.02.Note.escape.txt"}`) + req := httptest.NewRequest(http.MethodPost, "/api/rename", body) + req.Header.Set("Cookie", cookie) + handlerRename.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden && rr.Code != http.StatusInternalServerError { + t.Fatalf("expected from-path escape or missing-source failure, got %d", rr.Code) + } + }) + + t.Run("rename forbidden root and dry run", func(t *testing.T) { + rr := httptest.NewRecorder() + body := bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`) + req := httptest.NewRequest(http.MethodPost, "/api/rename", body) + req.Header.Set("Cookie", sessionCookie(t, "nobody", "token")) + handlerRename.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden rename root, got %d", rr.Code) + } + + orig := internal.DryRun + internal.DryRun = true + defer func() { internal.DryRun = orig }() + rr = httptest.NewRecorder() + body = bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`) + req = httptest.NewRequest(http.MethodPost, "/api/rename", body) + req.Header.Set("Cookie", cookie) + handlerRename.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected dry-run rename success, got %d", rr.Code) + } + }) + + t.Run("rename to-path escape", func(t *testing.T) { + orig := internal.DryRun + internal.DryRun = false + defer func() { internal.DryRun = orig }() + rr := httptest.NewRecorder() + body := bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"../2026.01.02.Note.escape.txt"}`) + req := httptest.NewRequest(http.MethodPost, "/api/rename", body) + req.Header.Set("Cookie", cookie) + handlerRename.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden rename escape, got %d", rr.Code) + } + }) + + t.Run("rename missing source", func(t *testing.T) { + rr := httptest.NewRecorder() + body := bytes.NewBufferString(`{"lib":"library","from":"missing.txt","to":"2026.01.02.Note.missing.txt"}`) + req := httptest.NewRequest(http.MethodPost, "/api/rename", body) + req.Header.Set("Cookie", cookie) + handlerRename.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected rename error, got %d", rr.Code) + } + }) + + t.Run("delete unauthorized and escape", func(t *testing.T) { + rr := httptest.NewRecorder() + handlerDelete.ServeHTTP(rr, httptest.NewRequest(http.MethodDelete, "/api/file?path=a", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized delete, got %d", rr.Code) + } + + rr = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=../escape", nil) + req.Header.Set("Cookie", cookie) + handlerDelete.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden && rr.Code != http.StatusInternalServerError { + t.Fatalf("expected delete escape failure, got %d", rr.Code) + } + }) + + t.Run("delete forbidden root and missing file", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil) + req.Header.Set("Cookie", sessionCookie(t, "nobody", "token")) + handlerDelete.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden delete root, got %d", rr.Code) + } + + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=missing.txt", nil) + req.Header.Set("Cookie", cookie) + handlerDelete.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected missing-file delete error, got %d", rr.Code) + } + }) + + t.Run("delete file without recursion", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil) + req.Header.Set("Cookie", cookie) + handlerDelete.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected delete success, got %d", rr.Code) + } + if _, err := os.Stat(filepath.Join(root, "library", "keep.txt")); !os.IsNotExist(err) { + t.Fatalf("expected keep.txt to be removed") + } + }) + + t.Run("delete dry run", func(t *testing.T) { + orig := internal.DryRun + internal.DryRun = true + defer func() { internal.DryRun = orig }() + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil) + req.Header.Set("Cookie", cookie) + handlerDelete.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected dry-run delete success, got %d", rr.Code) + } + }) + + t.Run("mkdir unauthorized and path escape", func(t *testing.T) { + rr := httptest.NewRecorder() + handlerMkdir.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`))) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized mkdir, got %d", rr.Code) + } + + rr = httptest.NewRecorder() + body := bytes.NewBufferString(`{"lib":"library","path":"../escape"}`) + req := httptest.NewRequest(http.MethodPost, "/api/mkdir", body) + req.Header.Set("Cookie", cookie) + handlerMkdir.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest && rr.Code != http.StatusForbidden { + t.Fatalf("expected mkdir escape failure, got %d", rr.Code) + } + }) + + t.Run("mkdir forbidden root and dry run", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`)) + req.Header.Set("Cookie", sessionCookie(t, "nobody", "token")) + handlerMkdir.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden mkdir root, got %d", rr.Code) + } + + orig := internal.DryRun + internal.DryRun = true + defer func() { internal.DryRun = orig }() + rr = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`)) + req.Header.Set("Cookie", cookie) + handlerMkdir.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected dry-run mkdir success, got %d", rr.Code) + } + }) + + t.Run("mkdir create failure", func(t *testing.T) { + orig := internal.DryRun + internal.DryRun = false + defer func() { internal.DryRun = orig }() + + blocker := filepath.Join(root, "library", "blocker") + if err := os.WriteFile(blocker, []byte("file"), 0o644); err != nil { + t.Fatalf("write blocker file: %v", err) + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"blocker/child"}`)) + req.Header.Set("Cookie", cookie) + handlerMkdir.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected mkdir create failure, got %d", rr.Code) + } + }) + + t.Run("mkdir empty path", func(t *testing.T) { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":" "}`)) + req.Header.Set("Cookie", cookie) + handlerMkdir.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected empty folder rejection, got %d", rr.Code) + } + }) + + t.Run("mkdir missing library root", func(t *testing.T) { + mediaRoot = filepath.Join(root, "file-root") + if err := os.WriteFile(mediaRoot, []byte("not a dir"), 0o644); err != nil { + t.Fatalf("write file root: %v", err) + } + rr := httptest.NewRecorder() + body := bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`) + req := httptest.NewRequest(http.MethodPost, "/api/mkdir", body) + req.Header.Set("Cookie", cookie) + handlerMkdir.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected bad request for missing library root, got %d", rr.Code) + } + }) + + t.Run("debug write unauthorized and dry run", func(t *testing.T) { + handlerDebug := debugWriteTestHandler(um) + + rr := httptest.NewRecorder() + handlerDebug.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/debug/write-test", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected unauthorized debug write, got %d", rr.Code) + } + + orig := internal.DryRun + internal.DryRun = true + defer func() { internal.DryRun = orig }() + rr = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/debug/write-test", nil) + req.Header.Set("Cookie", cookie) + handlerDebug.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected dry-run debug write success, got %d", rr.Code) + } + }) + + t.Run("debug write forbidden root", func(t *testing.T) { + handlerDebug := debugWriteTestHandler(um) + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/debug/write-test", nil) + req.Header.Set("Cookie", sessionCookie(t, "nobody", "token")) + handlerDebug.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected forbidden debug write root, got %d", rr.Code) + } + }) +} diff --git a/backend/internal/auth.go b/backend/internal/auth.go index d968b32..6a6a8eb 100644 --- a/backend/internal/auth.go +++ b/backend/internal/auth.go @@ -8,12 +8,15 @@ import ( "github.com/golang-jwt/jwt/v5" ) +var parseJWT = jwt.ParseWithClaims + +// CurrentUser parses the Pegasus session cookie and validates its JWT claims. func CurrentUser(r *http.Request) (Claims, error) { c, err := r.Cookie(CookieName) if err != nil { return Claims{}, err } - tok, err := jwt.ParseWithClaims(c.Value, &Claims{}, func(_ *jwt.Token) (any, error) { return sessionKey, nil }) + tok, err := parseJWT(c.Value, &Claims{}, func(_ *jwt.Token) (any, error) { return sessionKey, nil }) if err != nil { return Claims{}, err } diff --git a/backend/internal/auth_test.go b/backend/internal/auth_test.go index 938cf11..ea44505 100644 --- a/backend/internal/auth_test.go +++ b/backend/internal/auth_test.go @@ -98,3 +98,20 @@ func TestCurrentUserMalformedToken(t *testing.T) { t.Fatalf("expected parse error for malformed token") } } + +func TestCurrentUserInvalidClaimType(t *testing.T) { + origParse := parseJWT + defer func() { parseJWT = origParse }() + + parseJWT = func(string, jwt.Claims, jwt.Keyfunc, ...jwt.ParserOption) (*jwt.Token, error) { + return &jwt.Token{Valid: true, Claims: jwt.MapClaims{"ok": true}}, nil + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: CookieName, Value: "token"}) + + _, err := CurrentUser(req) + if err == nil { + t.Fatalf("expected invalid-session error") + } +} diff --git a/backend/internal/debuglog.go b/backend/internal/debuglog.go index 218c4ab..affb0ef 100644 --- a/backend/internal/debuglog.go +++ b/backend/internal/debuglog.go @@ -8,15 +8,20 @@ import ( "strings" ) +// Debug enables verbose request and state logging. var Debug = os.Getenv("PEGASUS_DEBUG") == "1" + +// DryRun makes mutating handlers log their intent instead of touching disk. var DryRun = os.Getenv("PEGASUS_DRY_RUN") == "1" +// Logf writes a log line only when Debug is enabled. func Logf(format string, args ...any) { if Debug { log.Printf(format, args...) } } +// RedactHeaders clones a header map and masks sensitive values. func RedactHeaders(h http.Header) http.Header { cp := http.Header{} for k, v := range h { diff --git a/backend/internal/fs.go b/backend/internal/fs.go index 74d0913..aaac4db 100644 --- a/backend/internal/fs.go +++ b/backend/internal/fs.go @@ -8,14 +8,17 @@ import ( "strings" ) +var absPath = filepath.Abs + +// SafeJoin resolves rel under root and rejects any path that escapes the root. func SafeJoin(root, rel string) (string, error) { rel = strings.TrimPrefix(rel, "/") p := filepath.Join(root, rel) - ap, err := filepath.Abs(p) + ap, err := absPath(p) if err != nil { return "", err } - ar, err := filepath.Abs(root) + ar, err := absPath(root) if err != nil { return "", err } diff --git a/backend/internal/fs_test.go b/backend/internal/fs_test.go index dd0caeb..e70630e 100644 --- a/backend/internal/fs_test.go +++ b/backend/internal/fs_test.go @@ -1,6 +1,7 @@ package internal import ( + "errors" "path/filepath" "testing" ) @@ -34,3 +35,52 @@ func TestSafeJoinAllowsRoot(t *testing.T) { t.Fatalf("expected root path %q got %q", root, got) } } + +func TestSafeJoinAbsError(t *testing.T) { + origAbs := absPath + defer func() { absPath = origAbs }() + + absPath = func(string) (string, error) { + return "", errors.New("boom") + } + + if _, err := SafeJoin(t.TempDir(), "child"); err == nil { + t.Fatalf("expected abs-path error") + } +} + +func TestSafeJoinRejectsSiblingPrefix(t *testing.T) { + origAbs := absPath + defer func() { absPath = origAbs }() + + absPath = func(p string) (string, error) { + switch p { + case "/root": + return "/root", nil + case "/root/child": + return "/rootx/child", nil + default: + return p, nil + } + } + + if _, err := SafeJoin("/root", "child"); err == nil { + t.Fatalf("expected sibling-prefix rejection") + } +} + +func TestSafeJoinRejectsRootAbsError(t *testing.T) { + origAbs := absPath + defer func() { absPath = origAbs }() + + absPath = func(p string) (string, error) { + if p == "/root" { + return "", errors.New("root failed") + } + return p, nil + } + + if _, err := SafeJoin("/root", "child"); err == nil { + t.Fatalf("expected root abs-path error") + } +} diff --git a/backend/internal/jellyfin.go b/backend/internal/jellyfin.go index fff0005..402a4bf 100644 --- a/backend/internal/jellyfin.go +++ b/backend/internal/jellyfin.go @@ -4,15 +4,14 @@ package internal import ( "bytes" "encoding/json" - "fmt" // <-- keep this; used by fmt.Errorf + "fmt" "net/http" "os" "time" ) -// Minimal Jellyfin client - -type jfAuthResult struct { +// AuthResult is the subset of Jellyfin auth response Pegasus needs. +type AuthResult struct { AccessToken string `json:"AccessToken"` User struct { Id string `json:"Id"` @@ -20,11 +19,13 @@ type jfAuthResult struct { } `json:"User"` } +// Jellyfin is a tiny client for the auth and refresh endpoints Pegasus uses. type Jellyfin struct { BaseURL string Client *http.Client } +// NewJellyfin builds a lightweight client for Jellyfin auth and refresh calls. func NewJellyfin() *Jellyfin { return &Jellyfin{ BaseURL: os.Getenv("JELLYFIN_URL"), @@ -32,9 +33,9 @@ func NewJellyfin() *Jellyfin { } } -// Authenticate against /Users/AuthenticateByName using Pw (plaintext) -func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult, error) { - var out jfAuthResult +// AuthenticateByName signs into Jellyfin with the plaintext password Jellyfin expects. +func (j *Jellyfin) AuthenticateByName(username, password string) (AuthResult, error) { + var out AuthResult body := map[string]string{"Username": username, "Pw": password} b, _ := json.Marshal(body) diff --git a/backend/internal/jellyfin_test.go b/backend/internal/jellyfin_test.go index ffe6f44..2d3affb 100644 --- a/backend/internal/jellyfin_test.go +++ b/backend/internal/jellyfin_test.go @@ -90,3 +90,14 @@ func TestAuthenticateByNameRequestError(t *testing.T) { t.Fatalf("expected transport error") } } + +func TestNewJellyfinReadsEnv(t *testing.T) { + t.Setenv("JELLYFIN_URL", "http://example.invalid") + j := NewJellyfin() + if j.BaseURL != "http://example.invalid" { + t.Fatalf("unexpected base url %q", j.BaseURL) + } + if j.Client == nil { + t.Fatalf("expected default client") + } +} diff --git a/backend/internal/naming.go b/backend/internal/naming.go index 15eec5d..2b376e5 100644 --- a/backend/internal/naming.go +++ b/backend/internal/naming.go @@ -16,6 +16,7 @@ var ( finalNameValidate = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9]{1,8}$`) ) +// SanitizeDesc normalizes a free-form description into a safe filename segment. func SanitizeDesc(in string) string { s := strings.TrimSpace(in) if s == "" { @@ -29,6 +30,7 @@ func SanitizeDesc(in string) string { return s } +// ComposeFinalName builds the on-disk upload filename from the user date, description, and original name. func ComposeFinalName(dateStr, desc, origFilename string) (string, error) { ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), ".")) if ext == "" { diff --git a/backend/internal/naming_test.go b/backend/internal/naming_test.go index 8a41220..e2401b0 100644 --- a/backend/internal/naming_test.go +++ b/backend/internal/naming_test.go @@ -48,3 +48,9 @@ func TestComposeFinalName(t *testing.T) { t.Fatalf("unexpected fallback name %q", fallback) } } + +func TestComposeFinalNameRejectsLongExtension(t *testing.T) { + if _, err := ComposeFinalName("2026-04-09", "desc", "movie.toolongext"); err == nil { + t.Fatalf("expected invalid final-name error") + } +} diff --git a/backend/internal/refresh_test.go b/backend/internal/refresh_test.go index fb05333..17d5204 100644 --- a/backend/internal/refresh_test.go +++ b/backend/internal/refresh_test.go @@ -84,3 +84,25 @@ func TestRefreshLibrarySkipsWithoutURL(t *testing.T) { t.Fatalf("expected timer to self-clear after skip") } } + +func TestRefreshLibraryUsesFallbackClientAndHandlesServerError(t *testing.T) { + defer safeClearTimer() + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + t.Setenv("JELLYFIN_URL", srv.URL) + t.Setenv("JELLYFIN_API_KEY", "admin-token") + + j := &Jellyfin{} + j.RefreshLibrary("fallback-user-token") + + time.Sleep(2500 * time.Millisecond) + if got := calls.Load(); got != 1 { + t.Fatalf("expected one refresh request, got %d", got) + } +} diff --git a/backend/internal/session.go b/backend/internal/session.go index 98a3b89..e05e0a7 100644 --- a/backend/internal/session.go +++ b/backend/internal/session.go @@ -9,6 +9,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// Claims are the signed session fields Pegasus stores in the browser cookie. type Claims struct { Username string `json:"u"` JFToken string `json:"t"` @@ -17,9 +18,14 @@ type Claims struct { var sessionKey = []byte(os.Getenv("PEGASUS_SESSION_KEY")) var cookieSecure = os.Getenv("PEGASUS_COOKIE_INSECURE") != "1" +var signJWT = func(tok *jwt.Token) (string, error) { + return tok.SignedString(sessionKey) +} +// CookieName is the session cookie name used by Pegasus. const CookieName = "pegasus_session" +// SetSession signs and writes a Pegasus session cookie. func SetSession(w http.ResponseWriter, username, jfToken string) error { now := time.Now() tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ @@ -30,7 +36,7 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error { IssuedAt: jwt.NewNumericDate(now), }, }) - signed, err := tok.SignedString(sessionKey) + signed, err := signJWT(tok) if err != nil { return err } @@ -45,6 +51,7 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error { return nil } +// ClearSession expires the Pegasus session cookie immediately. func ClearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: CookieName, diff --git a/backend/internal/session_test.go b/backend/internal/session_test.go index cc39e8a..c5ce2a9 100644 --- a/backend/internal/session_test.go +++ b/backend/internal/session_test.go @@ -95,3 +95,17 @@ func TestClearSessionExpiresCookie(t *testing.T) { t.Fatalf("expected secure cookie") } } + +func TestSetSessionSigningError(t *testing.T) { + origSign := signJWT + defer func() { signJWT = origSign }() + + signJWT = func(*jwt.Token) (string, error) { + return "", http.ErrNoCookie + } + + rr := httptest.NewRecorder() + if err := SetSession(rr, "brad", "jf"); err == nil { + t.Fatalf("expected signing error") + } +} diff --git a/backend/internal/usermap.go b/backend/internal/usermap.go index 5e3c87b..8a27a39 100644 --- a/backend/internal/usermap.go +++ b/backend/internal/usermap.go @@ -10,8 +10,7 @@ import ( "gopkg.in/yaml.v3" ) -// StringOrList allows both a single string ("Stein_90s") -// and a list of strings (["Stein_90s","Home Videos"]) in YAML. +// StringOrList allows a single string or a list of strings in YAML. type StringOrList []string func (s *StringOrList) UnmarshalYAML(value *yaml.Node) error { @@ -35,11 +34,12 @@ func (s *StringOrList) UnmarshalYAML(value *yaml.Node) error { } } +// UserMap maps a Jellyfin username to one or more relative media roots. type UserMap struct { - // Maps Jellyfin username -> one or more relative media subdirs Map map[string]StringOrList `yaml:"map"` } +// LoadUserMap loads the YAML mapping file used to resolve Jellyfin users to media roots. func LoadUserMap(path string) (*UserMap, error) { b, err := os.ReadFile(path) if err != nil { diff --git a/backend/internal/usermap_test.go b/backend/internal/usermap_test.go index 972bb78..bc84f6d 100644 --- a/backend/internal/usermap_test.go +++ b/backend/internal/usermap_test.go @@ -90,3 +90,17 @@ func TestResolveAllMissingAndInvalidEntries(t *testing.T) { t.Fatalf("expected invalid-mapping error") } } + +func TestResolveReturnsFirstMappingAndMissingUser(t *testing.T) { + m := &UserMap{Map: map[string]StringOrList{"brad": {"first", "second"}}} + got, err := m.Resolve("brad") + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if got != "first" { + t.Fatalf("expected first mapping, got %q", got) + } + if _, err := m.Resolve("missing"); err == nil { + t.Fatalf("expected missing-user error") + } +} diff --git a/backend/main.go b/backend/main.go index 79fdbeb..90617f5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,733 +1,12 @@ // backend/main.go package main -import ( - "embed" - "encoding/json" - "fmt" - "io" - "io/fs" - "log" - "net/http" - "os" - "path/filepath" - "regexp" - "strings" - "time" - "sort" +import "log" - "github.com/go-chi/chi/v5" - "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/tus/tusd/pkg/filestore" - tusd "github.com/tus/tusd/pkg/handler" - "github.com/tus/tusd/pkg/memorylocker" - - "scm.bstein.dev/bstein/Pegasus/backend/internal" -) - -//go:embed web/dist -var webFS embed.FS - -var ( - mediaRoot = env("PEGASUS_MEDIA_ROOT", "/media") - userMapFile = env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml") - tusDir = env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus")) - jf = internal.NewJellyfin() -) - -var ( - version = "dev" // set via -ldflags "-X main.version=1.2.0 -X main.git=$(git rev-parse --short HEAD) -X main.builtAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)" - git = "" - builtAt = "" -) - -type loggingRW struct { - http.ResponseWriter - status int -} - -type listEntry struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"is_dir"` - Size int64 `json:"size"` - Mtime int64 `json:"mtime"` -} - -func writeJSON(w http.ResponseWriter, v any) { - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(v); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) } +var fatal = log.Fatal func main() { - internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot) - - if os.Getenv("PEGASUS_SESSION_KEY") == "" { - log.Fatal("PEGASUS_SESSION_KEY is not set") + if err := run(); err != nil { + fatal(err) } - - um, err := internal.LoadUserMap(userMapFile) - must(err, "load user map") - if internal.Debug { - keys := make([]string, 0, len(um.Map)) - for k := range um.Map { keys = append(keys, k) } - sort.Strings(keys) - show := keys - if len(keys) > 10 { show = keys[:10] } - internal.Logf("user-map loaded (%d): %v%s", len(keys), show, func() string { - if len(keys) > len(show) { return " ..." } - return "" - }()) - } - - // === tusd setup (resumable uploads) === - store := filestore.FileStore{Path: tusDir} - // ensure upload scratch dir exists - if err := os.MkdirAll(tusDir, 0o2775); err != nil { - log.Fatalf("mkdir %s: %v", tusDir, err) - } - locker := memorylocker.New() - composer := tusd.NewStoreComposer() - store.UseIn(composer) - locker.UseIn(composer) - - // completeC := make(chan tusd.HookEvent) - config := tusd.Config{ - BasePath: "/tus/", - StoreComposer: composer, - NotifyCompleteUploads: true, - MaxSize: 8 * 1024 * 1024 * 1024, // 8GB per file - RespectForwardedHeaders: true, // <<< important for https behind Traefik - } - tusHandler, err := tusd.NewUnroutedHandler(config) - must(err, "init tus handler") - completeC := tusHandler.CompleteUploads - - // ---- post-finish hook: enforce naming & mapping ---- - go func() { - for ev := range completeC { - claims, err := claimsFromHook(ev) - if err != nil { - internal.Logf("tus: no session: %v", err) - continue - } - - // read metadata set by the UI - meta := ev.Upload.MetaData - desc := strings.TrimSpace(meta["desc"]) - date := strings.TrimSpace(meta["date"]) // YYYY-MM-DD or empty - subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/") - lib := strings.Trim(strings.TrimSpace(meta["lib"]), "/") - orig := meta["filename"] - if orig == "" { orig = "upload.bin" } - - // Decide if description is required by extension (videos only) - ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), ".")) - isVideo := map[string]bool{ - "mp4": true, "mkv": true, "mov": true, "avi": true, "m4v": true, - "webm": true, "mpg": true, "mpeg": true, "ts": true, "m2ts": true, - }[ext] - if isVideo && desc == "" { - log.Printf("tus: missing desc for video; rejecting") - return - } - if desc == "" { desc = "upload" } // images can default - - // Resolve destination root under /media - var libRoot string - if lib != "" { - lib = sanitizeSegment(lib) - libRoot = filepath.Join(mediaRoot, lib) // explicit library - } else { - if rr, err := um.Resolve(claims.Username); err == nil && rr != "" { - libRoot = filepath.Join(mediaRoot, rr) // fallback to first mapped root - } else { - libRoot = "" // no valid root - } - } - - // The library root must already exist; we only create one-level subfolders. - if libRoot == "" { - log.Printf("upload: library root not resolved for user %s", claims.Username) - continue - } - if fi, err := os.Stat(libRoot); err != nil || !fi.IsDir() { - log.Printf("upload: library root missing or not accessible: %s (%v)", libRoot, err) - continue - } - - // Optional one-level subdir under the library - destRoot := libRoot - if subdir != "" { - subdir = sanitizeSegment(subdir) - destRoot = filepath.Join(libRoot, subdir) - if err := os.MkdirAll(destRoot, 0o2775); err != nil { - log.Printf("mkdir %s: %v", destRoot, err) - continue - } - } - - // Compose final filename and move from TUS scratch to final - finalName := composeFinalName(date, desc, orig) - dst := filepath.Join(destRoot, finalName) - if err := moveFromTus(ev, dst); err != nil { - log.Printf("move failed: %v", err) - continue - } - - jf.RefreshLibrary(claims.JFToken) - } - }() - - // === chi router === - r := chi.NewRouter() - r.Use(corsForTus) - - // health/version - r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - }) - r.Get("/version", func(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, map[string]any{ - "version": version, - "git": git, - "built_at": builtAt, - }) - }) - - // auth - r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) { - var f struct { - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&f); err != nil { - http.Error(w, "bad json", http.StatusBadRequest) - return - } - res, err := jf.AuthenticateByName(f.Username, f.Password) - if err != nil { - http.Error(w, "invalid credentials", http.StatusUnauthorized) - return - } - // βœ… ensure this username is mapped before creating a session - if _, err := um.Resolve(f.Username); err != nil { - internal.Logf("login ok but map missing for %q (JF name=%q)", f.Username, res.User.Name) - http.Error(w, "no mapping", http.StatusForbidden) - return - } - // βœ… store the typed login name in the session - if err := internal.SetSession(w, f.Username, res.AccessToken); err != nil { - http.Error(w, "session error", http.StatusInternalServerError) - return - } - writeJSON(w, map[string]any{"ok": true}) - }) - - r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) { - internal.ClearSession(w) - writeJSON(w, map[string]any{"ok": true}) - }) - - // whoami - r.Get("/api/whoami", func(w http.ResponseWriter, r *http.Request) { - cl, err := internal.CurrentUser(r) - if err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - dr, err := um.Resolve(cl.Username) - if err != nil { - http.Error(w, "no mapping", http.StatusForbidden) - return - } - all, _ := um.ResolveAll(cl.Username) // ignore error here since Resolve succeeded - writeJSON(w, map[string]any{ - "username": cl.Username, - "root": dr, // first root (back-compat) - "roots": all, // all roots (for future UI) - }) - }) - - // list entries - r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) { - cl, err := internal.CurrentUser(r) - if err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - // choose library: requested (if allowed) or default - allRoots, _ := um.ResolveAll(cl.Username) - reqLib := sanitizeSegment(r.URL.Query().Get("lib")) - var rootRel string - if reqLib != "" && contains(allRoots, reqLib) { - rootRel = reqLib - } else { - rootRel, err = um.Resolve(cl.Username) - if err != nil { - internal.Logf("list: map missing for %q", cl.Username) - http.Error(w, "no mapping", http.StatusForbidden) - return - } - } - - // one-level subdir (optional) - q := sanitizeSegment(r.URL.Query().Get("path")) // clamp to single segment - - rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) - dirAbs := rootAbs - if q != "" { - if dirAbs, err = internal.SafeJoin(rootAbs, q); err != nil { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - } - - ents, err := os.ReadDir(dirAbs) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - out := make([]listEntry, 0, len(ents)) - for _, d := range ents { - info, _ := d.Info() - var size int64 - if info != nil && !d.IsDir() { - size = info.Size() - } - var mtime int64 - if info != nil { - mtime = info.ModTime().Unix() - } - out = append(out, listEntry{ - Name: d.Name(), - Path: filepath.Join(q, d.Name()), - IsDir: d.IsDir(), - Size: size, - Mtime: mtime, - }) - } - - writeJSON(w, out) - }) - - // rename - var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}(?:\.[A-Za-z0-9_-]{1,64})?\.[A-Za-z0-9]{1,8}$`) - r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) { - cl, err := internal.CurrentUser(r) - if err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - var p struct { - Lib string `json:"lib"` - From string `json:"from"` - To string `json:"to"` - } - if err := json.NewDecoder(r.Body).Decode(&p); err != nil { - http.Error(w, "bad json", http.StatusBadRequest) - return - } - - // enforce final name format on files (directories may be free-form one level) - if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) { - http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest) - return - } - - // choose library: requested (if allowed) or default - allRoots, _ := um.ResolveAll(cl.Username) - lib := sanitizeSegment(p.Lib) - var rootRel string - if lib != "" && contains(allRoots, lib) { - rootRel = lib - } else { - rootRel, _ = um.Resolve(cl.Username) - } - - rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) - fromAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.From, "/")) - if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return } - toAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.To, "/")) - if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return } - - _ = os.MkdirAll(filepath.Dir(toAbs), 0o2775) - if internal.DryRun { - internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs) - } else if err := os.Rename(fromAbs, toAbs); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError); return - } - jf.RefreshLibrary(cl.JFToken) - writeJSON(w, map[string]any{"ok": true}) - }) - - // delete - r.Delete("/api/file", func(w http.ResponseWriter, r *http.Request) { - cl, err := internal.CurrentUser(r) - if err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - - // choose library: requested (if allowed) or default - allRoots, _ := um.ResolveAll(cl.Username) - lib := sanitizeSegment(r.URL.Query().Get("lib")) - var rootRel string - if lib != "" && contains(allRoots, lib) { - rootRel = lib - } else { - rootRel, _ = um.Resolve(cl.Username) - } - - path := strings.TrimPrefix(r.URL.Query().Get("path"), "/") - rec := r.URL.Query().Get("recursive") == "true" - - rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) - abs, err := internal.SafeJoin(rootAbs, path) - if err != nil { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - if internal.DryRun { - internal.Logf("[DRY] rm%s %s", map[bool]string{true: " -r", false: ""}[rec], abs) - } else if rec { - err = os.RemoveAll(abs) - } else { - err = os.Remove(abs) - } - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - jf.RefreshLibrary(cl.JFToken) - writeJSON(w, map[string]any{"ok": true}) - }) - - // mkdir (create subdirectory under the user's mapped root) - r.Post("/api/mkdir", func(w http.ResponseWriter, r *http.Request) { - cl, err := internal.CurrentUser(r) - if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return } - - var p struct { - Lib string `json:"lib"` - Path string `json:"path"` // single-level folder name - } - if err := json.NewDecoder(r.Body).Decode(&p); err != nil { - http.Error(w, "bad json", http.StatusBadRequest); return - } - - // choose library: requested (if allowed) or default - allRoots, _ := um.ResolveAll(cl.Username) - lib := sanitizeSegment(p.Lib) - var rootRel string - if lib != "" && contains(allRoots, lib) { - rootRel = lib - } else { - rootRel, err = um.Resolve(cl.Username) - if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return } - } - - rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) - if fi, err := os.Stat(rootAbs); err != nil || !fi.IsDir() { - http.Error(w, "library not found", http.StatusBadRequest) - return - } - - seg := sanitizeSegment(p.Path) // single-level + charset + spacesβ†’_ - if seg == "" { - http.Error(w, "folder name is empty", http.StatusBadRequest) - return - } - - abs, err := internal.SafeJoin(rootAbs, seg) - if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return } - - if internal.DryRun { - internal.Logf("[DRY] mkdir -p %s", abs) - } else if err := os.MkdirAll(abs, 0o2775); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, map[string]any{"ok": true}) - }) - - // mount tus (behind auth) - r.Route("/tus", func(rt chi.Router) { - rt.Use(sessionRequired) - // Create upload: POST /tus/ - rt.Post("/", tusHandler.PostFile) - // Upload resource endpoints: /tus/{id} - rt.Head("/{id}", tusHandler.HeadFile) - rt.Patch("/{id}", tusHandler.PatchFile) - rt.Delete("/{id}", tusHandler.DelFile) - rt.Get("/{id}", tusHandler.GetFile) // optional - }) - // metrics - r.Handle("/metrics", promhttp.Handler()) - - // static app (serve embedded web/dist) - appFS, _ := fs.Sub(webFS, "web/dist") - static := http.FileServer(http.FS(appFS)) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - static.ServeHTTP(w, r) - }) - // catch-all for SPA routes, GET only - r.Method("GET", "/*", http.StripPrefix("/", static)) - - // debug endpoints (registered before server starts) - r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) { - if !internal.Debug { - http.Error(w, "disabled", http.StatusForbidden) - return - } - writeJSON(w, map[string]any{ - "mediaRoot": mediaRoot, - "tusDir": tusDir, - "userMapFile": userMapFile, - }) - }) - r.Get("/debug/write-test", func(w http.ResponseWriter, r *http.Request) { - if !internal.Debug { - http.Error(w, "disabled", http.StatusForbidden) - return - } - cl, err := internal.CurrentUser(r) - if err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - rootRel, err := um.Resolve(cl.Username) - if err != nil { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel) - test := filepath.Join(rootAbs, fmt.Sprintf("TEST.%d.txt", time.Now().Unix())) - if internal.DryRun { - internal.Logf("[DRY] write %s", test) - } else { - _ = os.WriteFile(test, []byte("ok\n"), 0o644) - } - writeJSON(w, map[string]string{"wrote": test}) - }) - - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound) - }) - - // ---- wrap router with verbose request logging in debug ---- - root := http.Handler(r) - if internal.Debug { - root = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - id := time.Now().UnixNano() - internal.Logf(">> %d %s %s %v", id, req.Method, req.URL.Path, internal.RedactHeaders(req.Header)) - rw := &loggingRW{ResponseWriter: w, status: 200} - start := time.Now() - r.ServeHTTP(rw, req) - internal.Logf("<< %d %s %s %d %s", id, req.Method, req.URL.Path, rw.status, time.Since(start)) - }) - } - - addr := env("PEGASUS_BIND", ":8080") - log.Printf("Pegasus listening on %s", addr) - srv := &http.Server{Addr: addr, Handler: root, ReadTimeout: 0, WriteTimeout: 0} - log.Fatal(srv.ListenAndServe()) -} - -// === helpers & middleware === -func sessionRequired(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if _, err := internal.CurrentUser(r); err != nil { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -func corsForTus(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // CORS for tus (preflight must succeed; cookies allowed) - if origin := r.Header.Get("Origin"); origin != "" { - w.Header().Set("Access-Control-Allow-Origin", origin) // cannot be * when credentials=true - w.Header().Set("Vary", "Origin") - } - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Allow-Methods", "GET,POST,HEAD,PATCH,DELETE,OPTIONS") - w.Header().Set("Access-Control-Max-Age", "86400") - w.Header().Set("Access-Control-Allow-Headers", - "Content-Type, Tus-Resumable, Upload-Length, Upload-Defer-Length, Upload-Metadata, Upload-Offset, Upload-Concat, Upload-Checksum, X-Requested-With") - w.Header().Set("Access-Control-Expose-Headers", - "Location, Upload-Offset, Upload-Length, Tus-Resumable, Upload-Checksum") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next.ServeHTTP(w, r) - }) -} - -func claimsFromHook(ev tusd.HookEvent) (internal.Claims, error) { - // Parse our session cookie from incoming request headers captured by tusd - req := ev.HTTPRequest - if req.Header == nil { - return internal.Claims{}, http.ErrNoCookie - } - // Re-create a dummy http.Request to reuse cookie parsing & jwt verification - r := http.Request{Header: http.Header(req.Header)} - return internal.CurrentUser(&r) -} - -func env(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def } -func must(err error, msg string) { if err != nil { log.Fatalf("%s: %v", msg, err) } } - -func sanitizeSegment(s string) string { - s = strings.TrimSpace(s) - s = strings.ReplaceAll(s, " ", "_") - s = strings.Trim(s, "/") - if i := strings.IndexByte(s, '/'); i >= 0 { s = s[:i] } - // allow only [A-Za-z0-9_.-] - out := make([]rune, 0, len(s)) - for _, r := range s { - switch { - case r >= 'a' && r <= 'z', - r >= 'A' && r <= 'Z', - r >= '0' && r <= '9', - r == '_', r == '-', r == '.': - out = append(out, r) - default: - out = append(out, '_') - } - } - if len(out) > 64 { out = out[:64] } - return string(out) -} - -func contains(ss []string, v string) bool { - for _, s := range ss { - if s == v { return true } - } - return false -} - -// sanitizeDescriptor keeps [A-Za-z0-9_-], converts whitespace to underscores, -// collapses runs, and limits to 64 chars. Used in final filenames. -func sanitizeDescriptor(s string) string { - s = strings.TrimSpace(s) - s = strings.ReplaceAll(s, " ", "_") - if s == "" { return "upload" } - var b []rune - lastUnderscore := false - for _, r := range s { - ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' - if !ok { r = '_' } - if r == '_' { - if lastUnderscore { continue } - lastUnderscore = true - } else { - lastUnderscore = false - } - b = append(b, r) - if len(b) >= 64 { break } - } - if len(b) == 0 { return "upload" } - return string(b) -} - -// stemOf strips the final extension and cleans the base. -func stemOf(orig string) string { - base := strings.TrimSuffix(orig, filepath.Ext(orig)) - s := sanitizeDescriptor(strings.ReplaceAll(base, ".", "_")) - if s == "" { s = "file" } - return s -} - -// composeFinalName mirrors the UI: YYYY.MM.DD.Description.ext -// date is expected as YYYY-MM-DD; falls back to today if missing/bad. -func composeFinalName(date, desc, orig string) string { - var y, m, d string - if len(date) >= 10 && date[4] == '-' && date[7] == '-' { - y, m, d = date[:4], date[5:7], date[8:10] - } else { - now := time.Now() - y = fmt.Sprintf("%04d", now.Year()) - m = fmt.Sprintf("%02d", int(now.Month())) - d = fmt.Sprintf("%02d", now.Day()) - } - clean := sanitizeDescriptor(desc) - if clean == "" { clean = "upload" } - stem := stemOf(orig) - ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), ".")) - if ext == "" { ext = "bin" } - return fmt.Sprintf("%s.%s.%s.%s.%s.%s", y, m, d, clean, stem, ext) -} - -// moveFromTus relocates the finished upload file from the TUS scratch directory to dst. -// Works with tusd's filestore by probing common data file names. -func moveFromTus(ev tusd.HookEvent, dst string) error { - // Likely data file names/locations used by tusd filestore across versions - candidates := []string{ - filepath.Join(tusDir, ev.Upload.ID), - filepath.Join(tusDir, ev.Upload.ID+".bin"), - filepath.Join(tusDir, "data", ev.Upload.ID), - filepath.Join(tusDir, "data", ev.Upload.ID+".bin"), - filepath.Join(tusDir, "uploads", ev.Upload.ID), - filepath.Join(tusDir, "uploads", ev.Upload.ID+".bin"), - } - - var src string - for _, p := range candidates { - if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() { - src = p - break - } - } - if src == "" { - // last resort: scan the directory for a file starting with the id - entries, _ := os.ReadDir(tusDir) - for _, e := range entries { - if !e.IsDir() && strings.HasPrefix(e.Name(), ev.Upload.ID) { - src = filepath.Join(tusDir, e.Name()) - break - } - } - } - if src == "" { - return fmt.Errorf("tus data file not found for id %s", ev.Upload.ID) - } - - if err := os.MkdirAll(filepath.Dir(dst), 0o2775); err != nil { - return err - } - if internal.DryRun { - internal.Logf("[DRY] mv %s -> %s", src, dst) - return nil - } - - // Try fast rename; if it crosses devices, fall back to copy+remove. - if err := os.Rename(src, dst); err != nil { - in, err2 := os.Open(src) - if err2 != nil { return err2 } - defer in.Close() - out, err3 := os.Create(dst) - if err3 != nil { return err3 } - if _, err := io.Copy(out, in); err != nil { - _ = out.Close() - return err - } - _ = out.Close() - _ = os.Remove(src) - } - - // Remove sidecars if present - _ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".info")) - _ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".json")) - return nil } diff --git a/backend/main_test.go b/backend/main_test.go index 06a357b..16e6208 100644 --- a/backend/main_test.go +++ b/backend/main_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io" "net/http" "net/http/httptest" @@ -49,6 +50,9 @@ func TestSanitizeDescriptorAndStemOf(t *testing.T) { if got := sanitizeDescriptor(""); got != "upload" { t.Fatalf("expected upload fallback, got %q", got) } + if got := sanitizeDescriptor("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz"); len(got) != 64 { + t.Fatalf("expected truncation to 64 chars, got %d", len(got)) + } if got := stemOf("My.Video.File.mp4"); got != "My_Video_File" { t.Fatalf("unexpected stem %q", got) } @@ -259,6 +263,198 @@ func TestMoveFromTusMissingDataFile(t *testing.T) { } } +func TestMoveFromTusFallbackCopiesWhenRenameFails(t *testing.T) { + origTusDir := tusDir + origRenameFile := renameFile + tusDir = t.TempDir() + renameFile = func(string, string) error { + return fmt.Errorf("rename blocked") + } + t.Cleanup(func() { + tusDir = origTusDir + renameFile = origRenameFile + }) + + origDryRun := internal.DryRun + internal.DryRun = false + t.Cleanup(func() { + internal.DryRun = origDryRun + }) + + id := "upload-id-copy" + src := filepath.Join(tusDir, id) + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { + t.Fatalf("write src failed: %v", err) + } + dst := filepath.Join(t.TempDir(), "fallback", "file.bin") + ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: id}} + + if err := moveFromTus(ev, dst); err != nil { + t.Fatalf("moveFromTus fallback failed: %v", err) + } + b, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dst failed: %v", err) + } + if string(b) != "payload" { + t.Fatalf("unexpected dst payload %q", string(b)) + } + if _, err := os.Stat(src); !os.IsNotExist(err) { + t.Fatalf("expected source to be removed, stat err=%v", err) + } +} + +func TestMoveFromTusScansForPrefixedFiles(t *testing.T) { + origTusDir := tusDir + origRenameFile := renameFile + tusDir = t.TempDir() + renameFile = func(string, string) error { + return fmt.Errorf("rename blocked") + } + t.Cleanup(func() { + tusDir = origTusDir + renameFile = origRenameFile + }) + + origDryRun := internal.DryRun + internal.DryRun = false + t.Cleanup(func() { + internal.DryRun = origDryRun + }) + + id := "pref" + src := filepath.Join(tusDir, id+"-suffix.bin") + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { + t.Fatalf("write src failed: %v", err) + } + dst := filepath.Join(t.TempDir(), "scan", "file.bin") + ev := tusd.HookEvent{Upload: tusd.FileInfo{ID: id}} + + if err := moveFromTus(ev, dst); err != nil { + t.Fatalf("moveFromTus scan failed: %v", err) + } + if _, err := os.Stat(dst); err != nil { + t.Fatalf("expected scanned destination to exist: %v", err) + } +} + +func TestMoveFromTusOpenAndCreateFailures(t *testing.T) { + t.Run("open failure", func(t *testing.T) { + origTusDir := tusDir + origRenameFile := renameFile + tusDir = t.TempDir() + renameFile = func(string, string) error { + return fmt.Errorf("rename blocked") + } + t.Cleanup(func() { + tusDir = origTusDir + renameFile = origRenameFile + }) + + id := "open-failure" + src := filepath.Join(tusDir, id) + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { + t.Fatalf("write src failed: %v", err) + } + renameFile = func(string, string) error { + _ = os.Remove(src) + return fmt.Errorf("rename blocked") + } + + err := moveFromTus(tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}, filepath.Join(t.TempDir(), "dst.bin")) + if err == nil { + t.Fatalf("expected open failure") + } + }) + + t.Run("create failure", func(t *testing.T) { + origTusDir := tusDir + origRenameFile := renameFile + tusDir = t.TempDir() + renameFile = func(string, string) error { + return fmt.Errorf("rename blocked") + } + t.Cleanup(func() { + tusDir = origTusDir + renameFile = origRenameFile + }) + + id := "create-failure" + src := filepath.Join(tusDir, id) + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { + t.Fatalf("write src failed: %v", err) + } + dst := filepath.Join(t.TempDir(), "blocked-dst.bin") + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatalf("mkdir dst blocker failed: %v", err) + } + + err := moveFromTus(tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}, dst) + if err == nil { + t.Fatalf("expected create failure") + } + }) + + t.Run("mkdir failure", func(t *testing.T) { + origTusDir := tusDir + origRenameFile := renameFile + tusDir = t.TempDir() + renameFile = func(string, string) error { + return fmt.Errorf("rename blocked") + } + t.Cleanup(func() { + tusDir = origTusDir + renameFile = origRenameFile + }) + + id := "mkdir-failure" + src := filepath.Join(tusDir, id) + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { + t.Fatalf("write src failed: %v", err) + } + blocker := filepath.Join(t.TempDir(), "blocker") + if err := os.WriteFile(blocker, []byte("block"), 0o644); err != nil { + t.Fatalf("write blocker failed: %v", err) + } + dst := filepath.Join(blocker, "child.bin") + + err := moveFromTus(tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}, dst) + if err == nil { + t.Fatalf("expected mkdir failure") + } + }) + + t.Run("io copy failure", func(t *testing.T) { + origTusDir := tusDir + origRenameFile := renameFile + tusDir = t.TempDir() + renameFile = func(src, _ string) error { + if err := os.Remove(src); err != nil { + return err + } + if err := os.Mkdir(src, 0o755); err != nil { + return err + } + return fmt.Errorf("rename blocked") + } + t.Cleanup(func() { + tusDir = origTusDir + renameFile = origRenameFile + }) + + id := "copy-failure" + src := filepath.Join(tusDir, id) + if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil { + t.Fatalf("write src failed: %v", err) + } + dst := filepath.Join(t.TempDir(), "copy-failure.bin") + err := moveFromTus(tusd.HookEvent{Upload: tusd.FileInfo{ID: id}}, dst) + if err == nil { + t.Fatalf("expected copy failure") + } + }) +} + func TestWriteJSON(t *testing.T) { rr := httptest.NewRecorder() writeJSON(rr, map[string]any{"ok": true}) diff --git a/backend/middleware.go b/backend/middleware.go new file mode 100644 index 0000000..78a9e6e --- /dev/null +++ b/backend/middleware.go @@ -0,0 +1,90 @@ +// backend/middleware.go +package main + +import ( + "net/http" + "strings" + "time" + + "github.com/tus/tusd/pkg/handler" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +type loggingRW struct { + http.ResponseWriter + status int +} + +func (l *loggingRW) WriteHeader(code int) { + l.status = code + l.ResponseWriter.WriteHeader(code) +} + +func sessionRequired(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, err := internal.CurrentUser(r); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +func corsForTus(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET,POST,HEAD,PATCH,DELETE,OPTIONS") + w.Header().Set("Access-Control-Max-Age", "86400") + w.Header().Set( + "Access-Control-Allow-Headers", + "Content-Type, Tus-Resumable, Upload-Length, Upload-Defer-Length, Upload-Metadata, Upload-Offset, Upload-Concat, Upload-Checksum, X-Requested-With", + ) + w.Header().Set("Access-Control-Expose-Headers", "Location, Upload-Offset, Upload-Length, Tus-Resumable, Upload-Checksum") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func claimsFromHook(ev handler.HookEvent) (internal.Claims, error) { + req := ev.HTTPRequest + if req.Header == nil { + return internal.Claims{}, http.ErrNoCookie + } + r := http.Request{Header: http.Header(req.Header)} + return internal.CurrentUser(&r) +} + +func redactHeaders(h http.Header) http.Header { + cp := http.Header{} + for k, v := range h { + kk := strings.ToLower(k) + if kk == "cookie" || strings.HasPrefix(kk, "authorization") || strings.Contains(kk, "token") { + cp[k] = []string{""} + continue + } + cp[k] = append([]string(nil), v...) + } + return cp +} + +func debugHandler(next http.Handler) http.Handler { + if !internal.Debug { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + id := time.Now().UnixNano() + internal.Logf(">> %d %s %s %v", id, req.Method, req.URL.Path, redactHeaders(req.Header)) + rw := &loggingRW{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + next.ServeHTTP(rw, req) + internal.Logf("<< %d %s %s %d %s", id, req.Method, req.URL.Path, rw.status, time.Since(start)) + }) +} diff --git a/backend/middleware_test.go b/backend/middleware_test.go new file mode 100644 index 0000000..5938267 --- /dev/null +++ b/backend/middleware_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +func TestRedactHeaders(t *testing.T) { + got := redactHeaders(http.Header{ + "Cookie": []string{"session"}, + "Authorization": []string{"bearer token"}, + "X-Token": []string{"secret"}, + "X-Other": []string{"ok"}, + }) + + if got.Get("Cookie") != "" { + t.Fatalf("expected cookie to be redacted, got %q", got.Get("Cookie")) + } + if got.Get("Authorization") != "" { + t.Fatalf("expected authorization to be redacted, got %q", got.Get("Authorization")) + } + if got.Get("X-Token") != "" { + t.Fatalf("expected token header to be redacted, got %q", got.Get("X-Token")) + } + if got.Get("X-Other") != "ok" { + t.Fatalf("expected non-sensitive header to pass through, got %q", got.Get("X-Other")) + } +} + +func TestDebugHandlerWrapsHandler(t *testing.T) { + origDebug := internal.Debug + defer func() { internal.Debug = origDebug }() + internal.Debug = true + + called := false + h := debugHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/test", nil)) + + if !called { + t.Fatalf("expected wrapped handler to run") + } + if rr.Code != http.StatusTeapot { + t.Fatalf("unexpected status %d", rr.Code) + } +} diff --git a/backend/router_test.go b/backend/router_test.go new file mode 100644 index 0000000..bdb64fd --- /dev/null +++ b/backend/router_test.go @@ -0,0 +1,526 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + handler "github.com/tus/tusd/pkg/handler" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +type fakeJellyfin struct { + mu sync.Mutex + authResult internal.AuthResult + authErr error + refreshTokens []string +} + +func (f *fakeJellyfin) AuthenticateByName(username, password string) (internal.AuthResult, error) { + return f.authResult, f.authErr +} + +func (f *fakeJellyfin) RefreshLibrary(userToken string) { + f.mu.Lock() + defer f.mu.Unlock() + f.refreshTokens = append(f.refreshTokens, userToken) +} + +func (f *fakeJellyfin) refreshCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.refreshTokens) +} + +func testUserMap(t *testing.T, payload string) *internal.UserMap { + t.Helper() + path := filepath.Join(t.TempDir(), "user-map.yaml") + if err := os.WriteFile(path, []byte(payload), 0o644); err != nil { + t.Fatalf("write user map: %v", err) + } + um, err := internal.LoadUserMap(path) + if err != nil { + t.Fatalf("load user map: %v", err) + } + return um +} + +func sessionCookie(t *testing.T, username, token string) string { + t.Helper() + rr := httptest.NewRecorder() + if err := internal.SetSession(rr, username, token); err != nil { + t.Fatalf("SetSession: %v", err) + } + cookies := rr.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("expected session cookie") + } + return cookies[0].Name + "=" + cookies[0].Value +} + +func requestWithCookie(method, target, cookie string, body []byte) *http.Request { + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + return req +} + +func TestRouterLifecycleAndFilesystemRoutes(t *testing.T) { + origMediaRoot := mediaRoot + origTusDir := tusDir + origDebug := internal.Debug + origDryRun := internal.DryRun + defer func() { + mediaRoot = origMediaRoot + tusDir = origTusDir + internal.Debug = origDebug + internal.DryRun = origDryRun + }() + + root := t.TempDir() + mediaRoot = filepath.Join(root, "media") + tusDir = filepath.Join(root, "tus") + internal.Debug = false + internal.DryRun = false + if err := os.MkdirAll(filepath.Join(mediaRoot, "library", "album"), 0o2775); err != nil { + t.Fatalf("mkdir media root: %v", err) + } + if err := os.WriteFile(filepath.Join(mediaRoot, "library", "old.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("write source file: %v", err) + } + + um := testUserMap(t, "map:\n brad: library\n") + jf := &fakeJellyfin{ + authResult: internal.AuthResult{ + AccessToken: "jf-token", + }, + } + jf.authResult.User.Name = "brad" + + router := buildRouter(um, jf) + + t.Run("health and version", func(t *testing.T) { + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected health status %d", rr.Code) + } + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/version", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected version status %d", rr.Code) + } + }) + + t.Run("login whoami logout", func(t *testing.T) { + loginBody, _ := json.Marshal(map[string]string{"username": "brad", "password": "pass"}) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/login", "", loginBody)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected login status %d: %s", rr.Code, rr.Body.String()) + } + cookies := rr.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("expected login cookie") + } + session := cookies[0].Name + "=" + cookies[0].Value + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/api/whoami", session, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected whoami status %d", rr.Code) + } + var profile map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &profile); err != nil { + t.Fatalf("decode whoami: %v", err) + } + if profile["username"] != "brad" { + t.Fatalf("unexpected whoami payload %#v", profile) + } + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/logout", session, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected logout status %d", rr.Code) + } + }) + + session := sessionCookie(t, "brad", "jf-token") + + t.Run("list mkdir rename delete", func(t *testing.T) { + rr := httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/api/list?lib=library", session, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String()) + } + + mkdirBody, _ := json.Marshal(map[string]string{"lib": "library", "path": "new-folder"}) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/mkdir", session, mkdirBody)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected mkdir status %d: %s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(filepath.Join(mediaRoot, "library", "new-folder")); err != nil { + t.Fatalf("expected new folder to exist: %v", err) + } + + renameBody, _ := json.Marshal(map[string]string{ + "lib": "library", + "from": "old.txt", + "to": "2026.01.02.Note.old.txt", + }) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/rename", session, renameBody)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected rename status %d: %s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(filepath.Join(mediaRoot, "library", "2026.01.02.Note.old.txt")); err != nil { + t.Fatalf("expected renamed file to exist: %v", err) + } + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodDelete, "/api/file?lib=library&path=new-folder&recursive=true", session, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected delete status %d: %s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(filepath.Join(mediaRoot, "library", "new-folder")); !os.IsNotExist(err) { + t.Fatalf("expected deleted folder to be absent, err=%v", err) + } + }) + + t.Run("static metrics and tus auth", func(t *testing.T) { + rr := httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected static status %d", rr.Code) + } + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/metrics", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected metrics status %d", rr.Code) + } + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/tus/123", nil)) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected tus auth to reject unauthenticated request, got %d", rr.Code) + } + }) + + if jf.refreshCount() == 0 { + t.Fatalf("expected at least one Jellyfin refresh call") + } +} + +func TestDebugRoutesAndUploadProcessing(t *testing.T) { + origMediaRoot := mediaRoot + origTusDir := tusDir + origDebug := internal.Debug + origDryRun := internal.DryRun + defer func() { + mediaRoot = origMediaRoot + tusDir = origTusDir + internal.Debug = origDebug + internal.DryRun = origDryRun + }() + + root := t.TempDir() + mediaRoot = filepath.Join(root, "media") + tusDir = filepath.Join(root, "tus") + internal.Debug = true + internal.DryRun = false + if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil { + t.Fatalf("mkdir media root: %v", err) + } + + um := testUserMap(t, "map:\n brad: library\n") + jf := &fakeJellyfin{ + authResult: internal.AuthResult{AccessToken: "jf-token"}, + } + jf.authResult.User.Name = "brad" + + router := buildRouter(um, jf) + session := sessionCookie(t, "brad", "jf-token") + + rr := httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/debug/env", session, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected debug env status %d", rr.Code) + } + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/debug/write-test", session, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("unexpected debug write-test status %d", rr.Code) + } + + srcID := "upload-id-123" + if err := os.MkdirAll(tusDir, 0o2775); err != nil { + t.Fatalf("mkdir tus dir: %v", err) + } + if err := os.WriteFile(filepath.Join(tusDir, srcID+".bin"), []byte("payload"), 0o644); err != nil { + t.Fatalf("write tus payload: %v", err) + } + + event := handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{ + Header: http.Header{"Cookie": []string{session}}, + }, + Upload: handler.FileInfo{ + ID: srcID, + MetaData: map[string]string{ + "filename": "video.mp4", + "desc": "Trip", + "date": "2026-01-02", + "lib": "library", + }, + }, + } + + done := make(chan struct{}) + ch := make(chan handler.HookEvent, 2) + go func() { + watchUploadCompletions(ch, um, jf) + close(done) + }() + ch <- event + ch <- handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{ + Header: http.Header{"Cookie": []string{session}}, + }, + Upload: handler.FileInfo{ + ID: "bad-id", + MetaData: map[string]string{ + "filename": "clip.mp4", + "lib": "library", + }, + }, + } + close(ch) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("watchUploadCompletions did not drain channel") + } + + expected := filepath.Join(mediaRoot, "library", composeFinalName("2026-01-02", "Trip", "video.mp4")) + if _, err := os.Stat(expected); err != nil { + t.Fatalf("expected uploaded file at destination: %v", err) + } + if jf.refreshCount() == 0 { + t.Fatalf("expected upload completion to refresh Jellyfin") + } +} + +func TestProcessCompletedUploadErrorPaths(t *testing.T) { + origMediaRoot := mediaRoot + origTusDir := tusDir + origDryRun := internal.DryRun + defer func() { + mediaRoot = origMediaRoot + tusDir = origTusDir + internal.DryRun = origDryRun + }() + + root := t.TempDir() + mediaRoot = filepath.Join(root, "media") + tusDir = filepath.Join(root, "tus") + internal.DryRun = false + + um := testUserMap(t, "map:\n brad: library\n") + jf := &fakeJellyfin{authResult: internal.AuthResult{AccessToken: "jf-token"}} + jf.authResult.User.Name = "brad" + session := sessionCookie(t, "brad", "jf-token") + + t.Run("missing session", func(t *testing.T) { + ev := handler.HookEvent{} + if err := processCompletedUpload(ev, um, jf); err == nil { + t.Fatalf("expected missing session error") + } + }) + + t.Run("missing mapping", func(t *testing.T) { + ev := handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{sessionCookie(t, "nobody", "token")}}}, + Upload: handler.FileInfo{ + ID: "missing-mapping", + MetaData: map[string]string{ + "filename": "clip.mp4", + "desc": "Trip", + "lib": "library", + }, + }, + } + if err := processCompletedUpload(ev, um, jf); err == nil { + t.Fatalf("expected missing mapping error") + } + }) + + t.Run("missing library root isolated", func(t *testing.T) { + origMediaRoot := mediaRoot + origTusDir := tusDir + defer func() { + mediaRoot = origMediaRoot + tusDir = origTusDir + }() + + mediaRoot = filepath.Join(t.TempDir(), "media") + tusDir = filepath.Join(t.TempDir(), "tus") + if err := os.MkdirAll(tusDir, 0o2775); err != nil { + t.Fatalf("mkdir tus dir: %v", err) + } + id := "missing-root-isolated" + if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil { + t.Fatalf("write tus payload: %v", err) + } + ev := handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}}, + Upload: handler.FileInfo{ + ID: id, + MetaData: map[string]string{ + "filename": "clip.mp4", + "desc": "Trip", + "lib": "library", + }, + }, + } + if err := processCompletedUpload(ev, um, jf); err == nil { + t.Fatalf("expected missing library root error") + } + }) + + t.Run("missing description for video", func(t *testing.T) { + if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil { + t.Fatalf("mkdir media root: %v", err) + } + id := "video-no-desc" + if err := os.MkdirAll(tusDir, 0o2775); err != nil { + t.Fatalf("mkdir tus dir: %v", err) + } + if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil { + t.Fatalf("write tus payload: %v", err) + } + ev := handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}}, + Upload: handler.FileInfo{ + ID: id, + MetaData: map[string]string{ + "filename": "video.mp4", + "lib": "library", + }, + }, + } + if err := processCompletedUpload(ev, um, jf); err == nil { + t.Fatalf("expected missing desc error") + } + }) + + t.Run("missing library root", func(t *testing.T) { + id := "missing-root" + ev := handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}}, + Upload: handler.FileInfo{ + ID: id, + MetaData: map[string]string{ + "filename": "clip.mp4", + "desc": "Trip", + "lib": "library", + }, + }, + } + if err := processCompletedUpload(ev, um, jf); err == nil { + t.Fatalf("expected missing root error") + } + }) + + t.Run("subdir mkdir failure", func(t *testing.T) { + if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil { + t.Fatalf("mkdir media root: %v", err) + } + blocker := filepath.Join(mediaRoot, "library", "bad") + if err := os.WriteFile(blocker, []byte("file"), 0o644); err != nil { + t.Fatalf("write blocker file: %v", err) + } + id := "subdir-failure" + if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil { + t.Fatalf("write tus payload: %v", err) + } + ev := handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}}, + Upload: handler.FileInfo{ + ID: id, + MetaData: map[string]string{ + "filename": "clip.mp4", + "desc": "Trip", + "lib": "library", + "subdir": "bad/child", + }, + }, + } + if err := processCompletedUpload(ev, um, jf); err == nil { + t.Fatalf("expected mkdir failure") + } + }) + + t.Run("non-video defaults description", func(t *testing.T) { + if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil { + t.Fatalf("mkdir media root: %v", err) + } + id := "image-default-desc" + if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil { + t.Fatalf("write tus payload: %v", err) + } + ev := handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}}, + Upload: handler.FileInfo{ + ID: id, + MetaData: map[string]string{ + "filename": "photo.jpg", + "lib": "library", + }, + }, + } + if err := processCompletedUpload(ev, um, jf); err != nil { + t.Fatalf("expected default-desc upload to succeed: %v", err) + } + expected := filepath.Join(mediaRoot, "library", composeFinalName("", "upload", "photo.jpg")) + if _, err := os.Stat(expected); err != nil { + t.Fatalf("expected defaulted upload output: %v", err) + } + }) + + t.Run("missing filename falls back to upload.bin", func(t *testing.T) { + id := "missing-filename" + if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil { + t.Fatalf("write tus payload: %v", err) + } + ev := handler.HookEvent{ + HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}}, + Upload: handler.FileInfo{ + ID: id, + MetaData: map[string]string{ + "desc": "Trip", + "lib": "library", + }, + }, + } + if err := processCompletedUpload(ev, um, jf); err != nil { + t.Fatalf("expected upload with fallback filename to succeed: %v", err) + } + expected := filepath.Join(mediaRoot, "library", composeFinalName("", "Trip", "upload.bin")) + if _, err := os.Stat(expected); err != nil { + t.Fatalf("expected fallback filename output: %v", err) + } + }) +} diff --git a/backend/routes.go b/backend/routes.go new file mode 100644 index 0000000..f4ae33d --- /dev/null +++ b/backend/routes.go @@ -0,0 +1,71 @@ +// backend/routes.go +package main + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +//go:embed web/dist +var webFS embed.FS + +func buildRouter(um *internal.UserMap, jf jellyfinClient) http.Handler { + r := chi.NewRouter() + r.Use(corsForTus) + + r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + r.Get("/version", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{ + "version": version, + "git": git, + "built_at": builtAt, + }) + }) + + r.Post("/api/login", loginHandler(um, jf)) + r.Post("/api/logout", logoutHandler()) + r.Get("/api/whoami", whoamiHandler(um)) + r.Get("/api/list", listHandler(um)) + r.Post("/api/rename", renameHandler(um, jf)) + r.Delete("/api/file", deleteHandler(um, jf)) + r.Post("/api/mkdir", mkdirHandler(um, jf)) + + tusHandler := newTusHandler(um, jf) + r.Route("/tus", func(rt chi.Router) { + rt.Use(sessionRequired) + rt.Post("/", tusHandler.PostFile) + rt.Head("/{id}", tusHandler.HeadFile) + rt.Patch("/{id}", tusHandler.PatchFile) + rt.Delete("/{id}", tusHandler.DelFile) + rt.Get("/{id}", tusHandler.GetFile) + }) + + r.Handle("/metrics", promhttp.Handler()) + + appFS, err := fs.Sub(webFS, "web/dist") + if err == nil { + static := http.FileServer(http.FS(appFS)) + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + static.ServeHTTP(w, r) + }) + r.Method("GET", "/*", http.StripPrefix("/", static)) + } + + r.Get("/debug/env", debugEnvHandler()) + r.Get("/debug/write-test", debugWriteTestHandler(um)) + + r.NotFound(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound) + }) + + return debugHandler(r) +} diff --git a/backend/upload_handler.go b/backend/upload_handler.go new file mode 100644 index 0000000..5f86139 --- /dev/null +++ b/backend/upload_handler.go @@ -0,0 +1,102 @@ +// backend/upload_handler.go +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/tus/tusd/pkg/filestore" + handler "github.com/tus/tusd/pkg/handler" + "github.com/tus/tusd/pkg/memorylocker" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +func newTusHandler(um *internal.UserMap, jf jellyfinClient) *handler.UnroutedHandler { + store := filestore.FileStore{Path: tusDir} + locker := memorylocker.New() + composer := handler.NewStoreComposer() + store.UseIn(composer) + locker.UseIn(composer) + + config := handler.Config{ + BasePath: "/tus/", + StoreComposer: composer, + NotifyCompleteUploads: true, + MaxSize: 8 * 1024 * 1024 * 1024, + RespectForwardedHeaders: true, + } + tusHandler, err := handler.NewUnroutedHandler(config) + must(err, "init tus handler") + + go watchUploadCompletions(tusHandler.CompleteUploads, um, jf) + return tusHandler +} + +func watchUploadCompletions(completeC <-chan handler.HookEvent, um *internal.UserMap, jf jellyfinClient) { + for ev := range completeC { + if err := processCompletedUpload(ev, um, jf); err != nil { + log.Printf("tus upload processing failed: %v", err) + } + } +} + +func processCompletedUpload(ev handler.HookEvent, um *internal.UserMap, jf jellyfinClient) error { + claims, err := claimsFromHook(ev) + if err != nil { + internal.Logf("tus: no session: %v", err) + return err + } + + meta := ev.Upload.MetaData + desc := strings.TrimSpace(meta["desc"]) + date := strings.TrimSpace(meta["date"]) + subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/") + lib := strings.Trim(strings.TrimSpace(meta["lib"]), "/") + orig := meta["filename"] + if orig == "" { + orig = "upload.bin" + } + + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), ".")) + isVideo := map[string]bool{ + "mp4": true, "mkv": true, "mov": true, "avi": true, "m4v": true, + "webm": true, "mpg": true, "mpeg": true, "ts": true, "m2ts": true, + }[ext] + if isVideo && desc == "" { + return fmt.Errorf("missing desc for video") + } + if desc == "" { + desc = "upload" + } + + rootRel, err := resolveLibraryRoot(um, claims.Username, lib) + if err != nil { + return err + } + libRoot, _ := internal.SafeJoin(mediaRoot, rootRel) + if fi, err := os.Stat(libRoot); err != nil || !fi.IsDir() { + return fmt.Errorf("library root missing or not accessible") + } + + destRoot := libRoot + if subdir != "" { + subdir = sanitizeSegment(subdir) + destRoot = filepath.Join(libRoot, subdir) + if err := os.MkdirAll(destRoot, 0o2775); err != nil { + return err + } + } + + finalName := composeFinalName(date, desc, orig) + dst := filepath.Join(destRoot, finalName) + if err := moveFromTus(ev, dst); err != nil { + return err + } + + jf.RefreshLibrary(claims.JFToken) + return nil +} diff --git a/backend/uploads.go b/backend/uploads.go new file mode 100644 index 0000000..20d88f7 --- /dev/null +++ b/backend/uploads.go @@ -0,0 +1,136 @@ +// backend/uploads.go +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/tus/tusd/pkg/handler" + + "scm.bstein.dev/bstein/Pegasus/backend/internal" +) + +var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}(?:\.[A-Za-z0-9_-]{1,64})?\.[A-Za-z0-9]{1,8}$`) +var renameFile = os.Rename + +func sanitizeDescriptor(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, " ", "_") + if s == "" { + return "upload" + } + + var b []rune + lastUnderscore := false + for _, r := range s { + ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' + if !ok { + r = '_' + } + if r == '_' { + if lastUnderscore { + continue + } + lastUnderscore = true + } else { + lastUnderscore = false + } + b = append(b, r) + if len(b) >= 64 { + break + } +} + return string(b) +} + +func stemOf(orig string) string { + base := strings.TrimSuffix(orig, filepath.Ext(orig)) + s := sanitizeDescriptor(strings.ReplaceAll(base, ".", "_")) + return s +} + +func composeFinalName(date, desc, orig string) string { + var y, m, d string + if len(date) >= 10 && date[4] == '-' && date[7] == '-' { + y, m, d = date[:4], date[5:7], date[8:10] + } else { + now := time.Now() + y = fmt.Sprintf("%04d", now.Year()) + m = fmt.Sprintf("%02d", int(now.Month())) + d = fmt.Sprintf("%02d", now.Day()) + } + clean := sanitizeDescriptor(desc) + stem := stemOf(orig) + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), ".")) + if ext == "" { + ext = "bin" + } + return fmt.Sprintf("%s.%s.%s.%s.%s.%s", y, m, d, clean, stem, ext) +} + +func moveFromTus(ev handler.HookEvent, dst string) error { + candidates := []string{ + filepath.Join(tusDir, ev.Upload.ID), + filepath.Join(tusDir, ev.Upload.ID+".bin"), + filepath.Join(tusDir, "data", ev.Upload.ID), + filepath.Join(tusDir, "data", ev.Upload.ID+".bin"), + filepath.Join(tusDir, "uploads", ev.Upload.ID), + filepath.Join(tusDir, "uploads", ev.Upload.ID+".bin"), + } + + var src string + for _, p := range candidates { + if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() { + src = p + break + } + } + if src == "" { + entries, _ := os.ReadDir(tusDir) + for _, e := range entries { + if !e.IsDir() && strings.HasPrefix(e.Name(), ev.Upload.ID) { + src = filepath.Join(tusDir, e.Name()) + break + } + } + } + if src == "" { + return fmt.Errorf("tus data file not found for id %s", ev.Upload.ID) + } + + if err := os.MkdirAll(filepath.Dir(dst), 0o2775); err != nil { + return err + } + if internal.DryRun { + internal.Logf("[DRY] mv %s -> %s", src, dst) + return nil + } + + if err := renameFile(src, dst); err != nil { + in, err2 := os.Open(src) + if err2 != nil { + return err2 + } + defer in.Close() + + out, err3 := os.Create(dst) + if err3 != nil { + return err3 + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + _ = out.Close() + _ = os.Remove(src) + } + + _ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".info")) + _ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".json")) + return nil +} diff --git a/backend/util.go b/backend/util.go new file mode 100644 index 0000000..fe8f3a8 --- /dev/null +++ b/backend/util.go @@ -0,0 +1,67 @@ +// backend/util.go +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" + "strings" +) + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func must(err error, msg string) { + if err != nil { + log.Fatalf("%s: %v", msg, err) + } +} + +func contains(ss []string, v string) bool { + for _, s := range ss { + if s == v { + return true + } + } + return false +} + +func sanitizeSegment(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, " ", "_") + s = strings.Trim(s, "/") + if i := strings.IndexByte(s, '/'); i >= 0 { + s = s[:i] + } + + out := make([]rune, 0, len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', + r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', + r == '_', + r == '-', + r == '.': + out = append(out, r) + default: + out = append(out, '_') + } + } + if len(out) > 64 { + out = out[:64] + } + return string(out) +} diff --git a/backend/util_test.go b/backend/util_test.go new file mode 100644 index 0000000..b7f1eea --- /dev/null +++ b/backend/util_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "errors" + "net/http" + "testing" +) + +type failingWriter struct { + header http.Header +} + +func (f *failingWriter) Header() http.Header { + if f.header == nil { + f.header = http.Header{} + } + return f.header +} + +func (f *failingWriter) WriteHeader(status int) {} + +func (f *failingWriter) Write([]byte) (int, error) { + return 0, errors.New("boom") +} + +func TestWriteJSONWriteError(t *testing.T) { + w := &failingWriter{} + writeJSON(w, map[string]any{"ok": true}) + if got := w.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" { + t.Fatalf("expected fallback error content type, got %q", got) + } +} + +func TestEnvDefaultsAndContains(t *testing.T) { + if got := env("PEGASUS_TEST_MISSING", "fallback"); got != "fallback" { + t.Fatalf("unexpected env fallback %q", got) + } + if !contains([]string{"a", "b"}, "a") { + t.Fatalf("expected contains to find value") + } + if contains([]string{"a", "b"}, "z") { + t.Fatalf("expected contains to return false") + } +} + +func TestSanitizeSegmentTrimsAndClamps(t *testing.T) { + if got := sanitizeSegment(" /A bad/segment "); got != "A_bad" { + t.Fatalf("unexpected sanitized segment %q", got) + } + + long := "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz0123456789" + if got := sanitizeSegment(long); len(got) != 64 { + t.Fatalf("expected 64-char clamp, got %d", len(got)) + } +} diff --git a/frontend/src/Uploader.entry.test.ts b/frontend/src/Uploader.entry.test.ts new file mode 100644 index 0000000..850614e --- /dev/null +++ b/frontend/src/Uploader.entry.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' + +import Uploader from './Uploader' + +describe('Uploader entrypoint', () => { + it('re-exports the uploader view component', () => { + expect(typeof Uploader).toBe('function') + }) +}) diff --git a/frontend/src/Uploader.helpers.test.ts b/frontend/src/Uploader.helpers.test.ts index 262eeda..57bbcd8 100644 --- a/frontend/src/Uploader.helpers.test.ts +++ b/frontend/src/Uploader.helpers.test.ts @@ -1,9 +1,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' -import { +import uploaderUtils from './uploader-utils' + +const { clampOneLevel, composeName, extOf, + createNoResumeFingerprint, + createNoResumeStorage, + fmt, isDetailedError, isImageFile, isLikelyMobileUA, @@ -12,7 +17,7 @@ import { sanitizeDesc, sanitizeFolderName, stemOf, -} from './Uploader' +} = uploaderUtils describe('Uploader helpers', () => { let logSpy: ReturnType @@ -74,4 +79,50 @@ describe('Uploader helpers', () => { expect(typeof isLikelyMobileUA()).toBe('boolean') }) + + it('covers the mobile UA and size formatting branches', () => { + const originalMatchMedia = window.matchMedia + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: () => ({ matches: true }), + }) + expect(isLikelyMobileUA()).toBe(true) + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: () => ({ matches: false }), + }) + expect(isLikelyMobileUA()).toBe(false) + + if (originalMatchMedia) { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: originalMatchMedia, + }) + } else { + delete (window as any).matchMedia + } + + expect(fmt(1024 * 1024 * 1024)).toBe('1.0 GB') + }) + + it('formats sizes and exposes no-resume upload helpers', async () => { + expect(fmt(0)).toBe('0 B') + expect(fmt(1536)).toBe('1.5 KB') + + const storage = createNoResumeStorage() + await expect(storage.addUpload({})).resolves.toBeUndefined() + await expect(storage.removeUpload({})).resolves.toBeUndefined() + await expect(storage.listUploads()).resolves.toEqual([]) + await expect(storage.findUploadsByFingerprint('abc')).resolves.toEqual([]) + await expect(createNoResumeFingerprint()).resolves.toMatch(/^noresume-/) + }) + + it('returns false without a window and normalizes non-array rows', () => { + const originalWindow = window + vi.stubGlobal('window', undefined as any) + expect(isLikelyMobileUA()).toBe(false) + vi.stubGlobal('window', originalWindow as any) + expect(normalizeRows('bad input' as any)).toEqual([]) + }) }) diff --git a/frontend/src/Uploader.tsx b/frontend/src/Uploader.tsx index be3d6ea..6de80b4 100644 --- a/frontend/src/Uploader.tsx +++ b/frontend/src/Uploader.tsx @@ -1,756 +1 @@ -// frontend/src/Uploader.tsx -import React from 'react' -import { api, WhoAmI } from './api' -import * as tus from 'tus-js-client' - -type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number } -type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string } - -// ---------- helpers ---------- -export function sanitizeDesc(s: string) { - s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_') - if (!s) s = 'upload' - return s.slice(0, 64) -} -export function sanitizeFolderName(s: string) { - // 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-] - s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '') - s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_') - return s.slice(0, 64) -} -export function extOf(n: string) { - const i = n.lastIndexOf('.') - return i > -1 ? n.slice(i + 1).toLowerCase() : '' -} -export function stemOf(n: string) { - const i = n.lastIndexOf('.') - const stem = i > -1 ? n.slice(0, i) : n - // keep safe charset and avoid extra dots within segments - return sanitizeDesc(stem.replace(/\./g, '_')) || 'file' -} -export function composeName(date: string, desc: string, orig: string) { - const d = date || new Date().toISOString().slice(0, 10) - const [Y, M, D] = d.split('-') - const sDesc = sanitizeDesc(desc) - const sStem = stemOf(orig) - const ext = extOf(orig) || 'bin' - // New pattern: YYYY.MM.DD.Description.OriginalStem.ext - return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}` -} -export function clampOneLevel(p: string) { - if (!p) return '' - return p.replace(/^\/+|\/+$/g, '').split('/')[0] || '' -} -export function normalizeRows(listRaw: any[]): FileRow[] { - return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({ - name: r?.name ?? r?.Name ?? '', - path: r?.path ?? r?.Path ?? '', - is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false), - size: Number(r?.size ?? r?.Size ?? 0), - mtime: Number(r?.mtime ?? r?.Mtime ?? 0), - })) -} -const videoExt = new Set(['mp4', 'mkv', 'mov', 'avi', 'm4v', 'webm', 'mpg', 'mpeg', 'ts', 'm2ts']) -const imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff']) -const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '') -export const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name)) -export const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name)) - -export function isDetailedError(e: unknown): e is tus.DetailedError { - return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any)) -} - -export function isLikelyMobileUA(): boolean { - if (typeof window === 'undefined') return false - const ua = navigator.userAgent || '' - const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches - return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua) -} -function useIsMobile(): boolean { - const [mobile, setMobile] = React.useState(false) - React.useEffect(() => { - setMobile(isLikelyMobileUA()) - }, []) - return mobile -} - -// Disable tus resume completely (v2 API: addUpload/removeUpload/listUploads) -const NoResumeUrlStorage: any = { - addUpload: async (_u: any) => {}, - removeUpload: async (_u: any) => {}, - listUploads: async () => [], - findUploadsByFingerprint: async (_fp: string) => [], -} -const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}` - -// ---------- thumbnail ---------- -function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) { - const [url, setUrl] = React.useState() - React.useEffect(() => { - const u = URL.createObjectURL(file) - setUrl(u) - return () => { - try { - URL.revokeObjectURL(u) - } catch {} - } - }, [file]) - if (!url) return null - const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 } - if (isImageFile(file)) return - if (isVideoFile(file)) return