nsfwapp/backend/record_paths.go
2026-03-14 14:28:33 +01:00

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, ""
}