bugfixes
This commit is contained in:
parent
5d506a80ce
commit
c473b2f39c
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
.DS_Store
|
||||
backend/generated
|
||||
backend/.env
|
||||
backend/web/dist
|
||||
backend/data/pending-autostart/__global__.json
|
||||
backend/.gocache
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
BIN
backend/dist/nsfwapp-linux-amd64
vendored
BIN
backend/dist/nsfwapp-linux-amd64
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp.exe
vendored
BIN
backend/dist/nsfwapp.exe
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp_amd64.deb
vendored
BIN
backend/dist/nsfwapp_amd64.deb
vendored
Binary file not shown.
@ -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) {
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
289
backend/settings_database_backup.go
Normal file
289
backend/settings_database_backup.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user