updated for postgres

This commit is contained in:
Linrador 2026-03-03 10:11:30 +01:00
parent bdf14f8940
commit 4d69c90722
14 changed files with 1143 additions and 612 deletions

130
backend/crypto.go Normal file
View File

@ -0,0 +1,130 @@
// backend\crypto.go
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"os"
"path/filepath"
"strings"
)
// Wir speichern den Key neben der EXE, damit encryption nach Neustart weiterhin entschlüsselbar ist.
// Datei enthält base64(32 bytes).
const settingsKeyFile = "recorder_settings.key"
func settingsKeyPath() string {
if p, err := resolvePathRelativeToApp(settingsKeyFile); err == nil && strings.TrimSpace(p) != "" {
return p
}
return settingsKeyFile
}
func loadOrCreateSettingsKey() ([]byte, error) {
p := settingsKeyPath()
// Load existing
if b, err := os.ReadFile(p); err == nil {
s := strings.TrimSpace(string(b))
if s == "" {
return nil, errors.New("settings key file ist leer")
}
key, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, err
}
if len(key) != 32 {
return nil, errors.New("settings key muss 32 bytes sein")
}
return key, nil
}
// Create new
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, err
}
enc := base64.StdEncoding.EncodeToString(key)
// Ordner sicherstellen
if dir := filepath.Dir(p); dir != "." && strings.TrimSpace(dir) != "" {
_ = os.MkdirAll(dir, 0o755)
}
// best effort: Datei schreiben (für Windows perms gibt's hier keinen sauberen cross-platform chmod)
if err := atomicWriteFile(p, []byte(enc+"\n")); err != nil {
return nil, err
}
return key, nil
}
func encryptSettingString(plain string) (string, error) {
key, err := loadOrCreateSettingsKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ct := gcm.Seal(nil, nonce, []byte(plain), nil)
out := append(nonce, ct...)
return base64.StdEncoding.EncodeToString(out), nil
}
func decryptSettingString(enc string) (string, error) {
enc = strings.TrimSpace(enc)
if enc == "" {
return "", nil
}
key, err := loadOrCreateSettingsKey()
if err != nil {
return "", err
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
ns := gcm.NonceSize()
if len(raw) < ns+1 {
return "", errors.New("ciphertext zu kurz")
}
nonce := raw[:ns]
ct := raw[ns:]
pt, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(pt), nil
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -11,6 +11,10 @@ require (
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/pquerna/otp v1.5.0 // indirect
@ -20,6 +24,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.33.0 // indirect
)
require (

View File

@ -17,6 +17,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -38,6 +46,7 @@ github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@ -121,6 +130,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@ -129,6 +140,8 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=

File diff suppressed because it is too large Load Diff

47
backend/postgres_url.go Normal file
View File

@ -0,0 +1,47 @@
// backend\postgres_url.go
package main
import (
"net/url"
"strings"
)
// stripPasswordFromPostgresURL:
// - wenn URL ein Passwort hat, wird es extrahiert und URL ohne Passwort zurückgegeben
// - unterstützt postgres:// und postgresql://
func stripPasswordFromPostgresURL(raw string) (sanitized string, password string) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", ""
}
low := strings.ToLower(raw)
if !strings.HasPrefix(low, "postgres://") && !strings.HasPrefix(low, "postgresql://") {
// nicht anfassen, evtl. nutzt du später andere DSNs
return raw, ""
}
u, err := url.Parse(raw)
if err != nil {
return raw, ""
}
if u.User == nil {
return raw, ""
}
user := u.User.Username()
pw, hasPw := u.User.Password()
if hasPw && strings.TrimSpace(pw) != "" {
password = pw
}
// Sanitized: nur user ohne password
if user != "" {
u.User = url.User(user)
} else {
u.User = nil
}
return u.String(), password
}

View File

@ -0,0 +1 @@
JHibTjjK7wKRyW/0ozJ0FUB2XLIhPlb0U8EO2y/f344=

View File

@ -5,6 +5,8 @@ package main
import (
"fmt"
"net/http"
"net/url"
"strings"
)
// routes.go (package main)
@ -71,12 +73,17 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/tasks/assets/stream", assetsStream)
// --------------------------
// 3) ModelStore
// 3) ModelStore (Postgres)
// DSN kommt aus Settings: databaseUrl + gespeichertes Passwort
// --------------------------
modelsPath, _ := resolvePathRelativeToApp("data/models_store.db")
fmt.Println("📦 Models DB:", modelsPath)
store := NewModelStore(modelsPath)
dsn, err := buildPostgresDSNFromSettings()
if err != nil {
fmt.Println("⚠️ models DSN:", err)
}
fmt.Println("📦 Models DB (Postgres):", sanitizeDSNForLog(dsn))
store := NewModelStore(dsn)
if err := store.Load(); err != nil {
fmt.Println("⚠️ models load:", err)
}
@ -109,3 +116,74 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
return store
}
// buildPostgresDSNFromSettings baut eine DSN aus den Recorder-Settings:
// - databaseUrl kann bereits "postgres://user:pass@host/db" sein
// - oder ohne Passwort, dann wird das verschlüsselt gespeicherte Passwort eingesetzt
func buildPostgresDSNFromSettings() (string, error) {
// Settings sind bereits durch loadSettings() in main() geladen.
s := getSettings()
dbURL := strings.TrimSpace(s.DatabaseURL)
if dbURL == "" {
return "", fmt.Errorf("databaseUrl ist leer")
}
// Wenn databaseUrl ein Passwort enthält: verwenden,
// aber gleichzeitig safe sein, falls da Altbestand drin ist.
u, err := url.Parse(dbURL)
if err != nil {
return "", fmt.Errorf("databaseUrl ungültig: %w", err)
}
// 1) Wenn URL bereits Passwort enthält -> direkt verwenden
if u.User != nil {
if _, hasPw := u.User.Password(); hasPw {
return u.String(), nil
}
}
// 2) Passwort fehlt -> aus EncryptedDBPassword holen
enc := strings.TrimSpace(s.EncryptedDBPassword)
if enc == "" {
// kein Passwort gespeichert -> URL ohne Passwort ist ok (z.B. trust/peer auth)
return u.String(), nil
}
plainPw, err := decryptSettingString(enc)
if err != nil {
return "", fmt.Errorf("db password decrypt failed: %w", err)
}
plainPw = strings.TrimSpace(plainPw)
if plainPw == "" {
return u.String(), nil
}
// 3) Username muss in databaseUrl vorhanden sein, sonst kann man kein Passwort einsetzen
user := ""
if u.User != nil {
user = u.User.Username()
}
if strings.TrimSpace(user) == "" {
return "", fmt.Errorf("databaseUrl enthält keinen Username, kann Passwort nicht einsetzen")
}
u.User = url.UserPassword(user, plainPw)
return u.String(), nil
}
// sanitizeDSNForLog entfernt Passwort aus DSN, damit du es gefahrlos loggen kannst.
func sanitizeDSNForLog(dsn string) string {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return ""
}
u, err := url.Parse(dsn)
if err != nil {
return "<invalid dsn>"
}
if u.User != nil {
u.User = url.UserPassword(u.User.Username(), "****")
}
return u.String()
}

