// backend/main.go package main import ( "embed" "encoding/json" "fmt" "io" "io/fs" "log" "net/http" "os" "path/filepath" "regexp" "strings" "time" "sort" "github.com/go-chi/chi/v5" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/tus/tusd/pkg/filestore" tusd "github.com/tus/tusd/pkg/handler" "github.com/tus/tusd/pkg/memorylocker" "scm.bstein.dev/bstein/Pegasus/backend/internal" ) //go:embed web/dist var webFS embed.FS var ( mediaRoot = env("PEGASUS_MEDIA_ROOT", "/media") userMapFile = env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml") tusDir = env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus")) jf = internal.NewJellyfin() ) var ( version = "dev" // set via -ldflags "-X main.version=1.2.0 -X main.git=$(git rev-parse --short HEAD) -X main.builtAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)" git = "" builtAt = "" ) type loggingRW struct { http.ResponseWriter status int } type listEntry struct { Name string `json:"name"` Path string `json:"path"` IsDir bool `json:"is_dir"` Size int64 `json:"size"` Mtime int64 `json:"mtime"` } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(v); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) } func main() { internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot) if os.Getenv("PEGASUS_SESSION_KEY") == "" { log.Fatal("PEGASUS_SESSION_KEY is not set") } 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 }