updated for postgres
This commit is contained in:
parent
bdf14f8940
commit
4d69c90722
130
backend/crypto.go
Normal file
130
backend/crypto.go
Normal 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.
@ -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 (
|
||||
|
||||
@ -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
47
backend/postgres_url.go
Normal 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
|
||||
}
|
||||
1
backend/recorder_settings.key
Normal file
1
backend/recorder_settings.key
Normal file
@ -0,0 +1 @@
|
||||
JHibTjjK7wKRyW/0ozJ0FUB2XLIhPlb0U8EO2y/f344=
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
182
frontend/src/components/ui/PostgresUrlModal.tsx
Normal file
182
frontend/src/components/ui/PostgresUrlModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user