pegasus: tighten quality gate

This commit is contained in:
Brad Stein 2026-04-11 00:02:59 -03:00
parent 73019679c4
commit 0f4e928622
48 changed files with 4537 additions and 1549 deletions

View File

@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/go/pkg/mod go mod tidy
RUN --mount=type=cache,target=/go/pkg/mod \ RUN --mount=type=cache,target=/go/pkg/mod \
set -eux; \ set -eux; \
mkdir -p /out; \ 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; \ test -f /out/pegasus; \
upx -q --lzma /out/pegasus || true upx -q --lzma /out/pegasus || true

44
Jenkinsfile vendored
View File

@ -64,6 +64,8 @@ spec:
container('go-tester') { container('go-tester') {
sh ''' sh '''
set -eu set -eu
export PEGASUS_SESSION_KEY=test-session-key
export PEGASUS_COOKIE_INSECURE=1
mkdir -p build mkdir -p build
cd backend cd backend
go install github.com/jstemmer/go-junit-report/v2@latest go install github.com/jstemmer/go-junit-report/v2@latest
@ -108,43 +110,12 @@ spec:
} }
} }
stage('Combine coverage summary') { stage('Quality gate report') {
steps { steps {
container('publisher') { container('publisher') {
sh ''' sh '''
set -eu set -eu
python - <<'PY' python -m testing.pegasus_gate report
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
''' '''
} }
} }
@ -166,12 +137,7 @@ PY
container('publisher') { container('publisher') {
sh ''' sh '''
set -eu set -eu
backend_rc="$(cat build/backend-test.rc 2>/dev/null || echo 1)" python -m testing.pegasus_gate enforce
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
''' '''
} }
} }

57
backend/app.go Normal file
View File

@ -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)
}

214
backend/app_test.go Normal file
View File

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

46
backend/config.go Normal file
View File

@ -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
}

26
backend/config_test.go Normal file
View File

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

70
backend/handlers_auth.go Normal file
View File

@ -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,
})
}
}

View File

@ -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)
}
})
}

275
backend/handlers_fs.go Normal file
View File

@ -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})
}
}

447
backend/handlers_fs_test.go Normal file
View File

@ -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)
}
})
}

View File

