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) dateien, err := export.ProjektDateien(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(dateien)-1), // ohne _index.md }) sendeZIP(w, "munin-"+project.Slug, dateien) } // 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). eintrag, err := s.control.WorkspaceByID(r.Context(), sess.WorkspaceID) if err != nil { s.serverError(w, "Workspace konnte nicht gelesen werden", err) return } dateien, err := export.WorkspaceDateien(r.Context(), ws.Store, eintrag) 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(dateien)), }) sendeZIP(w, "munin-"+dateinamenTeil(eintrag.Name, "workspace"), dateien) } // sendeZIP 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 sendeZIP(w http.ResponseWriter, name string, dateien []export.Datei) { dateiname := 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="`+dateiname+`"`) // 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.SchreibeZIP(w, dateien); err != nil { // Nur ins Log: Antworten lässt sich nicht mehr. log.Printf("ZIP-Download abgebrochen: %v", err) } } // dateinamenTeil macht aus einem frei gewählten Namen ein sicheres Stück // Dateiname: Kleinbuchstaben, Ziffern und Bindestriche. Umlaute werden // umschrieben. Bleibt nichts übrig, gilt ersatz. // // Wichtig ist das wegen der Kopfzeile Content-Disposition: Ein Anführungszeichen // oder Zeilenumbruch im Workspace-Namen dürfte dort nicht hineingeraten. func dateinamenTeil(name, ersatz string) string { name = strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", "Ä", "ae", "Ö", "oe", "Ü", "ue").Replace(strings.ToLower(name)) var b strings.Builder bindestrich := false for _, z := range name { switch { case z >= 'a' && z <= 'z', z >= '0' && z <= '9': b.WriteRune(z) bindestrich = false case !bindestrich && b.Len() > 0: b.WriteByte('-') bindestrich = true } } ergebnis := strings.TrimSuffix(b.String(), "-") if ergebnis == "" { return ersatz } return ergebnis }