Files
petrbalvin f8ed33df83
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

161 lines
4.4 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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 ------------------------------------------------