package web import ( "fmt" "log" "net/http" "strings" "time" "munin/internal/auth" "munin/internal/export" "munin/internal/store" ) // ── Download als ZIP ───────────────────────────────────────────────────────── // // Der Export unter EXPORT_DIR landet auf der Platte des Servers — gut für // Sicherungen und Git, aber für ein Team unerreichbar. Der Download erzeugt // dasselbe als ZIP und schickt es direkt an den Browser: // // - unabhängig davon, ob EXPORT_DIR gesetzt ist // - nichts wird auf dem Server gespeichert, jedes ZIP entsteht frisch // - im selben Aufbau wie der Export, passt also direkt in den Import // (/p/{projekt}/import) — auch auf einem anderen Server // handleProjectDownload liefert ein Projekt als ZIP aus. Wer das darf, // entscheidet Session.CanDownloadProject (Route in server.go). func (s *Server) handleProjectDownload(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) project, _ := ProjectFrom(r) files, err := export.ProjectFiles(r.Context(), ws.Store, project) if err != nil { s.serverError(w, "Export fehlgeschlagen", err) return } ws.Store.LogAudit(r.Context(), store.AuditEvent{ ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(), Action: store.ActionExportDownload, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: fmt.Sprintf("ZIP mit %d Einträgen", len(files)-1), // ohne _index.md }) sendZIP(w, "munin-"+project.Slug, files) } // handleWorkspaceDownload liefert alle Projekte des gewählten Workspace als // ein ZIP aus — für Plattform- und Workspace-Admins. func (s *Server) handleWorkspaceDownload(w http.ResponseWriter, r *http.Request) { ws, sess := s.workspaceAndSession(r) // Der Name kommt aus der Kontroll-Datenbank, nicht aus der Sitzung — wie // beim Export auf die Platte (handleExport). entry, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Workspace konnte nicht gelesen werden", err) return } files, err := export.WorkspaceFiles(r.Context(), ws.Store, entry) if err != nil { s.serverError(w, "Export fehlgeschlagen", err) return } ws.Store.LogAudit(r.Context(), store.AuditEvent{ Actor: sess.ActorName(), Action: store.ActionExportDownload, Source: store.SourceWeb, IP: auth.ClientIP(r), Detail: fmt.Sprintf("ZIP des ganzen Workspace, %d Dateien", len(files)), }) sendZIP(w, "munin-"+filenamePart(entry.Name, "workspace"), files) } // sendZIP schickt die Dateien als ZIP-Download. name ist der Dateiname ohne // Datum und Endung. // // Bis hierher ist alles aus der Datenbank gelesen. Was jetzt noch scheitern // kann, ist nur die Verbindung — dann bekommt der Browser ein abgebrochenes // Archiv, und eine Fehlerseite wäre ohnehin nicht mehr möglich, weil die // Kopfzeilen schon unterwegs sind. func sendZIP(w http.ResponseWriter, name string, files []export.File) { filename := name + "-" + time.Now().Format("2006-01-02") + ".zip" w.Header().Set("Content-Type", "application/zip") // attachment: Der Browser speichert die Datei, statt sie anzeigen zu wollen. w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) // Der Inhalt ist der gesamte Bestand. Er gehört in keinen Zwischenspeicher, // weder im Browser noch in einem Proxy dazwischen. w.Header().Set("Cache-Control", "no-store") if err := export.WriteZIP(w, files); err != nil { // Nur ins Log: Antworten lässt sich nicht mehr. log.Printf("ZIP-Download abgebrochen: %v", err) } } // filenamePart macht aus einem frei gewählten Namen ein sicheres Stück // Dateiname: Kleinbuchstaben, Ziffern und Bindestriche. Umlaute werden // umschrieben. Bleibt nichts übrig, gilt replacement. // // Wichtig ist das wegen der Kopfzeile Content-Disposition: Ein Anführungszeichen // oder Zeilenumbruch im Workspace-Namen dürfte dort nicht hineingeraten. func filenamePart(name, replacement string) string { name = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", "Ä", "ae", "Ö", "oe", "Ü", "ue").Replace(strings.ToLower(name)) var b strings.Builder hyphen := false for _, z := range name { switch { case z >= 'a' && z <= 'z', z >= '0' && z <= '9': b.WriteRune(z) hyphen = false case !hyphen && b.Len() > 0: b.WriteByte('-') hyphen = true } } result := strings.TrimSuffix(b.String(), "-") if result == "" { return replacement } return result }