// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 package admin import ( "fmt" "net/http" "path/filepath" "regexp" "strings" "sourcedock.dev/petrbalvin/volumen/internal/diff" ) // unsafeSlugRe strips anything that is not safe in a header value. var unsafeSlugRe = regexp.MustCompile(`[^a-z0-9._-]`) // attachmentName reduces a path segment to a safe Content-Disposition // filename. func attachmentName(value string) string { return unsafeSlugRe.ReplaceAllString(value, "") } func (a *Admin) handleDownload(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") p := a.deps.Store.Find(slug, "") if p == nil { http.NotFound(w, r) return } content, err := p.ToFile() if err != nil { http.Error(w, "export failed", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/markdown; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.md"`, attachmentName(slug))) fmt.Fprint(w, content) } func (a *Admin) handleHistory(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") p := a.deps.Store.Find(slug, "") if p == nil { http.NotFound(w, r) return } revisions := a.deps.Store.Revisions(slug) rows := make([]revisionRow, 0, len(revisions)) for _, rev := range revisions { rows = append(rows, newRevisionRow(rev)) } heading := p.Title() if heading == "" { heading = slug } data := a.pageData(r) data.Slug = slug data.Heading = heading data.Revisions = rows data.Post = newEditorPost(p) data.Crumbs = []Crumb{ {Label: "Posts", Href: "/admin/", UI: true}, {Label: heading, Href: "/admin/posts/" + slug + "/edit"}, {Label: "History", IsLast: true, UI: true}, } a.renderPage(w, r, "history.html", data, http.StatusOK) } func (a *Admin) handleHistoryDownload(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") name := r.PathValue("name") content := a.deps.Store.RevisionContent(slug, name) if content == "" { http.NotFound(w, r) return } // The revision name is a server-generated stamp, but it arrives from // the URL: the value is reduced to what cannot end the quoted string // (a quote, a backslash, a control byte). Unlike attachmentName this // keeps the stamp's uppercase T and Z. safeName := strings.Map(func(r rune) rune { if r == '"' || r == '\\' || r < 0x20 || r == 0x7f { return -1 } return r }, filepath.Base(name)) w.Header().Set("Content-Type", "text/markdown; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s-%s"`, attachmentName(slug), safeName)) fmt.Fprint(w, content) } // handleHistoryDiff compares one archived revision with the current // content, so the editor can judge what a restore would change before // committing to it. func (a *Admin) handleHistoryDiff(w http.ResponseWriter, r *http.Request) { slug := r.PathValue("slug") name := r.PathValue("name") p := a.deps.Store.Find(slug, "") if p == nil { http.NotFound(w, r) return } revision := a.deps.Store.RevisionContent(slug, name) if revision == "" { http.NotFound(w, r) return } current, err := p.ToFile() if err != nil { http.Error(w, "export failed", http.StatusInternalServerError) return } var when string for _, rev := range a.deps.Store.Revisions(slug) { if rev.Name == name { when = rev.When break } } heading := p.Title() if heading == "" { heading = slug } data := a.pageData(r) data.Slug = slug data.Heading = heading data.DiffName = name data.DiffWhen = when data.DiffChunks = diff.Chunks(revision, current, 3) data.Post = newEditorPost(p) data.Crumbs = []Crumb{ {Label: "Posts", Href: "/admin/", UI: true}, {Label: heading, Href: "/admin/posts/" + slug + "/edit"}, {Label: "History", Href: "/admin/posts/" + slug + "/history", UI: true}, {Label: "Changes", IsLast: true, UI: true}, } a.renderPage(w, r, "diff.html", data, http.StatusOK) } func (a *Admin) handleHistoryRestore(w http.ResponseWriter, r *http.Request) { if !a.requireCSRF(w, r) { return } slug := r.PathValue("slug") name := r.PathValue("name") p := a.deps.Store.Find(slug, "") if p == nil { http.NotFound(w, r) return } if a.deps.Store.RestoreRevision(p, name) == nil { http.NotFound(w, r) return } http.Redirect(w, r, "/admin/posts/"+slug+"/edit?restored=1", http.StatusSeeOther) } // --- uploads and static SVGs ------------------------------------------------