diff --git a/.gitignore b/.gitignore index a307ee7..8dfd80e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store backend/generated +backend/.env backend/web/dist backend/data/pending-autostart/__global__.json backend/.gocache diff --git a/backend/.env b/backend/.env deleted file mode 100644 index e33764f..0000000 --- a/backend/.env +++ /dev/null @@ -1,2 +0,0 @@ -HTTPS_ENABLED=1 -AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173 \ No newline at end of file diff --git a/backend/auth_passkey.go b/backend/auth_passkey.go index 8e57467..d0d700b 100644 --- a/backend/auth_passkey.go +++ b/backend/auth_passkey.go @@ -100,6 +100,32 @@ func (am *AuthManager) initWebAuthn() error { } func authAllowedRPOrigins() []string { + out := make([]string, 0, 16) + seen := map[string]bool{} + + addOrigin := func(origin string) { + origin, ok := canonicalOrigin(origin) + if !ok || seen[origin] { + return + } + seen[origin] = true + out = append(out, origin) + } + + for _, host := range localAppHostCandidates() { + addOrigin(originForHost("https", host, appPort())) + } + + // Explicit env values remain supported, but they extend the dynamic list + // instead of replacing hostname/IP based origins. + for _, origin := range configuredAuthOrigins() { + addOrigin(origin) + } + + return out +} + +func configuredAuthOrigins() []string { raw := strings.TrimSpace(os.Getenv("AUTH_RP_ORIGINS")) // Backward compatible: alter Einzelwert funktioniert weiter. @@ -108,46 +134,151 @@ func authAllowedRPOrigins() []string { } if raw == "" { - raw = strings.Join([]string{ - "https://l14pbbk95100006.tegdssd.de:9999", - "https://localhost:9999", - "https://127.0.0.1:9999", - "https://10.0.1.25:9999", - "http://localhost:9999", - "http://127.0.0.1:9999", - "http://10.0.1.25:9999", - "http://l14pbbk95100006.tegdssd.de:5173", - "http://localhost:5173", - "http://127.0.0.1:5173", - "http://10.0.1.25:5173", - }, ",") + return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) - seen := map[string]bool{} - for _, p := range parts { - origin := strings.TrimSpace(p) - origin = strings.TrimRight(origin, "/") - if origin == "" { - continue + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) } + } + return out +} - if seen[origin] { - continue - } +func appPort() string { + port := strings.TrimSpace(os.Getenv("APP_PORT")) + if port == "" { + port = "9999" + } + return port +} - seen[origin] = true - out = append(out, origin) +func originForHost(scheme, host, port string) string { + scheme = strings.ToLower(strings.TrimSpace(scheme)) + host = normalizeOriginHost(host) + port = strings.TrimSpace(port) + + if scheme == "" || host == "" { + return "" + } + if port == "" { + return scheme + "://" + host } + return scheme + "://" + net.JoinHostPort(host, port) +} + +func normalizeOriginHost(host string) string { + host = strings.TrimSpace(host) + if strings.Contains(host, "://") { + if u, err := url.Parse(host); err == nil { + host = u.Hostname() + } + } else if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.Trim(host, "[]") + host = strings.TrimSuffix(host, ".") + return strings.ToLower(host) +} + +func localAppHostCandidates() []string { + out := make([]string, 0, 8) + seen := map[string]bool{} + + add := func(host string) { + host = normalizeOriginHost(host) + if host == "" || seen[host] { + return + } + seen[host] = true + out = append(out, host) + } + + add("localhost") + add("127.0.0.1") + + if host := strings.TrimSpace(os.Getenv("APP_HOST")); host != "" { + add(host) + } + + if host, err := os.Hostname(); err == nil { + add(host) + host = normalizeOriginHost(host) + if host != "" && !strings.Contains(host, ".") { + add(host + ".local") + } + } + + addInterfaceIPv4Hosts(add) + return out } +func addInterfaceIPv4Hosts(add func(string)) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return + } + + for _, addr := range addrs { + var ip net.IP + + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + default: + continue + } + + ip4 := ip.To4() + if ip4 == nil || ip4.IsLoopback() || ip4.IsUnspecified() || ip4.IsMulticast() { + continue + } + + add(ip4.String()) + } +} + +func canonicalOrigin(origin string) (string, bool) { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + if origin == "" { + return "", false + } + + u, err := url.Parse(origin) + if err != nil { + return "", false + } + + scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) + host := normalizeOriginHost(u.Hostname()) + if scheme == "" || host == "" { + return "", false + } + if scheme != "http" && scheme != "https" { + return "", false + } + + port := strings.TrimSpace(u.Port()) + if port == "" { + return scheme + "://" + host, true + } + + return scheme + "://" + net.JoinHostPort(host, port), true +} + func originFromRequest(r *http.Request) string { origin := strings.TrimSpace(r.Header.Get("Origin")) if origin != "" { + if normalized, ok := canonicalOrigin(origin); ok { + return normalized + } return strings.TrimRight(origin, "/") } @@ -170,18 +301,11 @@ func rpIDFromOrigin(origin string) (string, error) { return "", err } - host := strings.ToLower(strings.TrimSpace(u.Hostname())) + host := normalizeOriginHost(u.Hostname()) if host == "" { return "", errors.New("origin has no hostname") } - if net.ParseIP(host) != nil { - return "", errors.New( - "Passkeys/WebAuthn funktionieren nicht mit einer IP-Adresse als RP-ID. " + - "Öffne die App über einen Hostnamen, z. B. https://recorder.home.arpa:9999", - ) - } - return host, nil } @@ -234,10 +358,22 @@ func rpIDForOrigin(origin string) (string, error) { } func originIsAllowed(origin string, allowed []string) bool { - origin = strings.TrimRight(strings.TrimSpace(origin), "/") + normalizedOrigin, ok := canonicalOrigin(origin) + if ok { + origin = normalizedOrigin + } else { + origin = strings.TrimRight(strings.TrimSpace(origin), "/") + } for _, a := range allowed { - if origin == strings.TrimRight(strings.TrimSpace(a), "/") { + normalizedAllowed, ok := canonicalOrigin(a) + if ok { + a = normalizedAllowed + } else { + a = strings.TrimRight(strings.TrimSpace(a), "/") + } + + if origin == a { return true } } diff --git a/backend/build_deb.sh b/backend/build_deb.sh index 80063e8..8285d57 100644 --- a/backend/build_deb.sh +++ b/backend/build_deb.sh @@ -97,11 +97,10 @@ cat >"$PKG_ROOT/usr/bin/nsfwapp" <<'EOF' set -eu install_dir="/opt/nsfwapp" -data_home="${XDG_DATA_HOME:-$HOME/.local/share}" -app_dir="$data_home/nsfwapp" +app_dir="${NSFWAPP_HOME:-$HOME/nsfwapp}" runtime_bin="$app_dir/nsfwapp" -mkdir -p "$app_dir" +mkdir -p "$app_dir/record/done" if [ ! -f "$runtime_bin" ] || ! cmp -s "$install_dir/nsfwapp" "$runtime_bin"; then tmp="$app_dir/.nsfwapp.tmp.$$" @@ -136,10 +135,9 @@ cat >"$PKG_ROOT/usr/bin/nsfwapp-open-data" <<'EOF' #!/usr/bin/env sh set -eu -data_home="${XDG_DATA_HOME:-$HOME/.local/share}" -app_dir="$data_home/nsfwapp" +app_dir="${NSFWAPP_HOME:-$HOME/nsfwapp}" -mkdir -p "$app_dir" +mkdir -p "$app_dir/record/done" if command -v xdg-open >/dev/null 2>&1; then xdg-open "$app_dir" >/dev/null 2>&1 || true @@ -151,11 +149,10 @@ cat >"$PKG_ROOT/usr/bin/nsfwapp-open-log" <<'EOF' #!/usr/bin/env sh set -eu -data_home="${XDG_DATA_HOME:-$HOME/.local/share}" -app_dir="$data_home/nsfwapp" +app_dir="${NSFWAPP_HOME:-$HOME/nsfwapp}" log_file="$app_dir/recorder.log" -mkdir -p "$app_dir" +mkdir -p "$app_dir/record/done" [ -f "$log_file" ] || : >"$log_file" if command -v xdg-open >/dev/null 2>&1; then @@ -168,8 +165,8 @@ cat >"$PKG_ROOT/usr/bin/nsfwapp-stop" <<'EOF' #!/usr/bin/env sh set -eu -data_home="${XDG_DATA_HOME:-$HOME/.local/share}" -runtime_bin="$data_home/nsfwapp/nsfwapp" +app_dir="${NSFWAPP_HOME:-$HOME/nsfwapp}" +runtime_bin="$app_dir/nsfwapp" install_bin="/opt/nsfwapp/nsfwapp" pids="" @@ -258,16 +255,13 @@ Desktop: User-Daten: Die installierte Binary liegt unter /opt/nsfwapp/nsfwapp. - Beim Start wird sie nach ${XDG_DATA_HOME:-$HOME/.local/share}/nsfwapp/nsfwapp kopiert + Beim Start wird sie nach ${NSFWAPP_HOME:-$HOME/nsfwapp}/nsfwapp kopiert und von dort ausgefuehrt. Dadurch bleiben recorder.log, recorder_settings.json, - generated/ und eine optionale externe .env im User-Bereich. - -Optionale Konfiguration: - Lege eine .env neben die Runtime-Binary, z.B.: - ~/.local/share/nsfwapp/.env + generated/, record/ und record/done im User-Bereich. Wichtige Laufzeitprogramme: ffmpeg/ffprobe muessen fuer Recording, Teaser, Analyse und Videofunktionen verfuegbar sein. + pg_dump/pg_restore werden fuer Datenbank-Backup und Restore benoetigt. Der AI-Server braucht Python und die benoetigten Python-Pakete deiner ML-Umgebung. EOF @@ -278,7 +272,7 @@ Section: $SECTION Priority: $PRIORITY Architecture: $ARCH Maintainer: $MAINTAINER -Depends: ca-certificates, ffmpeg, python3, xdg-utils, libnotify-bin, procps +Depends: ca-certificates, ffmpeg, python3, postgresql-client, xdg-utils, libnotify-bin, procps Description: $DESCRIPTION_SHORT $DESCRIPTION_LONG EOF diff --git a/backend/build_manifest.env b/backend/build_manifest.env index 775c337..6d25912 100644 --- a/backend/build_manifest.env +++ b/backend/build_manifest.env @@ -1,7 +1,7 @@ # Build and Debian package metadata. PACKAGE_NAME="nsfwapp" APP_TITLE="NSFW App" -VERSION="1.1.0" +VERSION="1.2.0" ARCH="amd64" SECTION="video" PRIORITY="optional" diff --git a/backend/dist/nsfwapp-linux-amd64 b/backend/dist/nsfwapp-linux-amd64 index a82ff87..cfd8da9 100644 Binary files a/backend/dist/nsfwapp-linux-amd64 and b/backend/dist/nsfwapp-linux-amd64 differ diff --git a/backend/dist/nsfwapp.exe b/backend/dist/nsfwapp.exe index 2c17cb4..17be6bd 100644 Binary files a/backend/dist/nsfwapp.exe and b/backend/dist/nsfwapp.exe differ diff --git a/backend/dist/nsfwapp_amd64.deb b/backend/dist/nsfwapp_amd64.deb index 51e3358..aaa425a 100644 Binary files a/backend/dist/nsfwapp_amd64.deb and b/backend/dist/nsfwapp_amd64.deb differ diff --git a/backend/embed.go b/backend/embed.go index 859d198..3f3ed74 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -8,7 +8,7 @@ import ( "path/filepath" ) -//go:embed ml/*.py ml/detection_labels.json ai_server.py .env recorder-cert.pem recorder-key.pem yolo26n-pose.pt +//go:embed ml/*.py ml/detection_labels.json ai_server.py recorder-cert.pem recorder-key.pem yolo26n-pose.pt var embeddedMLFiles embed.FS func embeddedWriteFileIfNeeded(dstPath string, b []byte, perm os.FileMode) error { @@ -118,7 +118,7 @@ func embeddedPoseModelPath() (string, error) { } func embeddedDotEnvBytes() ([]byte, error) { - return embeddedMLFiles.ReadFile(".env") + return nil, os.ErrNotExist } func embeddedTLSCertBytes() ([]byte, error) { diff --git a/backend/routes.go b/backend/routes.go index 4a7ca49..14340ff 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -54,6 +54,8 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/settings", recordSettingsHandler) api.HandleFunc("/api/settings/browse", settingsBrowse) api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler) + api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler) + api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler) api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth)) diff --git a/backend/server.go b/backend/server.go index 3bdc76d..c008e31 100644 --- a/backend/server.go +++ b/backend/server.go @@ -856,7 +856,42 @@ func loadAppEnv() { func httpsEnabled() bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv("HTTPS_ENABLED"))) - return raw == "1" || raw == "true" || raw == "yes" || raw == "on" + if raw != "" { + return raw == "1" || raw == "true" || raw == "yes" || raw == "on" + } + + return !isDevRuntime() +} + +func isDevRuntime() bool { + raw := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) + switch raw { + case "dev", "development", "local": + return true + case "prod", "production", "release": + return false + } + + if exePath, err := os.Executable(); err == nil { + exePath = strings.ToLower(filepath.ToSlash(exePath)) + if strings.Contains(exePath, "/go-build") { + return true + } + + exeDir := filepath.Dir(exePath) + if existingFile(filepath.Join(exeDir, "go.mod")) && existingFile(filepath.Join(exeDir, "server.go")) { + return true + } + + return false + } + + cwd, err := os.Getwd() + if err != nil { + return false + } + + return existingFile(filepath.Join(cwd, "go.mod")) && existingFile(filepath.Join(cwd, "server.go")) } func resolveMaybeRelativeToExe(path string) string { @@ -906,22 +941,38 @@ func tlsKeyFile() string { } func publicAppURL() string { + urls := publicAppURLs() + if len(urls) > 0 { + return urls[0] + } + + return "http://localhost:9999" +} + +func publicAppURLs() []string { scheme := "http" if httpsEnabled() { scheme = "https" } - host := strings.TrimSpace(os.Getenv("APP_HOST")) - if host == "" { - host = "localhost" - } - port := strings.TrimSpace(os.Getenv("APP_PORT")) if port == "" { port = "9999" } - return scheme + "://" + host + ":" + port + out := make([]string, 0, 8) + seen := map[string]bool{} + + for _, host := range localAppHostCandidates() { + u := originForHost(scheme, host, port) + if u == "" || seen[u] { + continue + } + seen[u] = true + out = append(out, u) + } + + return out } // --- main --- @@ -937,14 +988,16 @@ func main() { appCtx, appCancel := context.WithCancel(context.Background()) defer appCancel() + listenAddr := ":" + appPort() + // Nur eine Instanz erlauben: - appLn, err := net.Listen("tcp", ":9999") + appLn, err := net.Listen("tcp", listenAddr) if err != nil { _ = showAppNotification( "Recorder", "Die Anwendung wird bereits ausgeführt.", ) - appLogln("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.") + appLogln("⚠️ Die App läuft bereits oder Port " + appPort() + " ist bereits belegt.") os.Exit(0) } defer appLn.Close() @@ -1029,16 +1082,16 @@ func main() { } if httpsEnabled() { - appLogln("🌐 HTTPS-API aktiv:", publicAppURL()) + appLogln("🌐 HTTPS-API aktiv:", strings.Join(publicAppURLs(), ", ")) appLogln("🔐 TLS Cert:", tlsCertFile()) appLogln("🔐 TLS Key: ", tlsKeyFile()) } else { - appLogln("🌐 HTTP-API aktiv: https://l14pbbk95100006.tegdssd.de:9999") + appLogln("🌐 HTTP-API aktiv:", strings.Join(publicAppURLs(), ", ")) } handler := withCORS(mux) srv := &http.Server{ - Addr: ":9999", + Addr: listenAddr, Handler: handler, } diff --git a/backend/settings.go b/backend/settings.go index 653b293..a09a647 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -68,8 +68,8 @@ var ( DatabaseURL: "", EncryptedDBPassword: "", - RecordDir: "/records", - DoneDir: "/records/done", + RecordDir: defaultRecordDir, + DoneDir: defaultDoneDir, FFmpegPath: "", AutoAddToDownloadList: false, @@ -111,6 +111,29 @@ var ( settingsFile = "recorder_settings.json" ) +const ( + defaultRecordDir = "record" + defaultDoneDir = "record/done" + legacyDefaultRecordDir = "/records" + legacyDefaultDoneDir = "/records/done" +) + +func normalizeSettingsDir(raw string, fallback string, legacyDefaults ...string) string { + path := strings.TrimSpace(raw) + if path == "" { + return fallback + } + + path = filepath.Clean(filepath.FromSlash(path)) + for _, legacy := range legacyDefaults { + if path == filepath.Clean(filepath.FromSlash(strings.TrimSpace(legacy))) { + return fallback + } + } + + return path +} + func settingsFilePath() string { // optionaler Override per ENV name := settingsFile @@ -135,12 +158,8 @@ func loadSettings() { if err == nil { s := getSettings() // ✅ startet mit Defaults if json.Unmarshal(b, &s) == nil { - if strings.TrimSpace(s.RecordDir) != "" { - s.RecordDir = filepath.Clean(strings.TrimSpace(s.RecordDir)) - } - if strings.TrimSpace(s.DoneDir) != "" { - s.DoneDir = filepath.Clean(strings.TrimSpace(s.DoneDir)) - } + s.RecordDir = normalizeSettingsDir(s.RecordDir, defaultRecordDir, legacyDefaultRecordDir) + s.DoneDir = normalizeSettingsDir(s.DoneDir, defaultDoneDir, legacyDefaultDoneDir) if strings.TrimSpace(s.FFmpegPath) != "" { s.FFmpegPath = strings.TrimSpace(s.FFmpegPath) } diff --git a/backend/settings_database_backup.go b/backend/settings_database_backup.go new file mode 100644 index 0000000..23635a7 --- /dev/null +++ b/backend/settings_database_backup.go @@ -0,0 +1,289 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const maxDatabaseRestoreBytes int64 = 2 << 30 + +type postgresToolConfig struct { + dbURL string + env []string +} + +func postgresToolConfigFromSettings() (postgresToolConfig, string, error) { + dsn, err := buildPostgresDSNFromSettings() + if err != nil { + return postgresToolConfig{}, "", err + } + + dbURL, env, err := postgresToolConnection(dsn) + if err != nil { + return postgresToolConfig{}, "", err + } + + return postgresToolConfig{ + dbURL: dbURL, + env: env, + }, dsn, nil +} + +func postgresToolConnection(dsn string) (string, []string, error) { + dsn = strings.TrimSpace(dsn) + if dsn == "" { + return "", nil, errors.New("databaseUrl ist leer") + } + + u, err := url.Parse(dsn) + if err != nil { + return "", nil, appErrorf("databaseUrl ungueltig: %w", err) + } + + switch strings.ToLower(strings.TrimSpace(u.Scheme)) { + case "postgres", "postgresql": + default: + return "", nil, appErrorf("databaseUrl muss postgres:// oder postgresql:// verwenden") + } + + env := os.Environ() + + if u.User != nil { + user := u.User.Username() + if pw, ok := u.User.Password(); ok && strings.TrimSpace(pw) != "" { + env = upsertEnv(env, "PGPASSWORD", pw) + } + if strings.TrimSpace(user) != "" { + u.User = url.User(user) + } else { + u.User = nil + } + } + + if sslMode := strings.TrimSpace(u.Query().Get("sslmode")); sslMode != "" { + env = upsertEnv(env, "PGSSLMODE", sslMode) + } + env = upsertEnv(env, "PGCONNECT_TIMEOUT", "10") + + return u.String(), env, nil +} + +func postgresToolPath(name string) (string, error) { + path, err := exec.LookPath(name) + if err == nil && strings.TrimSpace(path) != "" { + return path, nil + } + + return "", appErrorf("%s wurde nicht gefunden. Installiere postgresql-client oder nimm %s in PATH auf.", name, name) +} + +func runPostgresTool(ctx context.Context, tool string, cfg postgresToolConfig, args ...string) (string, error) { + toolPath, err := postgresToolPath(tool) + if err != nil { + return "", err + } + + cmd := exec.CommandContext(ctx, toolPath, args...) + cmd.Env = cfg.env + hideCommandWindow(cmd) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return trimCommandOutput(msg), err + } + + return trimCommandOutput(stderr.String()), nil +} + +func trimCommandOutput(s string) string { + s = strings.TrimSpace(s) + const maxLen = 4000 + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "\n..." +} + +func settingsDatabaseBackupHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + cfg, _, err := postgresToolConfigFromSettings() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + tmp, err := os.CreateTemp("", "nsfwapp-db-backup-*.dump") + if err != nil { + http.Error(w, "Backup-Datei konnte nicht erstellt werden: "+err.Error(), http.StatusInternalServerError) + return + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) + defer cancel() + + toolPath, err := postgresToolPath("pg_dump") + if err != nil { + _ = tmp.Close() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + cmd := exec.CommandContext( + ctx, + toolPath, + "--format=custom", + "--blobs", + "--no-owner", + "--no-privileges", + "--dbname="+cfg.dbURL, + ) + cmd.Env = cfg.env + cmd.Stdout = tmp + hideCommandWindow(cmd) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + runErr := cmd.Run() + closeErr := tmp.Close() + if runErr != nil { + msg := trimCommandOutput(stderr.String()) + if msg == "" { + msg = runErr.Error() + } + http.Error(w, "pg_dump fehlgeschlagen: "+msg, http.StatusInternalServerError) + return + } + if closeErr != nil { + http.Error(w, "Backup-Datei konnte nicht geschlossen werden: "+closeErr.Error(), http.StatusInternalServerError) + return + } + + info, err := os.Stat(tmpPath) + if err != nil { + http.Error(w, "Backup-Datei konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError) + return + } + + f, err := os.Open(tmpPath) + if err != nil { + http.Error(w, "Backup-Datei konnte nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError) + return + } + defer f.Close() + + filename := "nsfwapp-db-backup-" + time.Now().Format("20060102-150405") + ".dump" + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) + + http.ServeContent(w, r, filename, info.ModTime(), f) +} + +func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) + return + } + + cfg, dsn, err := postgresToolConfigFromSettings() + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxDatabaseRestoreBytes) + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, "Restore-Datei konnte nicht gelesen werden: "+err.Error(), http.StatusBadRequest) + return + } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + + file, header, err := r.FormFile("backup") + if err != nil { + http.Error(w, "Form-Feld 'backup' fehlt: "+err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + tmp, err := os.CreateTemp("", "nsfwapp-db-restore-*.dump") + if err != nil { + http.Error(w, "Restore-Datei konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError) + return + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + written, err := io.Copy(tmp, file) + closeErr := tmp.Close() + if err != nil { + http.Error(w, "Restore-Datei konnte nicht gespeichert werden: "+err.Error(), http.StatusBadRequest) + return + } + if closeErr != nil { + http.Error(w, "Restore-Datei konnte nicht geschlossen werden: "+closeErr.Error(), http.StatusInternalServerError) + return + } + if written <= 0 { + http.Error(w, "Restore-Datei ist leer", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute) + defer cancel() + + stderr, err := runPostgresTool( + ctx, + "pg_restore", + cfg, + "--clean", + "--if-exists", + "--no-owner", + "--no-privileges", + "--single-transaction", + "--dbname="+cfg.dbURL, + filepath.Clean(tmpPath), + ) + if err != nil { + http.Error(w, "pg_restore fehlgeschlagen: "+stderr, http.StatusInternalServerError) + return + } + + refreshedStore := NewModelStore(dsn) + if err := refreshedStore.Load(); err != nil { + appLogln("⚠️ Models DB nach Restore konnte nicht neu verbunden werden:", err) + } else { + setModelStore(refreshedStore) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "fileName": strings.TrimSpace(header.Filename), + "bytes": written, + }) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f82101d..bf5723f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -301,6 +301,25 @@ function extractFirstUrl(text: string): string | null { return null } +async function canReadClipboardWithoutPrompt(): Promise { + if (!navigator.clipboard?.readText) return false + + const permissions = (navigator as Navigator & { + permissions?: { + query?: (descriptor: { name: string }) => Promise<{ state?: string }> + } + }).permissions + + if (!permissions?.query) return false + + try { + const status = await permissions.query({ name: 'clipboard-read' }) + return status.state === 'granted' + } catch { + return false + } +} + type Provider = 'chaturbate' | 'mfc' function getProviderFromNormalizedUrl(normUrl: string): Provider | null { @@ -4009,6 +4028,7 @@ export default function App() { let cancelled = false let inFlight = false let timer: number | null = null + let listenersAttached = false const checkClipboard = async () => { if (cancelled || inFlight) return @@ -4041,6 +4061,10 @@ export default function App() { } } + const kick = () => { + void checkClipboard() + } + const schedule = (ms: number) => { if (cancelled) return timer = window.setTimeout(async () => { @@ -4049,20 +4073,26 @@ export default function App() { }, ms) } - const kick = () => { - void checkClipboard() + const startClipboardPolling = async () => { + const allowed = await canReadClipboardWithoutPrompt() + if (cancelled || !allowed) return + + listenersAttached = true + window.addEventListener('focus', kick) + document.addEventListener('visibilitychange', kick) + + schedule(0) } - window.addEventListener('focus', kick) - document.addEventListener('visibilitychange', kick) - - schedule(0) + void startClipboardPolling() return () => { cancelled = true if (timer) window.clearTimeout(timer) - window.removeEventListener('focus', kick) - document.removeEventListener('visibilitychange', kick) + if (listenersAttached) { + window.removeEventListener('focus', kick) + document.removeEventListener('visibilitychange', kick) + } } }, [autoAddEnabled, autoStartEnabled, startUrl]) diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 193f674..9ac08a7 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -322,12 +322,29 @@ async function copyTextToClipboard(text: string): Promise { ta.style.position = 'fixed' ta.style.opacity = '0' ta.style.pointerEvents = 'none' + ta.style.left = '-9999px' + ta.style.top = '0' document.body.appendChild(ta) - ta.focus() + ta.focus({ preventScroll: true }) ta.select() - const ok = document.execCommand('copy') - document.body.removeChild(ta) - return ok + + try { + return document.execCommand('copy') + } finally { + ta.blur() + ta.remove() + window.getSelection()?.removeAllRanges() + + const active = document.activeElement + if ( + active instanceof HTMLElement && + (active.tagName === 'TEXTAREA' || + active.tagName === 'INPUT' || + active.isContentEditable) + ) { + active.blur() + } + } } catch { return false } diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index deb9030..c52ceb1 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -281,8 +281,8 @@ function SettingsSection(props: { const DEFAULTS: RecorderSettings = { databaseUrl: '', hasDbPassword: false, - recordDir: 'records', - doneDir: 'records/done', + recordDir: 'record', + doneDir: 'record/done', ffmpegPath: '', autoAddToDownloadList: true, autoStartAddedDownloads: true, @@ -517,6 +517,32 @@ function cleanupTaskTitle(rawId: string) { return 'Aufräumen' } +function databaseBackupFilename(contentDisposition: string | null) { + const header = String(contentDisposition ?? '').trim() + if (!header) return 'nsfwapp-db-backup.dump' + + const encoded = /filename\*=UTF-8''([^;]+)/i.exec(header) + if (encoded?.[1]) { + try { + return decodeURIComponent(encoded[1].trim().replace(/^"|"$/g, '')) + } catch { + return encoded[1].trim().replace(/^"|"$/g, '') + } + } + + const plain = /filename="?([^";]+)"?/i.exec(header) + return plain?.[1]?.trim() || 'nsfwapp-db-backup.dump' +} + +function formatDatabaseBackupBytes(bytes: unknown) { + const n = Number(bytes) + if (!Number.isFinite(n) || n <= 0) return '' + if (n < 1024) return `${Math.round(n)} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB` + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB` +} + const SETTINGS_RATING_STAR_PATH = 'M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z' @@ -648,6 +674,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState(0) const saveSuccessTimerRef = useRef(null) const appLogPreRef = useRef(null) + const dbRestoreInputRef = useRef(null) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [msg, setMsg] = useState(null) const [err, setErr] = useState(null) @@ -659,9 +686,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const [dbModalOpen, setDbModalOpen] = useState(false) const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('') const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet + const [dbBackupBusy, setDbBackupBusy] = useState(false) + const [dbRestoreBusy, setDbRestoreBusy] = useState(false) + const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState(null) + const [dbMaintenanceErr, setDbMaintenanceErr] = useState(null) const now = Date.now() const saveSucceeded = saveSuccessUntilMs > now const saveFailed = Boolean(err) && !saving && !saveSucceeded + const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim() + const databaseSettingsDirty = + currentDatabaseUrl !== loadedDatabaseUrl || Boolean((pendingDbPassword || '').trim()) + const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy const [authStatus, setAuthStatus] = useState(null) const [securityBusy, setSecurityBusy] = useState(false) @@ -1196,6 +1231,105 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { } } + async function downloadDatabaseBackup() { + setDbMaintenanceMsg(null) + setDbMaintenanceErr(null) + + if (!loadedDatabaseUrl) { + setDbMaintenanceErr('Bitte zuerst eine Datenbank-Verbindung speichern.') + return + } + if (databaseSettingsDirty) { + setDbMaintenanceErr('Bitte die Datenbank-Einstellungen zuerst speichern.') + return + } + + setDbBackupBusy(true) + try { + const res = await fetch('/api/settings/database/backup', { cache: 'no-store' }) + if (!res.ok) { + const t = await res.text().catch(() => '') + throw new Error(t || `HTTP ${res.status}`) + } + + const blob = await res.blob() + const filename = databaseBackupFilename(res.headers.get('Content-Disposition')) + const href = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = href + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + window.URL.revokeObjectURL(href) + + const size = formatDatabaseBackupBytes(blob.size) + setDbMaintenanceMsg(size ? `Backup heruntergeladen (${size}).` : 'Backup heruntergeladen.') + } catch (e: any) { + setDbMaintenanceErr(e?.message ?? String(e)) + } finally { + setDbBackupBusy(false) + } + } + + function chooseDatabaseRestoreFile() { + setDbMaintenanceMsg(null) + setDbMaintenanceErr(null) + dbRestoreInputRef.current?.click() + } + + async function restoreDatabaseBackup(file?: File | null) { + if (!file) return + + setDbMaintenanceMsg(null) + setDbMaintenanceErr(null) + + if (!loadedDatabaseUrl) { + setDbMaintenanceErr('Bitte zuerst eine Datenbank-Verbindung speichern.') + return + } + if (databaseSettingsDirty) { + setDbMaintenanceErr('Bitte die Datenbank-Einstellungen zuerst speichern.') + return + } + + const ok = window.confirm( + `Datenbank-Backup "${file.name}" wiederherstellen?\n\n` + + 'Das ueberschreibt die aktuelle Datenbank. Dieser Schritt kann nicht automatisch rueckgaengig gemacht werden.' + ) + if (!ok) return + + setDbRestoreBusy(true) + try { + const form = new FormData() + form.append('backup', file) + + const res = await fetch('/api/settings/database/restore', { + method: 'POST', + body: form, + }) + if (!res.ok) { + const t = await res.text().catch(() => '') + throw new Error(t || `HTTP ${res.status}`) + } + + const data = await res.json().catch(() => null) + const size = formatDatabaseBackupBytes(data?.bytes ?? file.size) + setDbMaintenanceMsg(size ? `Restore abgeschlossen (${size}).` : 'Restore abgeschlossen.') + + window.dispatchEvent(new Event('models-changed')) + window.dispatchEvent( + new CustomEvent('models-db-changed', { + detail: { databaseUrl: loadedDatabaseUrl, restored: true }, + }) + ) + } catch (e: any) { + setDbMaintenanceErr(e?.message ?? String(e)) + } finally { + setDbRestoreBusy(false) + } + } + async function save() { setErr(null) setMsg(null) @@ -2016,6 +2150,68 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { + +
+
+
+
+ Backup / Restore +
+
+ Exportiert die gespeicherte PostgreSQL-Datenbank als pg_dump-Datei oder stellt sie aus einem Backup wieder her. +
+ {databaseSettingsDirty ? ( +
+ Die Datenbank-Einstellungen wurden geaendert. Bitte erst speichern. +
+ ) : null} +
+ +
+ + + +
+
+ + { + const file = e.currentTarget.files?.[0] ?? null + e.currentTarget.value = '' + void restoreDatabaseBackup(file) + }} + /> + + {dbMaintenanceErr ? ( +
+ {dbMaintenanceErr} +
+ ) : dbMaintenanceMsg ? ( +
+ {dbMaintenanceMsg} +
+ ) : null} +