406 lines
10 KiB
Go
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, ","),
|
|
)
|
|
}
|