@ -8,12 +8,15 @@ import (
"github.com/golang-jwt/jwt/v5" "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) { func CurrentUser(r *http.Request) (Claims, error) {
c, err := r.Cookie(CookieName) c, err := r.Cookie(CookieName)
if err != nil { if err != nil {
return Claims{}, err 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 { if err != nil {
return Claims{}, err return Claims{}, err
} }

View File

@ -98,3 +98,20 @@ func TestCurrentUserMalformedToken(t *testing.T) {
t.Fatalf("expected parse error for malformed token") 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")
}
}

View File

@ -8,15 +8,20 @@ import (
"strings" "strings"
) )
// Debug enables verbose request and state logging.
var Debug = os.Getenv("PEGASUS_DEBUG") == "1" 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" var DryRun = os.Getenv("PEGASUS_DRY_RUN") == "1"
// Logf writes a log line only when Debug is enabled.
func Logf(format string, args ...any) { func Logf(format string, args ...any) {
if Debug { if Debug {
log.Printf(format, args...) log.Printf(format, args...)
} }
} }
// RedactHeaders clones a header map and masks sensitive values.
func RedactHeaders(h http.Header) http.Header { func RedactHeaders(h http.Header) http.Header {
cp := http.Header{} cp := http.Header{}
for k, v := range h { for k, v := range h {

View File

@ -8,14 +8,17 @@ import (
"strings" "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) { func SafeJoin(root, rel string) (string, error) {
rel = strings.TrimPrefix(rel, "/") rel = strings.TrimPrefix(rel, "/")
p := filepath.Join(root, rel) p := filepath.Join(root, rel)
ap, err := filepath.Abs(p) ap, err := absPath(p)
if err != nil { if err != nil {
return "", err return "", err
} }
ar, err := filepath.Abs(root) ar, err := absPath(root)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@ -1,6 +1,7 @@
package internal package internal
import ( import (
"errors"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@ -34,3 +35,52 @@ func TestSafeJoinAllowsRoot(t *testing.T) {
t.Fatalf("expected root path %q got %q", root, got) 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")
}
}

View File

@ -4,15 +4,14 @@ package internal
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" // <-- keep this; used by fmt.Errorf "fmt"
"net/http" "net/http"
"os" "os"
"time" "time"
) )
// Minimal Jellyfin client // AuthResult is the subset of Jellyfin auth response Pegasus needs.
type AuthResult struct {
type jfAuthResult struct {
AccessToken string `json:"AccessToken"` AccessToken string `json:"AccessToken"`
User struct { User struct {
Id string `json:"Id"` Id string `json:"Id"`
@ -20,11 +19,13 @@ type jfAuthResult struct {
} `json:"User"` } `json:"User"`
} }
// Jellyfin is a tiny client for the auth and refresh endpoints Pegasus uses.
type Jellyfin struct { type Jellyfin struct {
BaseURL string BaseURL string
Client *http.Client Client *http.Client
} }
// NewJellyfin builds a lightweight client for Jellyfin auth and refresh calls.
func NewJellyfin() *Jellyfin { func NewJellyfin() *Jellyfin {
return &Jellyfin{ return &Jellyfin{
BaseURL: os.Getenv("JELLYFIN_URL"), BaseURL: os.Getenv("JELLYFIN_URL"),
@ -32,9 +33,9 @@ func NewJellyfin() *Jellyfin {
} }
} }
// Authenticate against /Users/AuthenticateByName using Pw (plaintext) // AuthenticateByName signs into Jellyfin with the plaintext password Jellyfin expects.
func (j *Jellyfin) AuthenticateByName(username, password string) (jfAuthResult, error) { func (j *Jellyfin) AuthenticateByName(username, password string) (AuthResult, error) {
var out jfAuthResult var out AuthResult
body := map[string]string{"Username": username, "Pw": password} body := map[string]string{"Username": username, "Pw": password}
b, _ := json.Marshal(body) b, _ := json.Marshal(body)

View File

@ -90,3 +90,14 @@ func TestAuthenticateByNameRequestError(t *testing.T) {
t.Fatalf("expected transport error") 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")
}
}

View File

@ -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}$`) 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 { func SanitizeDesc(in string) string {
s := strings.TrimSpace(in) s := strings.TrimSpace(in)
if s == "" { if s == "" {
@ -29,6 +30,7 @@ func SanitizeDesc(in string) string {
return s 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) { func ComposeFinalName(dateStr, desc, origFilename string) (string, error) {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), ".")) ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origFilename), "."))
if ext == "" { if ext == "" {

View File

@ -48,3 +48,9 @@ func TestComposeFinalName(t *testing.T) {
t.Fatalf("unexpected fallback name %q", fallback) 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")
}
}

View File

@ -84,3 +84,25 @@ func TestRefreshLibrarySkipsWithoutURL(t *testing.T) {
t.Fatalf("expected timer to self-clear after skip") 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)
}
}

View File

@ -9,6 +9,7 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
// Claims are the signed session fields Pegasus stores in the browser cookie.
type Claims struct { type Claims struct {
Username string `json:"u"` Username string `json:"u"`
JFToken string `json:"t"` JFToken string `json:"t"`
@ -17,9 +18,14 @@ type Claims struct {
var sessionKey = []byte(os.Getenv("PEGASUS_SESSION_KEY")) var sessionKey = []byte(os.Getenv("PEGASUS_SESSION_KEY"))
var cookieSecure = os.Getenv("PEGASUS_COOKIE_INSECURE") != "1" 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" const CookieName = "pegasus_session"
// SetSession signs and writes a Pegasus session cookie.
func SetSession(w http.ResponseWriter, username, jfToken string) error { func SetSession(w http.ResponseWriter, username, jfToken string) error {
now := time.Now() now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ tok := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
@ -30,7 +36,7 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
}, },
}) })
signed, err := tok.SignedString(sessionKey) signed, err := signJWT(tok)
if err != nil { if err != nil {
return err return err
} }
@ -45,6 +51,7 @@ func SetSession(w http.ResponseWriter, username, jfToken string) error {
return nil return nil
} }
// ClearSession expires the Pegasus session cookie immediately.
func ClearSession(w http.ResponseWriter) { func ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: CookieName, Name: CookieName,

View File

@ -95,3 +95,17 @@ func TestClearSessionExpiresCookie(t *testing.T) {
t.Fatalf("expected secure cookie") 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")
}
}

View File

@ -10,8 +10,7 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// StringOrList allows both a single string ("Stein_90s") // StringOrList allows a single string or a list of strings in YAML.
// and a list of strings (["Stein_90s","Home Videos"]) in YAML.
type StringOrList []string type StringOrList []string
func (s *StringOrList) UnmarshalYAML(value *yaml.Node) error { 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 { type UserMap struct {
// Maps Jellyfin username -> one or more relative media subdirs
Map map[string]StringOrList `yaml:"map"` 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) { func LoadUserMap(path string) (*UserMap, error) {
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
if err != nil { if err != nil {

View File

@ -90,3 +90,17 @@ func TestResolveAllMissingAndInvalidEntries(t *testing.T) {
t.Fatalf("expected invalid-mapping error") 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")
}
}

View File

@ -1,733 +1,12 @@
// backend/main.go // backend/main.go
package main package main
import ( import "log"
"embed"
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"sort"
"github.com/go-chi/chi/v5" var fatal = log.Fatal
"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) }
func main() { func main() {
internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot) if err := run(); err != nil {
fatal(err)
if os.Getenv("PEGASUS_SESSION_KEY") == "" {
log.Fatal("PEGASUS_SESSION_KEY is not set")
} }
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
} }

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -49,6 +50,9 @@ func TestSanitizeDescriptorAndStemOf(t *testing.T) {
if got := sanitizeDescriptor(""); got != "upload" { if got := sanitizeDescriptor(""); got != "upload" {
t.Fatalf("expected upload fallback, got %q", got) 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" { if got := stemOf("My.Video.File.mp4"); got != "My_Video_File" {
t.Fatalf("unexpected stem %q", got) 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) { func TestWriteJSON(t *testing.T) {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
writeJSON(rr, map[string]any{"ok": true}) writeJSON(rr, map[string]any{"ok": true})

90
backend/middleware.go Normal file
View File

@ -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{"<redacted>"}
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))
})
}

View File

@ -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") != "<redacted>" {
t.Fatalf("expected cookie to be redacted, got %q", got.Get("Cookie"))
}
if got.Get("Authorization") != "<redacted>" {
t.Fatalf("expected authorization to be redacted, got %q", got.Get("Authorization"))
}
if got.Get("X-Token") != "<redacted>" {
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)
}
}

526
backend/router_test.go Normal file
View File

@ -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)
}
})
}

71
backend/routes.go Normal file
View File

@ -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)
}

102
backend/upload_handler.go Normal file
View File

@ -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
}

136
backend/uploads.go Normal file
View File

@ -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
}

67
backend/util.go Normal file
View File

@ -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)
}

55
backend/util_test.go Normal file
View File

@ -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))
}
}

View File

@ -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')
})
})

View File

@ -1,9 +1,14 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { import uploaderUtils from './uploader-utils'
const {
clampOneLevel, clampOneLevel,
composeName, composeName,
extOf, extOf,
createNoResumeFingerprint,
createNoResumeStorage,
fmt,
isDetailedError, isDetailedError,
isImageFile, isImageFile,
isLikelyMobileUA, isLikelyMobileUA,
@ -12,7 +17,7 @@ import {
sanitizeDesc, sanitizeDesc,
sanitizeFolderName, sanitizeFolderName,
stemOf, stemOf,
} from './Uploader' } = uploaderUtils
describe('Uploader helpers', () => { describe('Uploader helpers', () => {
let logSpy: ReturnType<typeof vi.spyOn> let logSpy: ReturnType<typeof vi.spyOn>
@ -74,4 +79,50 @@ describe('Uploader helpers', () => {
expect(typeof isLikelyMobileUA()).toBe('boolean') 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([])
})
}) })

View File

@ -1,756 +1 @@
// frontend/src/Uploader.tsx export { default } from './UploaderView'
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<string>()
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 <img src={url} alt="" style={baseStyle} className="preview-thumb" />
if (isVideoFile(file)) return <video src={url} muted preload="metadata" playsInline style={baseStyle} className="preview-thumb" />
return (
<div style={{ ...baseStyle, display: 'grid', placeItems: 'center' }} className="preview-thumb">
📄
</div>
)
}
// ---------- component ----------
export default function Uploader() {
const mobile = useIsMobile()
const [me, setMe] = React.useState<WhoAmI | undefined>()
const [libs, setLibs] = React.useState<string[]>([])
const [lib, setLib] = React.useState<string>('') // required to upload
const [sub, setSub] = React.useState<string>('')
const [rootDirs, setRootDirs] = React.useState<string[]>([])
const [rows, setRows] = React.useState<FileRow[]>([])
const [status, setStatus] = React.useState<string>('')
const [globalDate, setGlobalDate] = React.useState<string>(new Date().toISOString().slice(0, 10))
const [uploading, setUploading] = React.useState<boolean>(false)
const [sel, setSel] = React.useState<Sel[]>([])
const [bulkDesc, setBulkDesc] = React.useState<string>('') // helper: apply to all videos
const folderInputRef = React.useRef<HTMLInputElement>(null)
// keep raw input for folder name (let user type anything; sanitize on create)
const [newFolderRaw, setNewFolderRaw] = React.useState('')
React.useEffect(() => {
const el = folderInputRef.current
if (!el) return
if (mobile) {
el.removeAttribute('webkitdirectory')
el.removeAttribute('directory')
} else {
el.setAttribute('webkitdirectory', '')
el.setAttribute('directory', '')
}
}, [mobile])
// initial load
React.useEffect(() => {
;(async () => {
try {
setStatus('Loading profile…')
const m = await api<WhoAmI>('/api/whoami')
setMe(m as any)
const mm: any = m
const L: string[] =
Array.isArray(mm?.roots) ? mm.roots : Array.isArray(mm?.libs) ? mm.libs : typeof mm?.root === 'string' && mm.root ? [mm.root] : []
setLibs(L)
setLib('') // do NOT auto-pick; user will choose
setSub('')
setStatus('Choose a library to start')
} catch (e: any) {
const msg = String(e?.message || e || '')
setStatus(`Profile error: ${msg}`)
if (msg.toLowerCase().includes('no mapping')) {
alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.')
try {
await api('/api/logout', { method: 'POST' })
} catch {}
location.replace('/')
}
}
})()
}, [])
React.useEffect(() => {
;(async () => {
if (!lib) {
setRootDirs([])
setRows([])
setSub('')
return
}
try {
setStatus(`Loading library “${lib}”…`)
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] refresh error', e)
setStatus(`List error: ${e?.message || e}`)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lib])
async function refresh(currLib: string, currSub: string) {
if (!currLib) return
const one = clampOneLevel(currSub)
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`))
const rootDirNames = listRoot
.filter((e) => e.is_dir)
.map((e) => e.name)
.filter(Boolean)
setRootDirs(rootDirNames.sort((a, b) => a.localeCompare(b)))
const subOk = one && rootDirNames.includes(one) ? one : ''
if (subOk !== currSub) setSub(subOk)
const path = subOk ? subOk : ''
const list = subOk ? await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path })}`) : listRoot
setRows(normalizeRows(list))
const show = `/${[currLib, path].filter(Boolean).join('/')}`
setStatus(`Ready · Destination: ${show}`)
}
function handleChoose(files: FileList) {
const arr = Array.from(files).map((f) => {
const base = (f as any).webkitRelativePath || f.name
const name = base.split('/').pop() || f.name
const desc = '' // start empty; required later for videos only
return { file: f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
})
console.log('[Pegasus] selected files', arr.map((a) => a.file.name))
setSel(arr)
}
// recompute finalName when global date changes
React.useEffect(() => {
setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) })))
}, [globalDate])
// Warn before closing mid-upload
React.useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (uploading) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [uploading])
// Apply description to all videos helper
function applyDescToAllVideos() {
if (!bulkDesc.trim()) return
setSel((old) =>
old.map((x) => (isVideoFile(x.file) ? { ...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name) } : x))
)
}
async function doUpload() {
if (!me) {
setStatus('Not signed in')
return
}
if (!lib) {
alert('Please select a Library to upload into.')
return
}
// Require description only for videos:
const missingVideos = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
if (missingVideos > 0) {
alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`)
return
}
setStatus('Starting upload…')
setUploading(true)
try {
for (const s of sel) {
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve, reject) => {
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
endpoint: '/tus/',
chunkSize: 5 * 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000, 10000],
metadata: {
filename: s.file.name,
lib: lib,
subdir: sub || '',
date: s.date,
desc: s.desc, // server enforces: required for videos only
},
onError: (err: Error | tus.DetailedError) => {
let msg = String(err)
if (isDetailedError(err)) {
const status = (err.originalRequest as any)?.status
const statusText = (err.originalRequest as any)?.statusText
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
}
console.error('[Pegasus] tus error', s.file.name, err)
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, err: msg } : x)))
setStatus(`${s.file.name}: ${msg}`)
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
reject(err as any)
},
onProgress: (sent: number, total: number) => {
const pct = Math.floor((sent / Math.max(total, 1)) * 100)
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: pct } : x)))
setStatus(`${s.finalName}: ${pct}%`)
},
onSuccess: () => {
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: 100, err: undefined } : x)))
setStatus(`${s.finalName} uploaded`)
console.log('[Pegasus] tus success', s.file.name)
resolve()
},
}
opts.withCredentials = true
;(opts as any).urlStorage = NoResumeUrlStorage
;(opts as any).fingerprint = NoResumeFingerprint
const up = new tus.Upload(s.file, opts)
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, lib, sub })
up.start()
})
}
setStatus('All uploads complete')
setSel([])
await refresh(lib, sub)
} finally {
setUploading(false)
}
}
// -------- one-level subfolder ops --------
async function createSubfolder(nameRaw: string) {
const name = sanitizeFolderName(nameRaw)
if (!name) return
try {
await api(`/api/mkdir`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, path: name }),
})
await refresh(lib, name) // jump into new folder
setSub(name)
} catch (e: any) {
console.error('[Pegasus] mkdir error', e)
alert(`Create folder failed:\n${e?.message || e}`)
}
}
async function renameFolder(oldName: string) {
const nn = prompt('New folder name:', oldName)
const newName = sanitizeFolderName(nn || '')
if (!newName || newName === oldName) return
try {
await api('/api/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldName, to: newName }),
})
const newSub = sub === oldName ? newName : sub
setSub(newSub)
await refresh(lib, newSub)
} catch (e: any) {
console.error('[Pegasus] rename folder error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deleteFolder(name: string) {
if (!confirm(`Delete folder “${name}” (and its contents)?`)) return
try {
await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive: 'true' })}`, { method: 'DELETE' })
const newSub = sub === name ? '' : sub
setSub(newSub)
await refresh(lib, newSub)
} catch (e: any) {
console.error('[Pegasus] delete folder error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
// destination listing (actions: rename files only at library root)
async function renamePath(oldp: string) {
const base = oldp.split('/').pop() || ''
const name = prompt('New name (YYYY.MM.DD.Description.OrigStem.ext):', base)
if (!name) return
try {
await api('/api/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldp, to: oldp.split('/').slice(0, -1).concat(sanitizeFolderName(name)).join('/') }),
})
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] rename error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deletePath(p: string, recursive: boolean) {
if (!confirm(`Delete ${p}${recursive ? ' (recursive)' : ''}?`)) return
try {
await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive ? 'true' : 'false' })}`, {
method: 'DELETE',
})
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] delete error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
// sort rows
const sortedRows = React.useMemo(() => {
const arr = Array.isArray(rows) ? rows.slice() : []
return arr.sort((a, b) => {
const dirFirst = Number(b?.is_dir ? 1 : 0) - Number(a?.is_dir ? 1 : 0)
if (dirFirst !== 0) return dirFirst
const an = a?.name ?? ''
const bn = b?.name ?? ''
return an.localeCompare(bn)
})
}, [rows])
// Existing filenames in the destination (to block collisions)
const existingNames = React.useMemo(() => new Set(sortedRows.filter((r) => !r.is_dir).map((r) => r.name)), [sortedRows])
// Duplicates inside this batch
const duplicateNamesInSelection = React.useMemo(() => {
const counts = new Map<string, number>()
sel.forEach((s) => counts.set(s.finalName, (counts.get(s.finalName) || 0) + 1))
return new Set(Array.from(counts.entries()).filter(([, c]) => c > 1).map(([n]) => n))
}, [sel])
const hasNameIssues = React.useMemo(
() => sel.some((s) => duplicateNamesInSelection.has(s.finalName) || existingNames.has(s.finalName)),
[sel, duplicateNamesInSelection, existingNames]
)
const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
const videosNeedingDesc = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
return (
<>
{/* Header context */}
<section>
<hgroup>
<h2>Signed in: {me?.username}</h2>
<p className="meta">
Destination: <strong>{destPath || '/(choose a library)'}</strong>
</p>
</hgroup>
</section>
{/* Choose content */}
<section>
<h3>Choose content</h3>
<div className="grid-3">
<label>
Default date
<input type="date" value={globalDate} onChange={(e) => setGlobalDate(e.target.value)} />
</label>
</div>
<div className="file-picker">
{mobile ? (
<>
<label>
Gallery/Photos
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
</label>
<label>
Camera (optional)
<input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
</label>
</>
) : (
<>
<label>
Select file(s)
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
</label>
<label>
Select folder(s)
<input type="file" multiple ref={folderInputRef} onChange={(e) => e.target.files && handleChoose(e.target.files)} />
</label>
</>
)}
</div>
</section>
{/* Review */}
<section>
<h3>Review Files</h3>
{sel.length === 0 ? (
<p className="meta">Select at least one file.</p>
) : (
<>
<article>
<div style={{ display: 'grid', gap: 12 }}>
<label>
Description for all videos (optional)
<input placeholder="Short video description" value={bulkDesc} onChange={(e) => setBulkDesc(e.target.value)} />
</label>
<button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}>
Apply to all videos
</button>
{videosNeedingDesc > 0 ? (
<small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
) : (
<small className="meta mono-wrap">All videos have descriptions</small>
)}
</div>
</article>
{sel.map((s, i) => (
<article key={i} className="video-card">
<div className="thumb-row">
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
</div>
<h4 className="filename wrap-anywhere" title={s.file.name}>
{s.file.name}
</h4>
<label>
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
<input
required={isVideoFile(s.file)}
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
value={s.desc}
onChange={(e) => {
const v = e.target.value
setSel((old) => old.map((x, idx) => (idx === i ? { ...x, desc: v, finalName: composeName(x.date, v, x.file.name) } : x)))
}}
/>
</label>
<label>
Date
<input
type="date"
value={s.date}
onChange={(e) => {
const v = e.target.value
setSel((old) => old.map((x, idx) => (idx === i ? { ...x, date: v, finalName: composeName(v, x.desc, x.file.name) } : x)))
}}
/>
</label>
<small className="meta" title={s.finalName}>
{s.finalName}
</small>
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
{s.err && <small className="meta bad">Error: {s.err}</small>}
{duplicateNamesInSelection.has(s.finalName) && (
<small className="meta bad">Duplicate name in this batch. Adjust description or date.</small>
)}
{existingNames.has(s.finalName) && <small className="meta bad">A file with this name already exists here.</small>}
</article>
))}
</>
)}
</section>
{/* Destination */}
<section>
<h3>Select Destination</h3>
<p className="meta">Manage a library & choose a destination.</p>
{/* Library only (required). No subfolder dropdown. */}
<div className="grid-3">
<label>
Library (required)
<select
value={lib}
onChange={(e) => {
const v = e.target.value
setLib(v)
setSub('')
if (v) void refresh(v, '')
}}
>
<option value=""> Select a library </option>
{libs.map((L) => (
<option key={L} value={L}>
{L}
</option>
))}
</select>
</label>
</div>
{!lib ? (
<p className="meta">Select a library to create subfolders and view contents.</p>
) : (
<>
{/* New subfolder: free typing; sanitize on create */}
<div className="grid-3">
<label style={{ gridColumn: '1 / -1' }}>
Add a new subfolder (optional):
<input
placeholder="letters, numbers, underscores, dashes"
value={newFolderRaw}
onChange={(e) => setNewFolderRaw(e.target.value)}
inputMode="text"
/>
</label>
{newFolderRaw.trim() && (
<div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<button
type="button"
onClick={() => {
void createSubfolder(newFolderRaw)
setNewFolderRaw('')
}}
>
Create
</button>
</div>
)}
</div>
{/* Current selection with actions; includes Home button to go to root */}
<article className="card">
<div className="row-between" style={{ marginBottom: 6 }}>
<div>
<span className="meta">Current Sub-Folder:</span> <strong className="wrap-anywhere">{sub || '(library root)'}</strong>
</div>
</div>
{sub && (
<div className="sub-actions">
<button
type="button"
className="icon-btn"
title="Go to library root"
aria-label="Go to library root"
onClick={() => { setSub(''); void refresh(lib, '') }}
>
🏠
</button>
<button
type="button"
onClick={() => void renameFolder(sub)}
>
Rename
</button>
<button
type="button"
className="icon-btn danger"
title="Delete subfolder"
aria-label="Delete subfolder"
onClick={() => void deleteFolder(sub)}
>
</button>
</div>
)}
</article>
{/* Subfolders: only Select (right), name + icon (left); clamp width to avoid horizontal overflow */}
<details open style={{ marginTop: 8 }}>
<summary>Subfolders</summary>
{rootDirs.length === 0 ? (
<p className="meta">
No subfolders yet. Youll upload into <b>/{lib}</b>.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{rootDirs.map((d) => (
<article key={d} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={d}>📁 {d}</div>
</div>
<div className="row-right">
<button
onClick={() => {
setSub(d)
void refresh(lib, d)
}}
>
Select
</button>
</div>
</article>
))}
</div>
)}
</details>
{/* Contents: info left (clamped), actions right */}
<details open style={{ marginTop: 8 }}>
<summary>Contents of {destPath || '/(choose)'}</summary>
{sortedRows.length === 0 ? (
<p className="meta">Empty.</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{sortedRows.map((f) => (
<article key={f.path} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={f.name || '(unnamed)'}>{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}</div>
<small className="meta meta-wrap">{f.is_dir ? 'folder' : fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}</small>
</div>
<div className="row-right">
{f.is_dir ? (
<button
onClick={() => {
setSub(f.name)
void refresh(lib, f.name)
}}
>
Open
</button>
) : (
<button
className="icon-btn bad"
title="Delete"
aria-label={`Delete ${f.name}`}
onClick={() => void deletePath(f.path, false)}
>
</button>
)}
</div>
</article>
))}
</div>
)}
</details>
</>
)}
</section>
<footer style={{ display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<small className="meta" aria-live="polite">
{status}
</small>
{hasNameIssues && <small className="meta bad">Resolve duplicate/existing filename conflicts to enable Upload.</small>}
</div>
<button
type="button"
onClick={() => {
const disabled = !lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues
if (!disabled) void doUpload()
}}
disabled={!lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues}
>
Upload {sel.length ? `(${sel.length})` : ''}
</button>
</footer>
</>
)
}
function fmt(n: number) {
if (n < 1024) return n + ' B'
const u = ['KB', 'MB', 'GB', 'TB']
let i = -1
do {
n /= 1024
i++
} while (n >= 1024 && i < u.length - 1)
return n.toFixed(1) + ' ' + u[i]
}

