added database migration
This commit is contained in:
parent
1800d98eff
commit
b55705c582
BIN
backend/dist/nsfwapp-linux-amd64
vendored
BIN
backend/dist/nsfwapp-linux-amd64
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp.exe
vendored
BIN
backend/dist/nsfwapp.exe
vendored
Binary file not shown.
BIN
backend/dist/nsfwapp_amd64.deb
vendored
BIN
backend/dist/nsfwapp_amd64.deb
vendored
Binary file not shown.
@ -117,10 +117,6 @@ func embeddedPoseModelPath() (string, error) {
|
|||||||
return dstPath, nil
|
return dstPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func embeddedDotEnvBytes() ([]byte, error) {
|
|
||||||
return nil, os.ErrNotExist
|
|
||||||
}
|
|
||||||
|
|
||||||
func embeddedTLSCertBytes() ([]byte, error) {
|
func embeddedTLSCertBytes() ([]byte, error) {
|
||||||
return embeddedMLFiles.ReadFile("recorder-cert.pem")
|
return embeddedMLFiles.ReadFile("recorder-cert.pem")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -303,6 +303,11 @@ func (s *ModelStore) init() error {
|
|||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.ensurePostgresSchema(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.normalizeNameOnlyChaturbate(); err != nil {
|
if err := s.normalizeNameOnlyChaturbate(); err != nil {
|
||||||
@ -383,6 +388,56 @@ func (s *ModelStore) ensureSQLiteSchema() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ModelStore) ensurePostgresSchema() error {
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS models (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
input TEXT NOT NULL DEFAULT '',
|
||||||
|
is_url BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
host TEXT DEFAULT '',
|
||||||
|
path TEXT DEFAULT '',
|
||||||
|
model_key TEXT NOT NULL DEFAULT '',
|
||||||
|
tags TEXT DEFAULT '',
|
||||||
|
last_stream TIMESTAMPTZ NULL,
|
||||||
|
last_seen_online BOOLEAN NULL,
|
||||||
|
last_seen_online_at TIMESTAMPTZ NULL,
|
||||||
|
cb_online_json TEXT DEFAULT '',
|
||||||
|
cb_online_fetched_at TIMESTAMPTZ NULL,
|
||||||
|
cb_online_last_error TEXT DEFAULT '',
|
||||||
|
profile_image_url TEXT DEFAULT '',
|
||||||
|
profile_image_mime TEXT DEFAULT '',
|
||||||
|
profile_image_blob BYTEA NULL,
|
||||||
|
profile_image_updated_at TIMESTAMPTZ NULL,
|
||||||
|
room_status TEXT DEFAULT '',
|
||||||
|
is_online BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
chat_room_url TEXT DEFAULT '',
|
||||||
|
image_url TEXT DEFAULT '',
|
||||||
|
last_online_at TIMESTAMPTZ NULL,
|
||||||
|
last_offline_at TIMESTAMPTZ NULL,
|
||||||
|
last_room_sync_at TIMESTAMPTZ NULL,
|
||||||
|
biocontext_json TEXT DEFAULT '',
|
||||||
|
biocontext_fetched_at TIMESTAMPTZ NULL,
|
||||||
|
watching BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
favorite BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
hot BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
keep BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
liked BOOLEAN NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_models_host_model_key ON models(host, model_key);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_models_model_key ON models(model_key);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`,
|
||||||
|
}
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := s.db.Exec(stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ModelStore) SetChaturbateRoomState(
|
func (s *ModelStore) SetChaturbateRoomState(
|
||||||
host string,
|
host string,
|
||||||
modelKey string,
|
modelKey string,
|
||||||
|
|||||||
@ -51,3 +51,68 @@ func TestModelStoreSQLiteCreatesSchemaAndPersists(t *testing.T) {
|
|||||||
t.Fatal("expected meta updatedAt")
|
t.Fatal("expected meta updatedAt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigrateModelStoreDataSQLiteToSQLiteReplacesTarget(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
sourcePath := filepath.Join(tmp, "source.sqlite")
|
||||||
|
targetPath := filepath.Join(tmp, "target.sqlite")
|
||||||
|
|
||||||
|
sourceConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: sourcePath, SQLitePath: sourcePath}
|
||||||
|
targetConfig := ModelStoreConfig{Type: databaseTypeSQLite, DSN: targetPath, SQLitePath: targetPath}
|
||||||
|
|
||||||
|
source := NewModelStore(sourceConfig)
|
||||||
|
defer source.Close()
|
||||||
|
if err := source.Load(); err != nil {
|
||||||
|
t.Fatalf("load source: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := source.EnsureByHostModelKey("chaturbate.com", "alice"); err != nil {
|
||||||
|
t.Fatalf("ensure source model: %v", err)
|
||||||
|
}
|
||||||
|
if err := source.SetProfileImage("chaturbate.com", "alice", "https://example.test/a.jpg", "image/jpeg", []byte{1, 2, 3, 4}, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
t.Fatalf("set profile image: %v", err)
|
||||||
|
}
|
||||||
|
if err := source.SetBioContext("chaturbate.com", "alice", `{"ok":true}`, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
t.Fatalf("set bio context: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := NewModelStore(targetConfig)
|
||||||
|
if err := target.Load(); err != nil {
|
||||||
|
t.Fatalf("load target: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := target.EnsureByHostModelKey("chaturbate.com", "stale"); err != nil {
|
||||||
|
t.Fatalf("ensure stale target model: %v", err)
|
||||||
|
}
|
||||||
|
_ = target.Close()
|
||||||
|
_ = source.Close()
|
||||||
|
|
||||||
|
count, err := migrateModelStoreData(sourceConfig, targetConfig)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrate sqlite to sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("expected 1 migrated row, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated := NewModelStore(targetConfig)
|
||||||
|
defer migrated.Close()
|
||||||
|
if err := migrated.Load(); err != nil {
|
||||||
|
t.Fatalf("load migrated target: %v", err)
|
||||||
|
}
|
||||||
|
models := migrated.List()
|
||||||
|
if len(models) != 1 {
|
||||||
|
t.Fatalf("expected target replacement with 1 row, got %d", len(models))
|
||||||
|
}
|
||||||
|
if models[0].ModelKey != "alice" {
|
||||||
|
t.Fatalf("unexpected migrated model: %q", models[0].ModelKey)
|
||||||
|
}
|
||||||
|
if models[0].ProfileImageCached == "" {
|
||||||
|
t.Fatal("expected migrated profile image blob marker")
|
||||||
|
}
|
||||||
|
bio, _, ok, err := migrated.GetBioContext("chaturbate.com", "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get migrated bio context: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || bio != `{"ok":true}` {
|
||||||
|
t.Fatalf("unexpected migrated bio context ok=%v bio=%q", ok, bio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -56,6 +56,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler)
|
api.HandleFunc("/api/settings/cleanup", settingsCleanupHandler)
|
||||||
api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler)
|
api.HandleFunc("/api/settings/database/backup", settingsDatabaseBackupHandler)
|
||||||
api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler)
|
api.HandleFunc("/api/settings/database/restore", settingsDatabaseRestoreHandler)
|
||||||
|
api.HandleFunc("/api/settings/database/migrate", settingsDatabaseMigrateHandler)
|
||||||
|
|
||||||
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))
|
api.HandleFunc("/api/pending-autostart", handlePendingAutoStart(auth))
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@ -807,26 +806,6 @@ func upsertEnv(env []string, key string, value string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadEmbeddedDotEnv() {
|
|
||||||
b, err := embeddedDotEnvBytes()
|
|
||||||
if err != nil {
|
|
||||||
appLogln("ℹ️ Keine eingebettete .env gefunden.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
envMap, err := godotenv.Parse(bytes.NewReader(b))
|
|
||||||
if err != nil {
|
|
||||||
appLogln("⚠️ Eingebettete .env konnte nicht gelesen werden:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range envMap {
|
|
||||||
setEnvIfMissing(k, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
appLogln("✅ Eingebettete .env geladen")
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadExternalDotEnvNextToExe() {
|
func loadExternalDotEnvNextToExe() {
|
||||||
exePath, err := os.Executable()
|
exePath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -840,17 +819,16 @@ func loadExternalDotEnvNextToExe() {
|
|||||||
envPath := filepath.Join(exeDir, ".env")
|
envPath := filepath.Join(exeDir, ".env")
|
||||||
|
|
||||||
if err := godotenv.Overload(envPath); err == nil {
|
if err := godotenv.Overload(envPath); err == nil {
|
||||||
appLogln("✅ Externe .env geladen und überschreibt embedded/env:", envPath)
|
appLogln("✅ Externe .env geladen:", envPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := godotenv.Overload(); err == nil {
|
if err := godotenv.Overload(); err == nil {
|
||||||
appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis und überschreibt embedded/env")
|
appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAppEnv() {
|
func loadAppEnv() {
|
||||||
loadEmbeddedDotEnv()
|
|
||||||
loadExternalDotEnvNextToExe()
|
loadExternalDotEnvNextToExe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
405
backend/settings_database_migrate.go
Normal file
405
backend/settings_database_migrate.go
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type databaseMigrationRequest struct {
|
||||||
|
DatabaseType string `json:"databaseType"`
|
||||||
|
SQLitePath string `json:"sqlitePath"`
|
||||||
|
DatabaseURL string `json:"databaseUrl"`
|
||||||
|
DBPassword string `json:"dbPassword,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelMigrationColumn struct {
|
||||||
|
name string
|
||||||
|
kind string
|
||||||
|
notNull bool
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
modelMigrationText = "text"
|
||||||
|
modelMigrationBool = "bool"
|
||||||
|
modelMigrationTime = "time"
|
||||||
|
modelMigrationBlob = "blob"
|
||||||
|
)
|
||||||
|
|
||||||
|
var modelMigrationColumns = []modelMigrationColumn{
|
||||||
|
{name: "id", kind: modelMigrationText, notNull: true},
|
||||||
|
{name: "input", kind: modelMigrationText, notNull: true},
|
||||||
|
{name: "is_url", kind: modelMigrationBool, notNull: true},
|
||||||
|
{name: "host", kind: modelMigrationText},
|
||||||
|
{name: "path", kind: modelMigrationText},
|
||||||
|
{name: "model_key", kind: modelMigrationText, notNull: true},
|
||||||
|
{name: "tags", kind: modelMigrationText},
|
||||||
|
{name: "last_stream", kind: modelMigrationTime},
|
||||||
|
{name: "last_seen_online", kind: modelMigrationBool},
|
||||||
|
{name: "last_seen_online_at", kind: modelMigrationTime},
|
||||||
|
{name: "cb_online_json", kind: modelMigrationText},
|
||||||
|
{name: "cb_online_fetched_at", kind: modelMigrationTime},
|
||||||
|
{name: "cb_online_last_error", kind: modelMigrationText},
|
||||||
|
{name: "profile_image_url", kind: modelMigrationText},
|
||||||
|
{name: "profile_image_mime", kind: modelMigrationText},
|
||||||
|
{name: "profile_image_blob", kind: modelMigrationBlob},
|
||||||
|
{name: "profile_image_updated_at", kind: modelMigrationTime},
|
||||||
|
{name: "room_status", kind: modelMigrationText},
|
||||||
|
{name: "is_online", kind: modelMigrationBool, notNull: true},
|
||||||
|
{name: "chat_room_url", kind: modelMigrationText},
|
||||||
|
{name: "image_url", kind: modelMigrationText},
|
||||||
|
{name: "last_online_at", kind: modelMigrationTime},
|
||||||
|
{name: "last_offline_at", kind: modelMigrationTime},
|
||||||
|
{name: "last_room_sync_at", kind: modelMigrationTime},
|
||||||
|
{name: "biocontext_json", kind: modelMigrationText},
|
||||||
|
{name: "biocontext_fetched_at", kind: modelMigrationTime},
|
||||||
|
{name: "watching", kind: modelMigrationBool, notNull: true},
|
||||||
|
{name: "favorite", kind: modelMigrationBool, notNull: true},
|
||||||
|
{name: "hot", kind: modelMigrationBool, notNull: true},
|
||||||
|
{name: "keep", kind: modelMigrationBool, notNull: true},
|
||||||
|
{name: "liked", kind: modelMigrationBool},
|
||||||
|
{name: "created_at", kind: modelMigrationTime, notNull: true},
|
||||||
|
{name: "updated_at", kind: modelMigrationTime, notNull: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
type dbNullBool struct {
|
||||||
|
sql.NullBool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *dbNullBool) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
b.Valid = false
|
||||||
|
b.Bool = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := value.(type) {
|
||||||
|
case bool:
|
||||||
|
b.Valid = true
|
||||||
|
b.Bool = v
|
||||||
|
return nil
|
||||||
|
case int64:
|
||||||
|
b.Valid = true
|
||||||
|
b.Bool = v != 0
|
||||||
|
return nil
|
||||||
|
case int:
|
||||||
|
b.Valid = true
|
||||||
|
b.Bool = v != 0
|
||||||
|
return nil
|
||||||
|
case []byte:
|
||||||
|
return b.scanString(string(v))
|
||||||
|
case string:
|
||||||
|
return b.scanString(v)
|
||||||
|
default:
|
||||||
|
var raw sql.NullBool
|
||||||
|
if err := raw.Scan(value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.NullBool = raw
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *dbNullBool) scanString(value string) error {
|
||||||
|
value = strings.TrimSpace(strings.ToLower(value))
|
||||||
|
if value == "" {
|
||||||
|
b.Valid = false
|
||||||
|
b.Bool = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "1", "t", "true", "yes", "y", "on":
|
||||||
|
b.Valid = true
|
||||||
|
b.Bool = true
|
||||||
|
return nil
|
||||||
|
case "0", "f", "false", "no", "n", "off":
|
||||||
|
b.Valid = true
|
||||||
|
b.Bool = false
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
parsed, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid bool value %q", value)
|
||||||
|
}
|
||||||
|
b.Valid = true
|
||||||
|
b.Bool = parsed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelMigrationValue struct {
|
||||||
|
column modelMigrationColumn
|
||||||
|
text sql.NullString
|
||||||
|
bool dbNullBool
|
||||||
|
tm dbNullTime
|
||||||
|
blob []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *modelMigrationValue) dest() any {
|
||||||
|
switch v.column.kind {
|
||||||
|
case modelMigrationBool:
|
||||||
|
return &v.bool
|
||||||
|
case modelMigrationTime:
|
||||||
|
return &v.tm
|
||||||
|
case modelMigrationBlob:
|
||||||
|
return &v.blob
|
||||||
|
default:
|
||||||
|
return &v.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v modelMigrationValue) arg(now time.Time) any {
|
||||||
|
switch v.column.kind {
|
||||||
|
case modelMigrationBool:
|
||||||
|
if !v.bool.Valid {
|
||||||
|
if v.column.notNull {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v.bool.Bool
|
||||||
|
case modelMigrationTime:
|
||||||
|
if !v.tm.Valid || v.tm.Time.IsZero() {
|
||||||
|
if v.column.notNull {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v.tm.Time.UTC()
|
||||||
|
case modelMigrationBlob:
|
||||||
|
if len(v.blob) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v.blob
|
||||||
|
default:
|
||||||
|
if !v.text.Valid {
|
||||||
|
if v.column.notNull {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return v.text.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v modelMigrationValue) textValue() string {
|
||||||
|
if !v.text.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(v.text.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsDatabaseMigrateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req databaseMigrationRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid json: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
current := getSettings()
|
||||||
|
target, err := databaseMigrationTargetSettings(current, req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceConfig, err := buildModelStoreConfigFromRecorderSettings(current)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Quell-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
targetConfig, err := buildModelStoreConfigFromRecorderSettings(target)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Ziel-Datenbank ungueltig: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := migrateModelStoreData(sourceConfig, targetConfig)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Migration fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshedStore := NewModelStore(targetConfig)
|
||||||
|
if err := refreshedStore.Load(); err != nil {
|
||||||
|
http.Error(w, "Ziel-Datenbank konnte nach Migration nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsMu.Lock()
|
||||||
|
settings = target
|
||||||
|
settingsMu.Unlock()
|
||||||
|
saveSettingsToDisk()
|
||||||
|
setModelStore(refreshedStore)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"rows": count,
|
||||||
|
"sourceType": sourceConfig.Type,
|
||||||
|
"targetType": targetConfig.Type,
|
||||||
|
"targetSQLite": targetConfig.SQLitePath,
|
||||||
|
"targetDatabase": sanitizeDSNForLog(targetConfig.DSN),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func databaseMigrationTargetSettings(current RecorderSettings, req databaseMigrationRequest) (RecorderSettings, error) {
|
||||||
|
next := current
|
||||||
|
next.DatabaseType = normalizeDatabaseType(req.DatabaseType)
|
||||||
|
next.SQLitePath = normalizeSQLitePath(req.SQLitePath)
|
||||||
|
next.DatabaseURL = strings.TrimSpace(req.DatabaseURL)
|
||||||
|
|
||||||
|
sanitizedURL, pwFromURL := stripPasswordFromPostgresURL(next.DatabaseURL)
|
||||||
|
pwFromURL = strings.TrimSpace(pwFromURL)
|
||||||
|
if pwFromURL == "****" {
|
||||||
|
pwFromURL = ""
|
||||||
|
}
|
||||||
|
if sanitizedURL != "" {
|
||||||
|
next.DatabaseURL = sanitizedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
plainPW := strings.TrimSpace(req.DBPassword)
|
||||||
|
if plainPW == "" {
|
||||||
|
plainPW = pwFromURL
|
||||||
|
}
|
||||||
|
if plainPW != "" {
|
||||||
|
enc, err := encryptSettingString(plainPW)
|
||||||
|
if err != nil {
|
||||||
|
return RecorderSettings{}, appErrorf("konnte DB-Passwort nicht verschluesseln: %w", err)
|
||||||
|
}
|
||||||
|
next.EncryptedDBPassword = enc
|
||||||
|
}
|
||||||
|
|
||||||
|
if next.DatabaseType == databaseTypePostgres && strings.TrimSpace(next.DatabaseURL) == "" {
|
||||||
|
return RecorderSettings{}, errors.New("Database URL fehlt")
|
||||||
|
}
|
||||||
|
if next.DatabaseType == databaseTypeSQLite && strings.TrimSpace(next.SQLitePath) == "" {
|
||||||
|
return RecorderSettings{}, errors.New("SQLite-Datei fehlt")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateModelStoreData(sourceConfig, targetConfig ModelStoreConfig) (int64, error) {
|
||||||
|
if sameModelStoreConfig(sourceConfig, targetConfig) {
|
||||||
|
return 0, errors.New("Quelle und Ziel sind identisch")
|
||||||
|
}
|
||||||
|
|
||||||
|
source := NewModelStore(sourceConfig)
|
||||||
|
if err := source.Load(); err != nil {
|
||||||
|
return 0, appErrorf("Quelle konnte nicht geoeffnet werden: %w", err)
|
||||||
|
}
|
||||||
|
defer source.Close()
|
||||||
|
|
||||||
|
target := NewModelStore(targetConfig)
|
||||||
|
if err := target.Load(); err != nil {
|
||||||
|
return 0, appErrorf("Ziel konnte nicht geoeffnet werden: %w", err)
|
||||||
|
}
|
||||||
|
defer target.Close()
|
||||||
|
|
||||||
|
return migrateModelsTable(source.db, target.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameModelStoreConfig(a, b ModelStoreConfig) bool {
|
||||||
|
a.Type = normalizeDatabaseType(a.Type)
|
||||||
|
b.Type = normalizeDatabaseType(b.Type)
|
||||||
|
if a.Type != b.Type {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.Type == databaseTypeSQLite {
|
||||||
|
return filepath.Clean(strings.TrimSpace(a.SQLitePath)) == filepath.Clean(strings.TrimSpace(b.SQLitePath))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(a.DSN) == strings.TrimSpace(b.DSN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateModelsTable(sourceDB, targetDB *sql.DB) (int64, error) {
|
||||||
|
columnNames := make([]string, 0, len(modelMigrationColumns))
|
||||||
|
for _, column := range modelMigrationColumns {
|
||||||
|
columnNames = append(columnNames, column.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := sourceDB.Query("SELECT " + strings.Join(columnNames, ",") + " FROM models ORDER BY updated_at ASC, id ASC;")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
tx, err := targetDB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM models;`); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
insertSQL := modelMigrationInsertSQL(columnNames)
|
||||||
|
stmt, err := tx.Prepare(insertSQL)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for rows.Next() {
|
||||||
|
values := make([]modelMigrationValue, len(modelMigrationColumns))
|
||||||
|
dests := make([]any, len(modelMigrationColumns))
|
||||||
|
for i, column := range modelMigrationColumns {
|
||||||
|
values[i] = modelMigrationValue{column: column}
|
||||||
|
dests[i] = values[i].dest()
|
||||||
|
}
|
||||||
|
if err := rows.Scan(dests...); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
if values[0].textValue() == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
args := make([]any, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
args[i] = value.arg(now)
|
||||||
|
}
|
||||||
|
if _, err := stmt.Exec(args...); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelMigrationInsertSQL(columnNames []string) string {
|
||||||
|
placeholders := make([]string, 0, len(columnNames))
|
||||||
|
updates := make([]string, 0, len(columnNames)-1)
|
||||||
|
for i, column := range columnNames {
|
||||||
|
placeholders = append(placeholders, fmt.Sprintf("$%d", i+1))
|
||||||
|
if column != "id" {
|
||||||
|
updates = append(updates, column+"=EXCLUDED."+column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"INSERT INTO models (%s) VALUES (%s) ON CONFLICT(id) DO UPDATE SET %s;",
|
||||||
|
strings.Join(columnNames, ","),
|
||||||
|
strings.Join(placeholders, ","),
|
||||||
|
strings.Join(updates, ","),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -547,6 +547,10 @@ function normalizeSqlitePath(value: unknown) {
|
|||||||
return path || 'nsfwapp.sqlite'
|
return path || 'nsfwapp.sqlite'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function databaseTypeLabel(value: 'sqlite' | 'postgres') {
|
||||||
|
return value === 'sqlite' ? 'SQLite' : 'Postgres'
|
||||||
|
}
|
||||||
|
|
||||||
function formatDatabaseBackupBytes(bytes: unknown) {
|
function formatDatabaseBackupBytes(bytes: unknown) {
|
||||||
const n = Number(bytes)
|
const n = Number(bytes)
|
||||||
if (!Number.isFinite(n) || n <= 0) return ''
|
if (!Number.isFinite(n) || n <= 0) return ''
|
||||||
@ -703,6 +707,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
const [pendingDbPassword, setPendingDbPassword] = useState('') // wird nur beim Speichern gesendet
|
||||||
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
const [dbBackupBusy, setDbBackupBusy] = useState(false)
|
||||||
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
const [dbRestoreBusy, setDbRestoreBusy] = useState(false)
|
||||||
|
const [dbMigrationBusy, setDbMigrationBusy] = useState(false)
|
||||||
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
|
const [dbMaintenanceMsg, setDbMaintenanceMsg] = useState<string | null>(null)
|
||||||
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
|
const [dbMaintenanceErr, setDbMaintenanceErr] = useState<string | null>(null)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@ -711,13 +716,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const currentDatabaseType = normalizeDatabaseType((value as any).databaseType)
|
const currentDatabaseType = normalizeDatabaseType((value as any).databaseType)
|
||||||
const currentSqlitePath = normalizeSqlitePath((value as any).sqlitePath)
|
const currentSqlitePath = normalizeSqlitePath((value as any).sqlitePath)
|
||||||
const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim()
|
const currentDatabaseUrl = String((value as any).databaseUrl ?? '').trim()
|
||||||
const databaseSettingsDirty =
|
const databaseLocationDirty =
|
||||||
currentDatabaseType !== loadedDatabaseType ||
|
currentDatabaseType !== loadedDatabaseType ||
|
||||||
currentSqlitePath !== loadedSqlitePath ||
|
currentSqlitePath !== loadedSqlitePath ||
|
||||||
currentDatabaseUrl !== loadedDatabaseUrl ||
|
currentDatabaseUrl !== loadedDatabaseUrl
|
||||||
Boolean((pendingDbPassword || '').trim())
|
const databaseSettingsDirty =
|
||||||
const databaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(loadedSqlitePath) : Boolean(loadedDatabaseUrl)
|
databaseLocationDirty || Boolean((pendingDbPassword || '').trim())
|
||||||
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy
|
const savedDatabaseConfigured = loadedDatabaseType === 'sqlite' ? Boolean(loadedSqlitePath) : Boolean(loadedDatabaseUrl)
|
||||||
|
const targetDatabaseConfigured = currentDatabaseType === 'sqlite' ? Boolean(currentSqlitePath) : Boolean(currentDatabaseUrl)
|
||||||
|
const databaseConfigured = savedDatabaseConfigured
|
||||||
|
const canMigrateDatabase = savedDatabaseConfigured && targetDatabaseConfigured && databaseLocationDirty
|
||||||
|
const dbMaintenanceBusy = dbBackupBusy || dbRestoreBusy || dbMigrationBusy
|
||||||
|
|
||||||
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
|
||||||
const [securityBusy, setSecurityBusy] = useState(false)
|
const [securityBusy, setSecurityBusy] = useState(false)
|
||||||
@ -1362,6 +1371,74 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function migrateDatabase() {
|
||||||
|
setDbMaintenanceMsg(null)
|
||||||
|
setDbMaintenanceErr(null)
|
||||||
|
|
||||||
|
const databaseType = currentDatabaseType
|
||||||
|
const sqlitePath = currentSqlitePath
|
||||||
|
const databaseUrl = currentDatabaseUrl
|
||||||
|
|
||||||
|
if (!savedDatabaseConfigured) {
|
||||||
|
setDbMaintenanceErr('Bitte zuerst eine Quell-Datenbank speichern.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!targetDatabaseConfigured) {
|
||||||
|
setDbMaintenanceErr('Bitte zuerst die Ziel-Datenbank angeben.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!databaseLocationDirty) {
|
||||||
|
setDbMaintenanceErr('Quelle und Ziel sind identisch.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Aktive Datenbank von ${databaseTypeLabel(loadedDatabaseType)} nach ${databaseTypeLabel(databaseType)} migrieren?\n\n` +
|
||||||
|
'Die Ziel-Datenbank wird mit den aktuellen Model-Daten ueberschrieben. Danach wird die App auf die Ziel-Datenbank umgestellt.'
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setDbMigrationBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings/database/migrate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
cache: 'no-store',
|
||||||
|
body: JSON.stringify({
|
||||||
|
databaseType,
|
||||||
|
sqlitePath,
|
||||||
|
databaseUrl,
|
||||||
|
dbPassword: pendingDbPassword || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text().catch(() => '')
|
||||||
|
throw new Error(t || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
const rows = Number(data?.rows ?? 0)
|
||||||
|
setDbMaintenanceMsg(`Migration abgeschlossen (${rows} Model${rows === 1 ? '' : 's'}).`)
|
||||||
|
|
||||||
|
setLoadedDatabaseType(databaseType)
|
||||||
|
setLoadedSqlitePath(sqlitePath)
|
||||||
|
setLoadedDatabaseUrl(databaseUrl)
|
||||||
|
setPendingDbPassword('')
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('recorder-settings-updated'))
|
||||||
|
window.dispatchEvent(new Event('models-changed'))
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('models-db-changed', {
|
||||||
|
detail: { databaseType, sqlitePath, databaseUrl, migrated: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (e: any) {
|
||||||
|
setDbMaintenanceErr(e?.message ?? String(e))
|
||||||
|
} finally {
|
||||||
|
setDbMigrationBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
setMsg(null)
|
setMsg(null)
|
||||||
@ -2269,12 +2346,22 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{databaseSettingsDirty ? (
|
{databaseSettingsDirty ? (
|
||||||
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
|
<div className="mt-2 text-xs text-amber-700 dark:text-amber-200">
|
||||||
Die Datenbank-Einstellungen wurden geaendert. Bitte erst speichern.
|
Backup/Restore nutzen die gespeicherte Datenbank. Migration nutzt diese geaenderten Werte als Ziel.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-wrap gap-2">
|
<div className="flex shrink-0 flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
color="blue"
|
||||||
|
isLoading={dbMigrationBusy}
|
||||||
|
disabled={saving || dbMaintenanceBusy || !canMigrateDatabase}
|
||||||
|
onClick={() => void migrateDatabase()}
|
||||||
|
>
|
||||||
|
Migrieren
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
color="emerald"
|
color="emerald"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user