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 geöffnet 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 geöffnet werden: %w", err) } defer source.Close() target := NewModelStore(targetConfig) if err := target.Load(); err != nil { return 0, appErrorf("Ziel konnte nicht geöffnet 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, ","), ) }