munin/internal/web/handlers_download.go
2026-10-19 10:13:56 +02:00

125 lines
4.5 KiB
Go

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
}