View File

@ -0,0 +1,178 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import UploaderView from './UploaderView'
const controllerMock = vi.hoisted(() => ({
useUploaderController: vi.fn(),
}))
vi.mock('./uploader-controller', () => ({
default: controllerMock.useUploaderController,
}))
function makeFile(name: string, type: string) {
return new File(['x'], name, { type })
}
function makeController(overrides: Record<string, unknown> = {}) {
const setSel = vi.fn()
const setBulkDesc = vi.fn()
const setGlobalDate = vi.fn()
const setLib = vi.fn()
const setSub = vi.fn()
const setNewFolderRaw = vi.fn()
const refresh = vi.fn()
return {
mobile: false,
me: { username: 'brad' },
libs: ['alpha', 'beta'],
lib: 'alpha',
sub: 'videos',
rootDirs: ['archive', 'videos'],
rows: [],
status: 'Ready',
globalDate: '2026-04-10',
uploading: false,
sel: [
{ file: makeFile('photo.jpg', 'image/jpeg'), desc: '', date: '2026-04-10', finalName: '2026.04.10.upload.photo.jpg', progress: 0 },
{ file: makeFile('clip.mp4', 'video/mp4'), desc: '', date: '2026-04-10', finalName: '2026.04.10.upload.clip.mp4', progress: 0 },
{ file: makeFile('note.pdf', 'application/pdf'), desc: '', date: '2026-04-10', finalName: '2026.04.10.upload.note.pdf', progress: 0 },
],
bulkDesc: 'holiday',
folderInputRef: { current: document.createElement('input') },
newFolderRaw: 'new-folder',
setGlobalDate,
setLib,
setSub,
setNewFolderRaw,
setBulkDesc,
setSel,
handleChoose: vi.fn(),
applyDescToAllVideos: vi.fn(),
doUpload: vi.fn(),
createSubfolder: vi.fn(),
renameFolder: vi.fn(),
deleteFolder: vi.fn(),
renamePath: vi.fn(),
deletePath: vi.fn(),
refresh,
sortedRows: [
{ name: 'archive', path: 'archive', is_dir: true, size: 0, mtime: 0 },
{ name: 'clip.mp4', path: 'clip.mp4', is_dir: false, size: 2048, mtime: 1713000000 },
],
existingNames: new Set(['clip.mp4']),
duplicateNamesInSelection: new Set(['2026.04.10.upload.clip.mp4']),
hasNameIssues: false,
destPath: '/alpha/videos',
videosNeedingDesc: 0,
...overrides,
}
}
beforeAll(() => {
if (!('createObjectURL' in URL)) {
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:thumb'), configurable: true })
} else {
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:thumb')
}
if (!('revokeObjectURL' in URL)) {
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true })
} else {
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
}
})
afterEach(() => {
vi.clearAllMocks()
})
describe('UploaderView', () => {
it('renders populated state and forwards interactions', async () => {
const controller = makeController()
controllerMock.useUploaderController.mockReturnValue(controller)
render(<UploaderView />)
await waitFor(() => expect(screen.getByText('Signed in: brad')).toBeTruthy())
expect(screen.getByText(/Destination:/)).toBeTruthy()
expect(screen.getByText('Ready')).toBeTruthy()
await waitFor(() => expect(document.querySelector('img')).not.toBeNull())
expect(document.querySelector('video')).not.toBeNull()
expect(screen.getByText('📄')).toBeTruthy()
fireEvent.change(screen.getByLabelText('Default date'), { target: { value: '2026-04-11' } })
fireEvent.change(screen.getByPlaceholderText('Short video description'), { target: { value: 'family trip' } })
const optionalImageInputs = screen.getAllByPlaceholderText('Optional for image')
fireEvent.change(optionalImageInputs[0], { target: { value: 'photo desc' } })
fireEvent.change(optionalImageInputs[1], { target: { value: 'note desc' } })
fireEvent.change(screen.getByPlaceholderText('Required for video'), { target: { value: 'clip desc' } })
fireEvent.change(screen.getAllByDisplayValue('2026-04-10')[1], { target: { value: '2026-04-12' } })
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'beta' } })
expect(controller.setGlobalDate).toHaveBeenCalledWith('2026-04-11')
expect(controller.setBulkDesc).toHaveBeenCalledWith('family trip')
expect(controller.setSel).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Apply to all videos' }))
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
fireEvent.click(screen.getByLabelText('Go to library root'))
fireEvent.click(screen.getByLabelText('Delete subfolder'))
fireEvent.click(screen.getAllByRole('button', { name: 'Select' })[0])
fireEvent.click(screen.getByRole('button', { name: 'Open' }))
fireEvent.click(screen.getByLabelText('Delete clip.mp4'))
fireEvent.click(screen.getByRole('button', { name: /Upload \(3\)/ }))
expect(controller.applyDescToAllVideos).toHaveBeenCalled()
expect(controller.createSubfolder).toHaveBeenCalledWith('new-folder')
expect(controller.renameFolder).toHaveBeenCalledWith('videos')
expect(controller.refresh).toHaveBeenCalled()
expect(controller.deleteFolder).toHaveBeenCalledWith('videos')
expect(controller.deletePath).toHaveBeenCalledWith('clip.mp4', false)
expect(controller.doUpload).toHaveBeenCalled()
expect(screen.getByText('photo.jpg')).toBeTruthy()
expect(screen.getByText('clip.mp4')).toBeTruthy()
expect(screen.getByText('note.pdf')).toBeTruthy()
})
it('renders the empty-library state', () => {
controllerMock.useUploaderController.mockReturnValue(
makeController({
lib: '',
sub: '',
rootDirs: [],
sel: [],
sortedRows: [],
videosNeedingDesc: 0,
hasNameIssues: false,
destPath: '/(choose a library)',
})
)
render(<UploaderView />)
expect(screen.getByText('Select at least one file.')).toBeTruthy()
expect(screen.getByText('Select a library to create subfolders and view contents.')).toBeTruthy()
})
it('renders empty destination sections when the library has no children', () => {
controllerMock.useUploaderController.mockReturnValue(
makeController({
lib: 'alpha',
sub: '',
rootDirs: [],
sel: [],
sortedRows: [],
destPath: '/alpha',
videosNeedingDesc: 0,
})
)
render(<UploaderView />)
expect(screen.getByText(/No subfolders yet/)).toBeTruthy()
expect(screen.getByText('Empty.')).toBeTruthy()
})
})

