package main import ( "bytes" "context" "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 } if _, err := buildModelStoreConfigFromSettings(); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) 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 settingsDatabaseRestoreHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) return } if _, err := buildModelStoreConfigFromSettings(); 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 } 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, }) }