diff --git a/backend/crypto.go b/backend/crypto.go new file mode 100644 index 0000000..abfb020 --- /dev/null +++ b/backend/crypto.go @@ -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 +} diff --git a/backend/data/models_store.db b/backend/data/models_store.db deleted file mode 100644 index 3939b0b..0000000 Binary files a/backend/data/models_store.db and /dev/null differ diff --git a/backend/data/models_store.db-shm b/backend/data/models_store.db-shm deleted file mode 100644 index afb5364..0000000 Binary files a/backend/data/models_store.db-shm and /dev/null differ diff --git a/backend/data/models_store.db-wal b/backend/data/models_store.db-wal deleted file mode 100644 index 33765d7..0000000 Binary files a/backend/data/models_store.db-wal and /dev/null differ diff --git a/backend/go.mod b/backend/go.mod index e0de491..d5338bc 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ( diff --git a/backend/go.sum b/backend/go.sum index 005df21..2d35994 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/models_store.go b/backend/models_store.go index 536d93a..c41d25d 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -1,19 +1,16 @@ -// backend\models_store.go +// backend/models_store.go package main import ( "database/sql" - "encoding/json" "errors" "net/http" "net/url" - "os" - "path/filepath" "strings" "sync" "time" - _ "modernc.org/sqlite" + _ "github.com/jackc/pgx/v5/stdlib" ) type StoredModel struct { @@ -24,7 +21,7 @@ type StoredModel struct { Path string `json:"path,omitempty"` ModelKey string `json:"modelKey"` // Display/Key Tags string `json:"tags,omitempty"` - LastStream string `json:"lastStream,omitempty"` + LastStream string `json:"lastStream,omitempty"` // RFC3339Nano LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano @@ -38,8 +35,8 @@ type StoredModel struct { Keep bool `json:"keep"` Liked *bool `json:"liked,omitempty"` // null => unbekannt - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + CreatedAt string `json:"createdAt"` // RFC3339Nano + UpdatedAt string `json:"updatedAt"` // RFC3339Nano } type ModelsMeta struct { @@ -75,8 +72,7 @@ type ModelFlagsPatch struct { } type ModelStore struct { - dbPath string - legacyJSONPath string + dsn string db *sql.DB initOnce sync.Once @@ -86,6 +82,116 @@ type ModelStore struct { mu sync.Mutex } +func fmtTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339Nano) +} + +func fmtNullTime(nt sql.NullTime) string { + if !nt.Valid || nt.Time.IsZero() { + return "" + } + return nt.Time.UTC().Format(time.RFC3339Nano) +} + +func nullableTimeArg(nt sql.NullTime) any { + if !nt.Valid { + return nil + } + return nt.Time +} + +func ptrBoolFromNullBool(n sql.NullBool) *bool { + if !n.Valid { + return nil + } + v := n.Bool + return &v +} + +func ptrLikedFromNullBool(n sql.NullBool) *bool { + if !n.Valid { + return nil + } + v := n.Bool + return &v +} + +// parseRFC3339Nano: akzeptiert RFC3339/RFC3339Nano, sonst "invalid" -> (Valid=false) +func parseRFC3339Nano(s string) sql.NullTime { + s = strings.TrimSpace(s) + if s == "" { + return sql.NullTime{Valid: false} + } + // RFC3339Nano ist superset, aber manche Werte sind RFC3339 + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return sql.NullTime{Valid: true, Time: t.UTC()} + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return sql.NullTime{Valid: true, Time: t.UTC()} + } + return sql.NullTime{Valid: false} +} + +func NewModelStore(dsn string) *ModelStore { + return &ModelStore{dsn: strings.TrimSpace(dsn)} +} + +func (s *ModelStore) Load() error { return s.ensureInit() } + +func (s *ModelStore) ensureInit() error { + s.initOnce.Do(func() { + s.initErr = s.init() + }) + return s.initErr +} + +func (s *ModelStore) init() error { + if strings.TrimSpace(s.dsn) == "" { + return errors.New("db dsn fehlt") + } + + db, err := sql.Open("pgx", s.dsn) + if err != nil { + return err + } + + db.SetMaxOpenConns(5) + db.SetMaxIdleConns(5) + + if err := db.Ping(); err != nil { + _ = db.Close() + return err + } + + // ✅ Du hast die Tabelle schon in Postgres angelegt (mit richtigen Typen). + // Deshalb hier KEIN create/alter mehr, sonst riskierst du falsche Typen. + s.db = db + + if err := s.normalizeNameOnlyChaturbate(); err != nil { + return err + } + + return nil +} + +func canonicalHost(host string) string { + h := strings.ToLower(strings.TrimSpace(host)) + h = strings.TrimPrefix(h, "www.") + return h +} + +func canonicalID(host, modelKey string) string { + h := canonicalHost(host) + k := strings.TrimSpace(modelKey) + if h != "" { + return h + ":" + k + } + return k +} + func (s *ModelStore) EnsureByHostModelKey(host, modelKey string) (StoredModel, error) { if err := s.ensureInit(); err != nil { return StoredModel{}, err @@ -106,12 +212,12 @@ func (s *ModelStore) EnsureByHostModelKey(host, modelKey string) (StoredModel, e // 1) explizit host+key suchen var existingID string err := s.db.QueryRow(` - SELECT id - FROM models - WHERE lower(trim(host)) = lower(trim(?)) - AND lower(trim(model_key)) = lower(trim(?)) - LIMIT 1; - `, h, key).Scan(&existingID) +SELECT id +FROM models +WHERE lower(trim(host)) = lower(trim($1)) + AND lower(trim(model_key)) = lower(trim($2)) +LIMIT 1; +`, h, key).Scan(&existingID) if err == nil && existingID != "" { return s.getByID(existingID) @@ -120,8 +226,8 @@ func (s *ModelStore) EnsureByHostModelKey(host, modelKey string) (StoredModel, e return StoredModel{}, err } - // 2) nicht vorhanden -> "manual" anlegen (is_url=0, input=modelKey), ABER host gesetzt - now := time.Now().UTC().Format(time.RFC3339Nano) + // 2) nicht vorhanden -> "manual" anlegen (is_url=false, input=modelKey), ABER host gesetzt + now := time.Now().UTC() id := canonicalID(h, key) s.mu.Lock() @@ -133,15 +239,15 @@ INSERT INTO models ( tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at -) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO UPDATE SET - model_key=excluded.model_key, - host=excluded.host, - updated_at=excluded.updated_at; + model_key=EXCLUDED.model_key, + host=EXCLUDED.host, + updated_at=EXCLUDED.updated_at; `, - id, key, int64(0), h, "", key, - "", "", - int64(0), int64(0), int64(0), int64(0), nil, + id, key, false, h, "", key, + "", nil, + false, false, false, false, nil, now, now, ) if err != nil { @@ -166,16 +272,16 @@ func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) { var existingID string err := s.db.QueryRow(` - SELECT id - FROM models - WHERE lower(trim(model_key)) = lower(trim(?)) - ORDER BY - CASE WHEN is_url=1 THEN 1 ELSE 0 END DESC, - CASE WHEN host IS NOT NULL AND trim(host)<>'' THEN 1 ELSE 0 END DESC, - favorite DESC, - updated_at DESC - LIMIT 1; - `, key).Scan(&existingID) +SELECT id +FROM models +WHERE lower(trim(model_key)) = lower(trim($1)) +ORDER BY + CASE WHEN is_url=true THEN 1 ELSE 0 END DESC, + CASE WHEN host IS NOT NULL AND trim(host)<>'' THEN 1 ELSE 0 END DESC, + favorite DESC, + updated_at DESC +LIMIT 1; +`, key).Scan(&existingID) if err == nil && existingID != "" { return s.getByID(existingID) @@ -184,7 +290,7 @@ func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) { return StoredModel{}, err } - now := time.Now().UTC().Format(time.RFC3339Nano) + now := time.Now().UTC() id := canonicalID("", key) s.mu.Lock() @@ -196,14 +302,14 @@ INSERT INTO models ( tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at -) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO UPDATE SET - model_key=excluded.model_key, - updated_at=excluded.updated_at; + model_key=EXCLUDED.model_key, + updated_at=EXCLUDED.updated_at; `, - id, key, int64(0), "", "", key, - "", "", - int64(0), int64(0), int64(0), int64(0), nil, + id, key, false, "", "", key, + "", nil, + false, false, false, false, nil, now, now, ) if err != nil { @@ -221,7 +327,7 @@ func (s *ModelStore) FillMissingTagsFromChaturbateOnline(rooms []ChaturbateRoom) return } - now := time.Now().UTC().Format(time.RFC3339Nano) + now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() @@ -234,9 +340,9 @@ func (s *ModelStore) FillMissingTagsFromChaturbateOnline(rooms []ChaturbateRoom) stmt, err := tx.Prepare(` UPDATE models -SET tags = ?, updated_at = ? +SET tags = $1, updated_at = $2 WHERE lower(trim(host)) = 'chaturbate.com' - AND lower(trim(model_key)) = lower(trim(?)) + AND lower(trim(model_key)) = lower(trim($3)) AND (tags IS NULL OR trim(tags) = ''); `) if err != nil { @@ -259,248 +365,6 @@ WHERE lower(trim(host)) = 'chaturbate.com' _ = tx.Commit() } -// Backwards compatible: -// - wenn du ".json" übergibst, wird daraus automatisch ".db" -func NewModelStore(path string) *ModelStore { - path = strings.TrimSpace(path) - - lower := strings.ToLower(path) - dbPath := path - legacy := "" - - if strings.HasSuffix(lower, ".json") { - legacy = path - dbPath = strings.TrimSuffix(path, filepath.Ext(path)) + ".db" - } else if strings.HasSuffix(lower, ".db") || strings.HasSuffix(lower, ".sqlite") || strings.HasSuffix(lower, ".sqlite3") { - legacy = filepath.Join(filepath.Dir(path), "models_store.json") - } - - return &ModelStore{ - dbPath: dbPath, - legacyJSONPath: legacy, - } -} - -func (s *ModelStore) Load() error { return s.ensureInit() } - -func (s *ModelStore) ensureInit() error { - s.initOnce.Do(func() { - s.initErr = s.init() - }) - return s.initErr -} - -func (s *ModelStore) init() error { - if strings.TrimSpace(s.dbPath) == "" { - return errors.New("db path fehlt") - } - if err := os.MkdirAll(filepath.Dir(s.dbPath), 0o755); err != nil { - return err - } - - db, err := sql.Open("sqlite", s.dbPath) - if err != nil { - return err - } - - db.SetMaxOpenConns(5) - db.SetMaxIdleConns(5) - _, _ = db.Exec(`PRAGMA busy_timeout = 2500;`) - _, _ = db.Exec(`PRAGMA foreign_keys = ON;`) - _, _ = db.Exec(`PRAGMA journal_mode = WAL;`) - _, _ = db.Exec(`PRAGMA synchronous = NORMAL;`) - - if err := createModelsSchema(db); err != nil { - _ = db.Close() - return err - } - if err := ensureModelsColumns(db); err != nil { - _ = db.Close() - return err - } - - s.db = db - - if s.legacyJSONPath != "" { - if err := s.migrateFromJSONIfEmpty(); err != nil { - return err - } - } - - if err := s.normalizeNameOnlyChaturbate(); err != nil { - return err - } - - return nil -} - -func createModelsSchema(db *sql.DB) error { - _, err := db.Exec(` -CREATE TABLE IF NOT EXISTS models ( - id TEXT PRIMARY KEY, - input TEXT NOT NULL, - is_url INTEGER NOT NULL, - host TEXT, - path TEXT, - model_key TEXT NOT NULL, - tags TEXT NOT NULL DEFAULT '', - last_stream TEXT, - - biocontext_json TEXT, - biocontext_fetched_at TEXT, - - profile_image_url TEXT, - profile_image_mime TEXT, - profile_image_blob BLOB, - profile_image_updated_at TEXT, - - last_seen_online INTEGER NULL, -- NULL/0/1 - last_seen_online_at TEXT, - - watching INTEGER NOT NULL DEFAULT 0, - favorite INTEGER NOT NULL DEFAULT 0, - hot INTEGER NOT NULL DEFAULT 0, - keep INTEGER NOT NULL DEFAULT 0, - liked INTEGER NULL, -- NULL/0/1 - - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -`) - if err != nil { - return err - } - - _, _ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_models_host_key ON models(host, model_key);`) - _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_models_updated ON models(updated_at);`) - return nil -} - -func ensureModelsColumns(db *sql.DB) error { - cols := map[string]bool{} - - rows, err := db.Query(`PRAGMA table_info(models);`) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - var cid int - var name, typ string - var notnull, pk int - var dflt sql.NullString - if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { - return err - } - cols[name] = true - } - - if !cols["tags"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN tags TEXT NOT NULL DEFAULT '';`); err != nil { - return err - } - } - if !cols["last_stream"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN last_stream TEXT;`); err != nil { - return err - } - } - - // ✅ Biocontext - if !cols["biocontext_json"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN biocontext_json TEXT;`); err != nil { - return err - } - } - if !cols["biocontext_fetched_at"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN biocontext_fetched_at TEXT;`); err != nil { - return err - } - } - - // ✅ Profile image columns - if !cols["profile_image_url"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN profile_image_url TEXT;`); err != nil { - return err - } - } - if !cols["profile_image_mime"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN profile_image_mime TEXT;`); err != nil { - return err - } - } - if !cols["profile_image_blob"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN profile_image_blob BLOB;`); err != nil { - return err - } - } - if !cols["profile_image_updated_at"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN profile_image_updated_at TEXT;`); err != nil { - return err - } - } - - // ✅ Last seen online/offline - if !cols["last_seen_online"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN last_seen_online INTEGER NULL;`); err != nil { - return err - } - } - if !cols["last_seen_online_at"] { - if _, err := db.Exec(`ALTER TABLE models ADD COLUMN last_seen_online_at TEXT;`); err != nil { - return err - } - } - - return nil -} - -func canonicalHost(host string) string { - h := strings.ToLower(strings.TrimSpace(host)) - h = strings.TrimPrefix(h, "www.") - return h -} - -func canonicalID(host, modelKey string) string { - h := canonicalHost(host) - k := strings.TrimSpace(modelKey) - if h != "" { - return h + ":" + k - } - return k -} - -func boolToInt(b bool) int64 { - if b { - return 1 - } - return 0 -} - -func nullLikedFromPtr(p *bool) sql.NullInt64 { - if p == nil { - return sql.NullInt64{Valid: false} - } - return sql.NullInt64{Valid: true, Int64: boolToInt(*p)} -} - -func ptrLikedFromNull(n sql.NullInt64) *bool { - if !n.Valid { - return nil - } - v := n.Int64 != 0 - return &v -} - -func ptrBoolFromNullInt64(n sql.NullInt64) *bool { - if !n.Valid { - return nil - } - v := n.Int64 != 0 - return &v -} - // --- Profile image cache --- // SetProfileImage speichert Bild-URL + MIME + Blob. @@ -531,11 +395,11 @@ func (s *ModelStore) SetProfileImage(host, modelKey, sourceURL, mime string, dat mime = "image/jpeg" } - ts := strings.TrimSpace(updatedAt) - if ts == "" { - ts = time.Now().UTC().Format(time.RFC3339Nano) + nt := parseRFC3339Nano(updatedAt) + if !nt.Valid { + nt = sql.NullTime{Valid: true, Time: time.Now().UTC()} } - now := time.Now().UTC().Format(time.RFC3339Nano) + now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() @@ -543,10 +407,10 @@ func (s *ModelStore) SetProfileImage(host, modelKey, sourceURL, mime string, dat // Erst Update versuchen res, err := s.db.Exec(` UPDATE models -SET profile_image_url=?, profile_image_mime=?, profile_image_blob=?, profile_image_updated_at=?, updated_at=? -WHERE lower(trim(host)) = lower(trim(?)) - AND lower(trim(model_key)) = lower(trim(?)); -`, src, mime, data, ts, now, host, key) +SET profile_image_url=$1, profile_image_mime=$2, profile_image_blob=$3, profile_image_updated_at=$4, updated_at=$5 +WHERE lower(trim(host)) = lower(trim($6)) + AND lower(trim(model_key)) = lower(trim($7)); +`, src, mime, data, nullableTimeArg(nt), now, host, key) if err != nil { return err } @@ -577,21 +441,21 @@ func (s *ModelStore) SetProfileImageURLOnly(host, modelKey, sourceURL, updatedAt return nil } - ts := strings.TrimSpace(updatedAt) - if ts == "" { - ts = time.Now().UTC().Format(time.RFC3339Nano) + nt := parseRFC3339Nano(updatedAt) + if !nt.Valid { + nt = sql.NullTime{Valid: true, Time: time.Now().UTC()} } - now := time.Now().UTC().Format(time.RFC3339Nano) + now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() res, err := s.db.Exec(` UPDATE models -SET profile_image_url=?, profile_image_updated_at=?, updated_at=? -WHERE lower(trim(host)) = lower(trim(?)) - AND lower(trim(model_key)) = lower(trim(?)); -`, src, ts, now, host, key) +SET profile_image_url=$1, profile_image_updated_at=$2, updated_at=$3 +WHERE lower(trim(host)) = lower(trim($4)) + AND lower(trim(model_key)) = lower(trim($5)); +`, src, nullableTimeArg(nt), now, host, key) if err != nil { return err } @@ -618,7 +482,7 @@ func (s *ModelStore) GetProfileImageByID(id string) (mime string, data []byte, o err = s.db.QueryRow(` SELECT profile_image_mime, profile_image_blob FROM models -WHERE id = ? +WHERE id = $1 LIMIT 1; `, id).Scan(&mimeNS, &blob) @@ -656,14 +520,14 @@ func (s *ModelStore) GetBioContext(host, modelKey string) (jsonStr string, fetch } var js sql.NullString - var ts sql.NullString + var ts sql.NullTime err = s.db.QueryRow(` - SELECT biocontext_json, biocontext_fetched_at - FROM models - WHERE lower(trim(host)) = lower(trim(?)) - AND lower(trim(model_key)) = lower(trim(?)) - LIMIT 1; - `, host, key).Scan(&js, &ts) +SELECT biocontext_json, biocontext_fetched_at +FROM models +WHERE lower(trim(host)) = lower(trim($1)) + AND lower(trim(model_key)) = lower(trim($2)) +LIMIT 1; +`, host, key).Scan(&js, &ts) if errors.Is(err, sql.ErrNoRows) { return "", "", false, nil @@ -674,9 +538,9 @@ func (s *ModelStore) GetBioContext(host, modelKey string) (jsonStr string, fetch val := strings.TrimSpace(js.String) if val == "" { - return "", strings.TrimSpace(ts.String), false, nil + return "", fmtNullTime(ts), false, nil } - return val, strings.TrimSpace(ts.String), true, nil + return val, fmtNullTime(ts), true, nil } func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) error { @@ -690,18 +554,18 @@ func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) er } js := strings.TrimSpace(jsonStr) - ts := strings.TrimSpace(fetchedAt) - now := time.Now().UTC().Format(time.RFC3339Nano) + ts := parseRFC3339Nano(fetchedAt) // NullTime + now := time.Now().UTC() // time.Time s.mu.Lock() defer s.mu.Unlock() res, err := s.db.Exec(` UPDATE models -SET biocontext_json=?, biocontext_fetched_at=?, updated_at=? -WHERE lower(trim(host)) = lower(trim(?)) - AND lower(trim(model_key)) = lower(trim(?)); -`, js, ts, now, host, key) +SET biocontext_json=$1, biocontext_fetched_at=$2, updated_at=$3 +WHERE lower(trim(host)) = lower(trim($4)) + AND lower(trim(model_key)) = lower(trim($5)); +`, js, nullableTimeArg(ts), now, host, key) if err != nil { return err } @@ -726,15 +590,18 @@ func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenA return errors.New("host/modelKey fehlt") } - ts := strings.TrimSpace(seenAt) - if ts == "" { - ts = time.Now().UTC().Format(time.RFC3339Nano) + nt := parseRFC3339Nano(seenAt) + if !nt.Valid { + nt = sql.NullTime{Valid: true, Time: time.Now().UTC()} } - now := time.Now().UTC().Format(time.RFC3339Nano) + now := time.Now().UTC() - var onlineArg any = int64(0) + // ✅ last_seen_online ist in deiner DB BOOLEAN (nullable) + var onlineArg any if online { - onlineArg = int64(1) + onlineArg = true + } else { + onlineArg = false } s.mu.Lock() @@ -742,10 +609,10 @@ func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenA res, err := s.db.Exec(` UPDATE models -SET last_seen_online=?, last_seen_online_at=?, updated_at=? -WHERE lower(trim(host)) = lower(trim(?)) - AND lower(trim(model_key)) = lower(trim(?)); -`, onlineArg, ts, now, host, key) +SET last_seen_online=$1, last_seen_online_at=$2, updated_at=$3 +WHERE lower(trim(host)) = lower(trim($4)) + AND lower(trim(model_key)) = lower(trim($5)); +`, onlineArg, nullableTimeArg(nt), now, host, key) if err != nil { return err } @@ -760,129 +627,19 @@ WHERE lower(trim(host)) = lower(trim(?)) return nil } -func (s *ModelStore) migrateFromJSONIfEmpty() error { - var cnt int - if err := s.db.QueryRow(`SELECT COUNT(1) FROM models;`).Scan(&cnt); err != nil { - return err - } - if cnt != 0 { - return nil - } - - b, err := os.ReadFile(s.legacyJSONPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return err - } - if len(bytesTrimSpace(b)) == 0 { - return nil - } - - var list []StoredModel - if err := json.Unmarshal(b, &list); err != nil { - return err - } - if len(list) == 0 { - return nil - } - - tx, err := s.db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - // ✅ FIX: 15 Spalten => 15 Platzhalter - stmt, err := tx.Prepare(` -INSERT INTO models ( - id,input,is_url,host,path,model_key, - tags,last_stream, - watching,favorite,hot,keep,liked, - created_at,updated_at -) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) -ON CONFLICT(id) DO UPDATE SET - input=excluded.input, - is_url=excluded.is_url, - host=excluded.host, - path=excluded.path, - model_key=excluded.model_key, - tags=excluded.tags, - last_stream=excluded.last_stream, - updated_at=excluded.updated_at; -`) - if err != nil { - return err - } - defer stmt.Close() - - now := time.Now().UTC().Format(time.RFC3339Nano) - - for _, m := range list { - host := canonicalHost(m.Host) - modelKey := strings.TrimSpace(m.ModelKey) - if modelKey == "" { - continue - } - - id := canonicalID(host, modelKey) - - created := strings.TrimSpace(m.CreatedAt) - updated := strings.TrimSpace(m.UpdatedAt) - if created == "" { - created = now - } - if updated == "" { - updated = now - } - - liked := nullLikedFromPtr(m.Liked) - var likedArg any - if liked.Valid { - likedArg = liked.Int64 - } else { - likedArg = nil - } - - _, err = stmt.Exec( - id, - m.Input, - boolToInt(m.IsURL), - host, - m.Path, - modelKey, - m.Tags, - m.LastStream, - boolToInt(m.Watching), - boolToInt(m.Favorite), - boolToInt(m.Hot), - boolToInt(m.Keep), - likedArg, - created, - updated, - ) - if err != nil { - return err - } - } - - return tx.Commit() -} - -func bytesTrimSpace(b []byte) []byte { - return []byte(strings.TrimSpace(string(b))) -} - func (s *ModelStore) normalizeNameOnlyChaturbate() error { + // ✅ last_stream ist TIMESTAMPTZ -> niemals COALESCE(...,'') rows, err := s.db.Query(` SELECT - id, model_key, - tags, COALESCE(last_stream,''), + id, + model_key, + tags, + last_stream, watching,favorite,hot,keep,liked, - created_at,updated_at + created_at, + updated_at FROM models -WHERE is_url = 0 +WHERE is_url = false AND lower(trim(input)) = lower(trim(model_key)) AND (host IS NULL OR trim(host)='' OR lower(trim(host))='chaturbate.com'); `) @@ -892,26 +649,41 @@ WHERE is_url = 0 defer rows.Close() type rowT struct { - oldID, key, tags, lastStream, createdAt, updatedAt string - watching, favorite, hot, keep int64 - liked sql.NullInt64 + oldID, key, tags string + lastStream sql.NullTime + + watching, favorite, hot, keep bool + liked sql.NullBool + + createdAt, updatedAt sql.NullTime } var items []rowT for rows.Next() { var r rowT if err := rows.Scan( - &r.oldID, &r.key, - &r.tags, &r.lastStream, - &r.watching, &r.favorite, &r.hot, &r.keep, &r.liked, - &r.createdAt, &r.updatedAt, + &r.oldID, + &r.key, + &r.tags, + &r.lastStream, + &r.watching, + &r.favorite, + &r.hot, + &r.keep, + &r.liked, + &r.createdAt, + &r.updatedAt, ); err != nil { continue } + + r.oldID = strings.TrimSpace(r.oldID) r.key = strings.TrimSpace(r.key) - if r.key == "" || strings.TrimSpace(r.oldID) == "" { + + if r.oldID == "" || r.key == "" { continue } + items = append(items, r) } @@ -931,6 +703,18 @@ WHERE is_url = 0 const host = "chaturbate.com" for _, it := range items { + now := time.Now().UTC() + + created := now + if it.createdAt.Valid && !it.createdAt.Time.IsZero() { + created = it.createdAt.Time.UTC() + } + + updated := now + if it.updatedAt.Valid && !it.updatedAt.Time.IsZero() { + updated = it.updatedAt.Time.UTC() + } + newInput := "https://" + host + "/" + it.key + "/" newPath := "/" + it.key + "/" @@ -938,7 +722,7 @@ WHERE is_url = 0 err := tx.QueryRow(` SELECT id FROM models -WHERE lower(trim(host)) = lower(?) AND lower(trim(model_key)) = lower(?) +WHERE lower(trim(host)) = lower($1) AND lower(trim(model_key)) = lower($2) LIMIT 1; `, host, it.key).Scan(&targetID) @@ -952,11 +736,13 @@ LIMIT 1; var likedArg any if it.liked.Valid { - likedArg = it.liked.Int64 + likedArg = it.liked.Bool } else { likedArg = nil } + lastStreamArg := nullableTimeArg(it.lastStream) + if targetID == "" { targetID = canonicalID(host, it.key) @@ -966,12 +752,12 @@ INSERT INTO models ( tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at -) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?); +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15); `, - targetID, newInput, int64(1), host, newPath, it.key, - it.tags, it.lastStream, + targetID, newInput, true, host, newPath, it.key, + it.tags, lastStreamArg, it.watching, it.favorite, it.hot, it.keep, likedArg, - it.createdAt, it.updatedAt, + created, updated, ) if err != nil { return err @@ -980,30 +766,35 @@ INSERT INTO models ( _, err = tx.Exec(` UPDATE models SET input = CASE - WHEN is_url=0 OR input IS NULL OR trim(input)='' OR lower(trim(input))=lower(trim(model_key)) - THEN ? ELSE input END, - is_url = CASE WHEN is_url=0 THEN 1 ELSE is_url END, - host = CASE WHEN host IS NULL OR trim(host)='' THEN ? ELSE host END, - path = CASE WHEN path IS NULL OR trim(path)='' THEN ? ELSE path END, + WHEN is_url=false OR input IS NULL OR trim(input)='' OR lower(trim(input))=lower(trim(model_key)) + THEN $1 ELSE input END, + is_url = CASE WHEN is_url=false THEN true ELSE is_url END, + host = CASE WHEN host IS NULL OR trim(host)='' THEN $2 ELSE host END, + path = CASE WHEN path IS NULL OR trim(path)='' THEN $3 ELSE path END, - tags = CASE WHEN (tags IS NULL OR tags='') AND ?<>'' THEN ? ELSE tags END, - last_stream = CASE WHEN (last_stream IS NULL OR last_stream='') AND ?<>'' THEN ? ELSE last_stream END, + tags = CASE WHEN (tags IS NULL OR tags='') AND $4<>'' THEN $5 ELSE tags END, - watching = CASE WHEN ?=1 THEN 1 ELSE watching END, - favorite = CASE WHEN ?=1 THEN 1 ELSE favorite END, - hot = CASE WHEN ?=1 THEN 1 ELSE hot END, - keep = CASE WHEN ?=1 THEN 1 ELSE keep END, - liked = CASE WHEN liked IS NULL AND ? IS NOT NULL THEN ? ELSE liked END, + -- ✅ last_stream ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben + last_stream = CASE + WHEN last_stream IS NULL AND $6 IS NOT NULL THEN $6 + ELSE last_stream + END, - updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END -WHERE id = ?; + watching = CASE WHEN $7=true THEN true ELSE watching END, + favorite = CASE WHEN $8=true THEN true ELSE favorite END, + hot = CASE WHEN $9=true THEN true ELSE hot END, + keep = CASE WHEN $10=true THEN true ELSE keep END, + liked = CASE WHEN liked IS NULL AND $11 IS NOT NULL THEN $11 ELSE liked END, + + updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END +WHERE id = $13; `, newInput, host, newPath, it.tags, it.tags, - it.lastStream, it.lastStream, + lastStreamArg, it.watching, it.favorite, it.hot, it.keep, - likedArg, likedArg, - it.updatedAt, it.updatedAt, + likedArg, + updated, targetID, ) if err != nil { @@ -1012,7 +803,7 @@ WHERE id = ?; } if it.oldID != targetID { - if _, err := tx.Exec(`DELETE FROM models WHERE id=?;`, it.oldID); err != nil { + if _, err := tx.Exec(`DELETE FROM models WHERE id=$1;`, it.oldID); err != nil { return err } } @@ -1026,16 +817,17 @@ func (s *ModelStore) List() []StoredModel { return []StoredModel{} } + // ✅ last_stream ist TIMESTAMPTZ -> direkt lesen (NullTime), niemals COALESCE(...,'') rows, err := s.db.Query(` SELECT id,input,is_url,host,path,model_key, - tags, COALESCE(last_stream,''), - last_seen_online, COALESCE(last_seen_online_at,''), + tags, last_stream, + last_seen_online, last_seen_online_at, COALESCE(profile_image_url,''), - COALESCE(profile_image_updated_at,''), - CASE WHEN profile_image_blob IS NOT NULL AND length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, + profile_image_updated_at, + CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, watching,favorite,hot,keep,liked, - created_at,updated_at + created_at, updated_at FROM models ORDER BY updated_at DESC; `) @@ -1048,17 +840,23 @@ ORDER BY updated_at DESC; for rows.Next() { var ( - id, input, host, path, modelKey, tags, lastStream string - createdAt, updatedAt string + id, input, host, path, modelKey, tags string - isURL, watching, favorite, hot, keep int64 - liked sql.NullInt64 - lastSeenOnline sql.NullInt64 - lastSeenOnlineAt string + isURL bool + + lastStream sql.NullTime + + lastSeenOnline sql.NullBool + lastSeenOnlineAt sql.NullTime profileImageURL string - profileImageUpdatedAt string + profileImageUpdatedAt sql.NullTime hasProfileImage int64 + + watching, favorite, hot, keep bool + liked sql.NullBool + + createdAt, updatedAt time.Time ) if err := rows.Scan( @@ -1075,24 +873,26 @@ ORDER BY updated_at DESC; m := StoredModel{ ID: id, Input: input, - IsURL: isURL != 0, + IsURL: isURL, Host: host, Path: path, ModelKey: modelKey, - Watching: watching != 0, - LastSeenOnline: ptrBoolFromNullInt64(lastSeenOnline), - LastSeenOnlineAt: lastSeenOnlineAt, Tags: tags, - LastStream: lastStream, - Favorite: favorite != 0, - Hot: hot != 0, - Keep: keep != 0, - Liked: ptrLikedFromNull(liked), - CreatedAt: createdAt, - UpdatedAt: updatedAt, + LastStream: fmtNullTime(lastStream), + LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), + LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), + + Watching: watching, + Favorite: favorite, + Hot: hot, + Keep: keep, + Liked: ptrLikedFromNullBool(liked), + + CreatedAt: fmtTime(createdAt), + UpdatedAt: fmtTime(updatedAt), ProfileImageURL: profileImageURL, - ProfileImageUpdatedAt: profileImageUpdatedAt, + ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt), } if hasProfileImage != 0 { @@ -1111,12 +911,12 @@ func (s *ModelStore) Meta() ModelsMeta { } var count int - var updatedAt string - err := s.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(updated_at), '') FROM models;`).Scan(&count, &updatedAt) + var updatedAt sql.NullTime + err := s.db.QueryRow(`SELECT COUNT(*), MAX(updated_at) FROM models;`).Scan(&count, &updatedAt) if err != nil { return ModelsMeta{Count: 0, UpdatedAt: ""} } - return ModelsMeta{Count: count, UpdatedAt: updatedAt} + return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)} } // hostFilter: z.B. "chaturbate.com" (leer => alle Hosts) @@ -1135,14 +935,14 @@ func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite { rows, err = s.db.Query(` SELECT id,input,host,model_key,watching FROM models -WHERE watching = 1 +WHERE watching = true ORDER BY updated_at DESC; `) } else { rows, err = s.db.Query(` SELECT id,input,host,model_key,watching FROM models -WHERE watching = 1 AND host = ? +WHERE watching = true AND host = $1 ORDER BY updated_at DESC; `, hostFilter) } @@ -1154,7 +954,7 @@ ORDER BY updated_at DESC; out := make([]WatchedModelLite, 0, 64) for rows.Next() { var id, input, host, modelKey string - var watching int64 + var watching bool if err := rows.Scan(&id, &input, &host, &modelKey, &watching); err != nil { continue } @@ -1163,7 +963,7 @@ ORDER BY updated_at DESC; Input: input, Host: host, ModelKey: modelKey, - Watching: watching != 0, + Watching: watching, }) } return out @@ -1194,7 +994,7 @@ func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) { modelKey := strings.TrimSpace(p.ModelKey) id := canonicalID(host, modelKey) - now := time.Now().UTC().Format(time.RFC3339Nano) + now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() @@ -1205,23 +1005,23 @@ INSERT INTO models ( tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at -) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO UPDATE SET - input=excluded.input, - is_url=excluded.is_url, - host=excluded.host, - path=excluded.path, - model_key=excluded.model_key, - updated_at=excluded.updated_at; + input=EXCLUDED.input, + is_url=EXCLUDED.is_url, + host=EXCLUDED.host, + path=EXCLUDED.path, + model_key=EXCLUDED.model_key, + updated_at=EXCLUDED.updated_at; `, id, u.String(), - int64(1), + true, host, p.Path, modelKey, - "", "", - int64(0), int64(0), int64(0), int64(0), nil, + "", nil, + false, false, false, false, nil, now, now, ) @@ -1244,10 +1044,10 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) { defer s.mu.Unlock() var ( - watching, favorite, hot, keep int64 - liked sql.NullInt64 + watching, favorite, hot, keep bool + liked sql.NullBool ) - err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=?;`, patch.ID). + err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=$1;`, patch.ID). Scan(&watching, &favorite, &hot, &keep, &liked) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -1257,38 +1057,38 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) { } if patch.Watched != nil { - watching = boolToInt(*patch.Watched) + watching = *patch.Watched } if patch.Favorite != nil { - favorite = boolToInt(*patch.Favorite) + favorite = *patch.Favorite } if patch.Liked != nil { - liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)} + liked = sql.NullBool{Valid: true, Bool: *patch.Liked} } // Exklusivität if patch.Liked != nil && *patch.Liked { - favorite = int64(0) + favorite = false } if patch.Favorite != nil && *patch.Favorite { if patch.Liked == nil || !*patch.Liked { - liked = sql.NullInt64{Valid: true, Int64: 0} + liked = sql.NullBool{Valid: true, Bool: false} } } - now := time.Now().UTC().Format(time.RFC3339Nano) + now := time.Now().UTC() var likedArg any if liked.Valid { - likedArg = liked.Int64 + likedArg = liked.Bool } else { likedArg = nil } _, err = s.db.Exec(` UPDATE models -SET watching=?, favorite=?, hot=?, keep=?, liked=?, updated_at=? -WHERE id=?; +SET watching=$1, favorite=$2, hot=$3, keep=$4, liked=$5, updated_at=$6 +WHERE id=$7; `, watching, favorite, hot, keep, likedArg, now, patch.ID) if err != nil { return StoredModel{}, err @@ -1308,7 +1108,7 @@ func (s *ModelStore) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() - _, err := s.db.Exec(`DELETE FROM models WHERE id=?;`, id) + _, err := s.db.Exec(`DELETE FROM models WHERE id=$1;`, id) return err } @@ -1330,23 +1130,26 @@ func (s *ModelStore) UpsertFromImport(p ParsedModelDTO, tags, lastStream string, modelKey := strings.TrimSpace(p.ModelKey) id := canonicalID(host, modelKey) - now := time.Now().UTC().Format(time.RFC3339Nano) + now := time.Now().UTC() - fav := int64(0) + fav := false var likedArg any = nil if kind == "favorite" { - fav = int64(1) + fav = true } if kind == "liked" { - likedArg = int64(1) + likedArg = true } + // last_stream kommt aus CSV als String -> parse + ls := parseRFC3339Nano(lastStream) + s.mu.Lock() defer s.mu.Unlock() inserted := false var dummy int - err = s.db.QueryRow(`SELECT 1 FROM models WHERE id=? LIMIT 1;`, id).Scan(&dummy) + err = s.db.QueryRow(`SELECT 1 FROM models WHERE id=$1 LIMIT 1;`, id).Scan(&dummy) if err == sql.ErrNoRows { inserted = true } else if err != nil { @@ -1359,23 +1162,23 @@ INSERT INTO models ( tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at -) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO UPDATE SET - input=excluded.input, - is_url=excluded.is_url, - host=excluded.host, - path=excluded.path, - model_key=excluded.model_key, - tags=excluded.tags, - last_stream=excluded.last_stream, - watching=excluded.watching, - favorite=CASE WHEN excluded.favorite=1 THEN 1 ELSE favorite END, - liked=CASE WHEN excluded.liked IS NOT NULL THEN excluded.liked ELSE liked END, - updated_at=excluded.updated_at; + input=EXCLUDED.input, + is_url=EXCLUDED.is_url, + host=EXCLUDED.host, + path=EXCLUDED.path, + model_key=EXCLUDED.model_key, + tags=EXCLUDED.tags, + last_stream=EXCLUDED.last_stream, + watching=EXCLUDED.watching, + favorite=CASE WHEN EXCLUDED.favorite=true THEN true ELSE models.favorite END, + liked=CASE WHEN EXCLUDED.liked IS NOT NULL THEN EXCLUDED.liked ELSE models.liked END, + updated_at=EXCLUDED.updated_at; `, - id, u.String(), int64(1), host, p.Path, modelKey, - tags, lastStream, - boolToInt(watch), fav, int64(0), int64(0), likedArg, + id, u.String(), true, host, p.Path, modelKey, + tags, nullableTimeArg(ls), + watch, fav, false, false, likedArg, now, now, ) if err != nil { @@ -1388,31 +1191,37 @@ ON CONFLICT(id) DO UPDATE SET func (s *ModelStore) getByID(id string) (StoredModel, error) { var ( - input, host, path, modelKey, tags, lastStream string - createdAt, updatedAt string + input, host, path, modelKey, tags string - isURL, watching, favorite, hot, keep int64 - liked sql.NullInt64 - lastSeenOnline sql.NullInt64 - lastSeenOnlineAt string + isURL bool + + lastStream sql.NullTime + + lastSeenOnline sql.NullBool + lastSeenOnlineAt sql.NullTime profileImageURL string - profileImageUpdatedAt string + profileImageUpdatedAt sql.NullTime hasProfileImage int64 + + watching, favorite, hot, keep bool + liked sql.NullBool + + createdAt, updatedAt time.Time ) err := s.db.QueryRow(` SELECT input,is_url,host,path,model_key, - tags, COALESCE(last_stream,''), - last_seen_online, COALESCE(last_seen_online_at,''), + tags, last_stream, + last_seen_online, last_seen_online_at, COALESCE(profile_image_url,''), - COALESCE(profile_image_updated_at,''), - CASE WHEN profile_image_blob IS NOT NULL AND length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, + profile_image_updated_at, + CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, watching,favorite,hot,keep,liked, - created_at,updated_at + created_at, updated_at FROM models -WHERE id=?; +WHERE id=$1; `, id).Scan( &input, &isURL, &host, &path, &modelKey, &tags, &lastStream, @@ -1431,24 +1240,26 @@ WHERE id=?; m := StoredModel{ ID: id, Input: input, - IsURL: isURL != 0, + IsURL: isURL, Host: host, Path: path, ModelKey: modelKey, Tags: tags, - LastStream: lastStream, - LastSeenOnline: ptrBoolFromNullInt64(lastSeenOnline), - LastSeenOnlineAt: lastSeenOnlineAt, - Watching: watching != 0, - Favorite: favorite != 0, - Hot: hot != 0, - Keep: keep != 0, - Liked: ptrLikedFromNull(liked), - CreatedAt: createdAt, - UpdatedAt: updatedAt, + LastStream: fmtNullTime(lastStream), + LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), + LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), + + Watching: watching, + Favorite: favorite, + Hot: hot, + Keep: keep, + Liked: ptrLikedFromNullBool(liked), + + CreatedAt: fmtTime(createdAt), + UpdatedAt: fmtTime(updatedAt), ProfileImageURL: profileImageURL, - ProfileImageUpdatedAt: profileImageUpdatedAt, + ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt), } if hasProfileImage != 0 { diff --git a/backend/postgres_url.go b/backend/postgres_url.go new file mode 100644 index 0000000..1d20860 --- /dev/null +++ b/backend/postgres_url.go @@ -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 +} diff --git a/backend/recorder_settings.key b/backend/recorder_settings.key new file mode 100644 index 0000000..e6eda7c --- /dev/null +++ b/backend/recorder_settings.key @@ -0,0 +1 @@ +JHibTjjK7wKRyW/0ozJ0FUB2XLIhPlb0U8EO2y/f344= diff --git a/backend/routes.go b/backend/routes.go index a7dc759..a010899 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -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 "" + } + if u.User != nil { + u.User = url.UserPassword(u.User.Username(), "****") + } + return u.String() +} diff --git a/backend/server.go b/backend/server.go index c9f87cb..8b6ebcd 100644 --- a/backend/server.go +++ b/backend/server.go @@ -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) diff --git a/backend/settings.go b/backend/settings.go index 03153aa..b057bfe 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -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: diff --git a/frontend/src/components/ui/PostgresUrlModal.tsx b/frontend/src/components/ui/PostgresUrlModal.tsx new file mode 100644 index 0000000..5e6f8d6 --- /dev/null +++ b/frontend/src/components/ui/PostgresUrlModal.tsx @@ -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 = ( + <> + + + + ) + + return ( + +
+
+ Passwort wird verschlüsselt gespeichert (nicht in der URL). + {initialUrl ? ( + <> +
+ Aktuelle URL: {initialUrl} +
+ + ) : null} + {initialHasPassword ?
Passwort: ✅ gespeichert
: null} +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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="••••••••" + /> +
+ +
+ + +
+ +
+
Vorschau
+ {preview} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index d4689f5..b4d111b 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -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(DEFAULTS) const [saving, setSaving] = useState(false) + const [saveSuccessUntilMs, setSaveSuccessUntilMs] = useState(0) + const saveSuccessTimerRef = useRef(null) const [cleaning, setCleaning] = useState(false) const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null) const [msg, setMsg] = useState(null) const [err, setErr] = useState(null) const [diskStatus, setDiskStatus] = useState(null) - // ✅ Tasklist (Assets generieren) const assetsAbortRef = useRef(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: , + isLoading: false, + } + } + if (saveUiState === 'error') { + return { + text: 'Fehler', + color: 'red' as const, + icon: , + isLoading: false, + } + } + return { + text: 'Speichern', + color: 'indigo' as const, + icon: null, + isLoading: false, + } + })() const [assetsTask, setAssetsTask] = useState({ 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 ( -
+
+
Einstellungen
Recorder-Konfiguration, Automatisierung und Tasks.
- + + {/* Rechts: Alerts + Button */} +
+ {/* Alerts links neben Button */} + {saveUiState !== 'success' ? ( +
+ {err ? ( +
+ {err} +
+ ) : msg ? ( +
+ {msg} +
+ ) : null} +
+ ) : null} + + +
} grayBody @@ -392,17 +512,21 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { }} /> - {/* Alerts */} - {err && ( -
- {err} + {/* Alerts (mobile) */} + {saveUiState !== 'success' ? ( +
+ {err && ( +
+ {err} +
+ )} + {msg && ( +
+ {msg} +
+ )}
- )} - {msg && ( -
- {msg} -
- )} + ) : null} {/* Aufgaben */}
@@ -502,6 +626,50 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { Aufnahme- und Zielverzeichnisse sowie optionaler ffmpeg-Pfad.
+ + {/* Datenbank */} +
+
+
Datenbank
+
+ Postgres Verbindung. Passwort wird verschlüsselt gespeichert. +
+
+ +
+
+ + +
+ 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" + /> + + +
+
+
+
+ + setDbModalOpen(false)} + initialUrl={String((value as any).databaseUrl ?? '')} + initialHasPassword={Boolean((value as any).hasDbPassword)} + onApply={({ databaseUrl, dbPassword }) => { + setValue((v) => ({ ...v, databaseUrl })) + setPendingDbPassword(dbPassword || '') + }} + />
{/* Aufnahme-Ordner */}