View File

@ -0,0 +1,327 @@
import PreviewThumb from './uploader-thumb'
import uploaderUtils from './uploader-utils'
import useUploaderController from './uploader-controller'
const { composeName, isVideoFile, fmt } = uploaderUtils
export default function UploaderView() {
const c = useUploaderController()
return (
<>
<section>
<hgroup>
<h2>Signed in: {c.me?.username}</h2>
<p className="meta">
Destination: <strong>{c.destPath || '/(choose a library)'}</strong>
</p>
</hgroup>
</section>
<section>
<h3>Choose content</h3>
<div className="grid-3">
<label>
Default date
<input type="date" value={c.globalDate} onChange={(e) => c.setGlobalDate(e.target.value)} />
</label>
</div>
<div className="file-picker">
{c.mobile ? (
<>
<label>
Gallery/Photos
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
</label>
<label>
Camera (optional)
<input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
</label>
</>
) : (
<>
<label>
Select file(s)
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
</label>
<label>
Select folder(s)
<input type="file" multiple ref={c.folderInputRef} onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
</label>
</>
)}
</div>
</section>
<section>
<h3>Review Files</h3>
{c.sel.length === 0 ? (
<p className="meta">Select at least one file.</p>
) : (
<>
<article>
<div style={{ display: 'grid', gap: 12 }}>
<label>
Description for all videos (optional)
<input
placeholder="Short video description"
value={c.bulkDesc}
onChange={(e) => c.setBulkDesc(e.target.value)}
/>
</label>
<button type="button" className="stretch" onClick={c.applyDescToAllVideos} disabled={!c.bulkDesc.trim()}>
Apply to all videos
</button>
{c.videosNeedingDesc > 0 ? (
<small className="meta bad">{c.videosNeedingDesc} video(s) need a description</small>
) : (
<small className="meta mono-wrap">All videos have descriptions</small>
)}
</div>
</article>
{c.sel.map((s, i) => (
<article key={i} className="video-card">
<div className="thumb-row">
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
</div>
<h4 className="filename wrap-anywhere" title={s.file.name}>
{s.file.name}
</h4>
<label>
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
<input
required={isVideoFile(s.file)}
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
value={s.desc}
onChange={(e) => {
const v = e.target.value
c.setSel((old) => old.map((x, idx) => (idx === i ? { ...x, desc: v, finalName: composeName(x.date, v, x.file.name) } : x)))
}}
/>
</label>
<label>
Date
<input
type="date"
value={s.date}
onChange={(e) => {
const v = e.target.value
c.setSel((old) => old.map((x, idx) => (idx === i ? { ...x, date: v, finalName: composeName(v, x.desc, x.file.name) } : x)))
}}
/>
</label>
<small className="meta" title={s.finalName}>
{s.finalName}
</small>
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
{s.err && <small className="meta bad">Error: {s.err}</small>}
{c.duplicateNamesInSelection.has(s.finalName) && (
<small className="meta bad">Duplicate name in this batch. Adjust description or date.</small>
)}
{c.existingNames.has(s.finalName) && <small className="meta bad">A file with this name already exists here.</small>}
</article>
))}
</>
)}
</section>
<section>
<h3>Select Destination</h3>
<p className="meta">Manage a library & choose a destination.</p>
<div className="grid-3">
<label>
Library (required)
<select
value={c.lib}
onChange={(e) => {
const v = e.target.value
c.setLib(v)
c.setSub('')
if (v) void c.refresh(v, '')
}}
>
<option value=""> Select a library </option>
{c.libs.map((L) => (
<option key={L} value={L}>
{L}
</option>
))}
</select>
</label>
</div>
{!c.lib ? (
<p className="meta">Select a library to create subfolders and view contents.</p>
) : (
<>
<div className="grid-3">
<label style={{ gridColumn: '1 / -1' }}>
Add a new subfolder (optional):
<input
placeholder="letters, numbers, underscores, dashes"
value={c.newFolderRaw}
onChange={(e) => c.setNewFolderRaw(e.target.value)}
inputMode="text"
/>
</label>
{c.newFolderRaw.trim() && (
<div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<button
type="button"
onClick={() => {
void c.createSubfolder(c.newFolderRaw)
c.setNewFolderRaw('')
}}
>
Create
</button>
</div>
)}
</div>
<article className="card">
<div className="row-between" style={{ marginBottom: 6 }}>
<div>
<span className="meta">Current Sub-Folder:</span> <strong className="wrap-anywhere">{c.sub || '(library root)'}</strong>
</div>
</div>
{c.sub && (
<div className="sub-actions">
<button
type="button"
className="icon-btn"
title="Go to library root"
aria-label="Go to library root"
onClick={() => {
c.setSub('')
void c.refresh(c.lib, '')
}}
>
🏠
</button>
<button type="button" onClick={() => void c.renameFolder(c.sub)}>
Rename
</button>
<button
type="button"
className="icon-btn danger"
title="Delete subfolder"
aria-label="Delete subfolder"
onClick={() => void c.deleteFolder(c.sub)}
>
</button>
</div>
)}
</article>
<details open style={{ marginTop: 8 }}>
<summary>Subfolders</summary>
{c.rootDirs.length === 0 ? (
<p className="meta">
No subfolders yet. Youll upload into <b>/{c.lib}</b>.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{c.rootDirs.map((d) => (
<article key={d} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={d}>
📁 {d}
</div>
</div>
<div className="row-right">
<button
onClick={() => {
c.setSub(d)
void c.refresh(c.lib, d)
}}
>
Select
</button>
</div>
</article>
))}
</div>
)}
</details>
<details open style={{ marginTop: 8 }}>
<summary>Contents of {c.destPath || '/(choose)'}</summary>
{c.sortedRows.length === 0 ? (
<p className="meta">Empty.</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{c.sortedRows.map((f) => (
<article key={f.path} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={f.name || '(unnamed)'}>
{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}
</div>
<small className="meta meta-wrap">
{f.is_dir ? 'folder' : fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}
</small>
</div>
<div className="row-right">
{f.is_dir ? (
<button
onClick={() => {
c.setSub(f.name)
void c.refresh(c.lib, f.name)
}}
>
Open
</button>
) : (
<button
className="icon-btn bad"
title="Delete"
aria-label={`Delete ${f.name}`}
onClick={() => void c.deletePath(f.path, false)}
>
</button>
)}
</div>
</article>
))}
</div>
)}
</details>
</>
)}
</section>
<footer style={{ display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<small className="meta" aria-live="polite">
{c.status}
</small>
{c.hasNameIssues && <small className="meta bad">Resolve duplicate/existing filename conflicts to enable Upload.</small>}
</div>
<button
type="button"
onClick={() => {
const disabled = !c.lib || !c.sel.length || c.uploading || c.videosNeedingDesc > 0 || c.hasNameIssues
if (!disabled) void c.doUpload()
}}
disabled={!c.lib || !c.sel.length || c.uploading || c.videosNeedingDesc > 0 || c.hasNameIssues}
>
Upload {c.sel.length ? `(${c.sel.length})` : ''}
</button>
</footer>
</>
)
}

View File

@ -7,6 +7,7 @@ declare module 'tus-js-client' {
} }
} }
/** Issue an authenticated fetch against the Pegasus API and normalize the response body. */
export async function api<T=any>(path: string, init?: RequestInit): Promise<T> { export async function api<T=any>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(path, { credentials:'include', ...init }); const r = await fetch(path, { credentials:'include', ...init });
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
@ -16,6 +17,7 @@ export async function api<T=any>(path: string, init?: RequestInit): Promise<T> {
try { return JSON.parse(txt) as T } catch { return txt as any as T } try { return JSON.parse(txt) as T } catch { return txt as any as T }
} }
/** WHOAMI payload returned by the backend and kept backward-compatible during migration. */
export type WhoAmI = { export type WhoAmI = {
username: string; username: string;
root?: string; root?: string;

View File

@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const harness = vi.hoisted(() => {
const renderMock = vi.fn()
const createRootMock = vi.fn(() => ({ render: renderMock }))
return { renderMock, createRootMock }
})
vi.mock('react-dom/client', () => ({
createRoot: harness.createRootMock,
}))
vi.mock('./App', () => ({
default: function MockApp() {
return null
},
}))
describe('main entrypoint', () => {
beforeEach(() => {
vi.resetModules()
document.body.innerHTML = '<div id="root"></div>'
harness.createRootMock.mockClear()
harness.renderMock.mockClear()
})
it('mounts the app into #root', async () => {
await import('./main')
expect(harness.createRootMock).toHaveBeenCalled()
expect(harness.renderMock).toHaveBeenCalled()
})
it('fails fast when the root node is missing', async () => {
document.body.innerHTML = ''
await expect(import('./main')).rejects.toThrow('Missing <div id="root"></div> in index.html')
})
})

View File

@ -0,0 +1,348 @@
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const harness = vi.hoisted(() => {
const apiMock = vi.fn()
const uploadState = {
mode: 'success' as 'success' | 'error',
dispatchBeforeUnload: false,
lastBeforeUnloadEvent: undefined as BeforeUnloadEvent | undefined,
pauseUpload: false,
finishUpload: undefined as (() => void) | undefined,
}
class UploadMock {
opts: any
file: File
constructor(file: File, opts: any) {
this.file = file
this.opts = opts
}
start() {
if (uploadState.mode === 'error') {
this.opts.onError?.({ originalRequest: { status: 503, statusText: 'Service Unavailable' } })
return
}
if (uploadState.pauseUpload) {
uploadState.finishUpload = () => {
this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.()
}
return
}
if (uploadState.dispatchBeforeUnload) {
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
uploadState.lastBeforeUnloadEvent = event
window.dispatchEvent(event)
}
this.opts.onProgress?.(5, 10)
this.opts.onSuccess?.()
}
}
return { apiMock, uploadState, UploadMock }
})
vi.mock('./api', () => ({
api: harness.apiMock,
}))
vi.mock('tus-js-client', () => ({
Upload: harness.UploadMock,
}))
import useUploaderController from './uploader-controller'
function makeFile(name: string, type: string) {
return new File(['x'], name, { type })
}
function installGlobals() {
vi.stubGlobal('alert', vi.fn())
vi.stubGlobal('confirm', vi.fn(() => true))
vi.stubGlobal('prompt', vi.fn(() => 'renamed'))
vi.stubGlobal('location', { replace: vi.fn() } as any)
}
function installApi() {
harness.apiMock.mockImplementation(async (path: string) => {
if (path === '/api/whoami') {
return { username: 'brad', roots: ['alpha', 'beta'] }
}
if (path.startsWith('/api/list?')) {
const params = new URLSearchParams(path.split('?')[1])
if (params.get('path')) {
return [
{ name: 'nested', path: 'nested', is_dir: true, size: 0, mtime: 0 },
{ name: 'nested.mp4', path: 'nested/nested.mp4', is_dir: false, size: 4096, mtime: 1713000000 },
]
}
return [
{ name: 'videos', path: 'videos', is_dir: true, size: 0, mtime: 0 },
{ name: 'archive', path: 'archive', is_dir: true, size: 0, mtime: 0 },
{ name: 'clip.mp4', path: 'clip.mp4', is_dir: false, size: 2048, mtime: 1713000000 },
]
}
if (path === '/api/mkdir' || path === '/api/rename' || path.startsWith('/api/file?') || path === '/api/logout') {
return {}
}
throw new Error(`unexpected api path: ${path}`)
})
}
beforeEach(() => {
harness.apiMock.mockReset()
harness.uploadState.mode = 'success'
harness.uploadState.dispatchBeforeUnload = false
harness.uploadState.lastBeforeUnloadEvent = undefined
harness.uploadState.pauseUpload = false
harness.uploadState.finishUpload = undefined
installGlobals()
installApi()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useUploaderController', () => {
it('syncs folder input attributes for desktop and mobile UAs', async () => {
vi.stubGlobal('navigator', { userAgent: 'iPhone' } as any)
function Harness() {
const controller = useUploaderController()
return <input data-testid="folder" ref={controller.folderInputRef} {...({ webkitdirectory: '', directory: '' } as any)} />
}
const { getByTestId } = render(<Harness />)
const input = getByTestId('folder') as HTMLInputElement
await waitFor(() => {
expect(input.hasAttribute('webkitdirectory')).toBe(false)
expect(input.hasAttribute('directory')).toBe(false)
})
})
it('returns early before the profile loads', async () => {
const { result } = renderHook(() => useUploaderController())
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
act(() => {
result.current.setMe(undefined)
})
await waitFor(() => expect(result.current.me).toBeUndefined())
await act(async () => {
await result.current.doUpload()
})
await waitFor(() => expect(result.current.status).toBe('Not signed in'))
})
it('rejects uploads that still need video descriptions', async () => {
const { result } = renderHook(() => useUploaderController())
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
act(() => {
result.current.setLib('alpha')
result.current.handleChoose([makeFile('clip.mp4', 'video/mp4')] as any)
})
await waitFor(() => expect(result.current.sel).toHaveLength(1))
await act(async () => {
await result.current.doUpload()
})
expect((globalThis as any).alert).toHaveBeenCalledWith(
expect.stringContaining('Please add a short description for all videos')
)
})
it('loads data, supports folder ops, and uploads successfully', async () => {
const { result } = renderHook(() => useUploaderController())
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
act(() => {
result.current.setLib('alpha')
})
await waitFor(() => expect(result.current.rootDirs).toEqual(['archive', 'videos']))
expect(result.current.destPath).toBe('/alpha')
act(() => {
result.current.handleChoose([makeFile('clip.mp4', 'video/mp4'), makeFile('photo.jpg', 'image/jpeg')] as any)
})
await waitFor(() => expect(result.current.sel).toHaveLength(2))
act(() => {
result.current.setBulkDesc('family trip')
})
await waitFor(() => expect(result.current.bulkDesc).toBe('family trip'))
act(() => {
result.current.applyDescToAllVideos()
})
await waitFor(() => expect(result.current.sel[0].desc).toBe('family trip'))
act(() => {
result.current.setGlobalDate('2026-04-11')
})
await waitFor(() => expect(result.current.sel[0].date).toBe('2026-04-11'))
await act(async () => {
await result.current.createSubfolder('new folder')
})
await act(async () => {
await result.current.renameFolder('videos')
})
await act(async () => {
await result.current.deleteFolder('archive')
})
await act(async () => {
await result.current.renamePath('clip.mp4')
})
await act(async () => {
await result.current.deletePath('clip.mp4', false)
})
await act(async () => {
await result.current.refresh('alpha', 'videos')
})
expect(result.current.sortedRows[0].name).toBe('nested')
expect(result.current.existingNames.has('nested.mp4')).toBe(true)
await act(async () => {
await result.current.doUpload()
})
expect(harness.apiMock).toHaveBeenCalledWith('/api/mkdir', expect.any(Object))
expect(harness.apiMock).toHaveBeenCalledWith('/api/rename', expect.any(Object))
expect(harness.apiMock).toHaveBeenCalledWith(expect.stringContaining('/api/file?'), expect.any(Object))
expect(result.current.sel).toEqual([])
expect(result.current.status).toContain('Ready')
})
it('surfaces upload failures and the not-signed-in guard', async () => {
harness.uploadState.mode = 'error'
const { result } = renderHook(() => useUploaderController())
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
await act(async () => {
await result.current.doUpload()
})
expect((globalThis as any).alert).toHaveBeenCalledWith('Please select a Library to upload into.')
act(() => {
result.current.setLib('alpha')
result.current.setSel([
{ file: makeFile('clip.mp4', 'video/mp4'), desc: 'clip', date: '2026-04-10', finalName: '2026.04.10.clip.clip.mp4', progress: 0 },
])
})
await waitFor(() => expect(result.current.sel).toHaveLength(1))
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('mkdir failed')
})
await act(async () => {
await result.current.createSubfolder('broken folder')
})
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('rename folder failed')
})
await act(async () => {
await result.current.renameFolder('videos')
})
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('delete folder failed')
})
await act(async () => {
await result.current.deleteFolder('archive')
})
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('rename failed')
})
await act(async () => {
await result.current.renamePath('clip.mp4')
})
harness.apiMock.mockImplementationOnce(async () => {
throw new Error('delete failed')
})
await act(async () => {
await result.current.deletePath('clip.mp4', false)
})
await act(async () => {
await result.current.doUpload().catch(() => {})
})
expect((globalThis as any).alert).toHaveBeenCalledWith(expect.stringContaining('Upload failed'))
})
it('surfaces profile errors and logs out on missing mappings', async () => {
harness.apiMock.mockImplementation(async (path: string) => {
if (path === '/api/whoami') {
throw new Error('no mapping found')
}
if (path === '/api/logout') {
return {}
}
if (path.startsWith('/api/list?') || path === '/api/mkdir' || path === '/api/rename' || path.startsWith('/api/file?')) {
return {}
}
throw new Error(`unexpected api path: ${path}`)
})
const { result } = renderHook(() => useUploaderController())
await waitFor(() => expect(result.current.status).toMatch(/^Profile error:/))
expect((globalThis as any).alert).toHaveBeenCalledWith(
'Your account is not linked to any upload library yet. Please contact the admin to be granted access.'
)
expect((globalThis as any).location.replace).toHaveBeenCalledWith('/')
expect(harness.apiMock).toHaveBeenCalledWith('/api/logout', { method: 'POST' })
})
it('blocks unload while an upload is in flight', async () => {
harness.uploadState.pauseUpload = true
const { result } = renderHook(() => useUploaderController())
await waitFor(() => expect(result.current.libs).toEqual(['alpha', 'beta']))
act(() => {
result.current.setLib('alpha')
result.current.setSel([
{ file: makeFile('clip.mp4', 'video/mp4'), desc: 'clip', date: '2026-04-10', finalName: '2026.04.10.clip.clip.mp4', progress: 0 },
])
})
await waitFor(() => expect(result.current.sel).toHaveLength(1))
const uploadPromise = result.current.doUpload()
await waitFor(() => expect(result.current.uploading).toBe(true))
const event = new Event('beforeunload', { cancelable: true }) as BeforeUnloadEvent
const preventDefault = vi.spyOn(event, 'preventDefault')
await act(async () => {
window.dispatchEvent(event)
})
expect(preventDefault).toHaveBeenCalled()
expect(event.defaultPrevented).toBe(true)
await act(async () => {
harness.uploadState.finishUpload?.()
await uploadPromise
})
expect(result.current.status).toContain('Ready')
})
})

