nsfwapp/backend/settings_database_migrate.go
2026-06-23 15:27:25 +02:00

406 lines
10 KiB
Go

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, ","),
)
}