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 geöffnet 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 geöffnet 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 geöffnet 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, "'", "''") + "'" }