View File

@ -0,0 +1,422 @@
import React from 'react'
import * as tus from 'tus-js-client'
import { api, WhoAmI } from './api'
import uploaderUtils from './uploader-utils'
const {
clampOneLevel,
composeName,
createNoResumeFingerprint,
createNoResumeStorage,
isDetailedError,
isVideoFile,
normalizeRows,
sanitizeDesc,
sanitizeFolderName,
} = uploaderUtils
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 }
type ControllerState = {
mobile: boolean
me: WhoAmI | undefined
setMe: React.Dispatch<React.SetStateAction<WhoAmI | undefined>>
libs: string[]
lib: string
sub: string
rootDirs: string[]
rows: FileRow[]
status: string
globalDate: string
uploading: boolean
sel: Sel[]
bulkDesc: string
folderInputRef: React.RefObject<HTMLInputElement>
newFolderRaw: string
setGlobalDate: React.Dispatch<React.SetStateAction<string>>
setLib: React.Dispatch<React.SetStateAction<string>>
setSub: React.Dispatch<React.SetStateAction<string>>
setNewFolderRaw: React.Dispatch<React.SetStateAction<string>>
setBulkDesc: React.Dispatch<React.SetStateAction<string>>
setSel: React.Dispatch<React.SetStateAction<Sel[]>>
handleChoose: (files: FileList) => void
applyDescToAllVideos: () => void
doUpload: () => Promise<void>
createSubfolder: (nameRaw: string) => Promise<void>
renameFolder: (oldName: string) => Promise<void>
deleteFolder: (name: string) => Promise<void>
renamePath: (oldp: string) => Promise<void>
deletePath: (p: string, recursive: boolean) => Promise<void>
refresh: (currLib: string, currSub: string) => Promise<void>
sortedRows: FileRow[]
existingNames: Set<string>
duplicateNamesInSelection: Set<string>
hasNameIssues: boolean
destPath: string
videosNeedingDesc: number
}
const NoResumeUrlStorage = createNoResumeStorage()
function useIsMobile(): boolean {
const [mobile, setMobile] = React.useState(false)
React.useEffect(() => {
setMobile(uploaderUtils.isLikelyMobileUA())
}, [])
return mobile
}
export default function useUploaderController(): ControllerState {
const mobile = useIsMobile()
const [me, setMe] = React.useState<WhoAmI | undefined>()
const [libs, setLibs] = React.useState<string[]>([])
const [lib, setLib] = React.useState<string>('')
const [sub, setSub] = React.useState<string>('')
const [rootDirs, setRootDirs] = React.useState<string[]>([])
const [rows, setRows] = React.useState<FileRow[]>([])
const [status, setStatus] = React.useState<string>('')
const [globalDate, setGlobalDate] = React.useState<string>(new Date().toISOString().slice(0, 10))
const [uploading, setUploading] = React.useState<boolean>(false)
const [sel, setSel] = React.useState<Sel[]>([])
const [bulkDesc, setBulkDesc] = React.useState<string>('')
const folderInputRef = React.useRef<HTMLInputElement>(null)
const [newFolderRaw, setNewFolderRaw] = React.useState('')
React.useEffect(() => {
const el = folderInputRef.current
if (!el) return
if (mobile) {
el.removeAttribute('webkitdirectory')
el.removeAttribute('directory')
} else {
el.setAttribute('webkitdirectory', '')
el.setAttribute('directory', '')
}
}, [mobile])
React.useEffect(() => {
;(async () => {
try {
setStatus('Loading profile…')
const m = await api<WhoAmI>('/api/whoami')
setMe(m as any)
const mm: any = m
const L: string[] =
Array.isArray(mm?.roots) ? mm.roots : Array.isArray(mm?.libs) ? mm.libs : typeof mm?.root === 'string' && mm.root ? [mm.root] : []
setLibs(L)
setLib('')
setSub('')
setStatus('Choose a library to start')
} catch (e: any) {
const msg = String(e?.message || e || '')
setStatus(`Profile error: ${msg}`)
if (msg.toLowerCase().includes('no mapping')) {
alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.')
try {
await api('/api/logout', { method: 'POST' })
} catch {
// Best-effort logout cleanup only.
}
location.replace('/')
}
}
})()
}, [])
React.useEffect(() => {
;(async () => {
if (!lib) {
setRootDirs([])
setRows([])
setSub('')
return
}
try {
setStatus(`Loading library “${lib}”…`)
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] refresh error', e)
setStatus(`List error: ${e?.message || e}`)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lib])
React.useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (uploading) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [uploading])
async function refresh(currLib: string, currSub: string) {
if (!currLib) return
const one = clampOneLevel(currSub)
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`))
const rootDirNames = listRoot
.filter((e) => e.is_dir)
.map((e) => e.name)
.filter(Boolean)
setRootDirs(rootDirNames.sort((a, b) => a.localeCompare(b)))
const subOk = one && rootDirNames.includes(one) ? one : ''
if (subOk !== currSub) setSub(subOk)
const path = subOk ? subOk : ''
const list = subOk ? await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path })}`) : listRoot
setRows(normalizeRows(list))
const show = `/${[currLib, path].filter(Boolean).join('/')}`
setStatus(`Ready · Destination: ${show}`)
}
function handleChoose(files: FileList) {
const arr = Array.from(files).map((f) => {
const base = (f as any).webkitRelativePath || f.name
const name = base.split('/').pop() || f.name
const desc = ''
return { file: f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
})
console.log('[Pegasus] selected files', arr.map((a) => a.file.name))
setSel(arr)
}
React.useEffect(() => {
setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) })))
}, [globalDate])
function applyDescToAllVideos() {
if (!bulkDesc.trim()) return
setSel((old) =>
old.map((x) => (isVideoFile(x.file) ? { ...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name) } : x))
)
}
async function doUpload() {
if (!me) {
setStatus('Not signed in')
return
}
if (!lib) {
alert('Please select a Library to upload into.')
return
}
const missingVideos = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
if (missingVideos > 0) {
alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`)
return
}
setStatus('Starting upload…')
setUploading(true)
try {
for (const s of sel) {
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve, reject) => {
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
endpoint: '/tus/',
chunkSize: 5 * 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000, 10000],
metadata: {
filename: s.file.name,
lib: lib,
subdir: sub || '',
date: s.date,
desc: s.desc,
},
onError: (err: Error | tus.DetailedError) => {
let msg = String(err)
if (isDetailedError(err)) {
const status = (err.originalRequest as any)?.status
const statusText = (err.originalRequest as any)?.statusText
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
}
console.error('[Pegasus] tus error', s.file.name, err)
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, err: msg } : x)))
setStatus(`${s.file.name}: ${msg}`)
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
reject(err as any)
},
onProgress: (sent: number, total: number) => {
const pct = Math.floor((sent / Math.max(total, 1)) * 100)
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: pct } : x)))
setStatus(`${s.finalName}: ${pct}%`)
},
onSuccess: () => {
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: 100, err: undefined } : x)))
setStatus(`${s.finalName} uploaded`)
console.log('[Pegasus] tus success', s.file.name)
resolve()
},
}
opts.withCredentials = true
;(opts as any).urlStorage = NoResumeUrlStorage
;(opts as any).fingerprint = createNoResumeFingerprint
const up = new tus.Upload(s.file, opts)
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, lib, sub })
up.start()
})
}
setStatus('All uploads complete')
setSel([])
await refresh(lib, sub)
} finally {
setUploading(false)
}
}
async function createSubfolder(nameRaw: string) {
const name = sanitizeFolderName(nameRaw)
if (!name) return
try {
await api(`/api/mkdir`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, path: name }),
})
await refresh(lib, name)
setSub(name)
} catch (e: any) {
console.error('[Pegasus] mkdir error', e)
alert(`Create folder failed:\n${e?.message || e}`)
}
}
async function renameFolder(oldName: string) {
const nn = prompt('New folder name:', oldName)
const newName = sanitizeFolderName(nn || '')
if (!newName || newName === oldName) return
try {
await api('/api/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldName, to: newName }),
})
const newSub = sub === oldName ? newName : sub
setSub(newSub)
await refresh(lib, newSub)
} catch (e: any) {
console.error('[Pegasus] rename folder error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deleteFolder(name: string) {
if (!confirm(`Delete folder “${name}” (and its contents)?`)) return
try {
await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive: 'true' })}`, { method: 'DELETE' })
const newSub = sub === name ? '' : sub
setSub(newSub)
await refresh(lib, newSub)
} catch (e: any) {
console.error('[Pegasus] delete folder error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
async function renamePath(oldp: string) {
const base = oldp.split('/').pop() || ''
const name = prompt('New name (YYYY.MM.DD.Description.OrigStem.ext):', base)
if (!name) return
try {
await api('/api/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldp, to: oldp.split('/').slice(0, -1).concat(sanitizeFolderName(name)).join('/') }),
})
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] rename error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deletePath(p: string, recursive: boolean) {
if (!confirm(`Delete ${p}${recursive ? ' (recursive)' : ''}?`)) return
try {
await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive ? 'true' : 'false' })}`, {
method: 'DELETE',
})
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] delete error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
const sortedRows = React.useMemo(() => {
const arr = Array.isArray(rows) ? rows.slice() : []
return arr.sort((a, b) => {
const dirFirst = Number(b?.is_dir ? 1 : 0) - Number(a?.is_dir ? 1 : 0)
if (dirFirst !== 0) return dirFirst
const an = a?.name ?? ''
const bn = b?.name ?? ''
return an.localeCompare(bn)
})
}, [rows])
const existingNames = React.useMemo(() => new Set(sortedRows.filter((r) => !r.is_dir).map((r) => r.name)), [sortedRows])
const duplicateNamesInSelection = React.useMemo(() => {
const counts = new Map<string, number>()
sel.forEach((s) => counts.set(s.finalName, (counts.get(s.finalName) || 0) + 1))
return new Set(Array.from(counts.entries()).filter(([, c]) => c > 1).map(([n]) => n))
}, [sel])
const hasNameIssues = React.useMemo(
() => sel.some((s) => duplicateNamesInSelection.has(s.finalName) || existingNames.has(s.finalName)),
[sel, duplicateNamesInSelection, existingNames]
)
const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
const videosNeedingDesc = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
return {
mobile,
me,
setMe,
libs,
lib,
sub,
rootDirs,
rows,
status,
globalDate,
uploading,
sel,
bulkDesc,
folderInputRef,
newFolderRaw,
setGlobalDate,
setLib,
setSub,
setNewFolderRaw,
setBulkDesc,
setSel,
handleChoose,
applyDescToAllVideos,
doUpload,
createSubfolder,
renameFolder,
deleteFolder,
renamePath,
deletePath,
refresh,
sortedRows,
existingNames,
duplicateNamesInSelection,
hasNameIssues,
destPath,
videosNeedingDesc,
}
}

View File

@ -0,0 +1,32 @@
import React from 'react'
import uploaderUtils from './uploader-utils'
const { isImageFile, isVideoFile } = uploaderUtils
export default function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) {
const [url, setUrl] = React.useState<string>()
React.useEffect(() => {
const objectUrl = URL.createObjectURL(file)
setUrl(objectUrl)
return () => {
try {
URL.revokeObjectURL(objectUrl)
} catch {
// Ignore revocation races during unmount.
}
}
}, [file])
if (!url) return null
const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 }
if (isImageFile(file)) return <img src={url} alt="" style={baseStyle} className="preview-thumb" />
if (isVideoFile(file)) return <video src={url} muted preload="metadata" playsInline style={baseStyle} className="preview-thumb" />
return (
<div style={{ ...baseStyle, display: 'grid', placeItems: 'center' }} className="preview-thumb">
📄
</div>
)
}

View File

@ -0,0 +1,141 @@
import * as tus from 'tus-js-client'
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
type RowLike = {
name?: unknown
Name?: unknown
path?: unknown
Path?: unknown
is_dir?: unknown
IsDir?: unknown
isDir?: unknown
size?: unknown
Size?: unknown
mtime?: unknown
Mtime?: unknown
}
type NoResumeStorage = {
addUpload: (_u: any) => Promise<void>
removeUpload: (_u: any) => Promise<void>
listUploads: () => Promise<any[]>
findUploadsByFingerprint: (_fp: string) => Promise<any[]>
}
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() : '')
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)
}
function sanitizeFolderName(s: string) {
// Only allow a single path segment and collapse punctuation to keep mkdir/rename predictable.
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
return s.slice(0, 64)
}
function extOf(n: string) {
const i = n.lastIndexOf('.')
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
}
function stemOf(n: string) {
const i = n.lastIndexOf('.')
const stem = i > -1 ? n.slice(0, i) : n
// Keep the stem safe and avoid extra dots inside the composed upload name.
return sanitizeDesc(stem.replace(/\./g, '_')) || 'file'
}
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'
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
}
function clampOneLevel(p: string) {
if (!p) return ''
return p.replace(/^\/+|\/+$/g, '').split('/')[0] || ''
}
function normalizeRows(listRaw: unknown[]): FileRow[] {
return (Array.isArray(listRaw) ? listRaw : []).map((r: unknown) => {
const row = (r && typeof r === 'object' ? r : {}) as RowLike
return {
name: String(row.name ?? row.Name ?? ''),
path: String(row.path ?? row.Path ?? ''),
is_dir: Boolean(row.is_dir ?? row.IsDir ?? row.isDir ?? false),
size: Number(row.size ?? row.Size ?? 0),
mtime: Number(row.mtime ?? row.Mtime ?? 0),
}
})
}
const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name))
const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name))
function isDetailedError(e: unknown): e is tus.DetailedError {
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
}
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 fmt(n: number) {
if (n < 1024) return `${n} B`
const units = ['KB', 'MB', 'GB', 'TB']
let idx = -1
do {
n /= 1024
idx++
} while (n >= 1024 && idx < units.length - 1)
return `${n.toFixed(1)} ${units[idx]}`
}
function createNoResumeStorage(): NoResumeStorage {
return {
async addUpload(_u: any) {},
async removeUpload(_u: any) {},
async listUploads() {
return []
},
async findUploadsByFingerprint(_fp: string) {
return []
},
}
}
async function createNoResumeFingerprint() {
return `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
}
const uploaderUtils = {
sanitizeDesc,
sanitizeFolderName,
extOf,
stemOf,
composeName,
clampOneLevel,
normalizeRows,
isVideoFile,
isImageFile,
isDetailedError,
isLikelyMobileUA,
fmt,
createNoResumeStorage,
createNoResumeFingerprint,
}
export default uploaderUtils

