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