161 lines
4.4 KiB
Go
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 ------------------------------------------------
|