125 lines
4.5 KiB
Go
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
|
|
}
|