bugfixes
This commit is contained in:
parent
5d506a80ce
commit
c473b2f39c
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
backend/generated
|
backend/generated
|
||||||
|
backend/.env
|
||||||
backend/web/dist
|
backend/web/dist
|
||||||
backend/data/pending-autostart/__global__.json
|
backend/data/pending-autostart/__global__.json
|
||||||
backend/.gocache
|
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 {
|
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"))
|
raw := strings.TrimSpace(os.Getenv("AUTH_RP_ORIGINS"))
|
||||||
|
|
||||||
// Backward compatible: alter Einzelwert funktioniert weiter.
|
// Backward compatible: alter Einzelwert funktioniert weiter.
|
||||||
@ -108,46 +134,151 @@ func authAllowedRPOrigins() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
raw = strings.Join([]string{
|
return nil
|
||||||
"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",
|
|
||||||
}, ",")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(raw, ",")
|
parts := strings.Split(raw, ",")
|
||||||
out := make([]string, 0, len(parts))
|
out := make([]string, 0, len(parts))
|
||||||
seen := map[string]bool{}
|
|
||||||
|
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
origin := strings.TrimSpace(p)
|
p = strings.TrimSpace(p)
|
||||||
origin = strings.TrimRight(origin, "/")
|
if p != "" {
|
||||||
if origin == "" {
|
out = append(out, p)
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
if seen[origin] {
|
func appPort() string {
|
||||||
continue
|
port := strings.TrimSpace(os.Getenv("APP_PORT"))
|
||||||
}
|
if port == "" {
|
||||||
|
port = "9999"
|
||||||
|
}
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
seen[origin] = true
|
func originForHost(scheme, host, port string) string {
|
||||||
out = append(out, origin)
|
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
|
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 {
|
func originFromRequest(r *http.Request) string {
|
||||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||||
if origin != "" {
|
if origin != "" {
|
||||||
|
if normalized, ok := canonicalOrigin(origin); ok {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
return strings.TrimRight(origin, "/")
|
return strings.TrimRight(origin, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,18 +301,11 @@ func rpIDFromOrigin(origin string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
host := normalizeOriginHost(u.Hostname())
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return "", errors.New("origin has no hostname")
|
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
|
return host, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,10 +358,22 @@ func rpIDForOrigin(origin string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func originIsAllowed(origin string, allowed []string) bool {
|
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 {
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,11 +97,10 @@ cat >"$PKG_ROOT/usr/bin/nsfwapp" <<'EOF'
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
install_dir="/opt/nsfwapp"
|
install_dir="/opt/nsfwapp"
|
||||||
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
app_dir="${NSFWAPP_HOME:-$HOME/nsfwapp}"
|
||||||
app_dir="$data_home/nsfwapp"
|
|
||||||
runtime_bin="$app_dir/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
|
if [ ! -f "$runtime_bin" ] || ! cmp -s "$install_dir/nsfwapp" "$runtime_bin"; then
|
||||||
tmp="$app_dir/.nsfwapp.tmp.$$"
|
tmp="$app_dir/.nsfwapp.tmp.$$"
|
||||||
@ -136,10 +135,9 @@ cat >"$PKG_ROOT/usr/bin/nsfwapp-open-data" <<'EOF'
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
app_dir="${NSFWAPP_HOME:-$HOME/nsfwapp}"
|
||||||
app_dir="$data_home/nsfwapp"
|
|
||||||
|
|
||||||
mkdir -p "$app_dir"
|
mkdir -p "$app_dir/record/done"
|
||||||
|
|
||||||
if command -v xdg-open >/dev/null 2>&1; then
|
if command -v xdg-open >/dev/null 2>&1; then
|
||||||
xdg-open "$app_dir" >/dev/null 2>&1 || true
|
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
|
#!/usr/bin/env sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
app_dir="${NSFWAPP_HOME:-$HOME/nsfwapp}"
|
||||||
app_dir="$data_home/nsfwapp"
|
|
||||||
log_file="$app_dir/recorder.log"
|
log_file="$app_dir/recorder.log"
|
||||||
|
|
||||||
mkdir -p "$app_dir"
|
mkdir -p "$app_dir/record/done"
|
||||||
[ -f "$log_file" ] || : >"$log_file"
|
[ -f "$log_file" ] || : >"$log_file"
|
||||||
|
|
||||||
if command -v xdg-open >/dev/null 2>&1; then
|
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
|
#!/usr/bin/env sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
app_dir="${NSFWAPP_HOME:-$HOME/nsfwapp}"
|
||||||
runtime_bin="$data_home/nsfwapp/nsfwapp"
|
runtime_bin="$app_dir/nsfwapp"
|
||||||
install_bin="/opt/nsfwapp/nsfwapp"
|
install_bin="/opt/nsfwapp/nsfwapp"
|
||||||
pids=""
|
pids=""
|
||||||
|
|
||||||
@ -258,16 +255,13 @@ Desktop:
|
|||||||
|
|
||||||
User-Daten:
|
User-Daten:
|
||||||
Die installierte Binary liegt unter /opt/nsfwapp/nsfwapp.
|
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,
|
und von dort ausgefuehrt. Dadurch bleiben recorder.log, recorder_settings.json,
|
||||||
generated/ und eine optionale externe .env im User-Bereich.
|
generated/, record/ und record/done im User-Bereich.
|
||||||
|
|
||||||
Optionale Konfiguration:
|
|
||||||
Lege eine .env neben die Runtime-Binary, z.B.:
|
|
||||||
~/.local/share/nsfwapp/.env
|
|
||||||
|
|
||||||
Wichtige Laufzeitprogramme:
|
Wichtige Laufzeitprogramme:
|
||||||
ffmpeg/ffprobe muessen fuer Recording, Teaser, Analyse und Videofunktionen verfuegbar sein.
|
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.
|
Der AI-Server braucht Python und die benoetigten Python-Pakete deiner ML-Umgebung.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@ -278,7 +272,7 @@ Section: $SECTION
|
|||||||
Priority: $PRIORITY
|
Priority: $PRIORITY
|
||||||
Architecture: $ARCH
|
Architecture: $ARCH
|
||||||
Maintainer: $MAINTAINER
|
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: $DESCRIPTION_SHORT
|
||||||
$DESCRIPTION_LONG
|
$DESCRIPTION_LONG
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# Build and Debian package metadata.
|
# Build and Debian package metadata.
|
||||||
PACKAGE_NAME="nsfwapp"
|
PACKAGE_NAME="nsfwapp"
|
||||||
APP_TITLE="NSFW App"
|
APP_TITLE="NSFW App"
|
||||||
VERSION="1.1.0"
|
VERSION="1.2.0"
|
||||||
ARCH="amd64"
|
ARCH="amd64"
|
||||||
SECTION="video"
|
SECTION="video"
|
||||||
PRIORITY="optional"
|
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"
|
"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
|
var embeddedMLFiles embed.FS
|
||||||
|
|
||||||
func embeddedWriteFileIfNeeded(dstPath string, b []byte, perm os.FileMode) error {
|
func embeddedWriteFileIfNeeded(dstPath string, b []byte, perm os.FileMode) error {
|
||||||
@ -118,7 +118,7 @@ func embeddedPoseModelPath() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func embeddedDotEnvBytes() ([]byte, error) {
|
func embeddedDotEnvBytes() ([]byte, error) {
|
||||||
return embeddedMLFiles.ReadFile(".env")
|
return nil, os.ErrNotExist
|
||||||
}
|
}
|
||||||
|
|
||||||
func embeddedTLSCertBytes() ([]byte, error) {
|
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", recordSettingsHandler)
|
||||||
api.HandleFunc("/api/settings/browse", settingsBrowse)
|
api.HandleFunc("/api/settings/browse", settingsBrowse)
|
||||||
api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler)
|
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))
|
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))
|
||||||
|
|
||||||
|
|||||||
@ -856,7 +856,42 @@ func loadAppEnv() {
|
|||||||
|
|
||||||
func httpsEnabled() bool {
|
func httpsEnabled() bool {
|
||||||
raw := strings.ToLower(strings.TrimSpace(os.Getenv("HTTPS_ENABLED")))
|
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 {
|
func resolveMaybeRelativeToExe(path string) string {
|
||||||
@ -906,22 +941,38 @@ func tlsKeyFile() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func publicAppURL() string {
|
func publicAppURL() string {
|
||||||
|
urls := publicAppURLs()
|
||||||
|
if len(urls) > 0 {
|
||||||
|
return urls[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "http://localhost:9999"
|
||||||
|
}
|
||||||
|
|
||||||
|
func publicAppURLs() []string {
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if httpsEnabled() {
|
if httpsEnabled() {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
host := strings.TrimSpace(os.Getenv("APP_HOST"))
|
|
||||||
if host == "" {
|
|
||||||
host = "localhost"
|
|
||||||
}
|
|
||||||
|
|
||||||
port := strings.TrimSpace(os.Getenv("APP_PORT"))
|
port := strings.TrimSpace(os.Getenv("APP_PORT"))
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "9999"
|
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 ---
|
// --- main ---
|
||||||
@ -937,14 +988,16 @@ func main() {
|
|||||||
appCtx, appCancel := context.WithCancel(context.Background())
|
appCtx, appCancel := context.WithCancel(context.Background())
|
||||||
defer appCancel()
|
defer appCancel()
|
||||||
|
|
||||||
|
listenAddr := ":" + appPort()
|
||||||
|
|
||||||
// Nur eine Instanz erlauben:
|
// Nur eine Instanz erlauben:
|
||||||
appLn, err := net.Listen("tcp", ":9999")
|
appLn, err := net.Listen("tcp", listenAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = showAppNotification(
|
_ = showAppNotification(
|
||||||
"Recorder",
|
"Recorder",
|
||||||
"Die Anwendung wird bereits ausgeführt.",
|
"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)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
defer appLn.Close()
|
defer appLn.Close()
|
||||||
@ -1029,16 +1082,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if httpsEnabled() {
|
if httpsEnabled() {
|
||||||
appLogln("🌐 HTTPS-API aktiv:", publicAppURL())
|
appLogln("🌐 HTTPS-API aktiv:", strings.Join(publicAppURLs(), ", "))
|
||||||
appLogln("🔐 TLS Cert:", tlsCertFile())
|
appLogln("🔐 TLS Cert:", tlsCertFile())
|
||||||
appLogln("🔐 TLS Key: ", tlsKeyFile())
|
appLogln("🔐 TLS Key: ", tlsKeyFile())
|
||||||
} else {
|
} else {
|
||||||
appLogln("🌐 HTTP-API aktiv: https://l14pbbk95100006.tegdssd.de:9999")
|
appLogln("🌐 HTTP-API aktiv:", strings.Join(publicAppURLs(), ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := withCORS(mux)
|
handler := withCORS(mux)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":9999",
|
Addr: listenAddr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,8 +68,8 @@ var (
|
|||||||
DatabaseURL: "",
|
DatabaseURL: "",
|
||||||
EncryptedDBPassword: "",
|
EncryptedDBPassword: "",
|
||||||
|
|
||||||
RecordDir: "/records",
|
RecordDir: defaultRecordDir,
|
||||||
DoneDir: "/records/done",
|
DoneDir: defaultDoneDir,
|
||||||
FFmpegPath: "",
|
FFmpegPath: "",
|
||||||
|
|
||||||
AutoAddToDownloadList: false,
|
AutoAddToDownloadList: false,
|
||||||
@ -111,6 +111,29 @@ var (
|
|||||||
settingsFile = "recorder_settings.json"
|
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 {
|
func settingsFilePath() string {
|
||||||
// optionaler Override per ENV
|
// optionaler Override per ENV
|
||||||
name := settingsFile
|
name := settingsFile
|
||||||
@ -135,12 +158,8 @@ func loadSettings() {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
s := getSettings() // ✅ startet mit Defaults
|
s := getSettings() // ✅ startet mit Defaults
|
||||||
if json.Unmarshal(b, &s) == nil {
|
if json.Unmarshal(b, &s) == nil {
|
||||||
if strings.TrimSpace(s.RecordDir) != "" {
|
s.RecordDir = normalizeSettingsDir(s.RecordDir, defaultRecordDir, legacyDefaultRecordDir)
|
||||||
s.RecordDir = filepath.Clean(strings.TrimSpace(s.RecordDir))
|
s.DoneDir = normalizeSettingsDir(s.DoneDir, defaultDoneDir, legacyDefaultDoneDir)
|
||||||
}
|
|
||||||
if strings.TrimSpace(s.DoneDir) != "" {
|
|
||||||
s.DoneDir = filepath.Clean(strings.TrimSpace(s.DoneDir))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(s.FFmpegPath) != "" {
|
if strings.TrimSpace(s.FFmpegPath) != "" {
|
||||||
s.FFmpegPath = 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
|
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'
|
type Provider = 'chaturbate' | 'mfc'
|
||||||
|
|
||||||
function getProviderFromNormalizedUrl(normUrl: string): Provider | null {
|
function getProviderFromNormalizedUrl(normUrl: string): Provider | null {
|
||||||
@ -4009,6 +4028,7 @@ export default function App() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
let inFlight = false
|
let inFlight = false
|
||||||
let timer: number | null = null
|
let timer: number | null = null
|
||||||
|
let listenersAttached = false
|
||||||
|
|
||||||
const checkClipboard = async () => {
|
const checkClipboard = async () => {
|
||||||
if (cancelled || inFlight) return
|
if (cancelled || inFlight) return
|
||||||
@ -4041,6 +4061,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kick = () => {
|
||||||
|
void checkClipboard()
|
||||||
|
}
|
||||||
|
|
||||||
const schedule = (ms: number) => {
|
const schedule = (ms: number) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
timer = window.setTimeout(async () => {
|
timer = window.setTimeout(async () => {
|
||||||
@ -4049,20 +4073,26 @@ export default function App() {
|
|||||||
}, ms)
|
}, ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
const kick = () => {
|
const startClipboardPolling = async () => {
|
||||||
void checkClipboard()
|
const allowed = await canReadClipboardWithoutPrompt()
|
||||||
|
if (cancelled || !allowed) return
|
||||||
|
|
||||||
|
listenersAttached = true
|
||||||
|
window.addEventListener('focus', kick)
|
||||||
|
document.addEventListener('visibilitychange', kick)
|
||||||
|
|
||||||
|
schedule(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('focus', kick)
|
void startClipboardPolling()
|
||||||
document.addEventListener('visibilitychange', kick)
|
|
||||||
|
|
||||||
schedule(0)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
if (timer) window.clearTimeout(timer)
|
if (timer) window.clearTimeout(timer)
|
||||||
window.removeEventListener('focus', kick)
|
if (listenersAttached) {
|
||||||
document.removeEventListener('visibilitychange', kick)
|
window.removeEventListener('focus', kick)
|
||||||
|
document.removeEventListener('visibilitychange', kick)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
||||||
|
|
||||||
|
|||||||
@ -322,12 +322,29 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
|
|||||||
ta.style.position = 'fixed'
|
ta.style.position = 'fixed'
|
||||||
ta.style.opacity = '0'
|
ta.style.opacity = '0'
|
||||||
ta.style.pointerEvents = 'none'
|
ta.style.pointerEvents = 'none'
|
||||||
|
ta.style.left = '-9999px'
|
||||||
|
ta.style.top = '0'
|
||||||
document.body.appendChild(ta)
|
document.body.appendChild(ta)
|
||||||
ta.focus()
|
ta.focus({ preventScroll: true })
|
||||||
ta.select()
|
ta.select()
|
||||||
const ok = document.execCommand('copy')
|
|
||||||
document.body.removeChild(ta)
|
try {
|
||||||
return ok
|
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 {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -281,8 +281,8 @@ function SettingsSection(props: {
|
|||||||
const DEFAULTS: RecorderSettings = {
|
const DEFAULTS: RecorderSettings = {
|
||||||
databaseUrl: '',
|
databaseUrl: '',
|
||||||
hasDbPassword: false,
|
hasDbPassword: false,
|
||||||
recordDir: 'records',
|
recordDir: 'record',
|
||||||
doneDir: 'records/done',
|
doneDir: 'record/done',
|
||||||
ffmpegPath: '',
|
ffmpegPath: '',
|
||||||
autoAddToDownloadList: true,
|
autoAddToDownloadList: true,
|
||||||
autoStartAddedDownloads: true,
|
autoStartAddedDownloads: true,
|
||||||
@ -517,6 +517,32 @@ function cleanupTaskTitle(rawId: string) {
|
|||||||
return 'Aufräumen'
|
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 =
|
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'
|
'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 [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
|
||||||
const saveSuccessTimerRef = useRef<number | null>(null)
|
const saveSuccessTimerRef = useRef<number | null>(null)
|
||||||
const appLogPreRef = useRef<HTMLPreElement | null>(null)
|
const appLogPreRef = useRef<HTMLPreElement | null>(null)
|
||||||
|
const dbRestoreInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
||||||
const [msg, setMsg] = useState<string | null>(null)
|
const [msg, setMsg] = useState<string | null>(null)
|
||||||
const [err, setErr] = 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 [dbModalOpen, setDbModalOpen] = useState(false)
|
||||||
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
|
const [loadedDatabaseUrl, setLoadedDatabaseUrl] = useState('')
|
||||||
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
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 now = Date.now()
|
||||||
const saveSucceeded = saveSuccessUntilMs > now
|
const saveSucceeded = saveSuccessUntilMs > now
|
||||||
const saveFailed = Boolean(err) && !saving && !saveSucceeded
|
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 [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
||||||
const [securityBusy, setSecurityBusy] = useState(false)
|
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() {
|
async function save() {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
setMsg(null)
|
setMsg(null)
|
||||||
@ -2016,6 +2150,68 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user