// backend/record_paths.go package main import ( "fmt" "net/http" "net/url" "os" "path" "path/filepath" "strings" ) // ---------- Basic query helpers ---------- func q(r *http.Request, key string) string { return strings.TrimSpace(r.URL.Query().Get(key)) } // file query -> safe basename (no traversal) + url decode func safeBasenameQuery(r *http.Request, key string) (string, bool, error) { raw := strings.TrimSpace(r.URL.Query().Get(key)) if raw == "" { return "", false, nil } dec, err := url.QueryUnescape(raw) if err != nil { return "", false, err } dec = strings.TrimSpace(dec) if !isSafeBasename(dec) { return "", false, fmt.Errorf("invalid basename") } return dec, true, nil } func isAllowedVideoExt(name string) bool { ext := strings.ToLower(filepath.Ext(name)) return ext == ".mp4" || ext == ".ts" } // ---------- Safe path pieces ---------- func isSafeRelDir(rel string) bool { rel = strings.TrimSpace(rel) if rel == "" { return false } // normalize to slash for validation rel = filepath.ToSlash(rel) if strings.HasPrefix(rel, "/") { return false } clean := path.Clean(rel) // path.Clean => forward slashes if clean == "." { return true } if strings.HasPrefix(clean, "../") || clean == ".." { return false } // prevent weird traversal if strings.Contains(clean, `\`) { return false } return true } func isSafeBasename(name string) bool { name = strings.TrimSpace(name) if name == "" { return false } if strings.Contains(name, "/") || strings.Contains(name, "\\") { return false } return filepath.Base(name) == name } func setNoStoreHeaders(w http.ResponseWriter) { // verhindert Browser/Proxy Caching (wichtig für Logs/Status) w.Header().Set("Cache-Control", "no-store, max-age=0") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") } // ---------- Resolve dirs ---------- func exeDir() (string, error) { exePath, err := os.Executable() if err != nil { return "", err } exePath, err = filepath.Abs(exePath) if err != nil { return "", err } return filepath.Dir(exePath), nil } func resolvePathRelativeToApp(p string) (string, error) { p = strings.TrimSpace(p) if p == "" { return "", nil } p = filepath.Clean(filepath.FromSlash(p)) if filepath.IsAbs(p) { return p, nil } baseDir, err := exeDir() if err == nil { low := strings.ToLower(baseDir) // Heuristik: go run / tests -> exe liegt in Temp/go-build isTemp := strings.Contains(low, `\appdata\local\temp`) || strings.Contains(low, `\temp\`) || strings.Contains(low, `\tmp\`) || strings.Contains(low, `\go-build`) || strings.Contains(low, `/tmp/`) || strings.Contains(low, `/go-build`) if !isTemp { return filepath.Join(baseDir, p), nil } } // Fallback: Working Directory (Dev) wd, err := os.Getwd() if err != nil { return "", err } return filepath.Join(wd, p), nil } func getRecordingsDir() string { s := getSettings() abs, err := resolvePathRelativeToApp(s.RecordDir) if err == nil && strings.TrimSpace(abs) != "" { return abs } // Fallback (falls resolve fehlschlägt) return strings.TrimSpace(s.RecordDir) } func getKeepDir() string { s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { doneAbs = strings.TrimSpace(s.DoneDir) } if strings.TrimSpace(doneAbs) == "" { return "" } return filepath.Join(doneAbs, "keep") } func getDoneDir() string { s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err == nil && strings.TrimSpace(doneAbs) != "" { return doneAbs } return strings.TrimSpace(s.DoneDir) } // ---------- Finders ---------- func findVideoPath(file string) (string, error) { base := filepath.Base(file) // verhindert path traversal roots := []string{ getRecordingsDir(), getDoneDir(), getKeepDir(), } // 1) direkt in den Roots for _, root := range roots { root = strings.TrimSpace(root) if root == "" { continue } p := filepath.Join(root, base) if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, nil } } // 2) 1 Ebene Unterordner: root/*/file for _, root := range roots { root = strings.TrimSpace(root) if root == "" { continue } matches, _ := filepath.Glob(filepath.Join(root, "*", base)) for _, p := range matches { if st, err := os.Stat(p); err == nil && !st.IsDir() { return p, nil } } } return "", os.ErrNotExist } func findFileInDirOrOneLevelSubdirs(root string, file string, skipDirName string) (string, os.FileInfo, bool) { // direct p := filepath.Join(root, file) if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 { return p, fi, true } entries, err := os.ReadDir(root) if err != nil { return "", nil, false } for _, e := range entries { if !e.IsDir() { continue } if skipDirName != "" && e.Name() == skipDirName { continue } pp := filepath.Join(root, e.Name(), file) if fi, err := os.Stat(pp); err == nil && !fi.IsDir() && fi.Size() > 0 { return pp, fi, true } } return "", nil, false } func resolveDoneFileByName(doneAbs string, file string) (full string, from string, fi os.FileInfo, err error) { // 1) done (root + /done//) — "keep" wird übersprungen if p, fi, ok := findFileInDirOrOneLevelSubdirs(doneAbs, file, "keep"); ok { return p, "done", fi, nil } // 2) keep (root + /done/keep//) keepDir := filepath.Join(doneAbs, "keep") if p, fi, ok := findFileInDirOrOneLevelSubdirs(keepDir, file, ""); ok { return p, "keep", fi, nil } return "", "", nil, fmt.Errorf("not found") } func isTrashPath(p string) bool { p = strings.ReplaceAll(p, "\\", "/") return strings.Contains(p, "/.trash/") || strings.HasSuffix(p, "/.trash") } func durationFromMetaIfFresh(videoPath, assetDir string, fi os.FileInfo) (float64, bool) { metaPath := filepath.Join(assetDir, "meta.json") return readVideoMetaDuration(metaPath, fi) } // durationSecondsCacheOnly returns a cached duration if available and still fresh. // It relies on your existing durCache implementation elsewhere. func durationSecondsCacheOnly(path string, fi os.FileInfo) float64 { durCache.mu.Lock() e, ok := durCache.m[path] durCache.mu.Unlock() if ok && e.size == fi.Size() && e.mod.Equal(fi.ModTime()) && e.sec > 0 { return e.sec } return 0 } // ---------- Playback resolver (shared by video + scrubber/meta) ---------- // resolves a playable file path from ?file=... (done/keep/record) or ?id=... (jobs map) // returns absolute cleaned path func resolvePlayablePathFromQuery(r *http.Request) (string, bool, int, string) { // returns: (path, ok, httpStatus, errMsg) // 1) file mode if file, ok, err := safeBasenameQuery(r, "file"); err != nil { return "", false, http.StatusBadRequest, "ungültiger file" } else if ok { if !isAllowedVideoExt(file) { return "", false, http.StatusForbidden, "nicht erlaubt" } s := getSettings() recordAbs, err := resolvePathRelativeToApp(s.RecordDir) if err != nil { return "", false, http.StatusInternalServerError, "recordDir auflösung fehlgeschlagen: " + err.Error() } doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { return "", false, http.StatusInternalServerError, "doneDir auflösung fehlgeschlagen: " + err.Error() } // candidates: allow .ts and fallback to .mp4 ext := strings.ToLower(filepath.Ext(file)) names := []string{file} if ext == ".ts" { names = append(names, strings.TrimSuffix(file, ext)+".mp4") } for _, name := range names { if p, _, ok := findFileInDirOrOneLevelSubdirs(doneAbs, name, "keep"); ok { return filepath.Clean(strings.TrimSpace(p)), true, 0, "" } if p, _, ok := findFileInDirOrOneLevelSubdirs(filepath.Join(doneAbs, "keep"), name, ""); ok { return filepath.Clean(strings.TrimSpace(p)), true, 0, "" } if p, _, ok := findFileInDirOrOneLevelSubdirs(recordAbs, name, ""); ok { return filepath.Clean(strings.TrimSpace(p)), true, 0, "" } } return "", false, http.StatusNotFound, "datei nicht gefunden" } // 2) id mode id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { return "", false, http.StatusBadRequest, "id fehlt" } jobsMu.Lock() job, ok := jobs[id] jobsMu.Unlock() if !ok { return "", false, http.StatusNotFound, "job nicht gefunden" } outPath := filepath.Clean(strings.TrimSpace(job.Output)) if outPath == "" { return "", false, http.StatusNotFound, "output fehlt" } if !filepath.IsAbs(outPath) { abs, err := resolvePathRelativeToApp(outPath) if err != nil { return "", false, http.StatusInternalServerError, "pfad auflösung fehlgeschlagen: " + err.Error() } outPath = abs } fi, err := os.Stat(outPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() == 0 { return "", false, http.StatusNotFound, "datei nicht gefunden" } return outPath, true, 0, "" }