View File

@ -31,11 +31,6 @@ func main() {
os.Exit(1)
}
if err != nil {
fmt.Println("❌ auth init:", err)
os.Exit(1)
}
store := registerRoutes(mux, auth)
go startChaturbateOnlinePoller(store)

View File

@ -15,6 +15,9 @@ import (
)
type RecorderSettings struct {
DatabaseURL string `json:"databaseUrl"`
EncryptedDBPassword string `json:"encryptedDbPassword,omitempty"` // base64(nonce+ciphertext)
RecordDir string `json:"recordDir"`
DoneDir string `json:"doneDir"`
FFmpegPath string `json:"ffmpegPath"`
@ -41,6 +44,9 @@ type RecorderSettings struct {
var (
settingsMu sync.Mutex
settings = RecorderSettings{
DatabaseURL: "",
EncryptedDBPassword: "",
RecordDir: "/records",
DoneDir: "/records/done",
FFmpegPath: "",
@ -66,10 +72,7 @@ var (
func settingsFilePath() string {
// optionaler Override per ENV
name := strings.TrimSpace(os.Getenv("RECORDER_SETTINGS_FILE"))
if name == "" {
name = settingsFile
}
name := settingsFile
// Standard: relativ zur EXE / App-Dir (oder fallback auf Working Dir bei go run)
if p, err := resolvePathRelativeToApp(name); err == nil && strings.TrimSpace(p) != "" {
return p
@ -121,6 +124,22 @@ func loadSettings() {
settings = s
settingsMu.Unlock()
}
s.DatabaseURL = strings.TrimSpace(s.DatabaseURL)
// Optional: falls in der JSON mal ein URL MIT Passwort steht (Altbestand)
// -> Passwort extrahieren und verschlüsselt ablegen (nur wenn noch keins gesetzt ist)
if s.DatabaseURL != "" && strings.TrimSpace(s.EncryptedDBPassword) == "" {
sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(s.DatabaseURL)
if sanitizedURL != "" {
s.DatabaseURL = sanitizedURL
}
if strings.TrimSpace(pwFromURL) != "" {
if enc, err := encryptSettingString(strings.TrimSpace(pwFromURL)); err == nil {
s.EncryptedDBPassword = enc
}
}
}
}
// Ordner sicherstellen
@ -162,16 +181,71 @@ func saveSettingsToDisk() {
// fmt.Println("✅ settings saved:", p)
}
type RecorderSettingsPublic struct {
RecordDir string `json:"recordDir"`
DoneDir string `json:"doneDir"`
FFmpegPath string `json:"ffmpegPath"`
DatabaseURL string `json:"databaseUrl"`
HasDBPassword bool `json:"hasDbPassword"`
AutoAddToDownloadList bool `json:"autoAddToDownloadList"`
AutoStartAddedDownloads bool `json:"autoStartAddedDownloads"`
UseChaturbateAPI bool `json:"useChaturbateApi"`
UseMyFreeCamsWatcher bool `json:"useMyFreeCamsWatcher"`
AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"`
AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"`
BlurPreviews bool `json:"blurPreviews"`
TeaserPlayback string `json:"teaserPlayback"`
TeaserAudio bool `json:"teaserAudio"`
EnableNotifications bool `json:"enableNotifications"`
}
func toPublicSettings(s RecorderSettings) RecorderSettingsPublic {
return RecorderSettingsPublic{
RecordDir: s.RecordDir,
DoneDir: s.DoneDir,
FFmpegPath: s.FFmpegPath,
DatabaseURL: strings.TrimSpace(s.DatabaseURL),
HasDBPassword: strings.TrimSpace(s.EncryptedDBPassword) != "",
AutoAddToDownloadList: s.AutoAddToDownloadList,
AutoStartAddedDownloads: s.AutoStartAddedDownloads,
UseChaturbateAPI: s.UseChaturbateAPI,
UseMyFreeCamsWatcher: s.UseMyFreeCamsWatcher,
AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads,
AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB,
BlurPreviews: s.BlurPreviews,
TeaserPlayback: s.TeaserPlayback,
TeaserAudio: s.TeaserAudio,
EnableNotifications: s.EnableNotifications,
}
}
type RecorderSettingsIn struct {
RecorderSettings
DBPassword string `json:"dbPassword,omitempty"` // nur vom Frontend, NIE auf Disk speichern
}
func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(getSettings())
_ = json.NewEncoder(w).Encode(toPublicSettings(getSettings()))
return
case http.MethodPost:
var in RecorderSettings
var in RecorderSettingsIn
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
return
@ -234,9 +308,36 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
return
}
// --- DB URL + Passwort behandeln ---
// 1) Trim
in.DatabaseURL = strings.TrimSpace(in.DatabaseURL)
// 2) Migration: wenn in.DatabaseURL ein Passwort enthält, extrahieren
// und URL ohne Passwort zurückschreiben.
sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(in.DatabaseURL)
if sanitizedURL != "" {
in.DatabaseURL = sanitizedURL
}
// 3) Wenn Frontend ein Passwort sendet, hat das Priorität.
plainPW := strings.TrimSpace(in.DBPassword)
if plainPW == "" {
plainPW = pwFromURL
}
// 4) Wenn wir ein neues Passwort haben: encrypten & speichern (nur encrypted!)
if plainPW != "" {
enc, err := encryptSettingString(plainPW)
if err != nil {
http.Error(w, "konnte DB-Passwort nicht verschlüsseln: "+err.Error(), http.StatusInternalServerError)
return
}
in.EncryptedDBPassword = enc
}
// ✅ Settings im RAM aktualisieren
settingsMu.Lock()
settings = in
settings = in.RecorderSettings
settingsMu.Unlock()
// ✅ Settings auf Disk persistieren
@ -256,7 +357,7 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(getSettings())
_ = json.NewEncoder(w).Encode(toPublicSettings(getSettings()))
return
default:

View File

@ -0,0 +1,182 @@
// frontend\src\components\ui\PostgresUrlModal.tsx
'use client'
import { useMemo, useState } from 'react'
import Modal from './Modal'
import Button from './Button'
type Props = {
open: boolean
onClose: () => void
initialUrl?: string
initialHasPassword?: boolean
onApply: (v: { databaseUrl: string; dbPassword: string }) => void
}
function safeInt(v: any, fallback: number) {
const n = Number(v)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
}
function buildPostgresUrl(opts: {
user: string
host: string
port: number
db: string
sslmode: string
}) {
const user = (opts.user || '').trim() || 'postgres'
const host = (opts.host || '').trim() || '127.0.0.1'
const port = safeInt(opts.port, 5432)
const db = (opts.db || '').trim() || 'postgres'
const sslmode = (opts.sslmode || '').trim() || 'disable'
// Passwort absichtlich NICHT in URL
return `postgres://${encodeURIComponent(user)}@${host}:${port}/${encodeURIComponent(db)}?sslmode=${encodeURIComponent(
sslmode
)}`
}
export default function PostgresUrlModal({
open,
onClose,
initialUrl,
initialHasPassword,
onApply,
}: Props) {
// Defaults (du kannst später optional initialUrl parsen, ist aber nicht nötig)
const [host, setHost] = useState('127.0.0.1')
const [port, setPort] = useState(5432)
const [db, setDb] = useState('nsfwapp')
const [user, setUser] = useState('postgres')
const [password, setPassword] = useState('')
const [sslmode, setSslmode] = useState<'disable' | 'require' | 'verify-full'>('disable')
const preview = useMemo(() => buildPostgresUrl({ user, host, port, db, sslmode }), [user, host, port, db, sslmode])
const footer = (
<>
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="primary"
onClick={() => {
onApply({ databaseUrl: preview, dbPassword: password })
onClose()
}}
>
Übernehmen
</Button>
</>
)
return (
<Modal
open={open}
onClose={onClose}
title="Postgres URL erstellen"
width="max-w-2xl"
footer={footer}
>
<div className="px-4 pb-4 sm:px-6 sm:pb-6 space-y-4">
<div className="text-xs text-gray-600 dark:text-gray-300">
Passwort wird <span className="font-semibold">verschlüsselt</span> gespeichert (nicht in der URL).
{initialUrl ? (
<>
<div className="mt-1">
Aktuelle URL: <code className="break-all">{initialUrl}</code>
</div>
</>
) : null}
{initialHasPassword ? <div className="mt-1">Passwort: gespeichert</div> : null}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">Host</label>
<input
value={host}
onChange={(e) => setHost(e.target.value)}
className="sm:col-span-9 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="127.0.0.1"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">Port</label>
<input
type="number"
min={1}
max={65535}
value={port}
onChange={(e) => setPort(safeInt(e.target.value, 5432))}
className="sm:col-span-9 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">Datenbank</label>
<input
value={db}
onChange={(e) => setDb(e.target.value)}
className="sm:col-span-9 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="nsfwapp"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">User</label>
<input
value={user}
onChange={(e) => setUser(e.target.value)}
className="sm:col-span-9 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="postgres"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">Passwort</label>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
className="sm:col-span-9 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="••••••••"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">SSL</label>
<select
value={sslmode}
onChange={(e) => setSslmode(e.target.value as any)}
className="sm:col-span-9 h-10 rounded-lg px-3 text-sm bg-white text-gray-900 ring-1 ring-gray-200 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]"
>
<option value="disable">disable</option>
<option value="require">require</option>
<option value="verify-full">verify-full</option>
</select>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-200">
<div className="font-semibold mb-1">Vorschau</div>
<code className="break-all">{preview}</code>
</div>
</div>
</Modal>
)
}

View File

@ -8,8 +8,13 @@ import LabeledSwitch from './LabeledSwitch'
import Task from './Task'
import TaskList from './TaskList'
import type { TaskItem } from './TaskList'
import PostgresUrlModal from './PostgresUrlModal'
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid'
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'
type RecorderSettings = {
databaseUrl?: string
hasDbPassword?: boolean
recordDir: string
doneDir: string
ffmpegPath?: string
@ -37,6 +42,8 @@ type DiskStatus = {
const DEFAULTS: RecorderSettings = {
databaseUrl: '',
hasDbPassword: false,
recordDir: 'records',
doneDir: 'records/done',
ffmpegPath: '',
@ -66,13 +73,55 @@ function shortTaskFilename(name?: string, max = 52) {
export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
const [saving, setSaving] = useState(false)
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
const saveSuccessTimerRef = useRef<number | null>(null)
const [cleaning, setCleaning] = useState(false)
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
// ✅ Tasklist (Assets generieren)
const assetsAbortRef = useRef<AbortController | null>(null)
const [dbModalOpen, setDbModalOpen] = useState(false)
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
const now = Date.now()
const saveSucceeded = saveSuccessUntilMs > now
const saveFailed = Boolean(err) && !saving && !saveSucceeded
type SaveUiState = 'idle' | 'saving' | 'success' | 'error'
const saveUiState: SaveUiState = saving ? 'saving' : saveSucceeded ? 'success' : saveFailed ? 'error' : 'idle'
const saveButton = (() => {
if (saveUiState === 'saving') {
return {
text: 'Speichern…',
color: 'blue' as const,
icon: null, // Spinner kommt über isLoading
isLoading: true,
}
}
if (saveUiState === 'success') {
return {
text: 'Gespeichert',
color: 'emerald' as const,
icon: <CheckIcon className="size-4" />,
isLoading: false,
}
}
if (saveUiState === 'error') {
return {
text: 'Fehler',
color: 'red' as const,
icon: <XMarkIcon className="size-4" />,
isLoading: false,
}
}
return {
text: 'Speichern',
color: 'indigo' as const,
icon: null,
isLoading: false,
}
})()
const [assetsTask, setAssetsTask] = useState<TaskItem>({
id: 'generate-assets',
@ -96,6 +145,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
useEffect(() => {
return () => {
if (saveSuccessTimerRef.current != null) {
window.clearTimeout(saveSuccessTimerRef.current)
saveSuccessTimerRef.current = null
}
}
}, [])
useEffect(() => {
let alive = true
@ -107,6 +164,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
.then((data: RecorderSettings) => {
if (!alive) return
setValue({
databaseUrl: String((data as any).databaseUrl ?? ''),
hasDbPassword: Boolean((data as any).hasDbPassword ?? false),
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
doneDir: (data.doneDir || DEFAULTS.doneDir).toString(),
ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''),
@ -193,6 +252,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const recordDir = value.recordDir.trim()
const doneDir = value.doneDir.trim()
const ffmpegPath = (value.ffmpegPath ?? '').trim()
const databaseUrl = String((value as any).databaseUrl ?? '').trim()
if (!recordDir || !doneDir) {
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
@ -225,6 +285,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
databaseUrl,
dbPassword: pendingDbPassword || undefined,
recordDir,
doneDir,
ffmpegPath,
@ -246,8 +308,24 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
throw new Error(t || `HTTP ${res.status}`)
}
setMsg('✅ Gespeichert.')
// Button-Label: "Gespeichert" für 2.5s
const until = Date.now() + 2500
setSaveSuccessUntilMs(until)
if (saveSuccessTimerRef.current != null) {
window.clearTimeout(saveSuccessTimerRef.current)
}
saveSuccessTimerRef.current = window.setTimeout(() => {
setSaveSuccessUntilMs(0)
saveSuccessTimerRef.current = null
}, 2500)
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
} catch (e: any) {
setSaveSuccessUntilMs(0)
if (saveSuccessTimerRef.current != null) {
window.clearTimeout(saveSuccessTimerRef.current)
saveSuccessTimerRef.current = null
}
setErr(e?.message ?? String(e))
} finally {
setSaving(false)
@ -370,16 +448,58 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
return (
<Card
header={
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Recorder-Konfiguration, Automatisierung und Tasks.
</div>
</div>
<Button variant="primary" onClick={save} disabled={saving}>
Speichern
</Button>
{/* Rechts: Alerts + Button */}
<div className="flex items-start gap-2">
{/* Alerts links neben Button */}
{saveUiState !== 'success' ? (
<div className="hidden sm:flex min-w-0 max-w-[520px] items-stretch">
{err ? (
<div
className="
inline-flex items-center
px-3 py-[7px] text-sm
rounded-md border border-red-200 bg-red-50 text-red-700
dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200
"
>
{err}
</div>
) : msg ? (
<div
className="
inline-flex items-center
px-3 py-[7px] text-sm
rounded-md border border-green-200 bg-green-50 text-green-700
dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200
"
>
{msg}
</div>
) : null}
</div>
) : null}
<Button
variant="primary"
color={saveButton.color}
onClick={save}
disabled={saving}
className="shrink-0"
size="md"
isLoading={saveButton.isLoading}
leadingIcon={saveButton.icon}
>
{saveButton.text}
</Button>
</div>
</div>
}
grayBody
@ -392,17 +512,21 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}}
/>
{/* Alerts */}
{err && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{err}
{/* Alerts (mobile) */}
{saveUiState !== 'success' ? (
<div className="sm:hidden">
{err && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{err}
</div>
)}
{msg && (
<div className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200">
{msg}
</div>
)}
</div>
)}
{msg && (
<div className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200">
{msg}
</div>
)}
) : null}
{/* Aufgaben */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
@ -502,6 +626,50 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
</div>
</div>
{/* Datenbank */}
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="mb-3">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Datenbank</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Postgres Verbindung. Passwort wird verschlüsselt gespeichert.
</div>
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<label className="text-sm font-medium text-gray-900 dark:text-gray-200 sm:col-span-3">
Database URL
</label>
<div className="sm:col-span-9 flex gap-2">
<input
value={String((value as any).databaseUrl ?? '')}
onChange={(e) => setValue((v) => ({ ...v, databaseUrl: e.target.value }))}
placeholder="postgres://user@host:5432/db?sslmode=disable"
className="min-w-0 flex-1 rounded-lg px-3 py-2 text-sm bg-white text-gray-900 ring-1 ring-gray-200
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:bg-white/10 dark:text-white dark:ring-white/10"
/>
<Button variant="secondary" onClick={() => setDbModalOpen(true)} disabled={saving}>
URL erstellen
</Button>
</div>
</div>
</div>
</div>
<PostgresUrlModal
open={dbModalOpen}
onClose={() => setDbModalOpen(false)}
initialUrl={String((value as any).databaseUrl ?? '')}
initialHasPassword={Boolean((value as any).hasDbPassword)}
onApply={({ databaseUrl, dbPassword }) => {
setValue((v) => ({ ...v, databaseUrl }))
setPendingDbPassword(dbPassword || '')
}}
/>
<div className="space-y-3">
{/* Aufnahme-Ordner */}