nsfwapp/backend/settings_database_backup.go
2026-06-23 14:38:06 +02:00

465 lines
12 KiB
Go

package main
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const maxDatabaseRestoreBytes int64 = 2 << 30
type postgresToolConfig struct {
dbURL string
env []string
}
func postgresToolConfigFromSettings() (postgresToolConfig, string, error) {
dsn, err := buildPostgresDSNFromSettings()
if err != nil {
return postgresToolConfig{}, "", err
}
dbURL, env, err := postgresToolConnection(dsn)
if err != nil {
return postgresToolConfig{}, "", err
}
return postgresToolConfig{
dbURL: dbURL,
env: env,
}, dsn, nil
}
func postgresToolConnection(dsn string) (string, []string, error) {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return "", nil, errors.New("databaseUrl ist leer")
}
u, err := url.Parse(dsn)
if err != nil {
return "", nil, appErrorf("databaseUrl ungueltig: %w", err)
}
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
case "postgres", "postgresql":
default:
return "", nil, appErrorf("databaseUrl muss postgres:// oder postgresql:// verwenden")
}
env := os.Environ()
if u.User != nil {
user := u.User.Username()
if pw, ok := u.User.Password(); ok && strings.TrimSpace(pw) != "" {
env = upsertEnv(env, "PGPASSWORD", pw)
}
if strings.TrimSpace(user) != "" {
u.User = url.User(user)
} else {
u.User = nil
}
}
if sslMode := strings.TrimSpace(u.Query().Get("sslmode")); sslMode != "" {
env = upsertEnv(env, "PGSSLMODE", sslMode)
}
env = upsertEnv(env, "PGCONNECT_TIMEOUT", "10")
return u.String(), env, nil
}
func postgresToolPath(name string) (string, error) {
path, err := exec.LookPath(name)
if err == nil && strings.TrimSpace(path) != "" {
return path, nil
}
return "", appErrorf("%s wurde nicht gefunden. Installiere postgresql-client oder nimm %s in PATH auf.", name, name)
}
func runPostgresTool(ctx context.Context, tool string, cfg postgresToolConfig, args ...string) (string, error) {
toolPath, err := postgresToolPath(tool)
if err != nil {
return "", err
}
cmd := exec.CommandContext(ctx, toolPath, args...)
cmd.Env = cfg.env
hideCommandWindow(cmd)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return trimCommandOutput(msg), err
}
return trimCommandOutput(stderr.String()), nil
}
func trimCommandOutput(s string) string {
s = strings.TrimSpace(s)
const maxLen = 4000
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "\n..."
}
func settingsDatabaseBackupHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed)
return
}
storeConfig, err := buildModelStoreConfigFromSettings()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if storeConfig.Type == databaseTypeSQLite {
settingsSQLiteDatabaseBackupHandler(w, r, storeConfig)
return
}
cfg, _, err := postgresToolConfigFromSettings()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tmp, err := os.CreateTemp("", "nsfwapp-db-backup-*.dump")
if err != nil {
http.Error(w, "Backup-Datei konnte nicht erstellt werden: "+err.Error(), http.StatusInternalServerError)
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
toolPath, err := postgresToolPath("pg_dump")
if err != nil {
_ = tmp.Close()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
cmd := exec.CommandContext(
ctx,
toolPath,
"--format=custom",
"--blobs",
"--no-owner",
"--no-privileges",
"--dbname="+cfg.dbURL,
)
cmd.Env = cfg.env
cmd.Stdout = tmp
hideCommandWindow(cmd)
var stderr bytes.Buffer
cmd.Stderr = &stderr
runErr := cmd.Run()
closeErr := tmp.Close()
if runErr != nil {
msg := trimCommandOutput(stderr.String())
if msg == "" {
msg = runErr.Error()
}
http.Error(w, "pg_dump fehlgeschlagen: "+msg, http.StatusInternalServerError)
return
}
if closeErr != nil {
http.Error(w, "Backup-Datei konnte nicht geschlossen werden: "+closeErr.Error(), http.StatusInternalServerError)
return
}
info, err := os.Stat(tmpPath)
if err != nil {
http.Error(w, "Backup-Datei konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
f, err := os.Open(tmpPath)
if err != nil {
http.Error(w, "Backup-Datei konnte nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
filename := "nsfwapp-db-backup-" + time.Now().Format("20060102-150405") + ".dump"
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
http.ServeContent(w, r, filename, info.ModTime(), f)
}
func settingsSQLiteDatabaseBackupHandler(w http.ResponseWriter, r *http.Request, storeConfig ModelStoreConfig) {
tmp, err := os.CreateTemp("", "nsfwapp-db-backup-*.sqlite")
if err != nil {
http.Error(w, "Backup-Datei konnte nicht erstellt werden: "+err.Error(), http.StatusInternalServerError)
return
}
tmpPath := tmp.Name()
_ = tmp.Close()
_ = os.Remove(tmpPath)
defer os.Remove(tmpPath)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
db, err := sql.Open("sqlite", storeConfig.SQLitePath)
if err != nil {
http.Error(w, "SQLite konnte nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
return
}
defer db.Close()
if _, err := db.ExecContext(ctx, `PRAGMA busy_timeout = 5000;`); err != nil {
http.Error(w, "SQLite Backup konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError)
return
}
if _, err := db.ExecContext(ctx, "VACUUM INTO "+sqliteLiteral(tmpPath)); err != nil {
http.Error(w, "SQLite Backup fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
info, err := os.Stat(tmpPath)
if err != nil {
http.Error(w, "Backup-Datei konnte nicht gelesen werden: "+err.Error(), http.StatusInternalServerError)
return
}
f, err := os.Open(tmpPath)
if err != nil {
http.Error(w, "Backup-Datei konnte nicht geoeffnet werden: "+err.Error(), http.StatusInternalServerError)
return
}
defer f.Close()
filename := "nsfwapp-db-backup-" + time.Now().Format("20060102-150405") + ".sqlite"
w.Header().Set("Content-Type", "application/vnd.sqlite3")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
http.ServeContent(w, r, filename, info.ModTime(), f)
}
func settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
storeConfig, err := buildModelStoreConfigFromSettings()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxDatabaseRestoreBytes)
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "Restore-Datei konnte nicht gelesen werden: "+err.Error(), http.StatusBadRequest)
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
file, header, err := r.FormFile("backup")
if err != nil {
http.Error(w, "Form-Feld 'backup' fehlt: "+err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
tmp, err := os.CreateTemp("", "nsfwapp-db-restore-*.dump")
if err != nil {
http.Error(w, "Restore-Datei konnte nicht vorbereitet werden: "+err.Error(), http.StatusInternalServerError)
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
written, err := io.Copy(tmp, file)
closeErr := tmp.Close()
if err != nil {
http.Error(w, "Restore-Datei konnte nicht gespeichert werden: "+err.Error(), http.StatusBadRequest)
return
}
if closeErr != nil {
http.Error(w, "Restore-Datei konnte nicht geschlossen werden: "+closeErr.Error(), http.StatusInternalServerError)
return
}
if written <= 0 {
http.Error(w, "Restore-Datei ist leer", http.StatusBadRequest)
return
}
if storeConfig.Type == databaseTypeSQLite {
if err := restoreSQLiteDatabase(storeConfig, tmpPath); err != nil {
http.Error(w, "SQLite Restore fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
refreshedStore := NewModelStore(storeConfig)
if err := refreshedStore.Load(); err != nil {
appLogln("Models DB nach Restore konnte nicht neu verbunden werden:", err)
} else {
setModelStore(refreshedStore)
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"fileName": strings.TrimSpace(header.Filename),
"bytes": written,
})
return
}
cfg, dsn, err := postgresToolConfigFromSettings()
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Minute)
defer cancel()
stderr, err := runPostgresTool(
ctx,
"pg_restore",
cfg,
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
"--single-transaction",
"--dbname="+cfg.dbURL,
filepath.Clean(tmpPath),
)
if err != nil {
http.Error(w, "pg_restore fehlgeschlagen: "+stderr, http.StatusInternalServerError)
return
}
refreshedStore := NewModelStore(ModelStoreConfig{Type: databaseTypePostgres, DSN: dsn})
if err := refreshedStore.Load(); err != nil {
appLogln("⚠️ Models DB nach Restore konnte nicht neu verbunden werden:", err)
} else {
setModelStore(refreshedStore)
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"fileName": strings.TrimSpace(header.Filename),
"bytes": written,
})
}
func restoreSQLiteDatabase(storeConfig ModelStoreConfig, restorePath string) error {
restorePath = filepath.Clean(strings.TrimSpace(restorePath))
if restorePath == "" {
return errors.New("restore path fehlt")
}
if err := validateSQLiteDatabaseFile(restorePath); err != nil {
return err
}
targetPath := filepath.Clean(strings.TrimSpace(storeConfig.SQLitePath))
if targetPath == "" {
return errors.New("sqlite path fehlt")
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
oldStore := getModelStore()
if oldStore != nil && oldStore.isSQLite() && oldStore.db != nil {
_, _ = oldStore.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE);`)
_ = oldStore.Close()
}
if _, err := os.Stat(targetPath); err == nil {
backupPath := targetPath + ".before-restore-" + time.Now().Format("20060102-150405") + ".bak"
if _, err := copyDatabaseFile(targetPath, backupPath); err != nil {
return appErrorf("Sicherung der bisherigen SQLite-Datei fehlgeschlagen: %w", err)
}
}
_ = os.Remove(targetPath + "-wal")
_ = os.Remove(targetPath + "-shm")
_ = os.Remove(targetPath)
if _, err := copyDatabaseFile(restorePath, targetPath); err != nil {
return err
}
return nil
}
func validateSQLiteDatabaseFile(path string) error {
db, err := sql.Open("sqlite", path)
if err != nil {
return err
}
defer db.Close()
if err := db.Ping(); err != nil {
return err
}
var name string
err = db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='models' LIMIT 1;`).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return errors.New("SQLite-Datei enthaelt keine models-Tabelle")
}
return err
}
func copyDatabaseFile(src, dst string) (int64, error) {
in, err := os.Open(src)
if err != nil {
return 0, err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return 0, err
}
written, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return written, copyErr
}
if closeErr != nil {
return written, closeErr
}
return written, nil
}
func sqliteLiteral(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}