View File

@ -97,6 +97,15 @@ def _read_test_exit_code(path: Path) -> int:
return 1 return 1
def _load_gate_summary(path: Path) -> dict[str, object]:
if not path.exists():
return {"ok": False, "issues": []}
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {"ok": False, "issues": []}
def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, str]) -> float: def _fetch_existing_counter(pushgateway_url: str, metric: str, labels: dict[str, str]) -> float:
text = _read_http(f"{pushgateway_url.rstrip('/')}/metrics") text = _read_http(f"{pushgateway_url.rstrip('/')}/metrics")
if not text: if not text:
@ -151,6 +160,7 @@ def main() -> int:
) )
backend_rc_file = Path(os.getenv("BACKEND_TEST_RC_FILE", "build/backend-test.rc")) backend_rc_file = Path(os.getenv("BACKEND_TEST_RC_FILE", "build/backend-test.rc"))
frontend_rc_file = Path(os.getenv("FRONTEND_TEST_RC_FILE", "build/frontend-test.rc")) frontend_rc_file = Path(os.getenv("FRONTEND_TEST_RC_FILE", "build/frontend-test.rc"))
gate_summary = _load_gate_summary(Path(os.getenv("GATE_SUMMARY_FILE", "build/gate-summary.json")))
b = _load_junit(backend_junit) b = _load_junit(backend_junit)
f = _load_junit(frontend_junit) f = _load_junit(frontend_junit)
@ -168,9 +178,24 @@ def main() -> int:
backend_rc = _read_test_exit_code(backend_rc_file) backend_rc = _read_test_exit_code(backend_rc_file)
frontend_rc = _read_test_exit_code(frontend_rc_file) frontend_rc = _read_test_exit_code(frontend_rc_file)
backend_suite_result = "passed" if backend_rc == 0 else "failed"
frontend_suite_result = "passed" if frontend_rc == 0 else "failed"
branch = os.getenv("BRANCH_NAME", "")
build_number = os.getenv("BUILD_NUMBER", "")
commit = os.getenv("GIT_COMMIT", "")
labels = {
"suite": suite,
"branch": branch,
"build_number": build_number,
"commit": commit,
}
gate_ok = bool(gate_summary.get("ok"))
gate_issues = gate_summary.get("issues") or []
outcome = ( outcome = (
"ok" "ok"
if backend_rc == 0 if gate_ok
and backend_rc == 0
and frontend_rc == 0 and frontend_rc == 0
and totals["tests"] > 0 and totals["tests"] > 0
and totals["failures"] == 0 and totals["failures"] == 0
@ -195,21 +220,13 @@ def main() -> int:
else: else:
failed_count += 1 failed_count += 1
branch = os.getenv("BRANCH_NAME", "")
build_number = os.getenv("BUILD_NUMBER", "")
commit = os.getenv("GIT_COMMIT", "")
labels = {
"suite": suite,
"branch": branch,
"build_number": build_number,
"commit": commit,
}
payload_lines = [ payload_lines = [
"# TYPE platform_quality_gate_runs_total counter", "# TYPE platform_quality_gate_runs_total counter",
f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count:.0f}', f'platform_quality_gate_runs_total{{suite="{suite}",status="ok"}} {ok_count:.0f}',
f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count:.0f}', f'platform_quality_gate_runs_total{{suite="{suite}",status="failed"}} {failed_count:.0f}',
"# TYPE pegasus_test_suite_result gauge",
f'pegasus_test_suite_result{{suite="backend",status="{backend_suite_result}"}} 1',
f'pegasus_test_suite_result{{suite="frontend",status="{frontend_suite_result}"}} 1',
"# TYPE pegasus_quality_gate_tests_total gauge", "# TYPE pegasus_quality_gate_tests_total gauge",
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="passed"}} {passed}', f'pegasus_quality_gate_tests_total{{suite="{suite}",result="passed"}} {passed}',
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}', f'pegasus_quality_gate_tests_total{{suite="{suite}",result="failed"}} {totals["failures"]}',
@ -217,6 +234,10 @@ def main() -> int:
f'pegasus_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}', f'pegasus_quality_gate_tests_total{{suite="{suite}",result="skipped"}} {totals["skipped"]}',
"# TYPE pegasus_quality_gate_coverage_percent gauge", "# TYPE pegasus_quality_gate_coverage_percent gauge",
f'pegasus_quality_gate_coverage_percent{{suite="{suite}"}} {coverage_pct:.3f}', f'pegasus_quality_gate_coverage_percent{{suite="{suite}"}} {coverage_pct:.3f}',
"# TYPE pegasus_quality_gate_status gauge",
f'pegasus_quality_gate_status{{suite="{suite}",result="{"ok" if gate_ok else "failed"}"}} 1',
"# TYPE pegasus_quality_gate_issues_total gauge",
f'pegasus_quality_gate_issues_total{{suite="{suite}"}} {len(gate_issues)}',
"# TYPE pegasus_quality_gate_build_info gauge", "# TYPE pegasus_quality_gate_build_info gauge",
f"pegasus_quality_gate_build_info{_label_str(labels)} 1", f"pegasus_quality_gate_build_info{_label_str(labels)} 1",
] ]

