This commit is contained in:
Linrador 2026-06-23 14:06:01 +02:00
parent 5d506a80ce
commit c473b2f39c
16 changed files with 826 additions and 91 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.DS_Store
backend/generated
backend/.env
backend/web/dist
backend/data/pending-autostart/__global__.json
backend/.gocache

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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) {

View File

@ -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))

View File

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

View File

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

View File

@ -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,
})
}

View File

@ -301,6 +301,25 @@ function extractFirstUrl(text: string): string | null {
return null
}
async function canReadClipboardWithoutPrompt(): Promise<boolean> {
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])

View File

@ -322,12 +322,29 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
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
}

View File

@ -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<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null)
const appLogPreRef = useRef<HTMLPreElement | null>(null)
const dbRestoreInputRef = useRef<HTMLInputElement | null>(null)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(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<string | null>(null)
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(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<AuthSecurityStatus | null>(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) {
</Button>
</div>
</div>
<div className="border-t border-gray-200 pt-3 dark:border-white/10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Backup / Restore
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Exportiert die gespeicherte PostgreSQL-Datenbank als pg_dump-Datei oder stellt sie aus einem Backup wieder her.
</div>
{databaseSettingsDirty ? (
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
Die Datenbank-Einstellungen wurden geaendert. Bitte erst speichern.
</div>
) : null}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
variant="secondary"
color="emerald"
isLoading={dbBackupBusy}
disabled={saving || dbMaintenanceBusy || !loadedDatabaseUrl || databaseSettingsDirty}
onClick={() => void downloadDatabaseBackup()}
>
Backup herunterladen
</Button>
<Button
variant="secondary"
color="amber"
isLoading={dbRestoreBusy}
disabled={saving || dbMaintenanceBusy || !loadedDatabaseUrl || databaseSettingsDirty}
onClick={chooseDatabaseRestoreFile}
>
Restore
</Button>
</div>
</div>
<input
ref={dbRestoreInputRef}
type="file"
accept=".dump,.backup,.bin,application/octet-stream"
className="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0] ?? null
e.currentTarget.value = ''
void restoreDatabaseBackup(file)
}}
/>
{dbMaintenanceErr ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{dbMaintenanceErr}
</div>
) : dbMaintenanceMsg ? (
<div className="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200">
{dbMaintenanceMsg}
</div>
) : null}
</div>
</div>
</div>