360 lines
8.7 KiB
Go
360 lines
8.7 KiB
Go
// 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/<subdir>/) — "keep" wird übersprungen
|
|
if p, fi, ok := findFileInDirOrOneLevelSubdirs(doneAbs, file, "keep"); ok {
|
|
return p, "done", fi, nil
|
|
}
|
|
|
|
// 2) keep (root + /done/keep/<subdir>/)
|
|
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, ""
|
|
}
|