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 (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
github.com/pquerna/otp v1.5.0 // indirect
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
@ -20,6 +24,7 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
|
||||||
github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080=
|
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 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
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=
|
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/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/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.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 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
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/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-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// routes.go (package main)
|
// routes.go (package main)
|
||||||
@ -71,12 +73,17 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/tasks/assets/stream", assetsStream)
|
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 {
|
if err := store.Load(); err != nil {
|
||||||
fmt.Println("⚠️ models load:", err)
|
fmt.Println("⚠️ models load:", err)
|
||||||
}
|
}
|
||||||
@ -109,3 +116,74 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
|
|
||||||
return store
|
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)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("❌ auth init:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
store := registerRoutes(mux, auth)
|
store := registerRoutes(mux, auth)
|
||||||
|
|
||||||
go startChaturbateOnlinePoller(store)
|
go startChaturbateOnlinePoller(store)
|
||||||
|
|||||||
@ -15,6 +15,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type RecorderSettings struct {
|
type RecorderSettings struct {
|
||||||
|
DatabaseURL string `json:"databaseUrl"`
|
||||||
|
EncryptedDBPassword string `json:"encryptedDbPassword,omitempty"` // base64(nonce+ciphertext)
|
||||||
|
|
||||||
RecordDir string `json:"recordDir"`
|
RecordDir string `json:"recordDir"`
|
||||||
DoneDir string `json:"doneDir"`
|
DoneDir string `json:"doneDir"`
|
||||||
FFmpegPath string `json:"ffmpegPath"`
|
FFmpegPath string `json:"ffmpegPath"`
|
||||||
@ -41,6 +44,9 @@ type RecorderSettings struct {
|
|||||||
var (
|
var (
|
||||||
settingsMu sync.Mutex
|
settingsMu sync.Mutex
|
||||||
settings = RecorderSettings{
|
settings = RecorderSettings{
|
||||||
|
DatabaseURL: "",
|
||||||
|
EncryptedDBPassword: "",
|
||||||
|
|
||||||
RecordDir: "/records",
|
RecordDir: "/records",
|
||||||
DoneDir: "/records/done",
|
DoneDir: "/records/done",
|
||||||
FFmpegPath: "",
|
FFmpegPath: "",
|
||||||
@ -66,10 +72,7 @@ var (
|
|||||||
|
|
||||||
func settingsFilePath() string {
|
func settingsFilePath() string {
|
||||||
// optionaler Override per ENV
|
// optionaler Override per ENV
|
||||||
name := strings.TrimSpace(os.Getenv("RECORDER_SETTINGS_FILE"))
|
name := settingsFile
|
||||||
if name == "" {
|
|
||||||
name = settingsFile
|
|
||||||
}
|
|
||||||
// Standard: relativ zur EXE / App-Dir (oder fallback auf Working Dir bei go run)
|
// Standard: relativ zur EXE / App-Dir (oder fallback auf Working Dir bei go run)
|
||||||
if p, err := resolvePathRelativeToApp(name); err == nil && strings.TrimSpace(p) != "" {
|
if p, err := resolvePathRelativeToApp(name); err == nil && strings.TrimSpace(p) != "" {
|
||||||
return p
|
return p
|
||||||
@ -121,6 +124,22 @@ func loadSettings() {
|
|||||||
settings = s
|
settings = s
|
||||||
settingsMu.Unlock()
|
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
|
// Ordner sicherstellen
|
||||||
@ -162,16 +181,71 @@ func saveSettingsToDisk() {
|
|||||||
// fmt.Println("✅ settings saved:", p)
|
// 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) {
|
func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_ = json.NewEncoder(w).Encode(getSettings())
|
_ = json.NewEncoder(w).Encode(toPublicSettings(getSettings()))
|
||||||
return
|
return
|
||||||
|
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
var in RecorderSettings
|
var in RecorderSettingsIn
|
||||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@ -234,9 +308,36 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// ✅ Settings im RAM aktualisieren
|
||||||
settingsMu.Lock()
|
settingsMu.Lock()
|
||||||
settings = in
|
settings = in.RecorderSettings
|
||||||
settingsMu.Unlock()
|
settingsMu.Unlock()
|
||||||
|
|
||||||
// ✅ Settings auf Disk persistieren
|
// ✅ 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("Content-Type", "application/json")
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
_ = json.NewEncoder(w).Encode(getSettings())
|
_ = json.NewEncoder(w).Encode(toPublicSettings(getSettings()))
|
||||||
return
|
return
|
||||||
|
|
||||||
default:
|
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 Task from './Task'
|
||||||
import TaskList from './TaskList'
|
import TaskList from './TaskList'
|
||||||
import type { TaskItem } 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 = {
|
type RecorderSettings = {
|
||||||
|
databaseUrl?: string
|
||||||
|
hasDbPassword?: boolean
|
||||||
recordDir: string
|
recordDir: string
|
||||||
doneDir: string
|
doneDir: string
|
||||||
ffmpegPath?: string
|
ffmpegPath?: string
|
||||||
@ -37,6 +42,8 @@ type DiskStatus = {
|
|||||||
|
|
||||||
|
|
||||||
const DEFAULTS: RecorderSettings = {
|
const DEFAULTS: RecorderSettings = {
|
||||||
|
databaseUrl: '',
|
||||||
|
hasDbPassword: false,
|
||||||
recordDir: 'records',
|
recordDir: 'records',
|
||||||
doneDir: 'records/done',
|
doneDir: 'records/done',
|
||||||
ffmpegPath: '',
|
ffmpegPath: '',
|
||||||
@ -66,13 +73,55 @@ function shortTaskFilename(name?: string, max = 52) {
|
|||||||
export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
||||||
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
const [value, setValue] = useState<RecorderSettings>(DEFAULTS)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState<number>(0)
|
||||||
|
const saveSuccessTimerRef = useRef<number | null>(null)
|
||||||
const [cleaning, setCleaning] = useState(false)
|
const [cleaning, setCleaning] = useState(false)
|
||||||
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)
|
||||||
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
|
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
|
||||||
// ✅ Tasklist (Assets generieren)
|
|
||||||
const assetsAbortRef = useRef<AbortController | null>(null)
|
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>({
|
const [assetsTask, setAssetsTask] = useState<TaskItem>({
|
||||||
id: 'generate-assets',
|
id: 'generate-assets',
|
||||||
@ -96,6 +145,14 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
|
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
|
||||||
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
|
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveSuccessTimerRef.current != null) {
|
||||||
|
window.clearTimeout(saveSuccessTimerRef.current)
|
||||||
|
saveSuccessTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
@ -107,6 +164,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
.then((data: RecorderSettings) => {
|
.then((data: RecorderSettings) => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
setValue({
|
setValue({
|
||||||
|
databaseUrl: String((data as any).databaseUrl ?? ''),
|
||||||
|
hasDbPassword: Boolean((data as any).hasDbPassword ?? false),
|
||||||
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
|
recordDir: (data.recordDir || DEFAULTS.recordDir).toString(),
|
||||||
doneDir: (data.doneDir || DEFAULTS.doneDir).toString(),
|
doneDir: (data.doneDir || DEFAULTS.doneDir).toString(),
|
||||||
ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''),
|
ffmpegPath: String(data.ffmpegPath ?? DEFAULTS.ffmpegPath ?? ''),
|
||||||
@ -193,6 +252,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const recordDir = value.recordDir.trim()
|
const recordDir = value.recordDir.trim()
|
||||||
const doneDir = value.doneDir.trim()
|
const doneDir = value.doneDir.trim()
|
||||||
const ffmpegPath = (value.ffmpegPath ?? '').trim()
|
const ffmpegPath = (value.ffmpegPath ?? '').trim()
|
||||||
|
const databaseUrl = String((value as any).databaseUrl ?? '').trim()
|
||||||
|
|
||||||
if (!recordDir || !doneDir) {
|
if (!recordDir || !doneDir) {
|
||||||
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
|
setErr('Bitte Aufnahme-Ordner und Ziel-Ordner angeben.')
|
||||||
@ -225,6 +285,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
databaseUrl,
|
||||||
|
dbPassword: pendingDbPassword || undefined,
|
||||||
recordDir,
|
recordDir,
|
||||||
doneDir,
|
doneDir,
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
@ -246,8 +308,24 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
throw new Error(t || `HTTP ${res.status}`)
|
throw new Error(t || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
setMsg('✅ Gespeichert.')
|
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'))
|
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
setSaveSuccessUntilMs(0)
|
||||||
|
if (saveSuccessTimerRef.current != null) {
|
||||||
|
window.clearTimeout(saveSuccessTimerRef.current)
|
||||||
|
saveSuccessTimerRef.current = null
|
||||||
|
}
|
||||||
setErr(e?.message ?? String(e))
|
setErr(e?.message ?? String(e))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@ -370,16 +448,58 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
header={
|
header={
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-white">Einstellungen</div>
|
<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">
|
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
|
||||||
Recorder-Konfiguration, Automatisierung und Tasks.
|
Recorder-Konfiguration, Automatisierung und Tasks.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" onClick={save} disabled={saving}>
|
|
||||||
Speichern
|
{/* Rechts: Alerts + Button */}
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
grayBody
|
grayBody
|
||||||
@ -392,17 +512,21 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Alerts */}
|
{/* Alerts (mobile) */}
|
||||||
{err && (
|
{saveUiState !== 'success' ? (
|
||||||
<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">
|
<div className="sm:hidden">
|
||||||
{err}
|
{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>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Aufgaben */}
|
{/* Aufgaben */}
|
||||||
<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="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.
|
Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-3">
|
||||||
{/* Aufnahme-Ordner */}
|
{/* Aufnahme-Ordner */}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user