1
testing/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Pegasus test and quality-gate helpers."""

270
testing/pegasus_gate.py Normal file
View File

@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Evaluate Pegasus quality checks from test artifacts and source files."""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Iterable
ROOT = Path(__file__).resolve().parents[1]
BACKEND = ROOT / "backend"
FRONTEND = ROOT / "frontend"
BUILD = ROOT / "build"
PROD_EXTS = {".go", ".ts", ".tsx", ".py"}
@dataclass
class GateIssue:
check: str
path: str
detail: str
@dataclass
class GateReport:
ok: bool
issues: list[GateIssue]
file_count: int
backend_coverage: dict[str, float]
frontend_coverage: dict[str, dict[str, float]]
def _run(cmd: list[str], cwd: Path | None = None) -> tuple[int, str]:
proc = subprocess.run(cmd, cwd=cwd or ROOT, capture_output=True, text=True)
return proc.returncode, proc.stdout + proc.stderr
def _rel(path: Path) -> str:
return str(path.resolve().relative_to(ROOT))
def _production_files() -> list[Path]:
files: list[Path] = []
for path in ROOT.rglob("*"):
if not path.is_file():
continue
if path.name.endswith("_test.go"):
continue
if path.name.endswith(".test.ts") or path.name.endswith(".test.tsx"):
continue
if path.name == "AGENTS.md":
continue
if path.suffix not in PROD_EXTS and path.name not in {"Dockerfile", "Jenkinsfile"}:
continue
if any(part in {".git", "node_modules", "dist", "coverage", "build"} for part in path.parts):
continue
rel = path.relative_to(ROOT)
if rel.parts[0] == "backend" and path.suffix == ".go" and path.name.endswith(".go"):
files.append(path)
elif rel.parts[0] == "frontend" and len(rel.parts) >= 3 and rel.parts[1] == "src" and path.suffix in {".ts", ".tsx"}:
if rel.parts[2] in {"test", "types"}:
continue
if path.name.endswith(".test.ts") or path.name.endswith(".test.tsx") or path.name.endswith(".d.ts"):
continue
files.append(path)
elif path.name in {"Dockerfile", "Jenkinsfile"}:
files.append(path)
elif rel.parts[0] == "scripts" and path.suffix == ".py":
files.append(path)
return sorted(set(files))
def _line_count(path: Path) -> int:
return len(path.read_text(encoding="utf-8").splitlines())
def _check_loc(files: Iterable[Path]) -> list[GateIssue]:
issues: list[GateIssue] = []
for path in files:
if _line_count(path) > 500:
issues.append(GateIssue("loc", _rel(path), f"{_line_count(path)} lines exceeds 500"))
return issues
def _go_exported_comment_issues(files: Iterable[Path]) -> list[GateIssue]:
issues: list[GateIssue] = []
for path in files:
if path.suffix != ".go" or path.name.endswith("_test.go"):
continue
lines = path.read_text(encoding="utf-8").splitlines()
for idx, line in enumerate(lines):
m = re.match(r"^(func|type|var|const)\s+([A-Z][A-Za-z0-9_]*)", line.strip())
if not m:
continue
prev = idx - 1
while prev >= 0 and not lines[prev].strip():
prev -= 1
if prev < 0 or not lines[prev].lstrip().startswith("//"):
issues.append(GateIssue("go-doc", _rel(path), f"exported {m.group(1)} {m.group(2)} lacks leading comment"))
return issues
def _ts_export_comment_issues(files: Iterable[Path]) -> list[GateIssue]:
issues: list[GateIssue] = []
for path in files:
if path.suffix not in {".ts", ".tsx"} or path.name.endswith(".test.ts") or path.name.endswith(".test.tsx"):
continue
lines = path.read_text(encoding="utf-8").splitlines()
for idx, line in enumerate(lines):
if not re.match(r"^\s*export\s+(function|const|type)\s+[A-Z_a-z]", line):
continue
prev = idx - 1
while prev >= 0 and not lines[prev].strip():
prev -= 1
if prev < 0 or not lines[prev].lstrip().startswith("/**"):
issues.append(GateIssue("ts-doc", _rel(path), "exported symbol lacks JSDoc contract"))
return issues
def _go_vet() -> list[GateIssue]:
rc, output = _run(["go", "vet", "./..."], cwd=BACKEND)
if rc == 0:
return []
return [GateIssue("go-vet", "backend", output.strip() or "go vet failed")]
def _tsc_check() -> list[GateIssue]:
rc, output = _run(["npm", "exec", "tsc", "--", "--noEmit"], cwd=FRONTEND)
if rc == 0:
return []
return [GateIssue("tsc", "frontend", output.strip() or "tsc failed")]
def _parse_go_coverage(path: Path) -> dict[str, float]:
if not path.exists():
return {}
_, output = _run(["go", "tool", "cover", "-func", str(path)], cwd=BACKEND)
cover: dict[str, float] = {}
for line in output.splitlines():
if "\t" not in line or line.startswith("total:"):
continue
parts = line.split("\t")
file_part = parts[0].split(":")[0]
pct = float(parts[-1].rstrip("%"))
file_norm = file_part.replace("\\", "/")
if "/backend/" in file_norm:
file_norm = "backend/" + file_norm.split("/backend/", 1)[1]
elif file_norm.startswith("backend/"):
pass
else:
file_norm = file_norm.rsplit("/", 1)[-1]
cover[file_norm] = pct
return cover
def _parse_frontend_coverage(path: Path) -> dict[str, dict[str, float]]:
if not path.exists():
return {}
def _pct(value: object) -> float:
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
match = re.search(r"[0-9]+(?:\.[0-9]+)?", value)
if match:
return float(match.group(0))
return 0.0
def _normalize_key(raw: str) -> str:
key = raw.replace("\\", "/")
if key.startswith("./"):
key = key[2:]
for prefix in (str(ROOT).replace("\\", "/") + "/", str(FRONTEND).replace("\\", "/") + "/"):
if key.startswith(prefix):
key = key[len(prefix):]
break
key = key.lstrip("/")
if key.startswith("frontend/"):
return key
return f"frontend/{key}"
payload = json.loads(path.read_text(encoding="utf-8"))
out: dict[str, dict[str, float]] = {}
for file_path, summary in payload.items():
if file_path == "total":
continue
lines = summary.get("lines") or {}
statements = summary.get("statements") or {}
out[_normalize_key(str(file_path))] = {
"lines": _pct(lines.get("pct")),
"statements": _pct(statements.get("pct")),
}
return out
def _check_coverage(files: Iterable[Path]) -> list[GateIssue]:
issues: list[GateIssue] = []
go_cov = _parse_go_coverage(BUILD / "coverage-backend.out")
fe_cov = _parse_frontend_coverage(BUILD / "frontend-coverage" / "coverage-summary.json")
for path in files:
rel = _rel(path)
if path.suffix == ".go":
pct = go_cov.get(rel, 0.0)
if pct < 95.0:
issues.append(GateIssue("coverage", rel, f"go line coverage {pct:.2f}% < 95%"))
elif path.suffix in {".ts", ".tsx"}:
pct = fe_cov.get(rel, {}).get("lines", 0.0)
if pct < 95.0:
issues.append(GateIssue("coverage", rel, f"frontend line coverage {pct:.2f}% < 95%"))
return issues
def evaluate() -> GateReport:
files = _production_files()
issues = []
issues.extend(_check_loc(files))
issues.extend(_go_exported_comment_issues(files))
issues.extend(_ts_export_comment_issues(files))
issues.extend(_go_vet())
issues.extend(_tsc_check())
issues.extend(_check_coverage(files))
go_cov = _parse_go_coverage(BUILD / "coverage-backend.out")
fe_cov = _parse_frontend_coverage(BUILD / "frontend-coverage" / "coverage-summary.json")
report = GateReport(
ok=not issues,
issues=issues,
file_count=len(files),
backend_coverage=go_cov,
frontend_coverage=fe_cov,
)
BUILD.mkdir(exist_ok=True)
(BUILD / "gate-summary.json").write_text(
json.dumps(
{
"ok": report.ok,
"issues": [asdict(issue) for issue in report.issues],
"file_count": report.file_count,
"backend_coverage": report.backend_coverage,
"frontend_coverage": report.frontend_coverage,
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
return report
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("mode", nargs="?", choices={"report", "enforce"}, default="enforce")
args = parser.parse_args()
report = evaluate()
print(json.dumps({"ok": report.ok, "issues": [asdict(i) for i in report.issues]}, indent=2))
if args.mode == "enforce" and not report.ok:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())