Initial commit
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

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+480
View File
@@ -0,0 +1,480 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"html"
"log/slog"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/audit"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// Content is the content surface the admin uses: every post, one post by
// slug, the write and delete paths, the revision archive and the media
// library. It is an interface rather than *store.Store so the handlers can
// be tested against a fake and a second backend stays conceivable.
type Content interface {
All() []*post.Post
Find(slug, lang string) *post.Post
Save(p *post.Post) (*post.Post, error)
Delete(slug, lang string) (*post.Post, bool, error)
Undelete(slug string) *post.Post
Revisions(slug string) []store.Revision
RevisionContent(slug, name string) string
RestoreRevision(p *post.Post, name string) *post.Post
InvalidateCache()
StoreUpload(originalName string, data []byte) (string, error)
MediaPath(name string) (string, error)
DeleteMedia(url string) bool
ListMedia() []store.Media
}
// Deps are the shared services the admin UI needs.
type Deps struct {
Config *config.Config
Store Content
Users *users.Users
Templates *templates.Store
Tokens *tokens.Store
Audit *audit.Log
LoginLim *ratelimit.LoginLimiter
Sessions *session.Store
Webhooks *webhooks.Manager
// PreviewKey signs the shareable preview links: the session secret
// the app layer resolved, from [admin].session_key or from the
// secret.key file it generated, so a default deployment offers
// preview links the way the configuration documents it. Empty means
// no link can be signed and none is offered.
PreviewKey string
// WebhooksFile is the admin-managed hook store the settings forms
// rewrite, and StaticWebhooks the hooks that came from config.toml
// and are read-only here. Together they are what the manager
// delivers; every mutation re-saves the file and refreshes the
// manager with the merge of the two.
WebhooksFile string
StaticWebhooks []webhooks.Webhook
Version string
OnEvent func(event string, payload map[string]any)
Backup backup.Options
// CheckUpdate returns the latest available version ("" when none),
// and SelfUpdate replaces the running binary; both are wired by the
// CLI layer and may be nil.
CheckUpdate func() (string, error)
SelfUpdate func() (target string, err error)
// UpdateInfo returns the newer version for the update banner, or ""
// when the running version is current.
UpdateInfo func() string
}
// Admin serves /admin routes.
type Admin struct {
deps Deps
renderer *Renderer
// trustedProxies are the peers whose X-Forwarded-For is believed.
// The configuration is validated before the admin is built, so a
// parse failure here cannot happen.
trustedProxies []netip.Prefix
}
// New builds the admin handler.
func New(deps Deps) (*Admin, error) {
renderer, err := NewRenderer()
if err != nil {
return nil, err
}
trusted, err := deps.Config.TrustedProxyPrefixes()
if err != nil {
return nil, err
}
if !deps.Config.Server.TrustProxy {
trusted = nil
}
return &Admin{deps: deps, renderer: renderer, trustedProxies: trusted}, nil
}
// SetUpdateHooks wires the version-check callbacks after construction.
func (a *Admin) SetUpdateHooks(check func() (string, error), selfUpdate func() (string, error)) {
a.deps.CheckUpdate = check
a.deps.SelfUpdate = selfUpdate
a.deps.UpdateInfo = func() string {
latest, err := check()
if err != nil || latest == "" || latest == a.deps.Version {
return ""
}
return latest
}
}
// Handler returns the admin route tree.
func (a *Admin) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
})
// The interface sheets and fonts: public by design (the login page
// needs them before any session), confined to the embedded set.
mux.HandleFunc("GET /admin/assets/", web.AssetHandler)
mux.HandleFunc("GET /admin/login", a.handleLoginForm)
mux.HandleFunc("POST /admin/login", a.handleLogin)
mux.HandleFunc("GET /admin/twofactor", a.handleTwofactorForm)
mux.HandleFunc("POST /admin/twofactor", a.handleTwofactor)
mux.HandleFunc("POST /admin/logout", a.handleLogout)
a.registerSetupRoutes(mux)
a.registerPostRoutes(mux)
a.registerSettingsRoutes(mux)
a.registerMediaRoutes(mux)
// The subtree catch-all answers any /admin path no route above claims,
// so a mistyped URL meets the shell's own 404 page rather than the
// engine's bare text. It is the least specific pattern, so every
// registered route still wins, and it sits behind requireLogin so an
// anonymous visitor is sent to the sign-in screen first.
mux.HandleFunc("/admin/", a.requireLogin(a.handleNotFound))
return mux
}
// handleNotFound renders the admin 404 page inside the shell. Nothing
// about the request reaches the page beyond the interface strings, so the
// answer leaks nothing and carries the honest status.
func (a *Admin) handleNotFound(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
a.renderPage(w, r, "notfound.html", a.pageData(r), http.StatusNotFound)
}
// record appends an audit entry for one request, filling in the acting
// user and the client address so the log can answer "who, from where".
func (a *Admin) record(r *http.Request, action, resource string, detail map[string]any) {
a.deps.Audit.Record(audit.Entry{
User: a.currentUser(r),
Action: action,
Resource: resource,
Detail: detail,
IP: a.clientIP(r),
})
}
// clientIP resolves the request origin with proxy awareness.
func (a *Admin) clientIP(r *http.Request) string {
return web.ClientIP(r, a.trustedProxies)
}
// lang resolves the interface language for one request: the account's
// choice first, then the language cookie the choice set for the login
// screen, then the site language when it is one the UI ships, and
// English otherwise.
func (a *Admin) lang(r *http.Request, record *users.User) string {
if record != nil && record.Language != "" {
return record.Language
}
if c, err := r.Cookie(i18n.Cookie); err == nil {
if lang := i18n.Normalize(c.Value); lang != "" {
return lang
}
}
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
return lang
}
return "en"
}
// theme resolves the colour scheme for one request: the account's
// choice first, then the theme cookie the choice set for the login
// screen, then the default scheme.
func (a *Admin) theme(r *http.Request, record *users.User) string {
if record != nil && record.Theme != "" {
if web.ValidTheme(record.Theme) {
return record.Theme
}
}
if c, err := r.Cookie(web.ThemeCookie); err == nil && web.ValidTheme(c.Value) {
return c.Value
}
return web.DefaultTheme
}
// langFor resolves the interface language from the request alone,
// without a page context: the signed-in account's choice, the language
// cookie, the site language, then English.
func (a *Admin) langFor(r *http.Request) string {
return a.lang(r, a.deps.Users.Find(session.FromContext(r.Context()).Get("user")))
}
// tr translates an admin interface message in the request's language.
func (a *Admin) tr(r *http.Request, s string) string {
return i18n.Admin.T(a.langFor(r), s)
}
// trf translates an admin interface message with one value.
func (a *Admin) trf(r *http.Request, s, arg string) string {
return i18n.Admin.Tf(a.langFor(r), s, arg)
}
// trf2 translates an admin interface message with two values.
func (a *Admin) trf2(r *http.Request, s, first, second string) string {
return i18n.Admin.Tf2(a.langFor(r), s, first, second)
}
// pageData builds the shared template context for one request.
func (a *Admin) pageData(r *http.Request) *PageData {
sess := session.FromContext(r.Context())
username := sess.Get("user")
record := a.deps.Users.Find(username)
data := &PageData{
Config: a.deps.Config,
Path: r.URL.Path,
CSPNonce: web.Nonce(r.Context()),
Version: a.deps.Version,
Lang: a.lang(r, record),
Theme: a.theme(r, record),
CurrentUser: username,
CurrentUserRecord: record,
UsersExist: a.deps.Users.Any(),
CSRFToken: CSRFToken(sess),
IsLogin: strings.HasPrefix(r.URL.Path, "/admin/login") || r.URL.Path == "/admin/twofactor",
IsSetup: r.URL.Path == "/admin/setup",
NavPosts: r.URL.Path == "/admin/" || r.URL.Path == "/admin",
NavNew: r.URL.Path == "/admin/posts/new",
NavImport: r.URL.Path == "/admin/posts/import",
NavMedia: strings.HasPrefix(r.URL.Path, "/admin/media"),
NavSettings: strings.HasPrefix(r.URL.Path, "/admin/settings"),
}
if a.deps.UpdateInfo != nil {
data.UpdateAvailable = a.deps.UpdateInfo()
}
if record != nil {
data.IsAuthenticated = true
data.CurrentRole = record.Role
data.DisplayName = record.Name
data.UserPhoto = record.Photo
if data.DisplayName == "" {
data.DisplayName = record.Username
}
data.UserInitial = firstUpper(data.DisplayName, "?")
}
return data
}
// templateEscape neutralises the characters that would end an HTML
// comment or open a tag inside one.
func templateEscape(s string) string {
s = strings.ReplaceAll(s, "--", "- -")
return html.EscapeString(s)
}
// renderPage executes an admin page with HTTP semantics.
func (a *Admin) renderPage(w http.ResponseWriter, r *http.Request, page string, data *PageData, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := a.renderer.Render(r.Context(), w, page, data); err != nil {
// The status line is already sent, and the page is the user's
// only signal, so it carries a note rather than nothing. The
// text is escaped: an error message quoting content must not
// close the comment and inject markup.
fmt.Fprintf(w, "<!-- template error: %s -->", templateEscape(err.Error()))
}
}
func (a *Admin) handleLoginForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// A session that already answered the password sits halfway in: the
// second step is where it belongs, not the first one again.
if sess.Get("totp_user") != "" {
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
return
}
// A deployment with no accounts is one that has not been set up
// yet: the login screen would only be a door with nothing behind
// it, so the first visit goes to the wizard instead. It is a redirect,
// not a rewrite, so the wizard has its own honest URL.
if needed, broken := a.setupNeeded(); needed && broken == nil {
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
return
}
a.renderPage(w, r, "login.html", a.pageData(r), http.StatusOK)
}
func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
ip := a.clientIP(r)
a.deps.LoginLim.Record(ip)
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
data := a.pageData(r)
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
data.RetryAfter = retryAfter
a.renderPage(w, r, "login.html", data, http.StatusTooManyRequests)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.PostFormValue("username")
password := r.PostFormValue("password")
if user := a.deps.Users.Authenticate(username, password); user != nil {
// An account with the second factor answers one more question
// before it is in: the session holds the half-way name and no
// user, so nothing behind requireLogin opens yet.
if user.TotpSecret != "" {
sess.Set("totp_user", user.Username)
sess.Set("totp_at", strconv.FormatInt(time.Now().Unix(), 10))
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
return
}
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
data := a.pageData(r)
data.Error = data.Tr("Invalid username or password.")
a.renderPage(w, r, "login.html", data, http.StatusUnauthorized)
}
// twofactorWindow bounds how long a password already answered may wait
// for its code before the whole sign-in starts over.
const twofactorWindow = 10 * time.Minute
// handleTwofactorForm shows the code prompt while a session holds a
// password-verified name; anything else goes back to the first step.
func (a *Admin) handleTwofactorForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
if !a.twofactorPending(sess) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
a.renderPage(w, r, "twofactor.html", a.pageData(r), http.StatusOK)
}
func (a *Admin) twofactorPending(sess *session.Session) bool {
name := sess.Get("totp_user")
if name == "" {
return false
}
started, err := strconv.ParseInt(sess.Get("totp_at"), 10, 64)
if err != nil || time.Since(time.Unix(started, 0)) > twofactorWindow {
sess.Delete("totp_user")
sess.Delete("totp_at")
return false
}
return true
}
// handleTwofactor answers the second question: a six-digit code from
// the account's application, or one of its recovery codes. The same
// limiter guards it as the password, so guessing a code costs the same
// lockout as guessing a password.
func (a *Admin) handleTwofactor(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
ip := a.clientIP(r)
a.deps.LoginLim.Record(ip)
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
data := a.pageData(r)
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
data.RetryAfter = retryAfter
a.renderPage(w, r, "twofactor.html", data, http.StatusTooManyRequests)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
if !a.twofactorPending(sess) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
name := sess.Get("totp_user")
code := strings.TrimSpace(r.PostFormValue("code"))
ok := false
if isTotpShape(code) {
ok = a.deps.Users.VerifyTotp(name, code, time.Now())
} else {
ok = a.deps.Users.ConsumeRecovery(name, code)
}
if !ok {
slog.Warn("admin: second factor refused", "user", name)
data := a.pageData(r)
data.Error = data.Tr("Wrong or expired code.")
a.renderPage(w, r, "twofactor.html", data, http.StatusUnauthorized)
return
}
user := a.deps.Users.Find(name)
if user == nil || user.TotpSecret == "" {
sess.Delete("totp_user")
sess.Delete("totp_at")
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
sess.Delete("totp_user")
sess.Delete("totp_at")
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
// isTotpShape reports whether the answer looks like an application
// code; everything else is tried as a recovery code.
func isTotpShape(code string) bool {
clean := strings.NewReplacer(" ", "", "-", "").Replace(code)
return len(clean) == 6 && strings.Trim(clean, "0123456789") == ""
}
func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
// Abandon rather than Clear: Clear would leave the session dirty, and
// the middleware's Save would then append a fresh cookie after
// Destroy's expiring one, which the browser applies last.
sess.Abandon()
a.deps.Sessions.Destroy(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
+364
View File
@@ -0,0 +1,364 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/audit"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
var csrfRe = regexp.MustCompile(`name="_csrf" value="([a-f0-9]+)"`)
type fixture struct {
handler http.Handler
admin *Admin
users *users.Users
store *session.Store
storeObj *store.Store
contentDir string
events []string
payloads []map[string]any
}
func newFixture(t *testing.T) *fixture {
t.Helper()
return newFixtureSeeded(t, true)
}
// newFixtureSeeded builds the same handler over an empty users file
// when seeded is false: that is the first-run state, with no account
// and the wizard serving the admin screen.
func newFixtureSeeded(t *testing.T, seeded bool) *fixture {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: config.PortUnset,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
// Preview links are signed with the session key, so the fixture sets
// one the way a real deployment does.
cfg.Admin.SessionKey = strings.Repeat("k", 64)
if err != nil {
t.Fatalf("config: %v", err)
}
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
usersObj := users.New(cfg.UsersFile)
if seeded {
if _, err := usersObj.Add("admin", "correct-horse-9", "admin"); err != nil {
t.Fatalf("seed admin: %v", err)
}
}
f := &fixture{storeObj: st, contentDir: content}
a, err := New(Deps{
Config: cfg,
Store: st,
Users: usersObj,
Templates: templates.New(filepath.Join(dir, "templates.toml")),
Tokens: tokens.New(filepath.Join(dir, "tokens.toml")),
Backup: backup.Options{
ContentDir: content,
UsersFile: cfg.UsersFile,
TemplatesFile: cfg.TemplatesFile(),
TokensFile: cfg.TokensFile(),
},
Audit: audit.New(""),
LoginLim: ratelimit.NewLoginLimiter(),
Sessions: session.New(strings.Repeat("k", 64), 0, false),
Version: "0.0.0-test",
PreviewKey: strings.Repeat("k", 64),
OnEvent: func(event string, payload map[string]any) {
f.events = append(f.events, event)
f.payloads = append(f.payloads, payload)
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
// Production mounts this handler inside the session middleware; the
// fixture mirrors that so cookies round-trip.
f.handler = a.deps.Sessions.Middleware(a.Handler())
f.admin = a
f.users = usersObj
f.store = a.deps.Sessions
return f
}
func (f *fixture) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
f.handler.ServeHTTP(rec, req)
return rec
}
func extractCSRF(t *testing.T, body string) string {
t.Helper()
m := csrfRe.FindStringSubmatch(body)
if m == nil {
t.Fatalf("no CSRF token in body:\n%s", body[:min(len(body), 500)])
}
return m[1]
}
func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == session.CookieName {
return cookie
}
}
t.Fatal("no session cookie")
return nil
}
func TestLoginPageRenders(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Sign in", "Volumen admin", "0.0.0-test"} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("content-type = %q", rec.Header().Get("Content-Type"))
}
}
func TestLoginRedirectsAuthenticatedUser(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// login performs the full CSRF + credential flow and returns the
// authenticated session cookie.
func login(t *testing.T, f *fixture, username, secret string) *http.Cookie {
t.Helper()
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
}
return sessionCookie(t, rec)
}
func TestLoginRejectsWrongPassword(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"nope"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid username or password.") {
t.Fatal("error message missing")
}
}
func TestLoginRejectsMissingCSRF(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
cookie := sessionCookie(t, get)
form := url.Values{"username": {"admin"}, "password": {"correct-horse-9"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", rec.Code)
}
}
func TestLoginRateLimitRendersCountdown(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
var last *httptest.ResponseRecorder
for range 12 {
form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"wrong"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
last = f.do(t, req)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("code = %d, want 429", last.Code)
}
if !strings.Contains(last.Body.String(), "Auto-unlock in") {
t.Fatal("lockout message missing")
}
if !strings.Contains(last.Body.String(), `id="lockout-countdown"`) {
t.Fatal("countdown element missing")
}
}
func TestLogout(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
// Grab a fresh CSRF token via the session cookie's page.
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
// Authenticated users are redirected; get the CSRF from the session
// store directly instead.
sess := f.store.Load(req)
csrf := CSRFToken(sess)
form := url.Values{"_csrf": {csrf}}
logoutReq := httptest.NewRequest(http.MethodPost, "/admin/logout", strings.NewReader(form.Encode()))
logoutReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
logoutReq.AddCookie(cookie)
rec := f.do(t, logoutReq)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestBareAdminRedirects(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// An /admin path no route claims meets the shell's own 404 page, not the
// engine's bare text, and an anonymous visitor is still sent to the
// sign-in screen first.
func TestAdminNotFound(t *testing.T) {
f := newFixture(t)
anon := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil))
if anon.Code != http.StatusSeeOther || anon.Header().Get("Location") != "/admin/login" {
t.Fatalf("anonymous: code=%d location=%q", anon.Code, anon.Header().Get("Location"))
}
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("content type = %q", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "Page not found") {
t.Fatalf("body lacks the 404 heading:\n%s", body[:min(len(body), 500)])
}
if !strings.Contains(body, `<html`) || !strings.Contains(body, `class="shell"`) {
t.Fatalf("body is not rendered in the shell")
}
post := httptest.NewRequest(http.MethodPost, "/admin/no-such-page", strings.NewReader(""))
post.AddCookie(cookie)
if rec := f.do(t, post); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
t.Fatalf("POST: code=%d", rec.Code)
}
}
func TestPasswordError(t *testing.T) {
if key, n := PasswordError("", 10, 1024); key != "New password cannot be empty." || n != 0 {
t.Fatalf("empty password = %q, %d", key, n)
}
if key, n := PasswordError("short", 10, 1024); key != "password.min" || n != 10 {
t.Fatalf("short password = %q, %d", key, n)
}
if key, n := PasswordError(strings.Repeat("x", 2000), 10, 1024); key != "password.max" || n != 1024 {
t.Fatalf("long password = %q, %d", key, n)
}
if key, n := PasswordError("password123", 10, 1024); key != "This password is too common." || n != 0 {
t.Fatalf("common password = %q, %d", key, n)
}
if key, n := PasswordError("a genuinely unique passphrase", 10, 1024); key != "" || n != 0 {
t.Fatalf("valid password = %q, %d", key, n)
}
}
func TestFirstUpper(t *testing.T) {
if got := firstUpper("petr", "?"); got != "P" {
t.Fatalf("got = %q", got)
}
if got := firstUpper("", "?"); got != "?" {
t.Fatalf("got = %q", got)
}
}
func TestHumanSize(t *testing.T) {
cases := map[int64]string{
0: "",
512: "1 kB",
10 * 1024: "10 kB",
1024 * 1024: "1.0 MB",
5 << 20: "5.0 MB",
1536 * 1024: "1.5 MB",
}
for in, want := range cases {
if got := humanSize(in); got != want {
t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
}
}
}
// A post-template body containing "</script>" must not be able to end
// the script element the JSON literal is embedded in.
func TestTemplatesJSONEscapesScriptClose(t *testing.T) {
list := []tplOption{{
Name: "s", Title: "T", Slug: "s", Tags: []string{},
Body: `Use <script>document.write("x")</script> carefully`,
}}
out := string(templatesJSON(list))
if strings.Contains(out, "</script>") {
t.Fatalf("literal script close survived: %s", out)
}
if !strings.Contains(out, `\u003c/script>`) {
t.Fatalf("expected unicode escapes: %s", out)
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
"sourcedock.dev/petrbalvin/volumen/internal/session"
)
// commonPasswords is the blocklist of trivially guessable passwords.
// This is the policy every admin password change goes through.
var commonPasswords = map[string]bool{
"password": true, "password1": true, "password123": true,
"123456": true, "12345678": true, "123456789": true,
"qwerty": true, "qwerty123": true, "letmein": true, "iloveyou": true,
"admin": true, "admin123": true, "welcome": true, "welcome1": true,
"monkey": true, "dragon": true, "football": true, "baseball": true,
"sunshine": true, "princess": true, "abc123": true, "111111": true,
"123123": true, "1q2w3e4r": true, "passw0rd": true, "trustno1": true,
"changeme": true, "secret": true, "secret123": true, "test": true,
"test123": true, "guest": true, "master": true, "000000": true,
"696969": true, "qwertyuiop": true, "superman": true, "batman": true,
"jordan": true, "harley": true, "hunter": true, "hunter2": true,
"shadow": true, "michael": true, "jennifer": true, "abcdef": true,
"abcdefg": true,
}
// PasswordError validates a newly chosen password and reports the
// first problem as a catalogue key. The key is either a plain sentence or
// the id of a plural message whose numeral n is the offending length;
// "" with n 0 means accepted.
func PasswordError(password string, minLength, maxLength int) (string, int) {
if strings.TrimSpace(password) == "" {
return "New password cannot be empty.", 0
}
length := len([]rune(password))
if length < minLength {
return "password.min", minLength
}
if length > maxLength {
return "password.max", maxLength
}
normalized := strings.ToLower(norm.NFKC.String(password))
if commonPasswords[normalized] {
return "This password is too common.", 0
}
return "", 0
}
// CSRFToken returns (and lazily creates) the CSRF token stored in the
// session.
func CSRFToken(sess *session.Session) string {
token := sess.Get("csrf")
if token == "" {
token = newTokenHex(32)
sess.Set("csrf", token)
}
return token
}
// sessionFingerprint derives the value the session carries to bind it to
// one password: it changes whenever the account's hash changes, so a
// password change or an admin reset retires every cookie issued before
// it. It is a digest of the stored hash, never of the password, and
// carries too few bits to help anyone invert the hash.
func sessionFingerprint(storedHash string) string {
sum := sha256.Sum256([]byte("volumen-session-v1:" + storedHash))
return hex.EncodeToString(sum[:8])
}
// ValidateCSRF compares the form's _csrf field against the session
// token in constant time.
func ValidateCSRF(r *http.Request, sess *session.Session) bool {
token := r.PostFormValue("_csrf")
sessionToken := sess.Get("csrf")
if token == "" || sessionToken == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(sessionToken), []byte(token)) == 1
}
func newTokenHex(nBytes int) string {
buf := make([]byte, nBytes)
if _, err := rand.Read(buf); err != nil {
return ""
}
return hex.EncodeToString(buf)
}
// firstUpper returns the uppercased first rune, or fallback.
func firstUpper(s, fallback string) string {
for _, r := range s {
if unicode.IsSpace(r) {
continue
}
return string(unicode.ToUpper(r))
}
return fallback
}
+63
View File
@@ -0,0 +1,63 @@
// 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"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
func (a *Admin) registerMediaRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/media", a.requireLogin(a.handleMediaLibrary))
mux.HandleFunc("POST /admin/media/{name}/delete", a.requireLogin(a.handleMediaDelete))
}
func (a *Admin) handleMediaLibrary(w http.ResponseWriter, r *http.Request) {
data := a.pageData(r)
items := a.deps.Store.ListMedia()
data.MediaItems = mediaRows(items)
data.MediaTotal = humanSize(totalSize(items))
data.Crumbs = []Crumb{{Label: "Media", IsLast: true, UI: true}}
a.renderPage(w, r, "media.html", data, http.StatusOK)
}
// totalSize sums the byte sizes of the media library.
func totalSize(items []store.Media) int64 {
var total int64
for _, item := range items {
total += item.Size
}
return total
}
// humanSize formats a byte count for the library summary: kilobytes
// below a megabyte (rounded up, so nothing reads as zero), megabytes
// above it, empty for an empty library.
func humanSize(total int64) string {
switch {
case total <= 0:
return ""
case total < 1024*1024:
kb := (total + 1023) / 1024
return fmt.Sprintf("%d kB", kb)
default:
return fmt.Sprintf("%.1f MB", float64(total)/(1024*1024))
}
}
func (a *Admin) handleMediaDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := r.PathValue("name")
if !a.deps.Store.DeleteMedia("/media/" + strings.TrimPrefix(name, "/")) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
a.record(r, "media.deleted", fmt.Sprintf("/media/%s", name), nil)
http.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}
+160
View File
@@ -0,0 +1,160 @@
// 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 ------------------------------------------------
+92
View File
@@ -0,0 +1,92 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
func (a *Admin) handleImportForm(w http.ResponseWriter, r *http.Request) {
data := a.pageData(r)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "Import", IsLast: true, UI: true},
}
a.renderPage(w, r, "import.html", data, http.StatusOK)
}
func (a *Admin) handleImport(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
data := a.pageData(r)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "Import", IsLast: true, UI: true},
}
fail := func(msg string) {
data.Error = i18n.Admin.T(data.Lang, msg)
a.renderPage(w, r, "import.html", data, http.StatusUnprocessableEntity)
}
file, header, err := r.FormFile("file")
if err != nil {
fail("No file selected.")
return
}
defer file.Close()
if !strings.HasSuffix(strings.ToLower(header.Filename), ".md") {
fail("Only .md files are accepted.")
return
}
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
if err != nil {
fail("File is too large.")
return
}
content := string(raw)
p, err := post.Parse(content)
if err != nil {
fail(i18n.Admin.Tf(data.Lang, "The file could not be read as a post: %s", err.Error()))
return
}
if p.Slug() == "" {
base := strings.ToLower(filepath.Base(header.Filename))
stem := strings.TrimSuffix(base, filepath.Ext(base))
p.Metadata.Set("slug", strings.ReplaceAll(stem, " ", "-"))
}
if p.Lang() == "" {
p.Metadata.Set("lang", a.deps.Config.Site.Language)
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
fail(err.Error())
return
}
if _, err := a.deps.Store.Save(p); err != nil {
fail("Import failed.")
return
}
http.Redirect(w, r, "/admin/posts/"+p.Slug()+"/edit", http.StatusSeeOther)
}
// readLimited reads at most limit+1 bytes so callers can detect
// oversize uploads.
func readLimited(r io.Reader, limit int64) ([]byte, error) {
raw, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, err
}
if int64(len(raw)) > limit {
return nil, fmt.Errorf("payload too large")
}
return raw, nil
}
+642
View File
@@ -0,0 +1,642 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/markdown"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// registerPostRoutes mounts the post, preview, import and upload
// endpoints. Literal paths are registered before {slug} patterns.
func (a *Admin) registerPostRoutes(mux *http.ServeMux) {
// The exact path, not a subtree: an unknown URL under /admin/ must be
// a 404 rather than a dashboard.
mux.HandleFunc("GET /admin/{$}", a.requireLogin(a.handleDashboard))
mux.HandleFunc("GET /admin/posts/exists", a.requireLogin(a.handleExists))
mux.HandleFunc("GET /admin/posts/new", a.requireLogin(a.handleNewForm))
mux.HandleFunc("GET /admin/posts/import", a.requireLogin(a.handleImportForm))
mux.HandleFunc("POST /admin/posts/import", a.requireLogin(a.handleImport))
mux.HandleFunc("POST /admin/posts/bulk", a.requireLogin(a.handleBulk))
mux.HandleFunc("POST /admin/posts", a.requireLogin(a.handleCreate))
mux.HandleFunc("POST /admin/preview", a.requireLogin(a.handlePreview))
mux.HandleFunc("POST /admin/uploads", a.requireLogin(a.handleUpload))
mux.HandleFunc("GET /admin/posts/{slug}/download", a.requireLogin(a.handleDownload))
mux.HandleFunc("GET /admin/posts/{slug}/history", a.requireLogin(a.handleHistory))
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}", a.requireLogin(a.handleHistoryDownload))
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}/diff", a.requireLogin(a.handleHistoryDiff))
mux.HandleFunc("POST /admin/posts/{slug}/history/{name}/restore", a.requireLogin(a.handleHistoryRestore))
mux.HandleFunc("GET /admin/posts/{slug}/edit", a.requireLogin(a.handleEditForm))
mux.HandleFunc("POST /admin/posts/{slug}/delete", a.requireLogin(a.handleDelete))
mux.HandleFunc("POST /admin/posts/{slug}/undelete", a.requireLogin(a.handleUndelete))
mux.HandleFunc("POST /admin/posts/{slug}/duplicate", a.requireLogin(a.handleDuplicate))
mux.HandleFunc("GET /admin/posts/{slug}/preview-link", a.requireLogin(a.handlePreviewLink))
mux.HandleFunc("POST /admin/posts/{slug}", a.requireLogin(a.handleUpdate))
mux.HandleFunc("GET /admin/icon.svg", a.handleIcon)
}
// requireLogin redirects unauthenticated requests to the login form.
func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
username := sess.Get("user")
record := a.deps.Users.Find(username)
if username == "" || record == nil {
if username != "" {
sess.Clear()
}
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// The session is bound to the password it was issued under: a
// change (the owner's or an admin reset) retires every cookie
// still in the wild, which is what "change the password" has to
// mean for a compromised account.
if sess.Get("pv") != sessionFingerprint(record.PasswordHash) {
sess.Clear()
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Everything logged from here on names the account it happened
// for.
ctx := web.WithLogger(r.Context(), web.Logger(r.Context()).With("user", username))
next(w, r.WithContext(ctx))
}
}
// maxBackupImportBytes bounds the settings import request. A backup
// archive legitimately exceeds max_upload_bytes (it carries every post
// and image), so it gets the same budget backup.Restore enforces on the
// decompressed side.
const maxBackupImportBytes = 512 << 20
// requestLimit is the byte bound on one admin POST body.
func (a *Admin) requestLimit(r *http.Request) int64 {
if r.URL.Path == "/admin/settings/import" {
return maxBackupImportBytes + 1<<20
}
return int64(a.deps.Config.Admin.MaxUploadBytes) + 1<<20
}
// requireCSRF validates the form token and writes the error response
// itself when invalid.
func (a *Admin) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
// The body is bounded before parsing: ParseMultipartForm's argument
// is only the in-memory threshold, and net/http drains the rest of a
// multipart body to temp files on disk whatever the threshold says.
// Wrapping the body also lifts ParseForm's internal 10 MiB urlencoded
// cap, so this limit is the one that applies.
r.Body = http.MaxBytesReader(w, r.Body, a.requestLimit(r))
var parseErr error
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
parseErr = r.ParseMultipartForm(32 << 20)
} else {
// ParseMultipartForm would call ParseForm internally, swallow its
// error and leave the body consumed, so the content type decides
// which parser runs.
parseErr = r.ParseForm()
}
if parseErr != nil {
if _, ok := errors.AsType[*http.MaxBytesError](parseErr); ok {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return false
}
if !errors.Is(parseErr, http.ErrNotMultipart) {
http.Error(w, "bad form", http.StatusBadRequest)
return false
}
// A body that claims a multipart type but is not parseable as one
// falls through; the token check rejects.
}
if !ValidateCSRF(r, session.FromContext(r.Context())) {
http.Error(w, a.tr(r, "Invalid CSRF token"), http.StatusForbidden)
return false
}
return true
}
func (a *Admin) currentUser(r *http.Request) string {
return session.FromContext(r.Context()).Get("user")
}
func (a *Admin) handleDashboard(w http.ResponseWriter, r *http.Request) {
notice := ""
if bulkAction := r.URL.Query().Get("bulk"); bulkAction == "delete" ||
bulkAction == "draft" || bulkAction == "publish" {
if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n > 0 {
id := map[string]string{
"delete": "posts.deleted", "draft": "posts.drafted", "publish": "posts.published",
}[bulkAction]
notice = i18n.Admin.N(a.langFor(r), id, n)
}
}
a.renderPage(w, r, "list.html", a.dashboardData(r, notice), http.StatusOK)
}
// dashboardData builds the dashboard page: one card per publication, the
// language versions merged into a group that the card can switch between,
// and the counters, the recent list and the tag cloud over the same set.
func (a *Admin) dashboardData(r *http.Request, notice string) *PageData {
posts := a.allPostsSorted()
lang := a.langFor(r)
groups := groupPosts(posts)
display := make([]*post.Post, 0, len(groups))
for _, group := range groups {
display = append(display, pickDisplay(group, lang))
}
cards := make([]postCard, 0, len(groups))
stats := dashboardStats{}
var recent []recentPost
type pending struct {
post *post.Post
due time.Time
}
var upcoming []pending
for i, group := range groups {
p := display[i]
card := newPostCard(p)
if len(group) > 1 {
variants := make([]postVariant, 0, len(group))
var slugs []string
seenSlug := map[string]bool{}
for _, member := range group {
variants = append(variants, newPostVariant(member))
if !seenSlug[member.Slug()] {
seenSlug[member.Slug()] = true
slugs = append(slugs, member.Slug())
}
}
slices.SortFunc(variants, func(x, y postVariant) int {
return strings.Compare(x.Lang, y.Lang)
})
card.Variants = variants
card.VariantsJS = variantsJSON(variants)
// One selection acts on the whole publication: the bulk
// form carries every variant slug, split by commas.
card.GroupSlugs = strings.Join(slugs, ",")
}
if card.GroupSlugs == "" {
card.GroupSlugs = p.Slug()
}
cards = append(cards, card)
switch p.Status() {
case post.StatusDraft:
stats.Drafts++
case post.StatusScheduled:
stats.Scheduled++
if due, ok := p.DueAt(); ok {
upcoming = append(upcoming, pending{p, due})
}
default:
stats.Published++
if len(recent) < 3 {
recent = append(recent, recentPost{
Slug: p.Slug(),
Title: p.Title(),
DateString: p.DateString(),
})
}
}
}
stats.Total = len(cards)
stats.Recent = recent
slices.SortStableFunc(upcoming, func(x, y pending) int {
return x.due.Compare(y.due)
})
for _, entry := range upcoming {
if len(stats.Upcoming) >= 5 {
break
}
when := entry.due.Format("2006-01-02")
if h, m := entry.due.Hour(), entry.due.Minute(); h != 0 || m != 0 {
when = entry.due.Format("2006-01-02 15:04")
}
stats.Upcoming = append(stats.Upcoming, scheduledPost{
Slug: entry.post.Slug(),
Title: entry.post.Title(),
When: when,
})
}
var tagCounts []tagCount
for _, entry := range payloads.BuildTagCounts(display) {
tagCounts = append(tagCounts, tagCount{Name: entry.Name, Count: entry.Count})
}
data := a.pageData(r)
data.Posts = cards
data.Stats = stats
data.TagCounts = tagCounts
data.Q = strings.TrimSpace(r.URL.Query().Get("q"))
data.Notice = notice
data.Crumbs = []Crumb{{Label: "Posts", IsLast: true, UI: true}}
return data
}
func (a *Admin) allPostsSorted() []*post.Post {
posts := a.deps.Store.All()
sorted := slices.Clone(posts)
slices.SortStableFunc(sorted, func(x, y *post.Post) int {
return strings.Compare(y.DateString(), x.DateString())
})
return sorted
}
// handleExists answers the slug-availability check of the editor's slug
// field.
func (a *Admin) handleExists(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get("slug")
if slug == "" {
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": ""})
return
}
existing := a.deps.Store.Find(slug, "")
if existing == nil || (r.URL.Query().Get("exclude") != "" &&
existing.Slug() == r.URL.Query().Get("exclude")) {
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": slug})
return
}
writeAdminJSON(w, http.StatusOK, map[string]any{
"available": false, "slug": slug, "title": existing.Title(),
})
}
// editorData fills the shared editor context for new and edit forms.
func (a *Admin) editorData(r *http.Request, mode string, p *post.Post, errorMsg string) *PageData {
data := a.pageData(r)
// The shared validation messages are catalogue keys; an unknown
// message falls back to itself, so nothing breaks untranslated.
data.Error = i18n.Admin.T(data.Lang, errorMsg)
data.Restored = r.URL.Query().Get("restored") != ""
data.Duplicated = r.URL.Query().Get("duplicated") != ""
data.AuthorPlaceholder = i18n.Admin.T(data.Lang, "Author name")
if record := data.CurrentUserRecord; record != nil && record.Name != "" {
data.AuthorPlaceholder = record.Name
}
data.IsNew = mode == "new"
data.IsEdit = mode == "edit"
view := newEditorPost(p)
// The author falls back to the current user record, and the fediverse
// handle to the user record and then to the site.
if view.Author == "" {
if record := data.CurrentUserRecord; record != nil {
view.Author = record.Name
} else {
view.Author = data.CurrentUser
}
}
if view.FediverseCreator == "" {
view.FediverseCreator = a.deps.Config.Site.FediverseCreator
if record := data.CurrentUserRecord; record != nil && record.FediverseCreator != "" {
view.FediverseCreator = record.FediverseCreator
}
}
// The author's ORCID rides on the account: a new post carries it
// unless its own frontmatter names another identifier.
if view.ORCID == "" {
if record := data.CurrentUserRecord; record != nil {
view.ORCID = record.Orcid
}
}
data.Post = view
// The page head's API link carries a preview token, so it opens a
// draft as well as a published post. The token is signed for a week,
// far longer than an editor tab stays open.
data.PreviewToken = preview.Token(view.Slug, a.deps.PreviewKey, time.Now())
// The default excerpt placeholder is interface copy; a derived
// excerpt (the post's own first paragraph) is content and passes
// through untranslated.
if view.ExcerptPlaceholder == "Short summary for listings and previews" {
view.ExcerptPlaceholder = i18n.Admin.T(data.Lang, view.ExcerptPlaceholder)
}
if data.IsNew {
options := tplOptions(a.deps.Templates.All())
data.PostTemplates = options
data.TemplatesJSON = templatesJSON(options)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "New post", IsLast: true, UI: true},
}
} else {
label := view.Title
if strings.TrimSpace(label) == "" {
label = i18n.Admin.T(data.Lang, "Untitled")
}
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: label, IsLast: true},
}
}
return data
}
func (a *Admin) handleNewForm(w http.ResponseWriter, r *http.Request) {
p := post.New(frontmatter.NewMeta(), "")
p.Metadata.Set("lang", a.deps.Config.Site.Language)
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, ""), http.StatusOK)
}
func (a *Admin) handleEditForm(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
http.NotFound(w, r)
return
}
a.renderPage(w, r, "form.html", a.editorData(r, "edit", existing, ""), http.StatusOK)
}
// formMap flattens the request form into a plain string map.
func formMap(r *http.Request) map[string]string {
out := map[string]string{}
for key, values := range r.PostForm {
if len(values) > 0 {
out[key] = values[0]
}
}
return out
}
func (a *Admin) handleCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
p, err := payloads.PostFromParams(formMap(r), nil)
if err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
return
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
return
}
saved, err := payloads.SavePost(a.deps.Store, p, nil)
if err != nil {
a.renderPage(w, r, "form.html",
a.editorData(r, "new", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
return
}
a.fire("post.created", saved)
a.record(r, "post.created", saved.Slug(), nil)
http.Redirect(w, r, "/admin/?saved=created", http.StatusSeeOther)
}
func (a *Admin) handleUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
http.NotFound(w, r)
return
}
p, err := payloads.PostFromParams(formMap(r), existing)
if err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
return
}
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
return
}
// SavePost moves the file when the slug changed, the same way the API
// does, so a rename behaves alike from either entry point.
saved, err := payloads.SavePost(a.deps.Store, p, existing)
if err != nil {
a.renderPage(w, r, "form.html",
a.editorData(r, "edit", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
return
}
a.fire("post.updated", saved)
a.record(r, "post.updated", saved.Slug(), nil)
http.Redirect(w, r, "/admin/?saved=updated", http.StatusSeeOther)
}
func (a *Admin) handleDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
deleted, undoable, err := a.deps.Store.Delete(slug, "")
if err != nil {
a.renderPage(w, r, "list.html",
a.dashboardData(r, i18n.Admin.Tf(a.langFor(r), "The post could not be deleted: %s", err.Error())), http.StatusInternalServerError)
return
}
if deleted == nil {
http.Redirect(w, r, "/admin/?saved=not_found", http.StatusSeeOther)
return
}
// The payload names the post under "post" like every other post
// event, so a subscriber sees one shape whichever entry point fired.
a.fireRaw("post.deleted", map[string]any{
"post": map[string]any{"slug": deleted.Slug(), "title": deleted.Title()},
})
a.record(r, "post.deleted", deleted.Slug(), nil)
target := "/admin/?saved=deleted"
if undoable {
// Only offer Undo when a tombstone exists to undo.
target += "&undo=" + url.QueryEscape(slug)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (a *Admin) handleUndelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
if restored := a.deps.Store.Undelete(slug); restored != nil {
a.fire("post.created", restored)
a.record(r, "post.undeleted", slug, nil)
http.Redirect(w, r, "/admin/?saved=undone", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/?saved=undelete_failed", http.StatusSeeOther)
}
func (a *Admin) handleDuplicate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
source := a.deps.Store.Find(slug, "")
if source == nil {
http.NotFound(w, r)
return
}
newSlug := a.nextAvailableSlug(slug + "-copy")
meta := frontmatter.NewMeta()
for _, key := range source.Metadata.Keys() {
if key == "slug" || key == "date" {
continue
}
value, _ := source.Metadata.Get(key)
meta.Set(key, value)
}
meta.Set("slug", newSlug)
meta.Set("draft", true)
clone := post.New(meta, source.Body)
if err := payloads.CreationError(clone, a.deps.Store, nil); err != nil {
slog.Warn("admin: duplicate rejected", "slug", slug, "error", err)
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
return
}
if _, err := a.deps.Store.Save(clone); err != nil {
slog.Warn("admin: duplicate failed", "slug", slug, "error", err)
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
return
}
a.fire("post.created", clone)
http.Redirect(w, r, "/admin/posts/"+newSlug+"/edit?saved=duplicated", http.StatusSeeOther)
}
func (a *Admin) nextAvailableSlug(base string) string {
candidate := base
for n := 2; a.deps.Store.Find(candidate, "") != nil; n++ {
candidate = fmt.Sprintf("%s-%d", base, n)
}
return candidate
}
func (a *Admin) handleBulk(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
action := r.PostFormValue("action")
var slugs []string
for slug := range strings.SplitSeq(r.PostFormValue("slugs"), ",") {
if slug != "" {
slugs = append(slugs, slug)
}
}
if len(slugs) == 0 || (action != "delete" && action != "draft" && action != "publish") {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
affected := 0
for _, slug := range slugs {
switch action {
case "delete":
deleted, _, err := a.deps.Store.Delete(slug, "")
if err == nil && deleted != nil {
a.fireRaw("post.deleted", map[string]any{
"post": map[string]any{"slug": slug, "title": deleted.Title()},
})
affected++
}
case "draft":
if cached := a.deps.Store.Find(slug, ""); cached != nil && !cached.Draft() {
// Cached posts are shared with other requests: clone first.
p := cached.Clone()
p.Metadata.Set("draft", true)
if _, err := a.deps.Store.Save(p); err == nil {
a.fire("post.updated", p)
affected++
}
}
case "publish":
if cached := a.deps.Store.Find(slug, ""); cached != nil && cached.Draft() {
p := cached.Clone()
p.Metadata.Delete("draft")
if _, err := a.deps.Store.Save(p); err == nil {
a.fireRaw("post.published", map[string]any{"post": payloads.BuildSummary(p)})
affected++
}
}
}
}
if affected > 0 {
a.record(r, "post.bulk_"+action, "", map[string]any{"slugs": slugs, "affected": affected})
}
http.Redirect(w, r, fmt.Sprintf("/admin/?bulk=%s&n=%d", action, affected), http.StatusSeeOther)
}
func (a *Admin) handlePreview(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
htmlOut, err := markdown.Render(r.PostFormValue("body"))
if err != nil {
http.Error(w, "render failed", http.StatusInternalServerError)
return
}
// The preview body carries no frontmatter, so the reference list it
// knows about comes from the saved post under the same slug: the
// editor then sees the bibliography the published page will show,
// not the raw [[refs]] marker. A new post has no saved refs, and its
// marker paragraph stays as written.
slug := r.PostFormValue("slug")
lang := r.PostFormValue("lang")
if slug != "" {
if p := a.deps.Store.Find(slug, lang); p != nil {
if refs := p.RefsLinked(); len(refs) > 0 {
htmlOut = biblio.LinkCitations(htmlOut, refs)
htmlOut = biblio.Place(htmlOut, refs)
}
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, htmlOut)
}
// --- import, download, history ---------------------------------------------
// fire and fireRaw notify the optional webhook sink.
func (a *Admin) fire(event string, p *post.Post) {
a.fireRaw(event, map[string]any{"post": payloads.BuildSummary(p)})
}
func (a *Admin) fireRaw(event string, payload map[string]any) {
if a.deps.OnEvent != nil {
a.deps.OnEvent(event, payload)
}
}
// handlePreviewLink returns a shareable preview URL for a draft or
// scheduled post. The link is signed with the session key, so without
// one no link can be honoured and none is offered.
func (a *Admin) handlePreviewLink(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if a.deps.Store.Find(slug, "") == nil {
http.NotFound(w, r)
return
}
token := preview.Token(slug, a.deps.PreviewKey, time.Now())
if token == "" {
writeAdminJSONError(w, http.StatusConflict, "no_session_key",
"Preview links need a session key: set [admin].session_key or make the state directory writable.")
return
}
base := strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
writeAdminJSON(w, http.StatusOK, map[string]any{
"url": fmt.Sprintf("%s/api/volumen/posts/%s?preview_token=%s", base, slug, token),
"token": token,
})
}
+929
View File
@@ -0,0 +1,929 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
)
func writeTestPost(t *testing.T, f *fixture, name, body string) {
t.Helper()
path := filepath.Join(f.contentDir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write post: %v", err)
}
}
const samplePost = `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "cs"
tags = ["go", "blog"]
+++
Hello **body**.
`
func TestDashboardRenders(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Posts", "Hello", `data-slug="hello"`, "Published", "Drafts",
`data-tag="go"`, "1 post", "Log out",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
func TestDashboardGroupsLanguageVariants(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "en"
translations = { cs = "ahoj" }
+++
English body.
`)
writeTestPost(t, f, "ahoj.md", `+++
title = "Ahoj"
slug = "ahoj"
date = 2026-08-18
lang = "cs"
translations = { en = "hello" }
+++
České tělo.
`)
writeTestPost(t, f, "lonely.md", `+++
title = "Lonely"
slug = "lonely"
date = 2026-08-17
lang = "en"
+++
Alone.
`)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
// The linked pair is one card, the unrelated post the second one.
if got := strings.Count(body, `<article class="post"`); got != 2 {
t.Fatalf("cards = %d, want 2", got)
}
if got := strings.Count(body, `data-variants=`); got != 1 {
t.Fatalf("cards with variants = %d, want 1", got)
}
if !strings.Contains(body, `data-slug="hello"`) || strings.Contains(body, `data-slug="ahoj"`) {
t.Fatal("the variant ahoj must not stand as its own card")
}
// Both language versions are reachable from the switch chips.
if !strings.Contains(body, `data-lang="en"`) || !strings.Contains(body, `data-lang="cs"`) {
t.Fatal("the card must offer both language variants")
}
// The selection acts on the whole publication: both slugs ride along.
if !strings.Contains(body, `value="hello,ahoj"`) && !strings.Contains(body, `value="ahoj,hello"`) {
t.Fatal("the bulk selection must carry every variant slug")
}
}
func TestDashboardRequiresLogin(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestNewFormRendersEditor(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"New post", `id="post-form"`, `action="/admin/posts"`, "Markdown"} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
func TestEditFormRendersPost(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Edit post", `value="Hello"`, `value="hello"`, `readonly`,
`action="/admin/posts/hello"`, "Hello **body**.",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
req = httptest.NewRequest(http.MethodGet, "/admin/posts/ghost/edit", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestCreatePostViaForm(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf},
"title": {"Fresh"},
"slug": {"fresh"},
"lang": {"cs"},
"tags": {"a, b"},
"body": {"content"},
"draft": {"on"},
"author": {"Petr"},
}
rec := postForm(t, f, "/admin/posts", form, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=created" {
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
if p := f.findPost("fresh"); p == nil || p.Title() != "Fresh" || !p.Draft() {
t.Fatalf("post = %v", p)
}
if len(f.events) == 0 || f.events[len(f.events)-1] != "post.created" {
t.Fatalf("events = %v", f.events)
}
}
func TestCreatePostValidationRendersForm(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}, "title": {"X"}, "slug": {"Bad Slug"}, "body": {"b"}}
rec := postForm(t, f, "/admin/posts", form, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid slug.") {
t.Fatal("validation message missing")
}
}
func TestUpdatePostKeepsPath(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf}, "title": {"Updated"}, "slug": {"hello"},
"lang": {"cs"}, "tags": {"go"}, "body": {"new body"},
}
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
p := f.findPost("hello")
if p == nil || p.Title() != "Updated" || p.Body != "new body\n" {
t.Fatalf("post = %v", p)
}
}
// The bibliography card writes the refs tables through the whole save
// path: the editor's JSON reaches the file as [[refs]] blocks, an
// author's ORCID survives as a name table, and a frontmatter key the
// form never names round-trips untouched beside them.
func TestEditorSavesTheBibliography(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Cite"
slug = "hello"
date = 2026-08-18
note = "keep me"
tags = ["go"]
[[refs]]
raw = "Old entry."
+++
Cites [1].
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// The edit form hands the stored list to the card's script, and the
// card sits below the editor with its own bounded list.
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
for _, want := range []string{`id="refs-card"`, "Old entry.", `id="ref-row-template"`, `id="refs-count"`, `class="refs-scroll"`} {
if !strings.Contains(body, want) {
t.Fatalf("edit form missing %q", want)
}
}
if strings.Index(body, `id="refs-card"`) < strings.Index(body, `id="post-form"`) {
t.Fatal("the bibliography card must not precede the form")
}
editorSection := strings.Index(body, `id="markdown-view"`)
refsCard := strings.Index(body, `id="refs-card"`)
if editorSection == -1 || refsCard == -1 || refsCard < editorSection {
t.Fatal("the bibliography card must sit below the editor")
}
form := url.Values{
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Cites [1].\n\n[[refs]]\n"},
"refs": {`[` +
`{"num":2,"raw":"Kept and edited."},` +
`{"raw":"Added verbatim.","doi":"10.1086/300499"},` +
`{"authors":[{"name":"Adam Riess","orcid":"0000-0002-1825-0097"}],` +
`"title":"Observational Evidence","year":"1998"}` +
`]`},
}
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
raw, err := os.ReadFile(filepath.Join(f.contentDir, "hello.md"))
if err != nil {
t.Fatalf("read post: %v", err)
}
file := string(raw)
for _, want := range []string{
`note = "keep me"`,
"[[refs]]",
"raw = \"Kept and edited.\"",
"num = 2",
"raw = \"Added verbatim.\"",
`doi = "10.1086/300499"`,
`title = "Observational Evidence"`,
`{name = "Adam Riess", orcid = "0000-0002-1825-0097"}`,
} {
if !strings.Contains(file, want) {
t.Fatalf("saved file missing %q:\n%s", want, file)
}
}
if strings.Contains(file, "Old entry.") {
t.Fatalf("the replaced entry survived:\n%s", file)
}
// The rewritten list renders with its citations linked.
p := f.findPost("hello")
refs := p.RefsLinked()
if len(refs) != 3 {
t.Fatalf("refs = %v", refs)
}
if refs[0].Num != 2 || refs[0].Raw != "Kept and edited." {
t.Fatalf("first entry = %+v", refs[0])
}
html, err := p.HTML()
if err != nil {
t.Fatalf("render: %v", err)
}
// The preserved numbers name the anchors: the first entry keeps its
// explicit num = 2, so the list carries ref-2 and ref-3 and no ref-1.
if !strings.Contains(html, `id="ref-2"`) || !strings.Contains(html, `id="ref-3"`) {
t.Fatalf("rendered list wrong:\n%s", html)
}
if strings.Contains(html, `id="ref-1"`) {
t.Fatalf("an unnumbered anchor appeared:\n%s", html)
}
}
// A save whose form carries no refs field keeps the stored list, so the
// bibliography never disappears under a page that does not edit it.
func TestEditorWithoutRefsKeepsTheStoredList(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Cite"
slug = "hello"
date = 2026-08-18
[[refs]]
raw = "Old entry."
+++
Body.
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Body.\n"},
}
if rec := postForm(t, f, "/admin/posts/hello", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
if refs := f.findPost("hello").Refs(); len(refs) != 1 || refs[0].Raw != "Old entry." {
t.Fatalf("refs = %v", refs)
}
}
func TestDeleteAndUndeleteFlow(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther ||
!strings.Contains(rec.Header().Get("Location"), "saved=deleted&undo=hello") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello") != nil {
t.Fatal("post still present")
}
// The webhook contract names the post under the "post" key, the same
// shape every other post event and the API's delete endpoint deliver.
last := len(f.events) - 1
if last < 0 || f.events[last] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
inner, ok := f.payloads[last]["post"].(map[string]any)
if !ok || inner["slug"] != "hello" || inner["title"] != "Hello" {
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
}
rec = postForm(t, f, "/admin/posts/hello/undelete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=undone" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello") == nil {
t.Fatal("post not restored")
}
}
// A bulk delete delivers the same post.deleted shape as the single
// delete, so a subscriber cannot tell which screen the change came from.
func TestBulkDeleteEventShape(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"hello"}}, cookie)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "n=1") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
last := len(f.events) - 1
if last < 0 || f.events[last] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
inner, ok := f.payloads[last]["post"].(map[string]any)
if !ok || inner["slug"] != "hello" {
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
}
}
func TestDuplicateCreatesDraftCopy(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther ||
!strings.Contains(rec.Header().Get("Location"), "/admin/posts/hello-copy/edit") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
copyPost := f.findPost("hello-copy")
if copyPost == nil || !copyPost.Draft() {
t.Fatalf("copy = %v", copyPost)
}
if copyPost.DateString() != "" {
t.Fatalf("copy kept the date: %q", copyPost.DateString())
}
// A second duplicate gets the -copy-2 suffix.
rec = postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Header().Get("Location"), "hello-copy-2") {
t.Fatalf("location = %q", rec.Header().Get("Location"))
}
}
// A duplicate that cannot be created must say so on the dashboard, not
// disappear behind a silent redirect.
func TestDuplicateFailureIsReported(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
doi = "not-a-doi"
+++
Body.
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=duplicate_failed" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello-copy") != nil {
t.Fatal("a rejected duplicate must not be saved")
}
}
// The editor's API link carries a valid preview token, so it opens a
// draft as well as a published post.
func TestEditFormCarriesPreviewToken(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
mark := `/api/volumen/posts/hello?preview_token=`
i := strings.Index(body, mark)
if i < 0 {
t.Fatal("API link without a preview token")
}
token, _, _ := strings.Cut(body[i+len(mark):], `"`)
if !preview.Valid(token, "hello", f.admin.deps.PreviewKey, time.Now()) {
t.Fatalf("preview token invalid: %q", token)
}
}
func TestBulkActions(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "a.md", "+++\nslug = \"a\"\ntitle = \"A\"\ndraft = true\n+++\nx\n")
writeTestPost(t, f, "b.md", "+++\nslug = \"b\"\ntitle = \"B\"\n+++\nx\n")
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"publish"}, "slugs": {"a"}}, cookie)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "bulk=publish&n=1") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("a").Draft() {
t.Fatal("post not published")
}
rec = postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"a,b"}}, cookie)
if !strings.Contains(rec.Header().Get("Location"), "n=2") {
t.Fatalf("location = %q", rec.Header().Get("Location"))
}
if f.findPost("a") != nil || f.findPost("b") != nil {
t.Fatal("posts not deleted")
}
}
func TestSlugExistsEndpoint(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
for path, want := range map[string]string{
"/admin/posts/exists?slug=hello": `"available":false`,
"/admin/posts/exists?slug=fresh": `"available":true`,
"/admin/posts/exists?slug=": `"available":true`,
"/admin/posts/exists?slug=hello&exclude=hello": `"available":true`,
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
t.Fatalf("%s: code=%d body=%s", path, rec.Code, rec.Body.String())
}
}
}
func TestPreviewEndpoint(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/preview", url.Values{"_csrf": {csrf}, "body": {"**bold**"}}, cookie)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<strong>bold</strong>") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPreviewLinkEndpoint(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "draft.md", "+++\nslug = \"d\"\ndraft = true\n+++\nx\n")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/d/preview-link", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "preview_token=") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestImportFlow(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "imported.md", "+++\ntitle = \"Imported\"\nslug = \"imported\"\n+++\nbody\n")
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/posts/imported/edit" {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if f.findPost("imported") == nil {
t.Fatal("import not saved")
}
// Non-.md rejected.
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "evil.txt", "content")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d", rec.Code)
}
// Import without a slug derives one from the file name.
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "derived-slug.md", "Just a body, no frontmatter.\n")
if rec.Code != http.StatusSeeOther {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if f.findPost("derived-slug") == nil {
t.Fatal("derived slug import failed")
}
}
func TestDownloadAndHistory(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// Saving twice archives one revision.
form := url.Values{
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
"lang": {"cs"}, "body": {"changed"},
}
postForm(t, f, "/admin/posts/hello", form, cookie)
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/download", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "title = \"Hello\"") {
t.Fatalf("download code=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Disposition"), `filename="hello.md"`) {
t.Fatalf("disposition = %q", rec.Header().Get("Content-Disposition"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history", nil)
req.AddCookie(cookie)
rec = f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "History") {
t.Fatalf("history code=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), " kB") {
t.Fatal("revision size missing")
}
}
func TestUploadRejectsGarbage(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf,
"file", "x.webp", "not an image at all")
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
rec = multipartForm(t, f, "/admin/uploads", cookie, csrf,
"file", "pic.png", string(webpData))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "/media/") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCSRFRequiredOnMutations(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
for _, path := range []string{
"/admin/posts", "/admin/posts/bulk", "/admin/preview",
"/admin/posts/import",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("_csrf=wrong"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("%s: code = %d, want 403", path, rec.Code)
}
}
}
// --- helpers ---------------------------------------------------------------
func (f *fixture) findPost(slug string) *post.Post {
return f.storeObj.Find(slug, "")
}
// csrfFromSession performs the login GET flow and returns the CSRF token.
func csrfFromSession(t *testing.T, f *fixture, cookie *http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code == http.StatusOK {
return extractCSRF(t, rec.Body.String())
}
// Authenticated: pull the token from the session instead.
sess := f.store.Load(req)
return CSRFToken(sess)
}
func postForm(t *testing.T, f *fixture, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
return f.do(t, req)
}
func multipartForm(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field, filename, content string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("_csrf", csrf)
part, err := mw.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write([]byte(content)); err != nil {
t.Fatalf("write part: %v", err)
}
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, path, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
return f.do(t, req)
}
// The history page's two per-revision endpoints are the ones an operator
// reaches for after a bad edit, so both are exercised: the download and
// the restore, including the redirect that carries the flash message.
func TestHistoryDownloadAndRestore(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// One save archives the original.
postForm(t, f, "/admin/posts/hello", url.Values{
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
"lang": {"cs"}, "body": {"changed"},
}, cookie)
revisions := f.admin.deps.Store.Revisions("hello")
if len(revisions) != 1 {
t.Fatalf("revisions = %v", revisions)
}
name := revisions[0].Name
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/"+name, nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("history download code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Hello **body**.") {
t.Fatalf("revision body = %s", rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "hello-"+name) {
t.Fatalf("disposition = %q", got)
}
// An unknown revision name is a 404, not an empty file.
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/nope.md", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("unknown revision code = %d", rec.Code)
}
// Restoring puts the archived body back and redirects to the editor.
req = httptest.NewRequest(http.MethodPost, "/admin/posts/hello/history/"+name+"/restore",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec = f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("restore code = %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/admin/posts/hello/edit?restored=1" {
t.Fatalf("location = %q", got)
}
restored := f.storeObj.Find("hello", "")
if restored == nil || !strings.Contains(restored.Body, "Hello **body**.") {
t.Fatalf("body after restore = %q", restored.Body)
}
}
// The brand SVG loads on every admin page, so a broken embed pattern
// would break the whole UI silently. The one asset is the icon: the
// favicon, the login brand and the topbar badge all read the same file.
func TestStaticSVGRoutes(t *testing.T) {
f := newFixture(t)
const path = "/admin/icon.svg"
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
t.Fatalf("%s: content-type = %q", path, got)
}
if !strings.Contains(rec.Body.String(), "<svg") {
t.Fatalf("%s: body is not an SVG", path)
}
}
func TestImportFormRenders(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/import", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `name="file"`) {
t.Fatalf("import form code=%d body=%s", rec.Code, rec.Body.String())
}
}
// Deleting a media file removes it from the library and from the public
// route, and a second delete reports the absence.
func TestMediaDelete(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf, "file", "x.webp", string(webpFixture()))
if rec.Code != http.StatusOK {
t.Fatalf("upload code = %d body=%s", rec.Code, rec.Body.String())
}
items := f.storeObj.ListMedia()
if len(items) != 1 {
t.Fatalf("media = %v", items)
}
name := items[0].Name
req := httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
t.Fatalf("delete code = %d", rec.Code)
}
if len(f.storeObj.ListMedia()) != 0 {
t.Fatal("media survived the delete")
}
req = httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("second delete code = %d, want 404", rec.Code)
}
}
// webpFixture is a minimal RIFF/WEBP header, enough for the signature
// check the upload path performs.
func webpFixture() []byte {
data := append([]byte("RIFF"), 0, 0, 0, 0)
return append(data, []byte("WEBPVP8 ")...)
}
// A malformed date in the editor form is rejected with the post
// re-rendered, rather than silently dropping an inherited schedule.
func TestEditorRejectsMalformedPublishAt(t *testing.T) {
f := newFixture(t)
if err := os.WriteFile(filepath.Join(f.contentDir, "sched.md"),
[]byte("+++\ntitle = \"S\"\nslug = \"sched\"\npublish_at = 2999-01-01\n+++\nbody\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/sched", url.Values{
"_csrf": {csrf}, "title": {"S"}, "slug": {"sched"},
"publish_at": {"not a date"}, "body": {"body"},
}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "publish_at must be an ISO 8601 date") {
t.Fatal("validation message missing")
}
if p := f.admin.deps.Store.Find("sched", ""); p == nil || !p.Scheduled() {
t.Fatal("the stored schedule was dropped by the rejected save")
}
}
// An admin POST body over the configured allowance is cut off with 413
// before it can fill the temp directory.
func TestAdminBodyOverTheLimitIs413(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
huge := strings.Repeat("a", 12*1024*1024)
rec := postForm(t, f, "/admin/posts", url.Values{
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
}, cookie)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("code = %d, want 413", rec.Code)
}
}
// The editor preview must show the bibliography the published page
// will show: the refs live in the saved post's frontmatter, which the
// body-only render cannot see on its own.
func TestPreviewWeavesSavedRefs(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "cite.md", `+++
title = "Cite"
slug = "cite"
[[refs]]
title = "Observational evidence from supernovae"
doi = "10.1103/PhysRevD.59.103502"
+++
Tvrzení [1].
[[refs]]
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/preview", url.Values{
"_csrf": {csrf}, "body": {"Tvrzení [1].\n\n[[refs]]\n"},
"slug": {"cite"},
}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`<section class="refs" id="references">`,
`<a class="ref-cite" href="#ref-1">[1]</a>`,
`href="https://doi.org/10.1103/PhysRevD.59.103502"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("preview missing %q:\n%s", want, body)
}
}
// A new post with no saved refs keeps the marker as inert text, and
// an unknown slug is just the plain body render.
for _, slug := range []string{"", "ghost"} {
rec := postForm(t, f, "/admin/preview", url.Values{
"_csrf": {csrf}, "body": {"[[refs]]\n"}, "slug": {slug},
}, cookie)
if !strings.Contains(rec.Body.String(), biblio.Marker) {
t.Fatalf("slug %q: preview rewrote an unsaved marker:\n%s", slug, rec.Body.String())
}
}
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"log/slog"
"net/http"
"strconv"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// writeAdminJSON writes one JSON object response; encoding/json escapes
// what a browser's JSON.parse requires, which a %q verb does not.
func writeAdminJSON(w http.ResponseWriter, status int, value map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.MarshalWrite(w, value, json.Deterministic(true)); err != nil {
slog.Warn("admin: cannot encode JSON response", "error", err)
}
}
func (a *Admin) handleUpload(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAdminJSONError(w, http.StatusBadRequest, "no_file", i18n.Admin.T(a.langFor(r), "No file was uploaded."))
return
}
defer file.Close()
limit := int64(a.deps.Config.Admin.MaxUploadBytes)
raw, err := readLimited(file, limit)
if err != nil {
writeAdminJSONError(w, http.StatusRequestEntityTooLarge, "too_large",
i18n.Admin.Tf(a.langFor(r),
"The file could not be read (limit %s bytes).",
strconv.FormatInt(limit, 10)))
return
}
if errMsg := validateImageData(raw); errMsg != "" {
writeAdminJSONError(w, http.StatusUnsupportedMediaType, errMsg,
i18n.Admin.T(a.langFor(r), "Only WebP, AVIF and SVG images are supported."))
return
}
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
if err != nil {
writeAdminJSONError(w, http.StatusInternalServerError, "upload_failed",
i18n.Admin.T(a.langFor(r), "The upload could not be stored."))
return
}
writeAdminJSON(w, http.StatusOK, map[string]any{"url": url})
}
func writeAdminJSONError(w http.ResponseWriter, status int, code, message string) {
writeAdminJSON(w, status, map[string]any{"error": code, "message": message})
}
// validateImageData reports why data is not an acceptable upload. The
// stored extension is taken from the byte signature, so the declared
// filename's type is irrelevant: what matters is that the bytes are one
// of the accepted image formats.
func validateImageData(data []byte) string {
if imagefile.Detect(data) == "" {
return "invalid_signature"
}
return ""
}
func (a *Admin) handleIcon(w http.ResponseWriter, _ *http.Request) {
a.serveStaticSVG(w, "volumen-icon.svg")
}
func (a *Admin) serveStaticSVG(w http.ResponseWriter, name string) {
data, err := web.StaticFile(name)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
+244
View File
@@ -0,0 +1,244 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package admin serves the server-rendered admin UI: authentication,
// post management, settings, and the media library.
package admin
import (
"bytes"
"context"
"encoding/json/v2"
"fmt"
"html/template"
"io"
"strings"
"sync"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/diff"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// Crumb is one breadcrumb entry. UI marks a fixed interface label the
// renderer translates; content labels (post titles) pass through.
type Crumb struct {
Label string
Href string
IsLast bool
UI bool
}
// PageData is the template context shared by every admin page; page
// handlers fill the specific fields they need.
type PageData struct {
Config *config.Config
Path string
CSPNonce string
Version string
UpdateAvailable string
// Lang is the interface language this request renders in ("en" or
// "cs"), resolved from the account, the language cookie, or the
// site language. It feeds the html lang attribute, which the date
// picker and the relative-time formatter read.
Lang string
// Theme is the colour scheme this request renders in, resolved
// from the account, the theme cookie, or the default. It feeds the
// html data-theme attribute the stylesheet's scheme blocks read.
Theme string
CurrentUser string
CurrentRole string
CurrentUserRecord *users.User
UsersExist bool
CSRFToken string
IsLogin bool
IsSetup bool
IsAuthenticated bool
// SetupI18n carries the wizard's strings in every shipped
// language, so the language chips can swap the page text without
// a reload and without losing what the operator typed.
SetupI18n template.JS
DisplayName string
UserPhoto string
UserInitial string
Crumbs []Crumb
Error string
RetryAfter int
Notice string
// Dashboard.
Posts []postCard
Stats dashboardStats
TagCounts []tagCount
Q string
// Editor and history.
IsNew bool
IsEdit bool
Post *editorPost
PostTemplates []tplOption
TemplatesJSON template.JS
Restored bool
Duplicated bool
AuthorPlaceholder string
PreviewToken string
Slug string
Heading string
Revisions []revisionRow
DiffChunks []diff.Chunk
DiffName string
DiffWhen string
// Settings.
IsAdmin bool
Roles []string
DefaultRole string
UserRows []userRow
TemplatesList []tplOption
WebhookRows []hookRow
WebhookDeliveries []deliveryRow
TokenRows []tokenRow
NewToken string
MediaItems []mediaRow
// The second factor: its state on the account, an enrolment in
// flight, and the one-time recovery codes a change just produced.
TotpEnabled bool
TotpPending bool
TotpSVG template.HTML
TotpSecret string
TotpURI string
RecoveryCodes []string
RecoveryNotice string
MediaTotal string
Target string
// Sidebar navigation highlighting.
NavPosts bool
NavNew bool
NavImport bool
NavMedia bool
NavSettings bool
}
// Tr translates a simple message in this request's language. Handlers
// use it for the strings they compose in Go; templates use the tr and
// trn funcs, which read the same catalogue.
func (d *PageData) Tr(s string) string {
return i18n.Admin.T(d.Lang, s)
}
// Trf translates a simple message with one value.
func (d *PageData) Trf(s, arg string) string {
return i18n.Admin.Tf(d.Lang, s, arg)
}
// langRenderer is one language's parsed template set. The translation
// funcs close over the language, so a page renders whole in one tongue
// with no per-string lookups in the handlers.
type langRenderer struct {
pages map[string]*template.Template
}
// Renderer executes the embedded admin templates in every shipped
// language.
type Renderer struct {
mu sync.Mutex
langs map[string]*langRenderer
}
// NewRenderer parses the layout together with every page template, one
// set per shipped language.
func NewRenderer() (*Renderer, error) {
fs := web.TemplateFS()
r := &Renderer{langs: map[string]*langRenderer{}}
for _, lang := range i18n.Languages {
base, err := template.New("layout.html").Funcs(funcMap(lang)).ParseFS(fs, "templates/layout.html")
if err != nil {
return nil, fmt.Errorf("parse layout (%s): %w", lang, err)
}
lr := &langRenderer{pages: map[string]*template.Template{}}
for _, page := range pageNames() {
clone, err := base.Clone()
if err != nil {
return nil, fmt.Errorf("clone layout for %s (%s): %w", page, lang, err)
}
if _, err := clone.ParseFS(fs, "templates/"+page); err != nil {
return nil, fmt.Errorf("parse %s (%s): %w", page, lang, err)
}
lr.pages[page] = clone
}
r.langs[lang] = lr
}
return r, nil
}
// pageNames lists the page templates parsed alongside the layout.
func pageNames() []string {
return []string{
"login.html", "setup.html", "twofactor.html", "list.html", "form.html", "history.html", "diff.html",
"import.html",
"settings.html", "media.html", "update.html", "notfound.html",
}
}
// Render executes the named page inside the layout shell, in the
// language the page data carries. The context is the request's, so a
// template failure is logged against it.
func (r *Renderer) Render(ctx context.Context, w io.Writer, page string, data *PageData) error {
lang := data.Lang
if !i18n.Valid(lang) {
lang = "en"
}
// Fixed breadcrumb labels are interface strings; content labels
// (a post title) pass through untouched.
for i, c := range data.Crumbs {
if c.UI {
data.Crumbs[i].Label = i18n.Admin.T(lang, c.Label)
}
}
r.mu.Lock()
lr := r.langs[lang]
r.mu.Unlock()
tmpl, ok := lr.pages[page]
if !ok {
return fmt.Errorf("unknown admin page %q", page)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
web.Logger(ctx).Warn("admin: template error", "page", page, "error", err)
return err
}
_, err := w.Write(buf.Bytes())
return err
}
func funcMap(lang string) template.FuncMap {
cat := i18n.Admin
return template.FuncMap{
"lower": strings.ToLower,
"join": func(items []string, sep string) string { return strings.Join(items, sep) },
"tr": func(s string) string { return cat.T(lang, s) },
"trh": func(s string) template.HTML { return template.HTML(cat.TH(lang, s)) },
"trf": func(s, arg string) string { return cat.Tf(lang, s, arg) },
"trn": func(n int, id string) string { return cat.N(lang, id, n) },
"i18nJSON": func() template.JS {
b, err := json.Marshal(cat.JS(lang))
if err != nil {
return "{}"
}
// The catalogue holds authored strings only, but the same
// script-embedding rule as templatesJSON applies: no literal
// "<" may reach the page inside a script element.
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
},
}
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/session"
)
// passwordPolicy returns the configured length bounds. Validate
// guarantees a minimum of at least one and a maximum at or above it
// before the server starts.
func (a *Admin) passwordPolicy() (int, int) {
return a.deps.Config.Admin.MinPasswordLength, a.deps.Config.Admin.MaxPasswordLength
}
func (a *Admin) handleSettingsPassword(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := a.currentUser(r)
if a.deps.Users.Authenticate(username, r.PostFormValue("current_password")) == nil {
a.renderSettings(w, r, a.tr(r, "Current password is incorrect."), "", http.StatusUnprocessableEntity)
return
}
newPassword := r.PostFormValue("new_password")
if strings.TrimSpace(newPassword) == "" {
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdatePassword(username, newPassword); err != nil {
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
// Every other session dies with the changed fingerprint; this one is
// re-bound, so the device the change was made on stays signed in.
if updated := a.deps.Users.Find(username); updated != nil {
session.FromContext(r.Context()).Set("pv", sessionFingerprint(updated.PasswordHash))
}
a.record(r, "user.password_changed", username, nil)
a.renderSettings(w, r, "", a.tr(r, "Password updated."), http.StatusOK)
}
func (a *Admin) handleSettingsUsername(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
current := a.currentUser(r)
name := strings.TrimSpace(r.PostFormValue("username"))
sess := session.FromContext(r.Context())
switch {
case name == "":
a.renderSettings(w, r, a.tr(r, "Username cannot be empty."), "", http.StatusUnprocessableEntity)
case !usernameRe.MatchString(name):
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
case name == current:
a.renderSettings(w, r, "", a.tr(r, "Username unchanged."), http.StatusOK)
default:
if _, err := a.deps.Users.Rename(current, name); err != nil {
a.renderSettings(w, r, a.trf(r, "The username could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
sess.Set("user", name)
a.record(r, "user.renamed", name, nil)
a.renderSettings(w, r, "", a.tr(r, "Username updated."), http.StatusOK)
}
}
func (a *Admin) handleSettingsName(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := strings.TrimSpace(r.PostFormValue("name"))
if _, err := a.deps.Users.UpdateName(a.currentUser(r), name); err != nil {
a.renderSettings(w, r, a.trf(r, "The display name could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
notice := a.tr(r, "Display name updated.")
if name == "" {
notice = a.tr(r, "Display name cleared.")
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
func (a *Admin) handleSettingsFediverse(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
value := strings.TrimSpace(r.PostFormValue("fediverse_creator"))
if value == "" {
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle cleared."), http.StatusOK)
return
}
if !fediverse.Valid(value) {
a.renderSettings(w, r, a.tr(r, "Fediverse handle must look like @user@host."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), value); err != nil {
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle updated."), http.StatusOK)
}
func (a *Admin) handleSettingsOrcid(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
value := identifiers.NormalizeORCID(r.PostFormValue("orcid"))
if value == "" {
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "ORCID cleared."), http.StatusOK)
return
}
if !identifiers.ValidORCID(value) {
a.renderSettings(w, r, a.tr(r, "ORCID must look like 0000-0002-1825-0097."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), value); err != nil {
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "ORCID updated."), http.StatusOK)
}
func (a *Admin) handleSettingsPhoto(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, header, err := r.FormFile("photo")
if err != nil {
a.renderSettings(w, r, a.tr(r, "No file selected."), "", http.StatusUnprocessableEntity)
return
}
defer file.Close()
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
if err != nil {
a.renderSettings(w, r, a.tr(r, "File is too large."), "", http.StatusUnprocessableEntity)
return
}
if validateImageData(raw) != "" {
a.renderSettings(w, r,
a.tr(r, "Only WebP, AVIF and SVG images are supported."), "", http.StatusUnprocessableEntity)
return
}
username := a.currentUser(r)
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
if err != nil {
a.renderSettings(w, r, a.tr(r, "The photo could not be stored."), "", http.StatusInternalServerError)
return
}
previous := ""
if record := a.deps.Users.Find(username); record != nil {
previous = record.Photo
}
if _, err := a.deps.Users.UpdatePhoto(username, url); err != nil {
a.renderSettings(w, r, a.trf(r, "The profile photo could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
if previous != "" && previous != url {
a.deleteUnreferencedMedia(previous)
}
a.renderSettings(w, r, "", a.tr(r, "Profile photo updated."), http.StatusOK)
}
func (a *Admin) handleSettingsPhotoRemove(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := a.currentUser(r)
previous := ""
if record := a.deps.Users.Find(username); record != nil {
previous = record.Photo
}
if _, err := a.deps.Users.UpdatePhoto(username, ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The profile photo could not be removed: %s", err.Error()), "", http.StatusInternalServerError)
return
}
if previous != "" {
a.deleteUnreferencedMedia(previous)
}
a.renderSettings(w, r, "", a.tr(r, "Profile photo removed."), http.StatusOK)
}
// deleteUnreferencedMedia removes a photo file no user references any
// more.
func (a *Admin) deleteUnreferencedMedia(url string) {
if !strings.HasPrefix(url, "/media/") {
return
}
for _, user := range a.deps.Users.All() {
if user.Photo == url {
return
}
}
a.deps.Store.DeleteMedia(url)
}
// --- users panel ------------------------------------------------------------
+170
View File
@@ -0,0 +1,170 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"maps"
"net/http"
"slices"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
)
// templateFields are the editor inputs a template may pre-fill beyond
// its title, slug, tags and body; anything else in the fields box is a
// typo waiting to seed every new post with a key nobody reads.
var templateFields = map[string]bool{
"lang": true, "author": true, "fediverse_creator": true,
"doi": true, "orcid": true,
"series": true, "series_order": true,
"excerpt": true, "cover": true, "cover_alt": true, "cover_caption": true,
}
// parseTemplateFields reads the fields box: key = value lines in TOML,
// each key an editor field. unknown names the first key outside the
// allowed set; err reports text that is not a small TOML document.
func parseTemplateFields(text string) (fields map[string]string, unknown string, err error) {
if strings.TrimSpace(text) == "" {
return nil, "", nil
}
data, err := interpres.ParseMap([]byte(text))
if err != nil {
return nil, "", fmt.Errorf("template fields must be key = value lines")
}
out := map[string]string{}
for _, key := range slices.Sorted(maps.Keys(data)) {
if !templateFields[key] {
return nil, key, nil
}
value := data[key]
if value == nil {
continue
}
if text, isString := value.(string); isString {
if text == "" {
continue
}
out[key] = text
continue
}
if number, isInt := value.(int64); isInt {
out[key] = strconv.FormatInt(number, 10)
continue
}
out[key] = fmt.Sprintf("%v", value)
}
if len(out) == 0 {
return nil, "", nil
}
return out, "", nil
}
func (a *Admin) handleSettingsTemplateCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := strings.TrimSpace(r.PostFormValue("name"))
if name == "" {
a.renderSettings(w, r, a.tr(r, "Template name is required."), "", http.StatusUnprocessableEntity)
return
}
fields, unknown, err := parseTemplateFields(r.PostFormValue("fields"))
switch {
case err != nil:
a.renderSettings(w, r, a.tr(r, "Template fields must be key = value TOML lines."), "", http.StatusUnprocessableEntity)
return
case unknown != "":
a.renderSettings(w, r, a.trf(r, "Unknown template field %s.", unknown), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Templates.Add(templates.PostTemplate{
Name: name,
Tags: payloads.ParseTags(r.PostFormValue("tags")),
Body: r.PostFormValue("body"),
Title: strings.TrimSpace(r.PostFormValue("title")),
Slug: strings.TrimSpace(r.PostFormValue("slug")),
Fields: fields,
}); err != nil {
a.renderSettings(w, r, a.trf(r, "That template could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.renderSettings(w, r, "", a.tr(r, "Template added."), http.StatusOK)
}
func (a *Admin) handleSettingsTemplateDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if err := a.deps.Templates.Delete(r.PathValue("name")); err != nil {
a.renderSettings(w, r, a.trf(r, "The template could not be deleted: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.renderSettings(w, r, "", a.tr(r, "Template deleted."), http.StatusOK)
}
// --- backup export / import -------------------------------------------------
// handleSettingsWebhookTest delivers a ping inline and reports the
// outcome from the delivery it produced, not from the shared history a
// concurrent delivery could reshuffle.
func (a *Admin) handleSettingsWebhookTest(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.Webhooks == nil {
a.renderSettings(w, r, a.tr(r, "No webhooks configured."), "", http.StatusUnprocessableEntity)
return
}
// The list is taken once: a settings change that reshuffles it between
// the bounds check and the fetch would test a different hook than the
// one the form named.
hooks := a.deps.Webhooks.Hooks()
index, err := strconv.Atoi(r.PathValue("index"))
if err != nil || index < 0 || index >= len(hooks) {
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
return
}
hook := hooks[index]
delivery := a.deps.Webhooks.TestHook(hook)
a.record(r, "webhook.tested", hook.URL, nil)
notice := a.tr(r, "Test delivery failed.")
if delivery.Status == "ok" {
notice = a.tr(r, "Test delivery sent.")
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
func (a *Admin) handleSettingsTokenCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
created, raw, err := a.deps.Tokens.Create(r.PostFormValue("name"), r.PostForm["scope"])
if err != nil {
a.renderSettings(w, r, a.trf(r, "The token could not be created: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "token.created", created.Name, nil)
data := a.settingsData(r)
data.NewToken = raw
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
func (a *Admin) handleSettingsTokenDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := r.PathValue("name")
notice := a.tr(r, "Token revoked.")
if !a.deps.Tokens.Revoke(name) {
notice = a.tr(r, "That token was not found.")
} else {
a.record(r, "token.revoked", name, nil)
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"log/slog"
"net/http"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
)
func (a *Admin) handleSettingsExport(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="volumen-backup.tar.gz"`)
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
// The archive streams straight to the client: the app routes it past
// the buffering wrappers, so no copy of it waits in memory. An error
// before the first byte still answers as a plain 500; after it, the
// download ends truncated and the gzip footer makes that visible.
sent := false
if err := backup.Write(writeTracker{w, &sent}, a.deps.Backup); err != nil {
slog.Error("admin: backup export failed", "error", err)
if !sent {
w.Header().Del("Content-Type")
w.Header().Del("Content-Disposition")
http.Error(w, "The backup could not be written: "+err.Error(), http.StatusInternalServerError)
}
}
}
// writeTracker records whether anything reached the client, so a failure
// can still choose between a clean error page and a logged truncation.
type writeTracker struct {
w http.ResponseWriter
sent *bool
}
func (t writeTracker) Write(p []byte) (int, error) {
*t.sent = true
return t.w.Write(p)
}
func (a *Admin) handleSettingsImport(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, _, err := r.FormFile("backup")
if err != nil {
a.renderSettings(w, r, a.tr(r, "No backup file selected."), "", http.StatusUnprocessableEntity)
return
}
defer file.Close()
written, err := backup.Restore(file, a.deps.Backup)
if written > 0 {
// A partial restore changed files on disk; the caches must drop
// even when a later entry failed, or the admin keeps serving the
// pre-import state until an unrelated write invalidates them.
a.deps.Store.InvalidateCache()
a.deps.Users.Invalidate()
a.deps.Templates.Invalidate()
a.deps.Tokens.Invalidate()
}
if err != nil {
slog.Warn("admin: backup import failed", "error", err)
a.renderSettings(w, r, a.trf(r, "Could not restore backup: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
if written == 0 {
a.renderSettings(w, r, a.tr(r, "The archive holds no files this deployment recognises."), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "backup.imported", fmt.Sprintf("%d files", written), nil)
a.renderSettings(w, r, "", i18n.Admin.N(a.langFor(r), "backup.files", written), http.StatusOK)
}
// --- updates ----------------------------------------------------------------
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"regexp"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
func (a *Admin) registerSettingsRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/settings", a.requireLogin(a.handleSettings))
mux.HandleFunc("POST /admin/settings/password", a.requireLogin(a.handleSettingsPassword))
mux.HandleFunc("POST /admin/settings/username", a.requireLogin(a.handleSettingsUsername))
mux.HandleFunc("POST /admin/settings/name", a.requireLogin(a.handleSettingsName))
mux.HandleFunc("POST /admin/settings/language", a.requireLogin(a.handleSettingsLanguage))
mux.HandleFunc("POST /admin/settings/theme", a.requireLogin(a.handleSettingsTheme))
mux.HandleFunc("POST /admin/settings/fediverse", a.requireLogin(a.handleSettingsFediverse))
mux.HandleFunc("POST /admin/settings/orcid", a.requireLogin(a.handleSettingsOrcid))
mux.HandleFunc("POST /admin/settings/photo", a.requireLogin(a.handleSettingsPhoto))
mux.HandleFunc("POST /admin/settings/photo/remove", a.requireLogin(a.handleSettingsPhotoRemove))
mux.HandleFunc("POST /admin/settings/users", a.requireAdmin(a.handleSettingsUserCreate))
mux.HandleFunc("POST /admin/settings/users/{name}/role", a.requireAdmin(a.handleSettingsUserRole))
mux.HandleFunc("POST /admin/settings/users/{name}/password", a.requireAdmin(a.handleSettingsUserPassword))
mux.HandleFunc("POST /admin/settings/users/{name}/delete", a.requireAdmin(a.handleSettingsUserDelete))
mux.HandleFunc("POST /admin/settings/templates", a.requireAdmin(a.handleSettingsTemplateCreate))
mux.HandleFunc("POST /admin/settings/templates/{name}/delete", a.requireAdmin(a.handleSettingsTemplateDelete))
mux.HandleFunc("GET /admin/settings/export", a.requireAdmin(a.handleSettingsExport))
mux.HandleFunc("POST /admin/settings/import", a.requireAdmin(a.handleSettingsImport))
mux.HandleFunc("POST /admin/settings/check-update", a.requireAdmin(a.handleSettingsCheckUpdate))
mux.HandleFunc("POST /admin/settings/update", a.requireAdmin(a.handleSettingsUpdate))
mux.HandleFunc("POST /admin/settings/webhooks", a.requireAdmin(a.handleSettingsWebhookAdd))
mux.HandleFunc("POST /admin/settings/webhooks/toggle", a.requireAdmin(a.handleSettingsWebhookToggle))
mux.HandleFunc("POST /admin/settings/webhooks/delete", a.requireAdmin(a.handleSettingsWebhookDelete))
mux.HandleFunc("POST /admin/settings/webhooks/{index}/test", a.requireAdmin(a.handleSettingsWebhookTest))
mux.HandleFunc("POST /admin/settings/tokens", a.requireAdmin(a.handleSettingsTokenCreate))
mux.HandleFunc("POST /admin/settings/tokens/{name}/delete", a.requireAdmin(a.handleSettingsTokenDelete))
mux.HandleFunc("POST /admin/settings/twofactor/start", a.requireLogin(a.handleTotpStart))
mux.HandleFunc("POST /admin/settings/twofactor/cancel", a.requireLogin(a.handleTotpCancel))
mux.HandleFunc("POST /admin/settings/twofactor/verify", a.requireLogin(a.handleTotpVerify))
mux.HandleFunc("POST /admin/settings/twofactor/disable", a.requireLogin(a.handleTotpDisable))
mux.HandleFunc("POST /admin/settings/twofactor/codes", a.requireLogin(a.handleTotpCodes))
}
// requireAdmin additionally enforces the admin role.
func (a *Admin) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return a.requireLogin(func(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
record := a.deps.Users.Find(sess.Get("user"))
if record == nil || record.Role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
})
}
// settingsData builds the settings page context: the account record,
// the user list, the post templates, the webhook rows and the API
// tokens.
func (a *Admin) settingsData(r *http.Request) *PageData {
data := a.pageData(r)
data.IsAdmin = data.CurrentRole == "admin"
data.Roles = users.Roles
data.DefaultRole = users.DefaultRole
data.UserRows = userRows(data.CurrentUser, a.deps.Users.All())
data.TemplatesList = tplOptions(a.deps.Templates.All())
if a.deps.Webhooks != nil {
// The manager delivers the config-declared hooks first, the
// admin-managed ones after it, so the row's position tells where
// it came from and which forms apply to it.
data.WebhookRows = hookRows(a.deps.Webhooks.Hooks(), len(a.deps.StaticWebhooks))
deliveries := a.deps.Webhooks.Deliveries("")
data.WebhookDeliveries = deliveryRows(deliveries)
for i, d := range deliveries {
if d.Status != "ok" {
data.WebhookDeliveries[i].Result = i18n.Admin.N(data.Lang, "deliveries.attempts", d.Attempts)
}
}
}
data.TokenRows = tokenRows(a.deps.Tokens.All())
a.fillTotpState(data, r)
data.Crumbs = []Crumb{{Label: "Settings", IsLast: true, UI: true}}
return data
}
// handleSettingsLanguage switches the signed-in account's interface
// language. The choice persists on the user record for every request
// and in a cookie, so the login screen follows it too; the confirmation
// renders in the language just picked.
func (a *Admin) handleSettingsLanguage(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
lang := r.PostFormValue("language")
if !i18n.Valid(lang) {
a.renderSettings(w, r, i18n.Admin.T("en", "Unsupported language."), "", http.StatusUnprocessableEntity)
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if _, err := a.deps.Users.UpdateLanguage(username, lang); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf("en", "The language could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: i18n.Cookie,
Value: lang,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: a.cookieSecure(),
SameSite: http.SameSiteLaxMode,
})
data := a.settingsData(r)
data.Lang = lang
data.Notice = i18n.Admin.T(lang, "The interface language is set.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// handleSettingsTheme switches the signed-in account's colour scheme.
// The choice persists on the user record for every request and in a
// cookie, so the login screen follows it too.
func (a *Admin) handleSettingsTheme(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
theme := r.PostFormValue("theme")
if !web.ValidTheme(theme) {
a.renderSettings(w, r, i18n.Admin.T("en", "Unsupported colour scheme."), "", http.StatusUnprocessableEntity)
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if _, err := a.deps.Users.UpdateTheme(username, theme); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf("en", "The colour scheme could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: web.ThemeCookie,
Value: theme,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: a.cookieSecure(),
SameSite: http.SameSiteLaxMode,
})
data := a.settingsData(r)
data.Theme = theme
data.Notice = i18n.Admin.T(data.Lang, "The colour scheme is set.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// cookieSecure reports whether the deployment serves over HTTPS or
// behind a trusted proxy, the condition the session cookie and the
// preference cookies take their Secure flag from.
func (a *Admin) cookieSecure() bool {
return a.deps.Config.Server.CookieSecure || a.deps.Config.Server.TrustProxy
}
// renderSettings renders the settings page with a flash message.
func (a *Admin) renderSettings(w http.ResponseWriter, r *http.Request, errorMsg, notice string, status int) {
data := a.settingsData(r)
data.Error = errorMsg
data.Notice = notice
a.renderPage(w, r, "settings.html", data, status)
}
// handleSettings renders the settings page.
func (a *Admin) handleSettings(w http.ResponseWriter, r *http.Request) {
a.renderSettings(w, r, "", "", http.StatusOK)
}
+820
View File
@@ -0,0 +1,820 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"bytes"
"maps"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/web"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
func settingsForm(t *testing.T, f *fixture, path string, extra url.Values) *httptest.ResponseRecorder {
t.Helper()
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}}
maps.Copy(form, extra)
return postForm(t, f, path, form, cookie)
}
func TestSettingsPageRenders(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Settings", "Account", "Users", "Templates", "Backup",
"API tokens", "Webhooks", "admin",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
// Every field that sets a password carries the strength meter: the
// own-account change and the add-user form are on the page itself,
// the per-user reset form rides each non-self user row. The floor
// attribute rides the same inputs and nowhere else on the page.
meters := strings.Count(body, `data-min-length=`)
wantMeters := 2
for _, row := range f.admin.deps.Users.All() {
if row.Username != "admin" {
wantMeters++
}
}
if meters != wantMeters {
t.Fatalf("password meters = %d, want %d", meters, wantMeters)
}
if !strings.Contains(body, `class="pw-level__bar"`) {
t.Fatal("meter bar markup missing")
}
}
func TestPasswordChange(t *testing.T) {
f := newFixture(t)
// Wrong current password.
rec := settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"nope"},
"new_password": {"another-good-pass"},
})
if !strings.Contains(rec.Body.String(), "Current password is incorrect.") {
t.Fatal("wrong-password message missing")
}
// Weak new password.
rec = settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"correct-horse-9"},
"new_password": {"short"},
})
if !strings.Contains(rec.Body.String(), "at least") {
t.Fatal("policy message missing")
}
// Success.
rec = settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"correct-horse-9"},
"new_password": {"a-brand-new-passphrase"},
})
if !strings.Contains(rec.Body.String(), "Password updated.") {
t.Fatal("success message missing")
}
if f.users.Authenticate("admin", "a-brand-new-passphrase") == nil {
t.Fatal("new password does not authenticate")
}
}
func TestUsernameChangeUpdatesSession(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/settings/username",
url.Values{"_csrf": {csrf}, "username": {"bad name!"}}, cookie)
if !strings.Contains(rec.Body.String(), "letters, numbers") {
t.Fatal("format message missing")
}
rec = postForm(t, f, "/admin/settings/username",
url.Values{"_csrf": {csrf}, "username": {"petr"}}, cookie)
if !strings.Contains(rec.Body.String(), "Username updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("petr") == nil {
t.Fatal("rename not applied")
}
// The session cookie was re-signed with the new username.
newCookie := sessionCookie(t, rec)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(newCookie)
rec = f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `value="petr"`) {
t.Fatalf("session lost after rename: code=%d", rec.Code)
}
}
func TestThemeChange(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"plasma"}})
if !strings.Contains(rec.Body.String(), "The colour scheme is set.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Theme != "plasma" {
t.Fatal("theme not stored on the account")
}
found := false
for _, c := range rec.Result().Cookies() {
if c.Name == web.ThemeCookie && c.Value == "plasma" {
found = true
}
}
if !found {
t.Fatal("theme cookie missing")
}
// The picker re-renders with the choice marked pressed.
if !strings.Contains(rec.Body.String(), `value="plasma" class="chip" aria-pressed="true"`) {
t.Fatal("picked scheme not marked active")
}
// An unknown scheme is refused and does not overwrite the choice.
rec = settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"sepia"}})
if !strings.Contains(rec.Body.String(), "Unsupported colour scheme.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Theme != "plasma" {
t.Fatal("invalid scheme overwrote the stored choice")
}
}
func TestNameAndFediverseChange(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/name", url.Values{"name": {"Petr Balvín"}})
if !strings.Contains(rec.Body.String(), "Display name updated.") {
t.Fatal("name message missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"nope"}})
if !strings.Contains(rec.Body.String(), "@user@host") {
t.Fatal("fediverse validation missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"@petr@social"}})
if !strings.Contains(rec.Body.String(), "Fediverse handle updated.") {
t.Fatal("fediverse message missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {""}})
if !strings.Contains(rec.Body.String(), "Fediverse handle cleared.") {
t.Fatal("fediverse clear missing")
}
}
func TestOrcidChange(t *testing.T) {
f := newFixture(t)
// A malformed iD is refused and nothing is stored.
rec := settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0098"}})
if !strings.Contains(rec.Body.String(), "ORCID must look like") {
t.Fatalf("orcid validation missing: %s", rec.Body.String())
}
if f.users.Find("admin").Orcid != "" {
t.Fatal("invalid orcid was stored")
}
// A valid iD is kept, normalised to upper case.
rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0097"}})
if !strings.Contains(rec.Body.String(), "ORCID updated.") {
t.Fatal("orcid message missing")
}
if got := f.users.Find("admin").Orcid; got != "0000-0002-1825-0097" {
t.Fatalf("stored orcid = %q", got)
}
// An empty value clears it.
rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {""}})
if !strings.Contains(rec.Body.String(), "ORCID cleared.") {
t.Fatal("orcid clear missing")
}
if got := f.users.Find("admin").Orcid; got != "" {
t.Fatalf("orcid not cleared: %q", got)
}
}
// A password an admin sets keeps its edge spaces: only the emptiness
// check may trim, the stored value must not, or the trimmed form would
// work where the typed one does not.
func TestUserCreateKeepsPasswordSpaces(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {" padded-passphrase "}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "User added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Authenticate("joe", " padded-passphrase ") == nil {
t.Fatal("the exact password, spaces included, must authenticate")
}
if f.users.Authenticate("joe", "padded-passphrase") != nil {
t.Fatal("the trimmed password must not authenticate")
}
}
func TestUserManagement(t *testing.T) {
f := newFixture(t)
// Create a second user.
rec := settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "User added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe") == nil {
t.Fatal("user not created")
}
// Duplicate rejected.
rec = settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "could not be added") {
t.Fatal("duplicate message missing")
}
// Role change.
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/users/joe/role",
url.Values{"_csrf": {csrf}, "role": {"admin"}}, cookie)
if !strings.Contains(rec.Body.String(), "Role updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe").Role != "admin" {
t.Fatal("role not applied")
}
// Own role cannot change.
rec = postForm(t, f, "/admin/settings/users/admin/role",
url.Values{"_csrf": {csrf}, "role": {"author"}}, cookie)
if !strings.Contains(rec.Body.String(), "own role") {
t.Fatal("self role-change not blocked")
}
// Delete.
rec = postForm(t, f, "/admin/settings/users/joe/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "User removed.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe") != nil {
t.Fatal("user not deleted")
}
// Own account cannot be deleted.
rec = postForm(t, f, "/admin/settings/users/admin/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "own account") {
t.Fatal("self delete not blocked")
}
}
func TestUserManagementRequiresAdmin(t *testing.T) {
f := newFixture(t)
f.users.Add("joe", "joes-good-passphrase", "author")
cookie := login(t, f, "joe", "joes-good-passphrase")
csrf := csrfFromSession(t, f, cookie)
req := httptest.NewRequest(http.MethodPost, "/admin/settings/users",
strings.NewReader(url.Values{
"_csrf": {csrf}, "username": {"x"}, "password": {"good-enough-pass"},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", rec.Code)
}
}
func TestTemplateCRUD(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Review"}, "tags": {"review, opinion"}, "body": {"## Summary"},
})
if !strings.Contains(rec.Body.String(), "Template added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if len(f.admin.deps.Templates.All()) != 1 {
t.Fatal("template not stored")
}
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{"name": {"Review"}})
if !strings.Contains(rec.Body.String(), "could not be added") {
t.Fatal("duplicate message missing")
}
// A fields box pre-fills the scientific editor inputs; the values are
// TOML, so strings are quoted and a bare number arrives as text.
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Paper"}, "fields": {"series = \"tds\"\ndoi = \"10.5281/zenodo.1\"\nseries_order = 3\n"},
})
if !strings.Contains(rec.Body.String(), "Template added.") {
t.Fatalf("fields template rejected: %s", rec.Body.String())
}
var paperFields map[string]string
for _, tpl := range f.admin.deps.Templates.All() {
if tpl.Name == "Paper" {
paperFields = tpl.Fields
}
}
if paperFields["series"] != "tds" || paperFields["doi"] != "10.5281/zenodo.1" ||
paperFields["series_order"] != "3" {
t.Fatalf("stored fields = %v", paperFields)
}
// A key outside the editor's inputs is refused, so a typo cannot
// silently seed every new post with a dead key.
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Nope"}, "fields": {"journal = \"Nature\"\n"},
})
if !strings.Contains(rec.Body.String(), "Unknown template field") {
t.Fatal("unknown field accepted")
}
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Broken"}, "fields": {"series == tds\n\n("},
})
if !strings.Contains(rec.Body.String(), "must be key = value") {
t.Fatal("unparsable fields accepted")
}
// The new-post form embeds the templates with the lowercase keys its
// picker reads, fields included.
cookie := login(t, f, "admin", "correct-horse-9")
newReq := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
newReq.AddCookie(cookie)
rec = f.do(t, newReq)
if !strings.Contains(rec.Body.String(), `"fields":{`) ||
!strings.Contains(rec.Body.String(), `"series":"tds"`) {
t.Fatal("template fields missing from the editor payload")
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/templates/Review/delete",
url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Template deleted.") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestTokenCRUD(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/settings/tokens",
url.Values{"_csrf": {csrf}, "name": {"ci"}}, cookie)
body := rec.Body.String()
if !strings.Contains(body, "Copy this token now") || !strings.Contains(body, "vol_") {
t.Fatalf("new token not shown: %s", body)
}
rec = postForm(t, f, "/admin/settings/tokens/ci/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Token revoked.") {
t.Fatalf("body = %s", rec.Body.String())
}
if len(f.admin.deps.Tokens.All()) != 0 {
t.Fatal("token not revoked")
}
}
func TestBackupExportImport(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "keep.md", "+++\nslug = \"keep\"\ntitle = \"Keep\"\n+++\nbody\n")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/gzip" {
t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type"))
}
archive := rec.Body.Bytes()
if len(archive) == 0 {
t.Fatal("empty archive")
}
// Wipe the content dir, then restore.
if err := os.Remove(filepath.Join(f.contentDir, "keep.md")); err != nil {
t.Fatalf("remove: %v", err)
}
csrf := csrfFromSession(t, f, cookie)
rec = multipartBytes(t, f, "/admin/settings/import", cookie, csrf, "backup", archive)
if !strings.Contains(rec.Body.String(), "Backup restored") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.storeObj.Find("keep", "") == nil {
t.Fatal("post not restored from backup")
}
}
func TestCheckUpdateWithoutWiring(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/check-update", nil)
if !strings.Contains(rec.Body.String(), "not available in this build") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestMediaLibraryAndDelete(t *testing.T) {
f := newFixture(t)
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
uploaded, err := f.storeObj.StoreUpload("pic.webp", webpData)
if err != nil {
t.Fatalf("upload: %v", err)
}
name := strings.TrimPrefix(uploaded, "/media/")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/media", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), name) {
t.Fatalf("code=%d", rec.Code)
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/media/"+name+"/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/media" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if _, err := f.storeObj.MediaPath(name); err == nil {
t.Fatal("media not deleted")
}
rec = postForm(t, f, "/admin/media/ghost.webp/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
// multipartBytes posts a binary file field.
func multipartBytes(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field string, data []byte) *httptest.ResponseRecorder {
t.Helper()
body, contentType := buildMultipart(t, csrf, field, "backup.tar.gz", data)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
req.AddCookie(cookie)
return f.do(t, req)
}
// buildMultipart renders a single-file multipart body.
func buildMultipart(t *testing.T, csrf, field, filename string, data []byte) (string, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("_csrf", csrf)
part, err := mw.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write part: %v", err)
}
_ = mw.Close()
return buf.String(), mw.FormDataContentType()
}
func TestPhotoUploadAndRemove(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
body, contentType := buildMultipart(t, csrf, "photo", "me.webp", webpData)
req := httptest.NewRequest(http.MethodPost, "/admin/settings/photo", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "Profile photo updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Photo == "" {
t.Fatal("photo not stored on the user")
}
rec = postForm(t, f, "/admin/settings/photo/remove", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Profile photo removed.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Photo != "" {
t.Fatal("photo not cleared")
}
}
func TestWebhookTestDelivery(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
}))
defer srv.Close()
f := newFixture(t)
f.admin.deps.Config.Webhooks = []config.Webhook{{URL: srv.URL}}
f.admin.deps.Webhooks = webhooks.NewManager(
[]webhooks.Webhook{{URL: srv.URL, Enabled: true}}, "0.0.0-test")
rec := settingsForm(t, f, "/admin/settings/webhooks/0/test", nil)
if !strings.Contains(rec.Body.String(), "Test delivery sent.") {
t.Fatalf("body = %s", rec.Body.String())
}
if atomic.LoadInt32(&hits) != 1 {
t.Fatalf("hits = %d", hits)
}
rec = settingsForm(t, f, "/admin/settings/webhooks/9/test", nil)
if !strings.Contains(rec.Body.String(), "Webhook not found.") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestUpdateHooksFlow(t *testing.T) {
f := newFixture(t)
f.admin.SetUpdateHooks(func() (string, error) { return "9.9.9", nil },
func() (string, error) { return "9.9.9", nil })
// Banner appears on the dashboard.
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "is available") {
t.Fatal("update banner missing")
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/update", url.Values{"_csrf": {csrf}}, cookie)
// The version is printed as the toolchain recorded it, prefix included.
if !strings.Contains(rec.Body.String(), "9.9.9") {
t.Fatalf("update page body = %s", rec.Body.String())
}
f.admin.SetUpdateHooks(func() (string, error) { return "", nil }, nil)
rec = settingsForm(t, f, "/admin/settings/check-update", nil)
if !strings.Contains(rec.Body.String(), "already the latest release") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestTokenCreateWithScopes(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}, "name": {"scoped"}, "scope": {"write", "delete"}}
rec := postForm(t, f, "/admin/settings/tokens", form, cookie)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "vol_") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
list := f.admin.deps.Tokens.All()
if len(list) != 1 {
t.Fatalf("tokens = %v", list)
}
if len(list[0].Scopes) != 2 || !list[0].HasScope("write") || !list[0].HasScope("delete") {
t.Fatalf("scopes = %v", list[0].Scopes)
}
if list[0].HasScope("read") {
t.Fatal("the removed read scope was granted")
}
}
func TestEditorSaveKeepsUnknownMetadata(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "aliased.md", `+++
title = "Aliased"
slug = "aliased"
aliases = ["old-slug"]
custom_field = "keep me"
[translations]
en = "aliased-en"
+++
body
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/aliased", url.Values{
"_csrf": {csrf}, "title": {"Aliased v2"}, "slug": {"aliased"},
"lang": {"cs"}, "body": {"new body"},
}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
p := f.storeObj.Find("aliased", "")
if p == nil {
t.Fatal("post lost")
}
if got := p.Aliases(); len(got) != 1 || got[0] != "old-slug" {
t.Fatalf("aliases lost: %v", got)
}
if got := p.Translations(); got["en"] != "aliased-en" {
t.Fatalf("translations lost: %v", got)
}
if _, ok := p.Metadata.Get("custom_field"); !ok {
t.Fatal("custom field lost")
}
if p.Title() != "Aliased v2" {
t.Fatalf("title = %q", p.Title())
}
}
// A webhook added in Settings lands in webhooks.toml and reaches the
// manager without a restart; a config-declared hook stays read-only.
func TestSettingsWebhookLifecycle(t *testing.T) {
f := newFixture(t)
f.admin.deps.WebhooksFile = filepath.Join(t.TempDir(), "webhooks.toml")
f.admin.deps.Webhooks = webhooks.NewManager(nil, "t")
f.admin.deps.StaticWebhooks = []webhooks.Webhook{{URL: "https://cfg.example/hook", Enabled: true}}
f.admin.deps.Webhooks.SetHooks(f.admin.deps.StaticWebhooks)
// A config hook renders as read-only: no toggle form for it.
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "https://cfg.example/hook") ||
strings.Contains(rec.Body.String(), "Remove this webhook?") {
t.Fatalf("config hook row wrong: %d", rec.Code)
}
// Add one.
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"https://example.com/hook"},
"secret": {"s3cret"},
"events": {"post.created, post.updated"},
"enabled": {"on"},
})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook added.") {
t.Fatalf("add: %d %s", rec.Code, rec.Body.String())
}
stored, err := webhooks.LoadFile(f.admin.deps.WebhooksFile)
if err != nil || len(stored) != 1 || stored[0].URL != "https://example.com/hook" ||
stored[0].Secret != "s3cret" || !stored[0].Enabled || len(stored[0].Events) != 2 {
t.Fatalf("stored = %+v err = %v", stored, err)
}
hooks := f.admin.deps.Webhooks.Hooks()
if len(hooks) != 2 || hooks[0].URL != "https://cfg.example/hook" || hooks[1].URL != "https://example.com/hook" {
t.Fatalf("manager = %+v", hooks)
}
// A duplicate URL and a broken URL are refused.
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"https://example.com/hook"}, "enabled": {"on"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "already configured") {
t.Fatalf("duplicate: %d %s", rec.Code, rec.Body.String())
}
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"ftp://example.com/hook"}, "enabled": {"on"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not a valid") {
t.Fatalf("invalid url: %d %s", rec.Code, rec.Body.String())
}
// Toggle flips the stored flag and the manager's.
rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{
"url": {"https://example.com/hook"},
})
if rec.Code != http.StatusOK {
t.Fatalf("toggle: %d %s", rec.Code, rec.Body.String())
}
stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile)
if stored[0].Enabled {
t.Fatal("toggle did not disable the hook")
}
if f.admin.deps.Webhooks.Hooks()[1].Enabled {
t.Fatal("manager kept the hook enabled")
}
// A config-declared URL is not toggleable.
rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{
"url": {"https://cfg.example/hook"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Webhook not found.") {
t.Fatalf("config hook toggle: %d %s", rec.Code, rec.Body.String())
}
// Delete removes the hook from the file and the manager.
rec = settingsForm(t, f, "/admin/settings/webhooks/delete", url.Values{
"url": {"https://example.com/hook"},
})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook removed.") {
t.Fatalf("delete: %d %s", rec.Code, rec.Body.String())
}
stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile)
if len(stored) != 0 {
t.Fatalf("store = %+v", stored)
}
if len(f.admin.deps.Webhooks.Hooks()) != 1 {
t.Fatalf("manager = %+v", f.admin.deps.Webhooks.Hooks())
}
}
func TestOversizedBodyRejected(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
huge := strings.Repeat("a", 1_048_577)
rec := postForm(t, f, "/admin/posts", url.Values{
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
if !strings.Contains(rec.Body.String(), "at most 1048576 bytes") {
t.Fatal("size message missing")
}
}
// A changed password retires every session issued before it: the cookie
// carries a fingerprint of the hash, and only the device the change was
// made on gets re-bound.
func TestPasswordChangeSignsOutOtherSessions(t *testing.T) {
f := newFixture(t)
first := login(t, f, "admin", "correct-horse-9")
second := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, first)
rec := postForm(t, f, "/admin/settings/password", url.Values{
"_csrf": {csrf}, "current_password": {"correct-horse-9"},
"new_password": {"new-good-passphrase"},
}, first)
if !strings.Contains(rec.Body.String(), "Password updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
// The change re-signs this device.s session; the cookie to test
// with is the one the response just set.
first = sessionCookie(t, rec)
// The other device.s session is dead.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(second)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("other session survived: %d %s", rec.Code, rec.Header().Get("Location"))
}
// The device the change was made on stays signed in.
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(first)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("current session died: %d", rec.Code)
}
// The old password no longer signs in; the new one does.
if f.users.Authenticate("admin", "correct-horse-9") != nil {
t.Fatal("the old password still authenticates")
}
if f.users.Authenticate("admin", "new-good-passphrase") == nil {
t.Fatal("the new password does not authenticate")
}
}
// An admin can reset another account's password; the account's sessions
// die with it, and the admin cannot shortcut their own current-password
// check through the route.
func TestAdminPasswordReset(t *testing.T) {
f := newFixture(t)
if _, err := f.users.Add("author", "authors-good-passphrase", "author"); err != nil {
t.Fatalf("author not created: %v", err)
}
authorCookie := login(t, f, "author", "authors-good-passphrase")
// Self-reset is refused.
rec := settingsForm(t, f, "/admin/settings/users/admin/password",
url.Values{"password": {"shortcut-passphrase"}})
if rec.Code != http.StatusUnprocessableEntity ||
!strings.Contains(rec.Body.String(), "own password") {
t.Fatalf("self reset not blocked: %d %s", rec.Code, rec.Body.String())
}
// Reset the author's password.
rec = settingsForm(t, f, "/admin/settings/users/author/password",
url.Values{"password": {"reset-passphrase-9"}})
if !strings.Contains(rec.Body.String(), "sessions were signed out") {
t.Fatalf("body = %s", rec.Body.String())
}
// The author's session is dead, and the new password works.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(authorCookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
t.Fatalf("author session survived the reset: %d", rec.Code)
}
if f.users.Authenticate("author", "reset-passphrase-9") == nil {
t.Fatal("the reset password does not authenticate")
}
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"encoding/base32"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/qrcode"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/totp"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
// The enrolment state rides the session: the candidate secret lives
// there between the QR page and the verifying code, so the users file
// only ever holds secrets that were proven by a working application.
const (
totpEnrollKey = "totp_enroll"
totpEnrollAt = "totp_enroll_at"
)
// enrolWindow bounds how long a candidate secret stays answerable.
const enrolWindow = 10 * time.Minute
// totpURI builds the otpauth URI every application understands.
func totpURI(secret, username string) string {
u := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: "/Volumen:" + username,
RawQuery: url.Values{"secret": {secret}, "issuer": {"Volumen"}, "algorithm": {"SHA1"}, "digits": {"6"}, "period": {"30"}}.Encode(),
}
return u.String()
}
// fillTotpState carries the second-factor state of the signed-in
// account and of an enrolment in flight onto the settings page.
func (a *Admin) fillTotpState(data *PageData, r *http.Request) {
record := a.deps.Users.Find(data.CurrentUser)
if record != nil && record.TotpSecret != "" {
data.TotpEnabled = true
return
}
sess := session.FromContext(r.Context())
secret := sess.Get(totpEnrollKey)
if secret == "" {
return
}
started, err := strconv.ParseInt(sess.Get(totpEnrollAt), 10, 64)
if err != nil || time.Since(time.Unix(started, 0)) > enrolWindow {
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
return
}
data.TotpPending = true
data.TotpSecret = secret
data.TotpURI = totpURI(secret, data.CurrentUser)
if svg, err := qrcode.SVG(data.TotpURI); err == nil {
data.TotpSVG = template.HTML(svg)
}
}
// decodeBase32Secret turns the stored candidate back into key bytes.
func decodeBase32Secret(encoded string) ([]byte, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded))
}
// totpOK checks a candidate secret against the code the application
// shows; no replay floor applies, this is the first use.
func totpOK(secret []byte, code string) bool {
ok, _ := totp.Validate(secret, code, time.Now(), 0)
return ok
}
// handleTotpStart begins enrolment: a fresh candidate secret travels to
// the settings page inside the session, and nothing is stored yet.
func (a *Admin) handleTotpStart(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
if record := a.deps.Users.Find(sess.Get("user")); record != nil && record.TotpSecret != "" {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Two-factor authentication is already on."), "", http.StatusUnprocessableEntity)
return
}
secret := users.GenerateTotpSecret()
sess.Set(totpEnrollKey, secret)
sess.Set(totpEnrollAt, strconv.FormatInt(time.Now().Unix(), 10))
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
}
// handleTotpCancel drops an enrolment in flight.
func (a *Admin) handleTotpCancel(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
}
// handleTotpVerify finishes enrolment: the code the application shows
// proves the candidate secret, which is stored together with a fresh
// set of recovery codes. The codes are shown exactly once, here.
func (a *Admin) handleTotpVerify(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
secret := sess.Get(totpEnrollKey)
if secret == "" {
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
return
}
code := r.PostFormValue("code")
decoded, err := decodeBase32Secret(secret)
if err != nil || !totpOK(decoded, code) {
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "That code did not match; start again."), "", http.StatusUnprocessableEntity)
return
}
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := a.deps.Users.EnableTotp(username, secret, hashes); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be enabled: %s", err.Error()), "", http.StatusInternalServerError)
return
}
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
a.record(r, "user.totp_enabled", username, nil)
data := a.settingsData(r)
data.RecoveryCodes = codes
data.RecoveryNotice = i18n.Admin.T(data.Lang, "Two-factor is on. Store these recovery codes now; they will not be shown again.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// handleTotpDisable turns the second factor off; possession of a
// current code is the proof, so a stolen cookie alone cannot.
func (a *Admin) handleTotpDisable(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.ClearTotp(username); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be disabled: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.totp_disabled", username, nil)
a.renderSettings(w, r, "", i18n.Admin.T(a.lang(r, nil), "Two-factor is off."), http.StatusOK)
}
// handleTotpCodes replaces the recovery codes; the old ones stop
// working, and the new ones are shown exactly once.
func (a *Admin) handleTotpCodes(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity)
return
}
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := a.deps.Users.ReplaceRecovery(username, hashes); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "The codes could not be replaced: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.totp_codes", username, nil)
data := a.settingsData(r)
data.RecoveryCodes = codes
data.RecoveryNotice = i18n.Admin.T(data.Lang, "New recovery codes. Store them now; they will not be shown again.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
)
func (a *Admin) handleSettingsCheckUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.CheckUpdate == nil {
a.renderSettings(w, r, "", a.tr(r, "Update checks are not available in this build."), http.StatusOK)
return
}
latest, err := a.deps.CheckUpdate()
if err != nil {
a.renderSettings(w, r, a.trf(r, "Update check failed: %s", err.Error()), "", http.StatusOK)
return
}
// The hook returns "" when the running version is current, so the
// comparison has already been made by the one implementation that
// knows how to make it.
if latest == "" {
a.renderSettings(w, r, "",
a.trf(r, "volumen %s is already the latest release.", a.deps.Version), http.StatusOK)
return
}
a.renderSettings(w, r, "", a.trf(r, "volumen %s is available.", latest), http.StatusOK)
}
func (a *Admin) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.SelfUpdate == nil {
a.renderSettings(w, r, a.tr(r, "Self-update is not available in this build."), "", http.StatusUnprocessableEntity)
return
}
target, err := a.deps.SelfUpdate()
if err != nil {
message := a.trf(r, "The upgrade failed: %s", err.Error())
if target != "" {
message = a.trf2(r, "Upgrade to %s failed: %s", target, err.Error())
}
a.renderSettings(w, r, message, "", http.StatusInternalServerError)
return
}
data := a.pageData(r)
data.Target = target
a.renderPage(w, r, "update.html", data, http.StatusOK)
}
// --- webhooks and tokens ----------------------------------------------------
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
)
func (a *Admin) handleSettingsUserCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
// A password keeps its edge spaces: the reset path stores them the
// same way, and trimming here would create a password only the
// trimmed form of which works.
password := r.PostFormValue("password")
role := r.PostFormValue("role")
if username == "" || strings.TrimSpace(password) == "" {
a.renderSettings(w, r, a.tr(r, "Username and password are required."), "", http.StatusUnprocessableEntity)
return
}
if !usernameRe.MatchString(username) {
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(password, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.Add(username, password, role); err != nil {
a.renderSettings(w, r, a.trf(r, "That user could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "user.created", username, nil)
a.renderSettings(w, r, "", a.tr(r, "User added."), http.StatusOK)
}
func (a *Admin) handleSettingsUserRole(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot change your own role."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.SetRole(target, r.PostFormValue("role")); err != nil {
a.renderSettings(w, r, a.trf(r, "The role could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "user.role_changed", target, nil)
a.renderSettings(w, r, "", a.tr(r, "Role updated."), http.StatusOK)
}
func (a *Admin) handleSettingsUserDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot delete your own account."), "", http.StatusUnprocessableEntity)
return
}
photo := ""
if record := a.deps.Users.Find(target); record != nil {
photo = record.Photo
}
if _, err := a.deps.Users.Delete(target); err != nil {
a.renderSettings(w, r, a.trf(r, "The user could not be removed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
if photo != "" {
a.deleteUnreferencedMedia(photo)
}
a.record(r, "user.deleted", target, nil)
a.renderSettings(w, r, "", a.tr(r, "User removed."), http.StatusOK)
}
// --- post templates ---------------------------------------------------------
// handleSettingsUserPassword resets another account's password. The
// account's sessions die with the change (the session fingerprint
// changes), which is the point: an admin resetting a password is
// remedying an account, and every cookie issued before must stop
// working.
func (a *Admin) handleSettingsUserPassword(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot reset your own password here."), "", http.StatusUnprocessableEntity)
return
}
if a.deps.Users.Find(target) == nil {
a.renderSettings(w, r, a.tr(r, "That user was not found."), "", http.StatusUnprocessableEntity)
return
}
newPassword := r.PostFormValue("password")
if strings.TrimSpace(newPassword) == "" {
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdatePassword(target, newPassword); err != nil {
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.password_reset", target, nil)
a.renderSettings(w, r, "", a.tr(r, "Password reset; that user's sessions were signed out."), http.StatusOK)
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// roleOption is one <option> of a role select.
type roleOption struct {
Value string
Selected bool
}
// userRow is one entry of the users panel.
type userRow struct {
Username string
Display string
Initial string
Role string
Photo string
IsSelf bool
RoleOptions []roleOption
}
// hookRow is one configured webhook endpoint.
type hookRow struct {
Index string
URL string
// Managed is false for a hook declared in config.toml, which the
// settings forms may test but not change.
Managed bool
Enabled bool
Signed bool
EventsText string
}
// deliveryRow is one webhook delivery log entry.
type deliveryRow struct {
Timestamp string
Event string
HookURL string
OK bool
StatusCode int
Error string
Attempts int
// Result is the pre-formatted failure text ("failed (N attempts)"),
// translated by the caller that knows the request's language.
Result string
}
// tokenRow is one API token table row.
type tokenRow struct {
Name string
CreatedDay string
LastUsedDay string
}
// mediaRow is one media library tile.
type mediaRow struct {
Name string
URL string
SizeKB string
// Dimensions is the header-carried pixel size ("1920 × 1080"), or
// "" when the container did not yield one.
Dimensions string
}
func roleOptions(current string) []roleOption {
out := make([]roleOption, 0, len(users.Roles))
for _, role := range users.Roles {
out = append(out, roleOption{Value: role, Selected: role == current})
}
return out
}
func userRows(current string, list []*users.User) []userRow {
out := make([]userRow, 0, len(list))
for _, user := range list {
display := user.Name
if display == "" {
display = user.Username
}
out = append(out, userRow{
Username: user.Username,
Display: display,
Initial: firstUpper(display, "?"),
Role: user.Role,
Photo: user.Photo,
IsSelf: user.Username == current,
RoleOptions: roleOptions(user.Role),
})
}
return out
}
// hookRows renders the manager's merged hook list; the first static
// count came from config.toml and the rest are the admin's to manage.
func hookRows(hooks []webhooks.Webhook, staticCount int) []hookRow {
out := make([]hookRow, 0, len(hooks))
for i, hook := range hooks {
eventsText := "all"
if len(hook.Events) > 0 {
eventsText = strings.Join(hook.Events, ", ")
}
out = append(out, hookRow{
Index: fmt.Sprintf("%d", i),
URL: hook.URL,
Managed: i >= staticCount,
Enabled: hook.Enabled,
Signed: hook.Secret != "",
EventsText: eventsText,
})
}
return out
}
func deliveryRows(list []webhooks.Delivery) []deliveryRow {
out := make([]deliveryRow, 0, len(list))
for _, d := range list {
out = append(out, deliveryRow{
Timestamp: d.Timestamp,
Event: d.Event,
HookURL: d.HookURL,
OK: d.Status == "ok",
StatusCode: d.StatusCode,
Error: d.Error,
Attempts: d.Attempts,
})
}
return out
}
func tokenRows(list []tokens.Token) []tokenRow {
out := make([]tokenRow, 0, len(list))
for _, token := range list {
lastUsed := "never"
if len(token.LastUsed) >= 10 {
lastUsed = token.LastUsed[:10]
}
created := token.Created
if len(created) >= 10 {
created = created[:10]
}
out = append(out, tokenRow{
Name: token.Name,
CreatedDay: created,
LastUsedDay: lastUsed,
})
}
return out
}
func mediaRows(list []store.Media) []mediaRow {
out := make([]mediaRow, 0, len(list))
for _, item := range list {
out = append(out, mediaRow{
Name: item.Name,
URL: item.URL,
SizeKB: fmt.Sprintf("%.1f", float64(item.Size)/1024),
Dimensions: pixelSize(item.Width, item.Height),
})
}
return out
}
// pixelSize renders the header-carried pixel size, empty when the
// container did not yield one.
func pixelSize(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
return fmt.Sprintf("%d × %d", width, height)
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/url"
"slices"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// fileHooks reads the admin-managed hook store.
func (a *Admin) fileHooks() ([]webhooks.Webhook, error) {
if a.deps.WebhooksFile == "" {
return nil, nil
}
return webhooks.LoadFile(a.deps.WebhooksFile)
}
// refreshWebhooks re-saves the store and applies the merged hook set to
// the manager, so a settings change delivers without a restart.
func (a *Admin) refreshWebhooks(hooks []webhooks.Webhook) error {
if err := webhooks.SaveFile(a.deps.WebhooksFile, hooks); err != nil {
return err
}
merged := make([]webhooks.Webhook, 0, len(a.deps.StaticWebhooks)+len(hooks))
merged = append(merged, a.deps.StaticWebhooks...)
merged = append(merged, hooks...)
a.deps.Webhooks.SetHooks(merged)
return nil
}
// handleSettingsWebhookAdd adds one endpoint to the store. Config-declared
// hooks are the operator's business and stay read-only; this list is the
// admin's to manage.
func (a *Admin) handleSettingsWebhookAdd(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, err := a.fileHooks()
if err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
hook, problem := validateHookInput(
r.PostFormValue("url"), strings.TrimSpace(r.PostFormValue("secret")),
r.PostFormValue("events"), r.PostFormValue("enabled") == "on",
append(slices.Clone(a.deps.StaticWebhooks), file...),
)
if problem != "" {
a.renderSettings(w, r, a.tr(r, problem), "", http.StatusUnprocessableEntity)
return
}
if err := a.refreshWebhooks(append(file, hook)); err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "webhook.added", hook.URL, nil)
a.renderSettings(w, r, "", a.tr(r, "Webhook added."), http.StatusOK)
}
// handleSettingsWebhookToggle flips one stored hook's enabled flag. The
// URL names the hook, because the row the form was rendered from may no
// longer be at its old index by the time the POST lands.
func (a *Admin) handleSettingsWebhookToggle(w http.ResponseWriter, r *http.Request) {
a.mutateStoredHook(w, r, "webhook.updated", "Webhook updated.",
func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) {
for i, hook := range hooks {
if hook.URL == url {
hooks[i].Enabled = !hook.Enabled
return hooks, true
}
}
return hooks, false
})
}
func (a *Admin) handleSettingsWebhookDelete(w http.ResponseWriter, r *http.Request) {
a.mutateStoredHook(w, r, "webhook.deleted", "Webhook removed.",
func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) {
for i, hook := range hooks {
if hook.URL == url {
return slices.Delete(hooks, i, i+1), true
}
}
return hooks, false
})
}
// mutateStoredHook applies a change to the stored hook the form's url
// field names, re-saves, and refreshes the manager.
func (a *Admin) mutateStoredHook(w http.ResponseWriter, r *http.Request, auditAction, notice string, apply func([]webhooks.Webhook, string) ([]webhooks.Webhook, bool)) {
if !a.requireCSRF(w, r) {
return
}
file, err := a.fileHooks()
if err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
target := r.PostFormValue("url")
changed, ok := apply(file, target)
if !ok {
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
return
}
if err := a.refreshWebhooks(changed); err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, auditAction, target, nil)
a.renderSettings(w, r, "", a.tr(r, notice), http.StatusOK)
}
// validateHookInput checks the add form: an absolute http(s) URL no
// configured hook already uses, an optional secret, and the event
// filter as a comma-separated list (empty delivers everything).
func validateHookInput(raw, secret, events string, enabled bool, existing []webhooks.Webhook) (webhooks.Webhook, string) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return webhooks.Webhook{}, "That URL is not a valid http(s) endpoint."
}
for _, hook := range existing {
if hook.URL == raw {
return webhooks.Webhook{}, "That URL is already configured."
}
}
return webhooks.Webhook{
URL: raw,
Secret: secret,
Events: payloads.ParseTags(events),
Enabled: enabled,
}, ""
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"errors"
"html/template"
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// setupNeeded reports whether the first-run wizard should serve: a
// readable users file that holds no accounts. A file that cannot be
// read answers through the second value: the wizard would refuse to
// write over it anyway, so the caller says so instead of offering a
// form that cannot work.
func (a *Admin) setupNeeded() (needed bool, broken error) {
if err := a.deps.Users.Health(); err != nil {
return false, err
}
return !a.deps.Users.Any(), nil
}
// registerSetupRoutes mounts the wizard. The routes are public in the
// same sense the login is public: they exist for the owner of the
// installation before any account does, and the wizard retires itself
// as soon as one account exists.
func (a *Admin) registerSetupRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/setup", a.handleSetupForm)
mux.HandleFunc("POST /admin/setup", a.handleSetup)
}
// handleSetupForm serves the wizard while no account exists. Once one
// does, the route sends the browser back to the login, which is the
// same answer as deleting the route: the first run happens exactly
// once. The ?lang query re-renders the page in another shipped language:
// the language chips are real links, so the choice works without
// JavaScript too.
func (a *Admin) handleSetupForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" && a.deps.Users.Find(sess.Get("user")) != nil {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
needed, broken := a.setupNeeded()
if broken != nil {
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
return
}
if !needed {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
lang := i18n.Normalize(r.URL.Query().Get("lang"))
a.renderSetup(w, r, "", http.StatusOK, lang)
}
// renderSetup draws the wizard page in the given language ("" keeps the
// site default) and with the error the last attempt reported when there
// is one. The wizard always opens on the clean defaults: the site
// language and the shipped scheme, never on the anonymous preview
// cookies, which belong to the login screen after an account exists and
// here would only be a leftover from a half-finished or reset setup. The
// cookies are expired on the way so the next screen starts from the same
// truth the form shows. The page carries both languages of its own
// strings so the chips can swap the text without a reload.
func (a *Admin) renderSetup(w http.ResponseWriter, r *http.Request, errorMsg string, status int, lang string) {
if lang == "" {
lang = a.siteLanguage()
}
data := a.pageData(r)
data.IsSetup = true
data.Lang = lang
data.Theme = web.DefaultTheme
data.Error = errorMsg
data.SetupI18n = setupI18n(data.Config.Admin.MinPasswordLength)
expires := &http.Cookie{MaxAge: -1, Path: "/admin", HttpOnly: true}
c1 := *expires
c1.Name = i18n.Cookie
http.SetCookie(w, &c1)
c2 := *expires
c2.Name = web.ThemeCookie
http.SetCookie(w, &c2)
a.renderPage(w, r, "setup.html", data, status)
}
// setupI18nKeys are the wizard's own interface strings, keyed by their
// English source; the page swaps them client-side when a language chip
// is clicked, so the typed values survive. "password.hint" is added
// separately because it carries the configured length.
var setupI18nKeys = []string{
"Welcome to Volumen",
"Set up the administrator account to open this installation.",
"Account",
"Username",
"Display name",
"Your real name",
"Password",
"Show password",
"Hide password",
"Language",
"Colour scheme",
"The page takes the colours as you choose.",
"Create account",
}
// setupI18n builds the page's bilingual payload: every wizard string in
// both shipped languages, and the script catalogue per language, escaped
// for embedding in a script element the way the shared catalogue is.
func setupI18n(minLength int) template.JS {
ui := make(map[string]map[string]string, len(setupI18nKeys)+1)
for _, key := range setupI18nKeys {
ui[key] = map[string]string{
"en": i18n.Admin.T("en", key),
"cs": i18n.Admin.T("cs", key),
}
}
ui["password.hint"] = map[string]string{
"en": i18n.Admin.N("en", "password.min", minLength),
"cs": i18n.Admin.N("cs", "password.min", minLength),
}
payload := map[string]any{
"ui": ui,
"js": map[string]any{
"en": i18n.Admin.JS("en"),
"cs": i18n.Admin.JS("cs"),
},
}
b, err := json.Marshal(payload, json.Deterministic(true))
if err != nil {
return "{}"
}
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
}
// siteLanguage is the interface language a request falls back to when no
// account and no cookie decide it: the configured site language when the
// UI ships it, English otherwise.
func (a *Admin) siteLanguage() string {
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
return lang
}
return "en"
}
// handleSetup creates the first administrator account, stores the
// interface choices with it and signs the operator straight in. The
// password goes through the same server policy as every other password
// change; the page meter is advice, this is the gate.
func (a *Admin) handleSetup(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
needed, broken := a.setupNeeded()
if broken != nil {
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
return
}
if !needed {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if username == "" {
username = "admin"
}
name := strings.TrimSpace(r.PostFormValue("name"))
language := i18n.Normalize(r.PostFormValue("language"))
if language == "" {
language = a.siteLanguage()
}
theme := r.PostFormValue("theme")
if !web.ValidTheme(theme) {
theme = web.DefaultTheme
}
secret := r.PostFormValue("password")
if !usernameRe.MatchString(username) {
a.renderSetup(w, r, i18n.Admin.T(language, "Username may use letters, numbers, dot, dash, underscore."), http.StatusUnprocessableEntity, language)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(secret, minLen, maxLen); key != "" {
msg := i18n.Admin.T(language, key)
if n > 0 {
msg = i18n.Admin.N(language, key, n)
}
a.renderSetup(w, r, msg, http.StatusUnprocessableEntity, language)
return
}
user, err := a.deps.Users.AddFirst(username, secret, language, theme, name)
switch {
case err == nil:
case errors.Is(err, users.ErrUsersExist):
// Another claim won the race a moment ago; the wizard is gone
// and the account is already there.
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
default:
a.renderSetup(w, r, i18n.Admin.Tf(language, "The account could not be created: %s", err.Error()), http.StatusInternalServerError, language)
return
}
// Sign the operator in with the same session shape the login uses,
// so the wizard ends where a first sign-in would: inside the
// dashboard, on one request.
sess := session.FromContext(r.Context())
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
a.record(r, "setup.first_user", user.Username, nil)
secure := a.cookieSecure()
http.SetCookie(w, &http.Cookie{
Name: i18n.Cookie,
Value: language,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: web.ThemeCookie,
Value: theme,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// A stale anonymous preview cookie (a leftover of an abandoned or reset
// setup) must not greet the operator on the wizard: the first run opens
// on the clean defaults.
func TestSetupFormIgnoresPreviewCookies(t *testing.T) {
f := newFixtureSeeded(t, false)
req := httptest.NewRequest(http.MethodGet, "/admin/setup", nil)
req.AddCookie(&http.Cookie{Name: i18n.Cookie, Value: "cs"})
req.AddCookie(&http.Cookie{Name: web.ThemeCookie, Value: "magma"})
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `<html lang="en" data-palette="viridis">`) {
t.Fatalf("wizard did not open on the clean defaults:\n%s", body[:min(len(body), 400)])
}
// The response expires both preview cookies so later screens are clean too.
var sawLang, sawTheme bool
for _, c := range rec.Result().Cookies() {
if c.Name == i18n.Cookie && c.MaxAge < 0 {
sawLang = true
}
if c.Name == web.ThemeCookie && c.MaxAge < 0 {
sawTheme = true
}
}
if !sawLang || !sawTheme {
t.Fatalf("preview cookies not expired on the wizard response: %v", rec.Result().Cookies())
}
}
// A deployment with no accounts shows the wizard in place of the login
// screen; the login URL itself redirects, so an old bookmark lands in
// the right place too.
func TestLoginFormRedirectsToWizard(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/setup" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestSetupFormServesWhileNoAccounts(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Welcome to Volumen", "name=\"password\"", `name="theme"`, `name="language"`} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
// The chips are real links, so the language is a server-side choice
// too: ?lang renders the whole page in it and the hidden field carries
// it into the account.
func TestSetupFormHonoursLangQuery(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup?lang=cs", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`<html lang="cs"`, "Účet", `name="language" id="setup-language" value="cs"`} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
// The bilingual bundle rides along for the client-side swap.
if !strings.Contains(body, "Vítejte ve Volumenu") {
t.Fatal("the language bundle is missing")
}
}
func TestSetupFormRetiresWhenAccountsExist(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// The wizard creates the first account, keeps the language and the
// colour scheme with it, and signs the operator in on the same trip.
func TestSetupCreatesAndSignsIn(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"balvin"},
"name": {"Petr Balvín"},
"password": {"a-genuinely-unique-passphrase"},
"language": {"cs"},
"theme": {"plasma"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
user := f.users.Find("balvin")
if user == nil || user.Role != "admin" {
t.Fatalf("first account missing: %v", user)
}
if user.Language != "cs" || user.Theme != "plasma" {
t.Fatalf("wizard choices not stored: lang=%q theme=%q", user.Language, user.Theme)
}
if user.Name != "Petr Balvín" {
t.Fatalf("display name = %q", user.Name)
}
// The session cookie from the wizard opens the dashboard: the
// operator is signed in, not sent back through the login.
authed := sessionCookie(t, rec)
dash := httptest.NewRequest(http.MethodGet, "/admin/", nil)
dash.AddCookie(authed)
if rec := f.do(t, dash); rec.Code != http.StatusOK {
t.Fatalf("dashboard after setup: code = %d", rec.Code)
}
// A second claim of the same wizard is refused and pointed at the
// login: the installation has exactly one first account. The CSRF
// token rides the same session, so the refusal comes from the
// accounts already existing, not from the form.
req2 := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req2.AddCookie(authed)
rec2 := f.do(t, req2)
if rec2.Code != http.StatusSeeOther || rec2.Header().Get("Location") != "/admin/login" {
t.Fatalf("second claim: code=%d location=%q", rec2.Code, rec2.Header().Get("Location"))
}
}
// The wizard POST validates with the same server-side rules every
// password change uses: the page meter is advice, this is the gate.
func TestSetupRejectsWeakPasswordAndBadCSRF(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"admin"},
"password": {"short"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("weak password: code = %d", rec.Code)
}
if f.users.Any() {
t.Fatal("a refused wizard still created an account")
}
noCSRF := url.Values{"username": {"admin"}, "password": {"a-genuinely-unique-passphrase"}}
req = httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(noCSRF.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("missing CSRF: code = %d", rec.Code)
}
}
func TestSetupRejectsBadUsername(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"not a name!"},
"password": {"a-genuinely-unique-passphrase"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("bad username: code = %d", rec.Code)
}
if f.users.Any() {
t.Fatal("a refused username still created an account")
}
}
// The default username is admin, and an empty field gets it: the form
// starts filled, a submit that cleared it still lands on a valid name.
func TestSetupDefaultsUsername(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {""},
"password": {"a-genuinely-unique-passphrase"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("empty username: code = %d body = %s", rec.Code, rec.Body.String())
}
if f.users.Find("admin") == nil {
t.Fatal("the empty username field did not fall back to admin")
}
}
+237
View File
@@ -0,0 +1,237 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"encoding/base32"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/totp"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
func currentCode(t *testing.T, secret string) string {
t.Helper()
return currentCodeIn(t, secret, 0)
}
// currentCodeIn computes the code of a neighbouring time step, so a
// test can answer twice without tripping the replay floor.
func currentCodeIn(t *testing.T, secret string, steps int) string {
t.Helper()
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
t.Fatalf("decode secret: %v", err)
}
return totp.Code(key, time.Now().Add(time.Duration(steps)*totp.Step))
}
// loginTo opens the first door and returns the session wherever it
// stands: the dashboard, or the second-factor step when the account
// has one.
func loginTo(t *testing.T, f *fixture, username, secret string) *http.Cookie {
t.Helper()
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
}
return sessionCookie(t, rec)
}
// TestLoginWithSecondFactor walks the whole door: password, code, in,
// and the recovery path when the application is lost.
func TestLoginWithSecondFactor(t *testing.T) {
f := newFixture(t)
secret := users.GenerateTotpSecret()
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := f.users.EnableTotp("admin", secret, hashes); err != nil {
t.Fatal(err)
}
cookie := loginTo(t, f, "admin", "correct-horse-9")
// The password alone no longer opens anything: the admin bounces
// to the login, which forwards a pending session to the step.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("password step: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/twofactor" {
t.Fatalf("login form forwards pending session: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Verification code") {
t.Fatalf("twofactor form: code=%d", rec.Code)
}
// The direct route redirects anonymous traffic to the first step.
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("anonymous twofactor: code=%d", rec.Code)
}
csrf := csrfFromSession(t, f, cookie)
// A wrong code is refused and changes nothing.
rec := postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong code: code=%d", rec.Code)
}
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("right code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("dashboard after second factor: %d", rec.Code)
}
// The recovery path: sign out, in again, spend one code; the same
// code never works twice.
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("recovery code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
cookie = sessionCookie(t, rec)
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("reused recovery code: code=%d", rec.Code)
}
// The typed shapes humans use still work.
rec = postForm(t, f, "/admin/twofactor",
url.Values{"_csrf": {csrf}, "code": {strings.ReplaceAll(codes[1], "-", " ")}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("spaced recovery code: code=%d", rec.Code)
}
}
// TestTotpEnrolment drives the settings flow: start, the QR page, the
// verifying code, the one-time recovery codes, and turning it off.
func TestTotpEnrolment(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// Before anything, the settings page offers the setup.
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
if !strings.Contains(body, "Set up two-factor") {
t.Fatal("setup offer missing")
}
// Start shows the QR and the secret, and stores nothing yet. The
// candidate rides the cookie, so the jar moves on with it.
rec := postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("start: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body = f.do(t, req).Body.String()
if !strings.Contains(body, "totp__qr") || !strings.Contains(body, `<path fill="#000"`) {
t.Fatal("QR panel missing after start")
}
if f.users.Find("admin").TotpSecret != "" {
t.Fatal("start stored a secret before verification")
}
// A wrong verifying code clears the candidate.
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("wrong verify: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
if strings.Contains(f.do(t, req).Body.String(), "totp__qr") {
t.Fatal("candidate survived a wrong code")
}
// The honest path: start again, verify with the code the
// application shows, receive the recovery codes once.
rec = postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body = f.do(t, req).Body.String()
i := strings.Index(body, `class="totp__secret"`)
if i < 0 {
t.Fatal("secret text missing")
}
rest := body[i:]
j := strings.Index(rest, ">")
k := strings.Index(rest[j:], "<")
secret := rest[j+1 : j+k]
if len(secret) < 26 {
t.Fatalf("secret looks wrong: %q", secret)
}
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("verify: %d body=%s", rec.Code, rec.Body.String()[:200])
}
page := rec.Body.String()
if !strings.Contains(page, "recovery__code") {
t.Fatal("recovery codes not shown once")
}
if f.users.Find("admin").TotpSecret == "" {
t.Fatal("enabled secret not stored")
}
// The next sign-in needs the second factor.
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Header().Get("Location") != "/admin/twofactor" {
t.Fatalf("second factor not asked: %q", rec.Header().Get("Location"))
}
// Turning it off asks for a current code.
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("sign-in code: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
rec = postForm(t, f, "/admin/settings/twofactor/disable", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("disable with wrong code: %d", rec.Code)
}
// The sign-in already spent this window's code: the next window's
// code answers, the spent one must not.
rec = postForm(t, f, "/admin/settings/twofactor/disable",
url.Values{"_csrf": {csrf}, "code": {currentCodeIn(t, secret, 1)}}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("disable: %d", rec.Code)
}
if f.users.Find("admin").TotpSecret != "" {
t.Fatal("secret survived disable")
}
}
+426
View File
@@ -0,0 +1,426 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"fmt"
"html/template"
"log/slog"
"maps"
"slices"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
)
// postVariant is one language version of a publication. The dashboard
// shows one card per publication and lists its variants here, so the card
// can switch what it names, links and shows between them.
type postVariant struct {
Lang string `json:"lang"`
Slug string `json:"slug"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
DateString string `json:"date"`
Timestamp int64 `json:"timestamp,omitempty"`
Status string `json:"status"`
Draft bool `json:"draft,omitempty"`
Scheduled bool `json:"scheduled,omitempty"`
Series string `json:"series,omitempty"`
HasOrder bool `json:"hasOrder,omitempty"`
SeriesOrder int `json:"seriesOrder,omitempty"`
Cover string `json:"cover,omitempty"`
}
// postCard is one dashboard card.
type postCard struct {
Slug string
Title string
Excerpt string
Lang string
Series string
HasOrder bool
SeriesOrder int
DateString string
Cover string
Draft bool
Scheduled bool
Tags []string
Timestamp int64
Status string
// Variants carries every language version of the publication;
// VariantsJSON is the same list prepared for the card's data
// attribute, so the page script can switch between them.
Variants []postVariant
VariantsJS template.JS
GroupSlugs string
}
// recentPost is one entry of the dashboard's recent strip.
type recentPost struct {
Slug string
Title string
DateString string
}
// scheduledPost is one entry of the dashboard's upcoming strip.
type scheduledPost struct {
Slug string
Title string
When string
}
// dashboardStats holds the counters shown above the post grid.
type dashboardStats struct {
Total int
Published int
Drafts int
Scheduled int
Recent []recentPost
Upcoming []scheduledPost
}
// tagCount is one tag-cloud chip.
type tagCount struct {
Name string
Count int
}
// editorPost carries the form-ready post fields for the editor.
type editorPost struct {
Slug string
Title string
Lang string
Author string
FediverseCreator string
DOI string
ORCID string
Date string
PublishAt string
TagsCSV string
Series string
SeriesOrder string
Excerpt string
ExcerptIsSet bool
ExcerptPlaceholder string
Cover string
CoverAlt string
CoverCaption string
Body string
Draft bool
AllLangs bool
Scheduled bool
// RefsJSON is the post's reference list as a JS literal for the
// bibliography card: the frontmatter tables as they are stored, so
// the editor edits what the file holds and no field is lost in a
// decode/encode cycle. RefsCount is the same list's length, shown in
// the card's head.
RefsJSON template.JS
RefsCount int
}
// revisionRow is one history table row.
type revisionRow struct {
Name string
When string
SizeKB string
}
// tplOption is one post-template dropdown entry.
type tplOption struct {
Name string `json:"name"`
Title string `json:"title"`
Slug string `json:"slug"`
Tags []string `json:"tags"`
Body string `json:"body"`
// Fields are the extra editor inputs the template pre-fills. The
// keys are the editor's own input names.
Fields map[string]string `json:"fields,omitzero"`
// FieldsText lists the same fields for the settings screen, sorted
// and joined, so the template row shows what it will pre-fill.
FieldsText string `json:"-"`
}
// newPostVariant maps one stored post onto one card variant.
func newPostVariant(p *post.Post) postVariant {
v := postVariant{
Lang: p.Lang(),
Slug: p.Slug(),
Title: p.Title(),
Excerpt: p.Excerpt(),
DateString: p.DateString(),
Status: statusKey(p),
Draft: p.Draft(),
Scheduled: p.Scheduled(),
Series: p.Series(),
Cover: p.Cover(),
}
if order, ok := p.SeriesOrder(); ok {
v.HasOrder = true
v.SeriesOrder = order
}
if ts, ok := p.PublishedTimestamp(); ok {
v.Timestamp = ts
}
return v
}
// statusKey names the post status with the filter vocabulary.
func statusKey(p *post.Post) string {
switch {
case p.Draft():
return "draft"
case p.Scheduled():
return "scheduled"
default:
return "published"
}
}
// groupPosts merges the posts whose frontmatter names each other in the
// translations map into one group: the dashboard then shows one card per
// publication instead of one per language file. The groups keep the order
// of their first appearance, and the posts arrive newest first, so the
// grid stays date-ordered.
func groupPosts(posts []*post.Post) [][]*post.Post {
parent := map[string]string{}
var find func(slug string) string
find = func(slug string) string {
root, ok := parent[slug]
if !ok {
parent[slug] = slug
return slug
}
if root == slug {
return slug
}
parent[slug] = find(root)
return parent[slug]
}
union := func(a, b string) {
ra, rb := find(a), find(b)
if ra != rb {
parent[rb] = ra
}
}
for _, p := range posts {
find(p.Slug())
}
for _, p := range posts {
for _, target := range p.Translations() {
if target != "" {
union(p.Slug(), target)
}
}
}
var order []string
members := map[string][]*post.Post{}
for _, p := range posts {
root := find(p.Slug())
if _, seen := members[root]; !seen {
order = append(order, root)
}
members[root] = append(members[root], p)
}
groups := make([][]*post.Post, 0, len(order))
for _, root := range order {
groups = append(groups, members[root])
}
return groups
}
// pickDisplay chooses the variant the card shows: the one in the
// interface language when the publication carries it, the newest one
// otherwise.
func pickDisplay(group []*post.Post, lang string) *post.Post {
if lang != "" {
for _, p := range group {
if p.Lang() == lang {
return p
}
}
}
return group[0]
}
// variantsJSON renders the language variants as a JS literal for the
// card's data attribute. The same script-embedding rule as templatesJSON
// applies: no literal "<" may reach the page.
func variantsJSON(variants []postVariant) template.JS {
raw, err := json.Marshal(variants)
if err != nil {
slog.Warn("admin: cannot encode post variants", "error", err)
return template.JS("[]")
}
return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`))
}
// newEditorPost maps a stored post onto the editor form fields.
func newEditorPost(p *post.Post) *editorPost {
view := &editorPost{
Slug: p.Slug(),
Title: p.Title(),
Lang: p.Lang(),
Author: p.Author(),
Date: p.DateString(),
TagsCSV: strings.Join(p.Tags(), ", "),
Series: p.Series(),
Excerpt: p.StoredExcerpt(),
Cover: p.Cover(),
CoverAlt: p.CoverAlt(),
CoverCaption: p.CoverCaption(),
Body: p.Body,
Draft: p.Draft(),
AllLangs: p.AllLangs(),
Scheduled: p.Scheduled(),
}
view.RefsJSON, view.RefsCount = refsJS(p)
if fc := p.FediverseCreator(); fc != "" {
view.FediverseCreator = fc
}
view.DOI = p.DOI()
view.ORCID = p.ORCID()
if d, ok := p.DueAt(); ok {
view.PublishAt = d.Format("2006-01-02")
}
if order, ok := p.SeriesOrder(); ok {
view.SeriesOrder = fmt.Sprintf("%d", order)
}
view.ExcerptIsSet = strings.TrimSpace(view.Excerpt) != ""
// An unset excerpt shows the derived text as a placeholder, so saving
// the form never writes text the author did not type.
view.ExcerptPlaceholder = "Short summary for listings and previews"
if !view.ExcerptIsSet {
if derived := p.Excerpt(); derived != "" {
view.ExcerptPlaceholder = derived
}
}
return view
}
// refsJS renders the post's stored reference tables as a JS literal for
// the bibliography card, with the list's length beside it. The same
// script-embedding rule as templatesJSON applies: no literal "<" may
// reach the page, and a post without refs still gets a literal the
// script can iterate.
func refsJS(p *post.Post) (template.JS, int) {
raw, _ := p.Metadata.Get("refs")
// A parsed file hands the tables over as []map[string]any while a
// freshly written list is []any; both shapes carry the same entries.
var list []any
switch tables := raw.(type) {
case []any:
list = tables
case []map[string]any:
list = make([]any, len(tables))
for i, table := range tables {
list[i] = table
}
}
if len(list) == 0 {
return template.JS("[]"), 0
}
encoded, err := json.Marshal(list)
if err != nil {
slog.Warn("admin: cannot encode the post references", "slug", p.Slug(), "error", err)
return template.JS("[]"), 0
}
return template.JS(strings.ReplaceAll(string(encoded), "<", `\u003c`)), len(list)
}
// newPostCard maps a stored post onto one dashboard card.
func newPostCard(p *post.Post) postCard {
card := postCard{
Slug: p.Slug(),
Title: p.Title(),
Excerpt: p.Excerpt(),
Lang: p.Lang(),
Series: p.Series(),
DateString: p.DateString(),
Cover: p.Cover(),
Draft: p.Draft(),
Scheduled: p.Scheduled(),
Tags: p.Tags(),
}
if order, ok := p.SeriesOrder(); ok {
card.HasOrder = true
card.SeriesOrder = order
}
if ts, ok := p.PublishedTimestamp(); ok {
card.Timestamp = ts
}
switch {
case card.Draft:
card.Status = "draft"
case card.Scheduled:
card.Status = "scheduled"
default:
card.Status = "published"
}
return card
}
// newRevisionRow formats one revision for the history table.
func newRevisionRow(rev store.Revision) revisionRow {
return revisionRow{
Name: rev.Name,
When: rev.When,
SizeKB: fmt.Sprintf("%.1f", float64(rev.Size)/1024),
}
}
// tplOptions converts stored post templates for the dropdown and the
// embedded JSON blob.
func tplOptions(list []templates.PostTemplate) []tplOption {
out := make([]tplOption, 0, len(list))
for _, tpl := range list {
tags := tpl.Tags
if tags == nil {
tags = []string{}
}
out = append(out, tplOption{
Name: tpl.Name,
Title: tpl.Title,
Slug: tpl.Slug,
Tags: tags,
Body: tpl.Body,
Fields: tpl.Fields,
FieldsText: fieldsText(tpl.Fields),
})
}
return out
}
// fieldsText renders a sorted "key = value" summary of template fields.
func fieldsText(fields map[string]string) string {
if len(fields) == 0 {
return ""
}
parts := make([]string, 0, len(fields))
for _, key := range slices.Sorted(maps.Keys(fields)) {
parts = append(parts, key+" = "+fields[key])
}
return strings.Join(parts, ", ")
}
// templatesJSON renders the template list as a JS literal.
func templatesJSON(list []tplOption) template.JS {
raw, err := json.Marshal(list)
if err != nil {
slog.Warn("admin: cannot encode post templates", "error", err)
return template.JS("[]")
}
// A template body is free-text admin content, and encoding/json/v2
// escapes only what JSON requires: a literal "</script>" inside the
// JSON would end the script element the literal is embedded in. "<"
// cannot occur outside a string in JSON, so escaping it as a unicode
// escape inside the literal closes the hole while staying valid JSON.
return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`))
}
+484
View File
@@ -0,0 +1,484 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package app assembles the volumen HTTP server: shared services,
// route registration, and the middleware chain.
package app
import (
"crypto/rand"
"encoding/hex"
json "encoding/json/v2"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/admin"
"sourcedock.dev/petrbalvin/volumen/internal/audit"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/httpapi"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/version"
"sourcedock.dev/petrbalvin/volumen/internal/web"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// Server holds every long-lived service the handlers share.
type Server struct {
Config *config.Config
Store *store.Store
Users *users.Users
Templates *templates.Store
Tokens *tokens.Store
Audit *audit.Log
LoginLim *ratelimit.LoginLimiter
Sessions *session.Store
Webhooks *webhooks.Manager
Admin *admin.Admin
OnEvent func(event string, payload map[string]any)
// previewKey is the session secret New resolved, which the admin
// signs preview links with and the API verifies them against.
previewKey string
}
// New validates the configuration and builds the server with all
// file-backed stores derived from it.
func New(cfg *config.Config, st *store.Store) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
secret, err := sessionSecret(cfg)
if err != nil {
return nil, err
}
cookieSecure := cfg.Server.CookieSecure || cfg.Server.TrustProxy
usersPath := cfg.UsersFile
hooks := make([]webhooks.Webhook, 0, len(cfg.Webhooks))
for _, hook := range cfg.Webhooks {
hooks = append(hooks, webhooks.Webhook{
URL: hook.URL, Secret: hook.Secret,
Events: hook.Events, Enabled: hook.Delivers(),
})
}
staticHooks := slices.Clone(hooks)
// The admin-managed hooks live beside the users file and apply
// without a restart. A file that cannot be read is a real fault and
// is reported, but it does not take the server down: the configured
// hooks still deliver.
webhooksFile := filepath.Join(filepath.Dir(usersPath), "webhooks.toml")
fileHooks, err := webhooks.LoadFile(webhooksFile)
if err != nil {
slog.Warn("app: ignoring the webhook store", "path", webhooksFile, "error", err)
} else {
hooks = append(hooks, fileHooks...)
}
manager := webhooks.NewManager(hooks, version.Version())
srv := &Server{
Config: cfg,
Store: st,
Users: users.New(usersPath),
Templates: templates.New(cfg.TemplatesFile()),
Tokens: tokens.New(cfg.TokensFile()),
Audit: audit.New(cfg.AuditLog),
LoginLim: ratelimit.NewLoginLimiter(),
Sessions: session.New(secret, time.Duration(cfg.Admin.SessionTTL)*time.Second, cookieSecure),
Webhooks: manager,
previewKey: secret,
OnEvent: func(event string, payload map[string]any) {
manager.Fire(event, payload, false)
},
}
adminHandler, err := admin.New(admin.Deps{
Config: cfg,
Store: st,
Users: srv.Users,
Templates: srv.Templates,
Tokens: srv.Tokens,
Audit: srv.Audit,
LoginLim: srv.LoginLim,
Sessions: srv.Sessions,
Webhooks: manager,
PreviewKey: secret,
WebhooksFile: webhooksFile,
StaticWebhooks: staticHooks,
Version: version.Version(),
OnEvent: srv.OnEvent,
Backup: BackupOptions(cfg),
})
if err != nil {
return nil, fmt.Errorf("init admin UI: %w", err)
}
srv.Admin = adminHandler
return srv, nil
}
// Handler builds the full middleware chain and route tree.
//
// Uploaded media and the backup export are served on their own branches:
// the session middleware and the gzip wrapper buffer whole responses,
// which would hold entire files in memory. Everything else flows through
// the full chain.
func (s *Server) Handler() http.Handler {
secure := s.Config.Server.CookieSecure || s.Config.Server.TrustProxy
mediaMux := http.NewServeMux()
mediaMux.HandleFunc("GET /media/{name...}", s.handleMedia)
mediaHandler := web.SecurityHeaders(secure)(mediaMux)
adminHandler := s.Admin.Handler()
// The backup export streams: it keeps the admin authentication but
// bypasses the wrappers that hold a whole response in memory, the
// session recorder and the gzip wrapper, so the archive reaches the
// client as it is written instead of waiting in a second copy. The
// session attaches read-only; a GET never mutates it.
var exportHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess := s.Sessions.Load(r)
adminHandler.ServeHTTP(w, r.WithContext(session.WithContext(r.Context(), sess)))
})
exportHandler = web.CrossOrigin()(exportHandler)
exportHandler = web.SecurityHeaders(secure)(exportHandler)
mux := http.NewServeMux()
api := httpapi.New(httpapi.Deps{
Config: s.Config,
Store: s.Store,
Tokens: s.Tokens,
OnEvent: s.OnEvent,
PreviewKey: s.previewKey,
})
mux.Handle("/api/volumen/", api)
mux.Handle("/admin/", adminHandler)
mux.Handle("/admin", adminHandler)
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", "/admin/")
w.WriteHeader(http.StatusSeeOther)
})
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /robots.txt", s.handleRobots)
mux.HandleFunc("GET /sitemap.xml", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", "/api/volumen/sitemap.xml")
w.WriteHeader(http.StatusMovedPermanently)
})
mux.HandleFunc("GET /favicon.ico", s.handleFavicon)
mux.HandleFunc("/", s.handleNotFound)
var handler http.Handler = web.RequestLogger(mux)
handler = s.Sessions.Middleware(handler)
handler = s.apiRateLimit(handler)
handler = web.SecurityHeaders(secure)(handler)
handler = web.CrossOrigin()(handler)
handler = web.Gzip(500)(handler)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin/settings/export" {
exportHandler.ServeHTTP(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/media/") {
mediaHandler.ServeHTTP(w, r)
return
}
handler.ServeHTTP(w, r)
})
}
func (s *Server) apiRateLimit(next http.Handler) http.Handler {
if s.Config.API.RateLimit <= 0 {
return next
}
limiter := ratelimit.New(
s.Config.API.RateLimit,
time.Duration(s.Config.API.RateLimitWindow)*time.Second,
)
trusted, err := s.Config.TrustedProxyPrefixes()
if err != nil || !s.Config.Server.TrustProxy {
trusted = nil
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/volumen" && !strings.HasPrefix(r.URL.Path, "/api/volumen/") {
next.ServeHTTP(w, r)
return
}
allowed, remaining, retryAfter := limiter.Check(web.ClientIP(r, trusted))
if !allowed {
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.Limit()))
w.Header().Set("X-RateLimit-Remaining", "0")
for key, value := range map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
} {
w.Header().Set(key, value)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_ = json.MarshalWrite(w, map[string]any{
"error": "rate_limited",
"retry_after": retryAfter,
}, json.Deterministic(true))
return
}
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", limiter.Limit()))
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", remaining))
next.ServeHTTP(w, r)
})
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
checks := map[string]string{}
overall := "ok"
if info, err := os.Stat(s.Config.ContentDir); err == nil && info.IsDir() {
checks["content_dir"] = "ok"
} else {
checks["content_dir"] = "missing"
overall = "degraded"
}
switch err := s.Users.Health(); {
case err != nil:
// A file that cannot be read means nobody can sign in, which is
// not a healthy deployment: say so rather than reporting a count
// of zero accounts.
slog.Error("volumen: healthz cannot read the users file", "error", err)
checks["users_file"] = "unreadable"
overall = "degraded"
default:
if info, statErr := os.Stat(s.Config.UsersFile); statErr == nil && info.Mode().IsRegular() {
checks["users_file"] = "ok"
} else {
checks["users_file"] = "missing (no accounts yet; /admin runs the first-run wizard)"
}
}
if err := s.Tokens.Health(); err != nil {
slog.Error("volumen: healthz cannot read the tokens file", "error", err)
checks["tokens_file"] = "unreadable"
overall = "degraded"
}
if err := s.Templates.Health(); err != nil {
slog.Error("volumen: healthz cannot read the templates file", "error", err)
checks["templates_file"] = "unreadable"
overall = "degraded"
}
if skipped := s.Store.Unreadable(); len(skipped) > 0 {
checks["content_files"] = fmt.Sprintf("%d file(s) cannot be parsed", len(skipped))
overall = "degraded"
}
freeMB, err := freeDiskMB(s.Config.ContentDir)
switch {
case err != nil:
// The path and the OS error are logged, not published: this
// endpoint is anonymous.
slog.Warn("volumen: healthz cannot read the content directory", "error", err)
checks["disk"] = "error"
case freeMB < 100:
checks["disk"] = fmt.Sprintf("low: %.0f MB free", freeMB)
overall = "degraded"
default:
checks["disk"] = fmt.Sprintf("ok (%.0f MB free)", freeMB)
}
status := http.StatusOK
if overall != "ok" {
status = http.StatusServiceUnavailable
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.MarshalWrite(w, map[string]any{"status": overall, "checks": checks}, json.Deterministic(true))
}
func freeDiskMB(path string) (float64, error) {
var st syscall.Statfs_t
if err := syscall.Statfs(path, &st); err != nil {
return 0, err
}
return float64(st.Bavail) * float64(st.Bsize) / (1024 * 1024), nil
}
func (s *Server) handleRobots(w http.ResponseWriter, _ *http.Request) {
base := s.Config.Site.BaseURL
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "User-agent: *\nAllow: /\nSitemap: %s/api/volumen/sitemap.xml\n", base)
}
func (s *Server) handleFavicon(w http.ResponseWriter, _ *http.Request) {
icon, err := web.StaticFile("volumen-icon.svg")
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(icon)
}
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
mediaPath, err := s.Store.MediaPath(name)
if err != nil {
// A name that is not an allowed image, that would escape the
// media directory, or that names nothing, is a 404: the route is
// public, so it says nothing about why.
s.writeNotFound(w)
return
}
// The type is set from the name's extension rather than sniffed, so a
// file whose bytes do not match its extension is still served as an
// image and never as a document.
contentType := imagefile.ContentType(name)
w.Header().Set("Cache-Control", "public, max-age=604800")
w.Header().Set("Content-Type", contentType)
if contentType == imagefile.MIMESVG {
// An SVG is the one accepted image that is also a document:
// opened at its own URL it would run on this origin. The
// sandbox and the locked-down policy make that a dead
// document, while the <img> uses of the file ignore both.
w.Header().Set("Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; sandbox")
}
http.ServeFile(w, r, mediaPath)
}
func (s *Server) writeNotFound(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_ = json.MarshalWrite(w, map[string]any{"error": "not_found"}, json.Deterministic(true))
}
func (s *Server) handleNotFound(w http.ResponseWriter, _ *http.Request) {
s.writeNotFound(w)
}
// BackupOptions names the files an archive carries, for the admin UI and
// the CLI export and import.
func BackupOptions(cfg *config.Config) backup.Options {
return backup.Options{
ContentDir: cfg.ContentDir,
UsersFile: cfg.UsersFile,
TemplatesFile: cfg.TemplatesFile(),
TokensFile: cfg.TokensFile(),
}
}
// PublishEvent reports that a scheduled post went live, as the same
// post.published event the admin delivers, so a hook subscribed to it
// hears about a post the scheduler published.
func (s *Server) PublishEvent(p *post.Post) {
if s.OnEvent == nil || p == nil {
return
}
s.OnEvent("post.published", map[string]any{"post": payloads.BuildSummary(p)})
}
// sessionSecret resolves the cookie signing key. The [admin].session_key
// in config is an override; with nothing set the server keeps its own
// secret in secret.key beside the users file, generating one on first
// start so a fresh installation can sign in without the operator
// editing the config. Production refuses a key shorter than 64 bytes
// whatever its source; development falls back to the ephemeral secret
// of session.New when the file cannot be written.
func sessionSecret(cfg *config.Config) (string, error) {
if key := cfg.Admin.SessionKey; key != "" {
return checkedSecret(cfg, key, "[admin].session_key")
}
path := cfg.SecretKeyFile()
raw, err := os.ReadFile(path)
switch {
case err == nil:
if key := strings.TrimSpace(string(raw)); key != "" {
return checkedSecret(cfg, key, path)
}
// An empty file is treated as no file: one more start and the
// key is generated and written, so the state converges.
case !errors.Is(err, os.ErrNotExist):
if cfg.IsProduction() {
return "", fmt.Errorf("read %s: %w", path, err)
}
slog.Warn("app: cannot read the session secret file", "path", path, "error", err)
return "", nil
}
// 32 random bytes as 64 hex characters, which is the length
// production requires. A system failure here dies inside
// crypto/rand rather than signing sessions with less entropy than
// the key looks like.
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate the session secret: %w", err)
}
key := hex.EncodeToString(buf)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
if cfg.IsProduction() {
return "", fmt.Errorf("create %s: %w", filepath.Dir(path), err)
}
slog.Warn("app: cannot create the session secret directory; using an ephemeral secret", "error", err)
return "", nil
}
// O_EXCL so two servers racing on a fresh data directory cannot
// each write a different key: the loser reads the winner's file.
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
raw, err := os.ReadFile(path)
if err != nil {
if cfg.IsProduction() {
return "", fmt.Errorf("read %s: %w", path, err)
}
return "", nil
}
return checkedSecret(cfg, strings.TrimSpace(string(raw)), path)
}
if err != nil {
if cfg.IsProduction() {
return "", fmt.Errorf("write %s: %w (or set [admin].session_key)", path, err)
}
slog.Warn("app: cannot write the session secret file; using an ephemeral secret", "error", err)
return "", nil
}
if _, err := f.WriteString(key + "\n"); err != nil {
f.Close()
if cfg.IsProduction() {
return "", fmt.Errorf("write %s: %w", path, err)
}
return "", nil
}
if err := f.Close(); err != nil && cfg.IsProduction() {
return "", fmt.Errorf("write %s: %w", path, err)
}
slog.Info("app: generated the session secret", "path", path)
return key, nil
}
// checkedSecret applies the production length rule to a key named by
// its source, which is the config field or the secret file.
func checkedSecret(cfg *config.Config, key, source string) (string, error) {
if len(key) < 64 && cfg.IsProduction() {
return "", fmt.Errorf(
"%s must be at least 64 bytes in production (got %d). "+
"Generate one with: openssl rand -hex 32", source, len(key))
}
return key, nil
}
+617
View File
@@ -0,0 +1,617 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
func newTestServer(t *testing.T) (*Server, http.Handler) {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Site.BaseURL = "https://site.example"
cfg.Server.Env = config.EnvProduction
cfg.Server.CookieSecure = true
cfg.Admin.SessionKey = strings.Repeat("s", 64)
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
srv, err := New(cfg, st)
if err != nil {
t.Fatalf("New: %v", err)
}
return srv, srv.Handler()
}
// With no session_key in the config, production starts anyway: the
// server generates its secret into secret.key beside the users file,
// owner-only, and every later start reuses the same key so sessions
// survive a restart. An explicitly configured key still wins and is
// still length-checked.
func TestNewGeneratesSessionSecret(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Server.Env = "production"
if err := os.MkdirAll(filepath.Join(dir, "posts"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := New(cfg, store.New(store.Options{ContentDir: filepath.Join(dir, "posts"), DefaultLang: "en", RevisionLimit: 10})); err != nil {
t.Fatalf("production with no session_key must generate the secret: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("read secret.key: %v", err)
}
key := strings.TrimSpace(string(raw))
if len(key) < 64 {
t.Fatalf("generated key length = %d", len(key))
}
info, err := os.Stat(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("stat secret.key: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("secret.key mode = %v, want 0600", info.Mode().Perm())
}
// The second start reads the file back and keeps the same key.
cfg2, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg2.Server.Env = "production"
secret, err := sessionSecret(cfg2)
if err != nil {
t.Fatalf("sessionSecret on the second start: %v", err)
}
if secret != key {
t.Fatal("the generated secret changed between starts")
}
// A configured override wins, and production still rejects it short.
cfg2.Admin.SessionKey = "short"
if _, err := sessionSecret(cfg2); err == nil {
t.Fatal("want error for a short production override")
}
}
// The generated secret.key signs preview links too, the way the
// configuration documents admin.session_key: a default deployment, with
// no key in the config, honours a preview token minted from the file,
// through the wired handler the browser talks to.
func TestGeneratedSecretSignsPreviewLinks(t *testing.T) {
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
draft := "+++\ntitle = \"Draft\"\nslug = \"draft\"\ndate = 2026-08-18\ndraft = true\n+++\n\nBody.\n"
if err := os.WriteFile(filepath.Join(content, "draft.md"), []byte(draft), 0o644); err != nil {
t.Fatalf("write draft: %v", err)
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Server.Env = "production"
cfg.Site.BaseURL = "https://site.example"
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
srv, err := New(cfg, st)
if err != nil {
t.Fatalf("New: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "secret.key"))
if err != nil {
t.Fatalf("read secret.key: %v", err)
}
key := strings.TrimSpace(string(raw))
if key == "" {
t.Fatal("no secret.key was generated")
}
handler := srv.Handler()
token := preview.Token("draft", key, time.Now())
if token == "" {
t.Fatal("the generated key cannot sign a preview token")
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
"/api/volumen/posts/draft?preview_token="+token, nil))
if rec.Code != http.StatusOK {
t.Fatalf("preview through the wired handler = %d %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Draft") {
t.Fatalf("the draft body did not reach the response: %s", rec.Body.String())
}
}
// Development starts without a writable location for the file too:
// the ephemeral fallback keeps the server usable, with the warning as
// the only signal.
func TestSessionSecretOverrideInDevelopment(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Admin.SessionKey = "a-development-key"
secret, err := sessionSecret(cfg)
if err != nil {
t.Fatalf("development accepts a short override: %v", err)
}
if secret != "a-development-key" {
t.Fatalf("secret = %q", secret)
}
}
func TestRootRedirectsToAdmin(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestHealthz(t *testing.T) {
srv, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
body := decode(t, rec)
if body["status"] != "ok" {
t.Fatalf("body = %v", body)
}
checks := body["checks"].(map[string]any)
if checks["content_dir"] != "ok" {
t.Fatalf("checks = %v", checks)
}
if checks["users_file"] == nil || checks["disk"] == nil {
t.Fatalf("checks = %v", checks)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Fatal("healthz must not be cached")
}
// Missing content directory degrades the status.
if err := os.RemoveAll(srv.Config.ContentDir); err != nil {
t.Fatalf("remove: %v", err)
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d", rec.Code)
}
}
func decode(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("invalid JSON %q: %v", rec.Body.String(), err)
}
return out
}
func TestRobotsSitemapFavicon(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
if !strings.Contains(rec.Body.String(), "Sitemap: https://site.example/api/volumen/sitemap.xml") {
t.Fatalf("robots = %q", rec.Body.String())
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil))
if rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/api/volumen/sitemap.xml" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/favicon.ico", nil))
if rec.Code != http.StatusOK ||
rec.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), "<svg") {
t.Fatal("favicon body is not SVG")
}
}
func TestMediaServing(t *testing.T) {
srv, handler := newTestServer(t)
mediaDir := filepath.Join(srv.Store.ContentDir, store.MediaDirName)
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(mediaDir, "pic.webp"), []byte("IIIIIIIIWEBP"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>`)
if err := os.WriteFile(filepath.Join(mediaDir, "figure.svg"), svg, 0o644); err != nil {
t.Fatalf("write: %v", err)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/pic.webp", nil))
if rec.Code != http.StatusOK || rec.Body.String() != "IIIIIIIIWEBP" {
t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "public, max-age=604800" {
t.Fatalf("cache-control = %q", rec.Header().Get("Cache-Control"))
}
// A raster image gets no document policy of its own.
if strings.Contains(rec.Header().Get("Content-Security-Policy"), "sandbox") {
t.Fatal("webp response carries a sandbox policy")
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/figure.svg", nil))
if rec.Code != http.StatusOK || rec.Body.String() != string(svg) {
t.Fatalf("svg code=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("svg content-type = %q", rec.Header().Get("Content-Type"))
}
// An SVG opened at its own URL is a document on this origin: the
// response must sandbox it.
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "sandbox") || !strings.Contains(csp, "default-src 'none'") {
t.Fatalf("svg content-security-policy = %q, want a sandboxed document", csp)
}
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/media/missing.webp", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestNotFoundJSON(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if decode(t, rec)["error"] != "not_found" {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestSecurityHeadersOnAPI(t *testing.T) {
_, handler := newTestServer(t)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers missing")
}
if strings.Contains(rec.Header().Get("Content-Security-Policy"), "nonce-") {
t.Fatal("nonce leaked onto API response")
}
if rec.Header().Get("Strict-Transport-Security") == "" {
t.Fatal("HSTS missing on secure deployment")
}
}
func TestAPIRateLimitHeaders(t *testing.T) {
srv, handler := newTestServer(t)
srv.Config.API.RateLimit = 2
srv.Config.API.RateLimitWindow = 60
handler = srv.Handler() // rebuild with the new limit
var limited bool
for range 5 {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Code == http.StatusTooManyRequests {
limited = true
body := decode(t, rec)
if body["error"] != "rate_limited" {
t.Fatalf("body = %v", body)
}
if rec.Header().Get("Retry-After") == "" {
t.Fatal("Retry-After missing")
}
break
}
if rec.Header().Get("X-RateLimit-Limit") != "2" {
t.Fatalf("limit header = %q", rec.Header().Get("X-RateLimit-Limit"))
}
}
if !limited {
t.Fatal("rate limit never triggered")
}
// Non-API routes are not limited.
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
if rec.Code != http.StatusOK {
t.Fatalf("robots blocked: %d", rec.Code)
}
}
func TestGzipThroughChain(t *testing.T) {
srv, handler := newTestServer(t)
// A large post body pushes the JSON response over the gzip threshold.
var b strings.Builder
b.WriteString("+++\ntitle = \"Big\"\nslug = \"big\"\ndate = 2026-01-01\nexcerpt = \"")
b.WriteString(strings.Repeat("x", 600))
b.WriteString("\"\n+++\nbody\n")
if err := os.WriteFile(filepath.Join(srv.Config.ContentDir, "big.md"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/big", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("content-encoding = %q (body %d bytes)",
rec.Header().Get("Content-Encoding"), rec.Body.Len())
}
if strconv.Itoa(rec.Body.Len()) == "0" {
t.Fatal("empty body")
}
}
func TestSessionCookieSecureFlag(t *testing.T) {
dir := t.TempDir()
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
if err := os.MkdirAll(filepath.Join(dir, "posts"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// trust_proxy implies secure cookies even without cookie_secure.
cfg.Server.TrustProxy = true
srv, err := New(cfg, store.New(store.Options{ContentDir: filepath.Join(dir, "posts"), DefaultLang: "en", RevisionLimit: 10}))
if err != nil {
t.Fatalf("New: %v", err)
}
if !srv.Sessions.Secure() {
t.Fatal("trust_proxy must imply secure session cookies")
}
}
func TestMediaServedWithoutGzipBuffering(t *testing.T) {
srv, handler := newTestServer(t)
mediaDir := filepath.Join(srv.Store.ContentDir, store.MediaDirName)
if err := os.MkdirAll(mediaDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Repetitive content that gzip would shrink dramatically if applied.
payload := strings.Repeat("WEBPDATA", 2000)
if err := os.WriteFile(filepath.Join(mediaDir, "big.webp"), []byte(payload), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/media/big.webp", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
t.Fatalf("media was compressed (%q); it must stream untouched", enc)
}
if rec.Body.Len() != len(payload) {
t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(payload))
}
if rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers missing on media responses")
}
}
// The backup export streams on its own branch: the archive arrives
// compressed by the backup writer alone, never re-wrapped by the gzip
// middleware, which also proves the response never waited in that
// wrapper's buffer.
func TestExportStreamsUnwrapped(t *testing.T) {
srv, handler := newTestServer(t)
if _, err := srv.Users.Add("admin", "correct-horse-battery", "admin"); err != nil {
t.Fatalf("Add: %v", err)
}
// Enough varied content that the archive crosses the gzip threshold
// and would engage the middleware were the export still flowing
// through it; repeated bytes compress away and prove nothing.
var b strings.Builder
b.WriteString("+++\ntitle = \"Big\"\nslug = \"big\"\ndate = 2026-01-01\nexcerpt = \"")
for i := range 400 {
b.WriteString(strconv.Itoa(i*7919+i*i) + " ")
}
b.WriteString("\"\n+++\nbody\n")
if err := os.WriteFile(filepath.Join(srv.Config.ContentDir, "big.md"), []byte(b.String()), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
login := func() *http.Cookie {
t.Helper()
form := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
handler.ServeHTTP(form, req)
csrf := extractCSRF(t, form.Body.String())
cookies := form.Result().Cookies()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf) + "&username=admin&password=correct-horse-battery")
req = httptest.NewRequest(http.MethodPost, "/admin/login", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" {
return c
}
}
t.Fatal("no session cookie after login")
return nil
}
req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
req.Header.Set("Accept-Encoding", "gzip")
req.AddCookie(login())
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/gzip" {
t.Fatalf("content-type = %q", got)
}
if enc := rec.Header().Get("Content-Encoding"); enc != "" {
t.Fatalf("export re-compressed (%q); it must stream untouched", enc)
}
if rec.Body.Len() < 500 {
t.Fatalf("body %d bytes, too small to prove the bypass", rec.Body.Len())
}
if rec.Body.Bytes()[0] != 0x1f || rec.Body.Bytes()[1] != 0x8b {
t.Fatal("body does not start with the gzip magic bytes")
}
}
// Logout must clear the cookie through the handler built by app.New: the
// session services are wired there, and a hand-built Deps in a test would
// hide a missing one.
func TestLogoutThroughTheWiredHandler(t *testing.T) {
srv, handler := newTestServer(t)
if _, err := srv.Users.Add("admin", "correct-horse-battery", "admin"); err != nil {
t.Fatalf("Add: %v", err)
}
login := func() *http.Cookie {
t.Helper()
form := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
handler.ServeHTTP(form, req)
csrf := extractCSRF(t, form.Body.String())
cookies := form.Result().Cookies()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf) + "&username=admin&password=correct-horse-battery")
req = httptest.NewRequest(http.MethodPost, "/admin/login", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" {
return c
}
}
t.Fatal("no session cookie after login")
return nil
}
session := login()
// The dashboard is reachable while signed in.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(session)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("dashboard code = %d", rec.Code)
}
csrf := extractCSRF(t, rec.Body.String())
// Logging out answers, clears the cookie and refuses the next request.
rec = httptest.NewRecorder()
body := strings.NewReader("_csrf=" + url.QueryEscape(csrf))
req = httptest.NewRequest(http.MethodPost, "/admin/logout", body)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(session)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("logout code = %d body=%s", rec.Code, rec.Body.String())
}
expired := false
for _, c := range rec.Result().Cookies() {
if c.Name == "volumen_session" && c.MaxAge < 0 {
expired = true
}
}
if !expired {
t.Fatal("logout did not expire the session cookie")
}
// A copy of the cookie taken before the logout stays valid until it
// expires, because the session lives entirely in the cookie. That is
// the documented trade-off of a signed cookie without server state,
// and why logging out expires the browser's copy rather than claiming
// to revoke it.
}
func extractCSRF(t *testing.T, body string) string {
t.Helper()
const marker = `name="_csrf" value="`
_, rest, ok := strings.Cut(body, marker)
if !ok {
t.Fatal("no CSRF token in the page")
}
token, _, ok := strings.Cut(rest, `"`)
if !ok {
t.Fatal("malformed CSRF token")
}
return token
}
+98
View File
@@ -0,0 +1,98 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package app
import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// BenchmarkServer drives the wired handler over real HTTP: a list, a
// single post, a tag feed and the sitemap, against a corpus the size of a
// site that has been running for a few years. It is also the workload the
// PGO profile is recorded from, which is why it exercises the whole chain
// rather than one function.
func BenchmarkServer(b *testing.B) {
const posts = 500
dir := b.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
b.Fatalf("mkdir: %v", err)
}
for i := range posts {
body := fmt.Sprintf(`+++
title = "Post %d"
slug = "post-%d"
date = 2026-01-%02d
lang = "en"
tags = ["go", "bench"]
series = "Bench"
series_order = %d
+++
## Section
A paragraph with **markup**, a [link](https://example.com) and enough
words to make the renderer do real work: %s
- one
- two
- three
`, i, i, i%28+1, i, strings.Repeat("lorem ipsum dolor sit amet ", 40))
path := filepath.Join(content, fmt.Sprintf("post-%d.md", i))
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
b.Fatalf("write: %v", err)
}
}
cfg := config.Defaults()
cfg.ContentDir = content
cfg.UsersFile = filepath.Join(dir, "users.toml")
cfg.Admin.SessionKey = strings.Repeat("k", 64)
cfg.Site.BaseURL = "https://site.example"
cfg.API.RateLimit = 0
srv, err := New(cfg, store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10}))
if err != nil {
b.Fatalf("New: %v", err)
}
// The access line each request writes is noise inside a benchmark.
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
b.Cleanup(func() { slog.SetDefault(previous) })
server := httptest.NewServer(srv.Handler())
defer server.Close()
targets := []string{
"/api/volumen/posts?limit=20",
"/api/volumen/posts/post-250",
"/api/volumen/tags",
"/api/volumen/tags/go/feed.json",
"/api/volumen/sitemap.xml",
}
client := server.Client()
b.ResetTimer()
for i := 0; b.Loop(); i++ {
resp, err := client.Get(server.URL + targets[i%len(targets)])
if err != nil {
b.Fatalf("get: %v", err)
}
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
b.Fatalf("read: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b.Fatalf("%s: status %d", targets[i%len(targets)], resp.StatusCode)
}
}
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package audit is a structured audit log for administrative actions.
//
// It writes JSON lines to a configurable file so operators can track who
// did what, when, and from which IP address. The lines are produced by a
// slog handler rather than assembled by hand: the handler owns the
// encoding and the escaping, and a key-renaming step keeps the field
// names this log has always used.
package audit
import (
"context"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
)
// Entry is one audit record. Only User and Action are always present.
type Entry struct {
User string
Action string
Resource string
Detail map[string]any
IP string
}
// Log is an append-only JSON-lines audit log. An empty path disables it.
type Log struct {
path string
mu sync.Mutex
file *os.File
logger *slog.Logger
}
// New creates a log writing to path; an empty path disables auditing.
func New(path string) *Log {
return &Log{path: path}
}
// Enabled reports whether entries are persisted.
func (l *Log) Enabled() bool {
return l != nil && l.path != ""
}
// Record appends one audit entry. Failures are logged, never raised: a
// line that cannot be written must not fail the action it records.
func (l *Log) Record(e Entry) {
if !l.Enabled() {
return
}
logger, err := l.handler()
if err != nil {
slog.Warn("audit: cannot open the log", "path", l.path, "error", err)
return
}
attrs := make([]slog.Attr, 0, 4)
if e.Resource != "" {
attrs = append(attrs, slog.String("resource", e.Resource))
}
if len(e.Detail) > 0 {
attrs = append(attrs, slog.Any("detail", e.Detail))
}
if e.IP != "" {
attrs = append(attrs, slog.String("ip", e.IP))
}
logger.LogAttrs(context.Background(), slog.LevelInfo, e.Action,
append([]slog.Attr{slog.String("user", e.User)}, attrs...)...)
}
// Close releases the file handle.
func (l *Log) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.file == nil {
return nil
}
err := l.file.Close()
l.file = nil
l.logger = nil
return err
}
// handler returns the logger backed by the audit file, opening it on
// first use. A failed open is retried on the next record rather than
// cached: a transient failure (a missing parent directory, descriptor
// exhaustion) must not silence the audit log for the life of the
// process, and records are rare enough that the retry costs nothing.
func (l *Log) handler() (*slog.Logger, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.logger != nil {
return l.logger, nil
}
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
return nil, err
}
file, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
l.file = file
l.logger = slog.New(slog.NewJSONHandler(file, &slog.HandlerOptions{
// The field names are the log's contract with the operator's
// tooling: a timestamp under "ts", the action as the message,
// and no level, which an audit line does not have.
ReplaceAttr: func(_ []string, attr slog.Attr) slog.Attr {
switch attr.Key {
case slog.TimeKey:
return slog.String("ts", attr.Value.Time().UTC().Format(time.RFC3339))
case slog.MessageKey:
attr.Key = "action"
case slog.LevelKey:
return slog.Attr{}
}
return attr
},
}))
return l.logger, nil
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package audit
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestDisabledLogWritesNothing(t *testing.T) {
l := New("")
if l.Enabled() {
t.Fatal("Enabled = true for empty path")
}
l.Record(Entry{User: "admin", Action: "login"})
}
func TestRecordAppendsJSONLines(t *testing.T) {
path := filepath.Join(t.TempDir(), "sub", "audit.log")
l := New(path)
if !l.Enabled() {
t.Fatal("Enabled = false for a real path")
}
l.Record(Entry{User: "admin", Action: "post.created", Resource: "hello", IP: "127.0.0.1"})
l.Record(Entry{User: "admin", Action: "post.deleted", Detail: map[string]any{"slug": "hello"}})
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2: %q", len(lines), data)
}
var first map[string]any
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatalf("line is not JSON: %v", err)
}
if first["user"] != "admin" || first["action"] != "post.created" ||
first["resource"] != "hello" || first["ip"] != "127.0.0.1" {
t.Fatalf("first entry = %v", first)
}
if _, ok := first["detail"]; ok {
t.Fatalf("unexpected detail in first entry: %v", first)
}
var second map[string]any
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
t.Fatalf("line is not JSON: %v", err)
}
detail, ok := second["detail"].(map[string]any)
if !ok || detail["slug"] != "hello" {
t.Fatalf("second entry detail = %v", second)
}
ts, _ := first["ts"].(string)
if len(ts) < 19 || !strings.Contains(ts, "T") {
t.Fatalf("ts = %q, want ISO timestamp", ts)
}
}
func TestRecordSurvivesUnwritablePath(t *testing.T) {
dir := t.TempDir()
l := New(filepath.Join(dir, "file-as-dir", "x", "audit.log"))
if err := os.WriteFile(filepath.Join(dir, "file-as-dir"), []byte("x"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
l.Record(Entry{User: "admin", Action: "login"}) // must not panic
}
// The handler opens the file once and appends to it, and Close releases
// the handle.
func TestFileIsOpenedOnceAndClosed(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.log")
l := New(path)
for range 50 {
l.Record(Entry{User: "admin", Action: "post.updated", Resource: "hello"})
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if got := strings.Count(string(data), "\n"); got != 50 {
t.Fatalf("lines = %d, want 50", got)
}
// A write after Close reopens it rather than losing the line.
l.Record(Entry{User: "admin", Action: "post.deleted"})
data, err = os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if got := strings.Count(string(data), "\n"); got != 51 {
t.Fatalf("lines = %d, want 51", got)
}
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
// An unwritable destination is reported once and never fails the caller.
func TestUnwritableDestinationIsNotFatal(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.log")
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
l := New(path)
l.Record(Entry{User: "admin", Action: "post.created"})
l.Record(Entry{User: "admin", Action: "post.created"})
if err := l.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
// A transient open failure must not silence the log for the life of the
// process: once the obstruction is gone, the next record writes.
func TestRecordRecoversAfterThePathBecomesWritable(t *testing.T) {
dir := t.TempDir()
// A regular file where the log's parent directory should be makes
// MkdirAll fail.
blocker := filepath.Join(dir, "blocked")
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
t.Fatalf("write blocker: %v", err)
}
log := New(filepath.Join(blocker, "sub", "audit.log"))
log.Record(Entry{User: "u", Action: "first"})
if err := log.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if err := os.Remove(blocker); err != nil {
t.Fatalf("remove blocker: %v", err)
}
log.Record(Entry{User: "u", Action: "second"})
if err := log.Close(); err != nil {
t.Fatalf("close: %v", err)
}
raw, err := os.ReadFile(filepath.Join(dir, "blocked", "sub", "audit.log"))
if err != nil {
t.Fatalf("the audit log never recovered: %v", err)
}
if !strings.Contains(string(raw), `"action":"second"`) {
t.Fatalf("recovered log = %s", raw)
}
}
+322
View File
@@ -0,0 +1,322 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package backup writes and restores the deployment's data as a
// gzip-compressed tar: the content directory under posts/, plus the
// users, templates and tokens files. The CLI and the admin UI both go
// through here, so an archive written by one restores in the other.
package backup
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// Archive entry names. The users, templates and tokens files are stored
// under these names rather than under their configured paths, so a
// deployment that renamed them still restores into its own layout.
const (
postsPrefix = "posts/"
usersEntry = "users.toml"
templatesEntry = "templates.toml"
tokensEntry = "tokens.toml"
)
// MaxDecompressed bounds the total size a restore will decompress, so a
// small archive cannot expand until the process runs out of memory.
const MaxDecompressed = 512 << 20
// Options names the files and directories an archive carries.
type Options struct {
ContentDir string
UsersFile string
TemplatesFile string
TokensFile string
}
// Write writes the archive. A file that is absent is skipped; a file
// that exists but cannot be read aborts the backup, because an archive
// that silently omits data looks complete and is not.
func Write(w io.Writer, opts Options) error {
gz := gzip.NewWriter(w)
tw := tar.NewWriter(gz)
if err := writeTree(tw, opts.ContentDir); err != nil {
return err
}
for _, file := range []struct{ entry, source string }{
{usersEntry, opts.UsersFile},
{templatesEntry, opts.TemplatesFile},
{tokensEntry, opts.TokensFile},
} {
if file.source == "" {
continue
}
if err := writeFile(tw, file.entry, file.source); err != nil {
return err
}
}
if err := tw.Close(); err != nil {
return fmt.Errorf("finish archive: %w", err)
}
if err := gz.Close(); err != nil {
return fmt.Errorf("finish compression: %w", err)
}
return nil
}
func writeTree(tw *tar.Writer, contentDir string) error {
if contentDir == "" {
return nil
}
err := filepath.WalkDir(contentDir, func(filePath string, d os.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("read %s: %w", filePath, err)
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(contentDir, filePath)
if err != nil {
return fmt.Errorf("locate %s: %w", filePath, err)
}
return writeFile(tw, postsPrefix+filepath.ToSlash(rel), filePath)
})
if err != nil {
return err
}
return nil
}
func writeFile(tw *tar.Writer, entry, source string) error {
info, err := os.Stat(source)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("read %s: %w", source, err)
}
if !info.Mode().IsRegular() {
return nil
}
file, err := os.Open(source)
if err != nil {
return fmt.Errorf("read %s: %w", source, err)
}
defer file.Close()
header := &tar.Header{
Name: entry,
Mode: int64(info.Mode().Perm()),
Size: info.Size(),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("archive %s: %w", entry, err)
}
if _, err := io.Copy(tw, file); err != nil {
return fmt.Errorf("archive %s: %w", source, err)
}
return nil
}
// Restore reads an archive and writes its entries into the deployment.
// It returns the number of files written. Entries the layout does not
// name are skipped; an entry that tries to escape its destination is
// refused outright.
func Restore(r io.Reader, opts Options) (int, error) {
gz, err := gzip.NewReader(io.LimitReader(r, MaxDecompressed))
if err != nil {
return 0, fmt.Errorf("the archive is not a gzip file: %w", err)
}
defer gz.Close()
// The limit applies to the decompressed bytes, which is what a
// compression bomb expands into.
tr := tar.NewReader(io.LimitReader(gz, MaxDecompressed))
root, err := openContentRoot(opts.ContentDir)
if err != nil {
return 0, err
}
defer root.Close()
written := 0
for {
header, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return written, fmt.Errorf("read the archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
name := path.Clean(strings.TrimPrefix(header.Name, "./"))
switch {
case name == usersEntry:
if err := writeTarget(opts.UsersFile, tr, header.Size); err != nil {
return written, err
}
case name == templatesEntry:
if err := writeTarget(opts.TemplatesFile, tr, header.Size); err != nil {
return written, err
}
case name == tokensEntry:
if err := writeTarget(opts.TokensFile, tr, header.Size); err != nil {
return written, err
}
case strings.HasPrefix(name, postsPrefix):
rel := strings.TrimPrefix(name, postsPrefix)
if !restorablePath(rel) {
continue
}
if err := writeInRoot(root, rel, tr, header.Size); err != nil {
return written, err
}
default:
continue
}
written++
}
return written, nil
}
// restorablePath reports whether a path inside posts/ may be written
// back. Only posts, revision archives and media files with an allowed
// image extension are accepted: the media directory is served from a
// public route, so an archive must not be able to plant a document
// there that a browser would execute.
func restorablePath(rel string) bool {
if rel == "" || strings.HasPrefix(rel, "..") || path.IsAbs(rel) {
return false
}
switch {
case strings.HasPrefix(rel, store.MediaDirName+"/"):
return imagefile.Allowed(path.Base(rel))
case rel == store.MediaDirName:
return false
}
return strings.HasSuffix(rel, ".md")
}
func openContentRoot(contentDir string) (*os.Root, error) {
if contentDir == "" {
return nil, errors.New("no content directory is configured")
}
if err := os.MkdirAll(contentDir, 0o755); err != nil {
return nil, fmt.Errorf("create the content directory: %w", err)
}
root, err := os.OpenRoot(contentDir)
if err != nil {
return nil, fmt.Errorf("open the content directory: %w", err)
}
return root, nil
}
// writeInRoot writes rel inside the content directory. os.Root resolves
// every operation inside that directory, so a name that escapes one,
// through .. or through a symlink, is refused by construction.
func writeInRoot(root *os.Root, rel string, r io.Reader, size int64) error {
clean := path.Clean("/" + rel)
rel = strings.TrimPrefix(clean, "/")
if dir := path.Dir(rel); dir != "." {
if err := root.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
}
data, err := readEntry(r, size)
if err != nil {
return fmt.Errorf("read %s: %w", rel, err)
}
if err := atomicWriteInRoot(root, rel, data); err != nil {
return fmt.Errorf("write %s: %w", rel, err)
}
return nil
}
// atomicWriteInRoot replaces rel through a temp file and a rename, the
// same shape the post store writes with: a crash mid-restore leaves the
// previous file intact rather than a truncated one.
func atomicWriteInRoot(root *os.Root, rel string, data []byte) error {
dir, base := path.Split(rel)
tmp := path.Join(dir, "."+base+".tmp")
handle, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
if _, err := handle.Write(data); err != nil {
handle.Close()
root.Remove(tmp)
return fmt.Errorf("write temp file: %w", err)
}
if err := handle.Close(); err != nil {
root.Remove(tmp)
return fmt.Errorf("close temp file: %w", err)
}
if err := root.Rename(tmp, rel); err != nil {
root.Remove(tmp)
return fmt.Errorf("replace: %w", err)
}
return nil
}
// writeTarget writes one of the standalone TOML files. The destination
// is the configured path, never a path from the archive.
func writeTarget(target string, r io.Reader, size int64) error {
if target == "" {
return errors.New("no destination is configured for that file")
}
data, err := readEntry(r, size)
if err != nil {
return fmt.Errorf("read %s: %w", filepath.Base(target), err)
}
dir := filepath.Dir(target)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create the directory for %s: %w", target, err)
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(target)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("chmod temp file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Rename(tmpPath, target); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("write %s: %w", target, err)
}
return nil
}
// readEntry reads one entry, refusing a declared size beyond the budget.
func readEntry(r io.Reader, size int64) ([]byte, error) {
if size > MaxDecompressed {
return nil, fmt.Errorf("entry declares %d bytes, over the %d byte limit", size, int64(MaxDecompressed))
}
data, err := io.ReadAll(io.LimitReader(r, MaxDecompressed))
if err != nil {
return nil, err
}
return data, nil
}
+289
View File
@@ -0,0 +1,289 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// layout builds a deployment on disk and returns its options.
func layout(t *testing.T) Options {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
media := filepath.Join(content, store.MediaDirName)
revisions := filepath.Join(content, ".revisions", "hello")
for _, d := range []string{content, media, revisions} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
}
write := func(path, body string) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
write(filepath.Join(content, "hello.md"), "+++\nslug = \"hello\"\n+++\nbody\n")
write(filepath.Join(content, "cs", "ahoj.md"), "+++\nslug = \"ahoj\"\n+++\ntelo\n")
write(filepath.Join(media, "abcd1234-upload.webp"), "RIFF....WEBPVP8 ")
write(filepath.Join(revisions, "20260102T030405Z.md"), "old body")
return Options{
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
TemplatesFile: filepath.Join(dir, "templates.toml"),
TokensFile: filepath.Join(dir, "tokens.toml"),
}
}
func withSecrets(t *testing.T, opts Options) Options {
t.Helper()
writeFixture(t, opts.UsersFile, "[[users]]\nusername = \"admin\"\nrole = \"admin\"\n")
writeFixture(t, opts.TemplatesFile, "[[templates]]\nname = \"Review\"\n")
writeFixture(t, opts.TokensFile, "[[tokens]]\nname = \"ci\"\ntoken_hash = \"abc\"\n")
return opts
}
func writeFixture(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func TestRoundTrip(t *testing.T) {
opts := withSecrets(t, layout(t))
var buf bytes.Buffer
if err := Write(&buf, opts); err != nil {
t.Fatalf("Write: %v", err)
}
// A fresh deployment restores what the archive carries.
fresh := layout(t)
fresh.UsersFile = filepath.Join(t.TempDir(), "renamed-users.toml")
fresh.TemplatesFile = filepath.Join(t.TempDir(), "renamed-templates.toml")
fresh.TokensFile = filepath.Join(t.TempDir(), "renamed-tokens.toml")
for _, path := range []string{
filepath.Join(fresh.ContentDir, "hello.md"),
filepath.Join(fresh.ContentDir, "cs", "ahoj.md"),
filepath.Join(fresh.ContentDir, store.MediaDirName, "abcd1234-upload.webp"),
filepath.Join(fresh.ContentDir, ".revisions", "hello", "20260102T030405Z.md"),
} {
if err := os.Remove(path); err != nil {
t.Fatalf("remove %s: %v", path, err)
}
}
written, err := Restore(&buf, fresh)
if err != nil {
t.Fatalf("Restore: %v", err)
}
// Four files inside posts/ plus the three standalone files.
if written != 7 {
t.Fatalf("written = %d, want 7", written)
}
for _, path := range []string{
filepath.Join(fresh.ContentDir, "hello.md"),
filepath.Join(fresh.ContentDir, "cs", "ahoj.md"),
filepath.Join(fresh.ContentDir, store.MediaDirName, "abcd1234-upload.webp"),
filepath.Join(fresh.ContentDir, ".revisions", "hello", "20260102T030405Z.md"),
fresh.UsersFile,
fresh.TemplatesFile,
fresh.TokensFile,
} {
if _, err := os.Stat(path); err != nil {
t.Errorf("missing after restore: %s (%v)", path, err)
}
}
// The renamed files receive their contents under the configured
// names, not under the archive's names.
raw, err := os.ReadFile(fresh.UsersFile)
if err != nil || !strings.Contains(string(raw), "admin") {
t.Fatalf("users file = %q, %v", raw, err)
}
}
func TestRestoreRefusesHostileEntries(t *testing.T) {
opts := layout(t)
archive := buildArchive(t, []archiveEntry{
{name: "../escape.md", body: "pwned"},
{name: "posts/../../escape.md", body: "pwned"},
{name: "/etc/passwd", body: "pwned"},
{name: "posts/media/evil.html", body: "<script>alert(1)</script>"},
{name: "posts/media/evil.js", body: "alert(1)"},
{name: "posts/notes.txt", body: "not a post"},
{name: "posts/keep.md", body: "+++\nslug = \"keep\"\n+++\nok\n"},
{name: "posts/media/abcd1234-upload.webp", body: "RIFF....WEBPVP8 "},
{name: "unrelated.toml", body: "x = 1"},
{name: "config.toml", body: "session_key = \"secret\""},
})
written, err := Restore(bytes.NewReader(archive), opts)
if err != nil {
t.Fatalf("Restore: %v", err)
}
if written != 2 {
t.Fatalf("written = %d, want 2 (keep.md and the image)", written)
}
if _, err := os.Stat(filepath.Join(opts.ContentDir, "keep.md")); err != nil {
t.Errorf("valid post not restored: %v", err)
}
if _, err := os.Stat(filepath.Join(opts.ContentDir, store.MediaDirName, "abcd1234-upload.webp")); err != nil {
t.Errorf("valid image not restored: %v", err)
}
for _, bad := range []string{
filepath.Join(filepath.Dir(opts.ContentDir), "escape.md"),
filepath.Join(opts.ContentDir, "media", "evil.html"),
filepath.Join(opts.ContentDir, "media", "evil.js"),
filepath.Join(opts.ContentDir, "notes.txt"),
} {
if _, err := os.Stat(bad); err == nil {
t.Errorf("hostile entry was written: %s", bad)
}
}
}
func TestRestoreRejectsNonArchives(t *testing.T) {
opts := layout(t)
if _, err := Restore(strings.NewReader("this is not a tar.gz"), opts); err == nil {
t.Fatal("a text file was accepted as an archive")
}
empty := buildArchive(t, nil)
if _, err := Restore(bytes.NewReader(empty), opts); err != nil {
t.Fatalf("an empty archive is not an error: %v", err)
}
}
func TestRestoreRefusesAnOversizedEntry(t *testing.T) {
opts := layout(t)
// The header declares more than the budget; the body is short, which
// is exactly the shape a compression bomb has.
archive := buildArchiveRaw(t, []archiveEntry{
{name: "posts/huge.md", body: "x", size: MaxDecompressed + 1},
}, false)
if _, err := Restore(bytes.NewReader(archive), opts); err == nil {
t.Fatal("an entry declaring more than the budget was accepted")
}
if _, err := os.Stat(filepath.Join(opts.ContentDir, "huge.md")); err == nil {
t.Fatal("the oversized entry was written")
}
}
func TestWriteRefusesAMissingDirectory(t *testing.T) {
opts := layout(t)
opts.ContentDir = filepath.Join(t.TempDir(), "absent", "posts")
if err := Write(&bytes.Buffer{}, opts); err == nil {
t.Fatal("a missing content directory was archived as if it were empty")
}
}
type archiveEntry struct {
name string
body string
size int64
}
// buildArchive writes a tar.gz with the given entries, taking sizes from
// the body unless a size is given.
func buildArchive(t *testing.T, entries []archiveEntry) []byte {
t.Helper()
return buildArchiveRaw(t, entries, true)
}
// buildArchiveRaw writes the entries; complete controls whether a
// declared size larger than the body is padded to match.
func buildArchiveRaw(t *testing.T, entries []archiveEntry, complete bool) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for _, entry := range entries {
size := int64(len(entry.body))
if entry.size > 0 {
size = entry.size
}
if err := tw.WriteHeader(&tar.Header{
Name: entry.name, Mode: 0o644, Size: size, Typeflag: tar.TypeReg,
}); err != nil {
t.Fatalf("header: %v", err)
}
if _, err := tw.Write([]byte(entry.body)); err != nil {
t.Fatalf("body: %v", err)
}
if !complete {
// The reader stops at the declared size, so the remaining
// bytes are never needed.
_ = tw.Flush()
_ = gz.Close()
return buf.Bytes()
}
}
if err := tw.Close(); err != nil {
t.Fatalf("close tar: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("close gzip: %v", err)
}
return buf.Bytes()
}
// A restore leaves no temp files behind and writes the standalone files
// owner-only, the same mode a direct write always used.
func TestRestoreLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
users := filepath.Join(dir, "users.toml")
archive := filepath.Join(dir, "backup.tar.gz")
data := buildArchive(t, []archiveEntry{
{name: "users.toml", body: "[[users]]\n"},
{name: "posts/a.md", body: "+++\nslug = \"a\"\ntitle = \"A\"\n+++\nx\n"},
})
if err := os.WriteFile(archive, data, 0o600); err != nil {
t.Fatalf("write archive: %v", err)
}
file, err := os.Open(archive)
if err != nil {
t.Fatalf("open: %v", err)
}
defer file.Close()
if _, err := Restore(file, Options{
ContentDir: content, UsersFile: users,
TemplatesFile: filepath.Join(dir, "templates.toml"),
TokensFile: filepath.Join(dir, "tokens.toml"),
}); err != nil {
t.Fatalf("restore: %v", err)
}
for _, list := range []string{dir, content} {
entries, err := os.ReadDir(list)
if err != nil {
t.Fatalf("read %s: %v", list, err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") && strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("temp file %s left behind in %s", e.Name(), list)
}
}
}
info, err := os.Stat(users)
if err != nil {
t.Fatalf("stat users: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("users.toml mode = %v, want 0600", info.Mode().Perm())
}
}
+497
View File
@@ -0,0 +1,497 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package biblio turns the structured `refs` frontmatter of a post into
// a rendered reference list, machine-readable citations, and the
// resolver links a scholar expects. It is a leaf: it reads plain
// metadata values and writes HTML and JSON shapes, importing no other
// domain package.
//
// The shape is measured on the author's own volumes: numbered `[n]`
// entries, inline `[n]` citations, and `doi:` and `arXiv:` identifiers
// embedded in free text. The engine keeps that convention and makes it
// live: each entry gets a target the inline citations point at, and
// every identifier becomes a link to its resolver.
package biblio
import (
stdhtml "html"
"regexp"
"strconv"
"strings"
)
// Author is one cited author: a name, and the author's ORCID when the
// reference records one.
type Author struct {
Name string `json:"name"`
ORCID string `json:"orcid,omitempty"`
}
// Entry is one reference. A hand-written entry that does not break down
// into the structured fields keeps its verbatim text in Raw, which the
// renderer prints as given, with any identifier inside it linked: a
// reference is never dropped or rewritten into something the author did
// not write.
type Entry struct {
Num int `json:"num"`
Authors []Author `json:"authors,omitempty"`
Title string `json:"title,omitempty"`
Venue string `json:"venue,omitempty"`
Year string `json:"year,omitempty"`
Volume string `json:"volume,omitempty"`
Pages string `json:"pages,omitempty"`
DOI string `json:"doi,omitempty"`
ArXiv string `json:"arxiv,omitempty"`
URL string `json:"url,omitempty"`
Raw string `json:"raw,omitempty"`
// Internal is the same-instance link: the API URL of the post whose
// DOI this entry cites, set by the caller that knows the instance
// (post.RefsLinked annotates each post's entries). Empty when the cited
// work is not published here, or is the citing post itself.
Internal string `json:"internal,omitempty"`
}
// href is the address the rendered link points at: the internal post
// when the entry cites a work published in this instance, otherwise
// the external resolver.
func (e Entry) href() string {
if e.Internal != "" {
return e.Internal
}
return e.Link()
}
// Marker is the token an author places where the reference list should
// be rendered; a body without it gets the list appended.
const Marker = "[[refs]]"
var (
markerRe = regexp.MustCompile(`(?s)<p>\s*\Q` + Marker + `\E\s*</p>`)
doiTokenRe = regexp.MustCompile(`(?i)\bdoi:\s*(10\.[0-9]{4,9}/[^\s]+)`)
arxivTokenRe = regexp.MustCompile(`(?i)\barXiv:\s*([A-Za-z0-9][A-Za-z0-9./:-]*)`)
yearRe = regexp.MustCompile(`\b(1[89][0-9]{2}|20[0-9]{2})\b`)
inlineRefRe = regexp.MustCompile(`\[(\d{1,3})\]`)
)
// Parse reads the refs array out of flattened frontmatter tables. Each
// element may carry authors, title, venue, year, volume, pages, doi,
// arxiv, url or raw; authors is a list of strings or of {name, orcid}
// tables. Entries without an explicit number are numbered by position.
func Parse(refs []map[string]any) []Entry {
var out []Entry
for i, raw := range refs {
entry := Entry{
Num: numOr(raw["num"], i+1),
Title: strings.TrimSpace(str(raw["title"])),
Venue: strings.TrimSpace(str(raw["venue"])),
Year: strings.TrimSpace(str(raw["year"])),
Volume: strings.TrimSpace(str(raw["volume"])),
Pages: strings.TrimSpace(str(raw["pages"])),
DOI: cleanDOI(str(raw["doi"])),
ArXiv: cleanArxiv(str(raw["arxiv"])),
URL: strings.TrimSpace(str(raw["url"])),
Raw: strings.TrimSpace(str(raw["raw"])),
}
entry.Authors = parseAuthors(raw["authors"])
entry.enrichFromText()
out = append(out, entry)
}
return out
}
func parseAuthors(v any) []Author {
list, ok := v.([]any)
if !ok {
return nil
}
var out []Author
for _, item := range list {
switch value := item.(type) {
case string:
if name := strings.TrimSpace(value); name != "" {
out = append(out, Author{Name: name})
}
case map[string]any:
author := Author{
Name: strings.TrimSpace(str(value["name"])),
ORCID: strings.TrimSpace(str(value["orcid"])),
}
if author.Name != "" {
out = append(out, author)
}
}
}
return out
}
func numOr(v any, fallback int) int {
switch n := v.(type) {
case int64:
return int(n)
case int:
return n
case float64:
return int(n)
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
return parsed
}
}
return fallback
}
func str(v any) string {
s, _ := v.(string)
return s
}
// cleanDOI accepts a bare identifier, a doi: prefix or a resolver URL
// and returns the bare form.
func cleanDOI(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if m := doiTokenRe.FindStringSubmatch(s); m != nil {
return strings.TrimRight(m[1], ".,;)")
}
s = strings.TrimPrefix(strings.TrimPrefix(s, "https://doi.org/"), "doi:")
return strings.TrimRight(strings.TrimSpace(s), ".,;)")
}
func cleanArxiv(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
if m := arxivTokenRe.FindStringSubmatch(s); m != nil {
s = m[1]
}
s = strings.TrimRight(strings.TrimPrefix(strings.TrimPrefix(s, "arXiv:"), "https://arxiv.org/abs/"), ".,;)")
if !strings.HasPrefix(s, "arXiv:") && s != "" {
// Already a bare id; accept anything identifier-shaped.
if strings.ContainsAny(s, " \t<\"") {
return ""
}
return s
}
return ""
}
// stripTokens removes the identifier tokens from the entry's own text,
// used once an entry is structured: the renderer puts the identifier at
// the end as a link, and it must not also sit in the title as prose.
func (e *Entry) stripTokens() {
if e.Title == "" && len(e.Authors) == 0 {
return // a raw entry prints its tokens inline as links instead
}
e.Title = tidyTokens(e.Title)
e.Venue = tidyTokens(e.Venue)
}
func tidyTokens(s string) string {
s = doiTokenRe.ReplaceAllString(s, "")
s = arxivTokenRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, ", ,", ",")
s = strings.TrimSpace(s)
s = strings.TrimRight(s, ",;")
return strings.TrimSpace(s)
}
// enrichFromText recovers identifiers and a year a hand-written entry
// kept in its free text, so a migrated volume gains live links without
// every field being split by hand.
func (e *Entry) enrichFromText() {
joined := strings.Join([]string{e.Title, e.Venue, e.Raw}, " ")
if e.DOI == "" {
if m := doiTokenRe.FindStringSubmatch(joined); m != nil {
e.DOI = strings.TrimRight(m[1], ".,;)")
e.stripTokens()
}
}
if e.ArXiv == "" {
if m := arxivTokenRe.FindStringSubmatch(joined); m != nil {
e.ArXiv = m[1]
e.stripTokens()
}
}
// The year is a structured field only when the entry is structured:
// a raw entry already prints its year inside its own text.
if e.Year == "" && (e.Title != "" || len(e.Authors) > 0) {
if m := yearRe.FindStringSubmatch(joined); m != nil {
e.Year = m[1]
}
}
}
// Link returns the resolver URL for this entry: DOI first, then arXiv,
// then a plain URL. Empty when the entry carries no identifier.
func (e Entry) Link() string {
switch {
case e.DOI != "":
return "https://doi.org/" + e.DOI
case e.ArXiv != "":
return "https://arxiv.org/abs/" + e.ArXiv
case e.URL != "":
return e.URL
default:
return ""
}
}
// ListHTML renders the numbered reference list. Each entry is anchored
// so an inline citation can point at it.
func ListHTML(entries []Entry) string {
if len(entries) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<section class="refs" id="references">` + "\n<ol>\n")
for _, e := range entries {
b.WriteString(`<li id="ref-` + strconv.Itoa(e.Num) + `">`)
b.WriteString(e.line())
b.WriteString("</li>\n")
}
b.WriteString("</ol>\n</section>\n")
return b.String()
}
// line renders one entry: authors, title, the venue tail, and the
// identifier link. A purely raw entry prints its verbatim text with
// embedded identifiers made clickable, and nothing else: the author's
// own wording already carries the year and venue.
func (e Entry) line() string {
if e.Raw != "" && e.Title == "" && len(e.Authors) == 0 {
return e.identifierLinks()
}
var b strings.Builder
if len(e.Authors) > 0 {
names := make([]string, 0, len(e.Authors))
for _, a := range e.Authors {
name := stdhtml.EscapeString(a.Name)
if a.ORCID != "" {
name += " " + link("https://orcid.org/"+a.ORCID, "orcid:"+a.ORCID)
}
names = append(names, name)
}
// An author string that already ends in a period ("Riess, A. G.
// a kol.") must not gain a second one at the segment boundary.
authorText := strings.Join(names, ", ")
b.WriteString(authorText)
if !strings.HasSuffix(authorText, ".") {
b.WriteString(".")
}
b.WriteString(" ")
}
if e.Title != "" {
b.WriteString(stdhtml.EscapeString(e.Title) + ".")
}
tail := make([]string, 0, 4)
if e.Venue != "" {
tail = append(tail, stdhtml.EscapeString(e.Venue))
}
if e.Volume != "" {
tail = append(tail, "vol. "+stdhtml.EscapeString(e.Volume))
}
if e.Pages != "" {
tail = append(tail, "pp. "+stdhtml.EscapeString(e.Pages))
}
if e.Year != "" {
tail = append(tail, stdhtml.EscapeString(e.Year))
}
if len(tail) > 0 {
if e.Title != "" {
b.WriteString(" ")
}
b.WriteString(strings.Join(tail, ", "))
b.WriteString(".")
}
if url := e.href(); url != "" {
b.WriteString(" " + link(url, label(e)))
}
return b.String()
}
// label names the identifier link by whichever resolver it uses.
func label(e Entry) string {
switch {
case e.DOI != "":
return "doi:" + e.DOI
case e.ArXiv != "":
return "arXiv:" + e.ArXiv
default:
return "url"
}
}
// identifierLinks prints a raw entry with each doi:/arXiv: token it
// embeds replaced by a live link, keeping the author's own wording
// around it untouched. A token equal to the entry's cited DOI takes the
// internal link when the entry has one; every other token keeps its
// resolver.
func (e Entry) identifierLinks() string {
raw, doi, arxiv := e.Raw, e.DOI, e.ArXiv
html := stdhtml.EscapeString(raw)
if doi != "" {
html = doiTokenRe.ReplaceAllStringFunc(html, func(match string) string {
id := strings.TrimRight(doiTokenRe.FindStringSubmatch(match)[1], ".,;)")
trail := match[len("doi:"):]
trail = trail[strings.Index(trail, id)+len(id):]
href := "https://doi.org/" + id
if e.Internal != "" && id == doi {
// The slug comes from the store, so it is escaped like
// every other href: a hand-edited file whose slug carries
// a quote must not open an attribute here.
href = stdhtml.EscapeString(e.Internal)
}
return `<a class="refs-link" href="` + href + `">doi:` + id + `</a>` + trail
})
}
if arxiv != "" {
html = arxivTokenRe.ReplaceAllStringFunc(html, func(match string) string {
id := arxivTokenRe.FindStringSubmatch(match)[1]
body := "arXiv:" + id
trail := match[len(body):]
return `<a class="refs-link" href="https://arxiv.org/abs/` + id + `">arXiv:` + id + `</a>` + trail
})
}
return html
}
// Citations renders the entries as schema.org citation objects for the
// post's JSON-LD block, so a machine reading the article also reads the
// works it cites, with each author and identifier resolved.
func Citations(entries []Entry) []map[string]any {
var out []map[string]any
for _, e := range entries {
name := e.Title
if name == "" {
name = e.Raw
}
if name == "" {
continue
}
c := map[string]any{"@type": "ScholarlyArticle", "position": e.Num, "name": name}
if len(e.Authors) > 0 {
persons := make([]map[string]any, 0, len(e.Authors))
for _, a := range e.Authors {
person := map[string]any{"@type": "Person", "name": a.Name}
if a.ORCID != "" {
person["identifier"] = "https://orcid.org/" + a.ORCID
}
persons = append(persons, person)
}
c["author"] = persons
}
if e.Venue != "" {
c["isPartOf"] = map[string]any{"@type": "PublicationJournal", "name": e.Venue}
}
if e.Year != "" {
c["datePublished"] = e.Year
}
if url := e.Link(); url != "" {
c["identifier"] = url
}
if e.Internal != "" {
c["url"] = e.Internal
}
out = append(out, c)
}
return out
}
func link(href, text string) string {
return `<a class="refs-link" href="` + stdhtml.EscapeString(href) + `">` +
stdhtml.EscapeString(text) + "</a>"
}
// Place splices the list into the rendered body: at the marker
// paragraph when the author wrote one, otherwise appended.
func Place(bodyHTML string, entries []Entry) string {
list := ListHTML(entries)
if list == "" {
return bodyHTML
}
if markerRe.MatchString(bodyHTML) {
return markerRe.ReplaceAllString(bodyHTML, list)
}
return strings.TrimRight(bodyHTML, "\n") + "\n" + list
}
// LinkCitations rewrites inline `[n]` markers in the rendered HTML into
// links to the numbered entries. It walks the markup and touches text
// only: tags and attribute values are copied verbatim, and the inside
// of <code>, <pre> and <a> elements is left alone, so code that holds
// bracketed numbers and the reference list itself keep their text.
func LinkCitations(html string, entries []Entry) string {
if len(entries) == 0 {
return html
}
highest := 0
for _, e := range entries {
if e.Num > highest {
highest = e.Num
}
}
var b strings.Builder
b.Grow(len(html) + 2*len(html)/8)
i := 0
skipping := "" // non-empty while inside a protected element
for i < len(html) {
if html[i] != '<' {
j := i
for j < len(html) && html[j] != '<' {
j++
}
b.WriteString(citeText(html[i:j], highest, skipping != ""))
i = j
continue
}
end := strings.IndexByte(html[i:], '>')
if end < 0 {
b.WriteString(html[i:])
break
}
tag := html[i : i+end+1]
b.WriteString(tag)
switch {
case skipping != "":
// Inside a protected element: wait for its close.
closing := "</" + skipping + ">"
if pos := strings.Index(strings.ToLower(tag), closing); pos >= 0 {
skipping = ""
}
case strings.HasPrefix(strings.ToLower(tag), "<pre"),
strings.HasPrefix(strings.ToLower(tag), "<code"),
strings.HasPrefix(strings.ToLower(tag), "<a"):
if !strings.HasSuffix(tag, "/>") {
kind := "a"
switch {
case strings.HasPrefix(strings.ToLower(tag), "<pre"):
kind = "pre"
case strings.HasPrefix(strings.ToLower(tag), "<code"):
kind = "code"
}
skipping = kind
}
}
i += end + 1
}
return b.String()
}
// citeText replaces the bracketed numbers within one text run when
// linking is allowed there.
func citeText(text string, highest int, protected bool) string {
if protected || highest == 0 {
return text
}
return inlineRefRe.ReplaceAllStringFunc(text, func(tok string) string {
n, err := strconv.Atoi(strings.Trim(tok, "[]"))
if err != nil || n < 1 || n > highest {
return tok
}
return `<a class="ref-cite" href="#ref-` + strconv.Itoa(n) + `">` + tok + "</a>"
})
}
+207
View File
@@ -0,0 +1,207 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package biblio
import (
"strings"
"testing"
)
func TestParseStructuredAndRaw(t *testing.T) {
entries := Parse([]map[string]any{
{
"title": "Observational evidence from supernovae",
"authors": []any{
map[string]any{"name": "Riess, A. G.", "orcid": "0000-0002-1825-0097"},
"Filippenko, A. V.",
},
"venue": "The Astronomical Journal",
"year": "1998",
"doi": "doi:10.1103/PhysRevD.59.103502.",
},
{
"raw": "Goldhaber, G. a kol., arXiv:astro-ph/0408076, doi:10.1088/1475-7516/2004/12/010.",
},
})
if len(entries) != 2 {
t.Fatalf("entries = %d", len(entries))
}
first := entries[0]
if first.Num != 1 || first.Title == "" || len(first.Authors) != 2 {
t.Fatalf("first = %+v", first)
}
if first.Authors[0].ORCID != "0000-0002-1825-0097" {
t.Fatalf("author orcid lost: %+v", first.Authors[0])
}
if first.DOI != "10.1103/PhysRevD.59.103502" {
t.Fatalf("doi not cleaned: %q", first.DOI)
}
second := entries[1]
if second.Num != 2 || second.DOI != "10.1088/1475-7516/2004/12/010" ||
second.ArXiv != "astro-ph/0408076" {
t.Fatalf("raw entry not enriched: %+v", second)
}
}
func TestListHTMLAndPlace(t *testing.T) {
entries := Parse([]map[string]any{
{"title": "Alpha", "doi": "10.1000/alpha", "authors": []any{"A. Author"}},
})
list := ListHTML(entries)
for _, want := range []string{
`<section class="refs" id="references">`, `<li id="ref-1">`,
`href="https://doi.org/10.1000/alpha"`, "orcid", "A. Author.",
} {
if want == "orcid" {
if strings.Contains(list, want) {
t.Fatalf("unexpected orcid: %s", list)
}
continue
}
if !strings.Contains(list, want) {
t.Fatalf("list missing %q:\n%s", want, list)
}
}
// The marker is replaced in place; without it the list is appended.
body := "<p>Intro.</p>\n<p>" + Marker + "</p>\n<p>After.</p>\n"
placed := Place(body, entries)
if !strings.Contains(placed, "Intro.") || !strings.Contains(placed, "After.") ||
strings.Contains(placed, Marker) {
t.Fatalf("marker placement wrong:\n%s", placed)
}
if !strings.HasSuffix(Place("<p>Only.</p>", entries), "</section>\n") {
t.Fatal("append mode missing")
}
if Place("<p>x</p>", nil) != "<p>x</p>" {
t.Fatal("empty entries should not change the body")
}
}
func TestLinkCitationsRewritesTextOnly(t *testing.T) {
entries := Parse([]map[string]any{{"title": "A"}, {"title": "B"}})
html := "<p>Work [1] and [2], even [3] out of range.</p>" +
"<p>Code stays: <code>list[1] = 2</code>.</p>" +
"<pre>array[2]=x</pre>" +
`<a href="/x#ref-1">text [1] inside link</a>` +
`<a href="/y" data-q="[2]">href untouched</a>`
out := LinkCitations(html, entries)
if !strings.Contains(out, `<a class="ref-cite" href="#ref-1">[1]</a>`) {
t.Fatalf("inline [1] not linked:\n%s", out)
}
if strings.Contains(out, `href="#ref-3"`) {
t.Fatal("out-of-range [3] was linked")
}
if !strings.Contains(out, "<code>list[1] = 2</code>") {
t.Fatal("code content was rewritten")
}
if !strings.Contains(out, "<pre>array[2]=x</pre>") {
t.Fatal("pre content was rewritten")
}
// Inside an existing anchor the marker stays text; no nesting.
if !strings.Contains(out, ">text [1] inside link</a>") {
t.Fatalf("anchor text was rewritten:\n%s", out)
}
if !strings.Contains(out, `data-q="[2]"`) {
t.Fatal("attribute value was rewritten")
}
}
func TestStructuredEntryStripsTokensFromText(t *testing.T) {
entries := Parse([]map[string]any{{
"title": "Timescale stretch parameterization, doi:10.1088/1475-7516/2004/12/010.",
"authors": []any{"Goldhaber, G. a kol."},
}})
e := entries[0]
if e.DOI != "10.1088/1475-7516/2004/12/010" {
t.Fatalf("doi not pulled out: %q", e.DOI)
}
line := ListHTML(entries)
if got := strings.Count(line, "refs-link"); got != 1 {
t.Fatalf("expected one identifier link, got %d:\n%s", got, line)
}
if before := line[:strings.Index(line, `<a `)]; strings.Contains(before, "10.1088") {
t.Fatalf("identifier left as prose before the link:\n%s", before)
}
if !strings.Contains(line, `href="https://doi.org/10.1088/1475-7516/2004/12/010"`) {
t.Fatalf("resolver link missing:\n%s", line)
}
}
func TestCitationsJSONLDShape(t *testing.T) {
entries := Parse([]map[string]any{
{
"title": "Alpha",
"authors": []any{map[string]any{"name": "A. Author", "orcid": "0000-0002-1825-0097"}},
"venue": "Journal", "year": "2020", "doi": "10.1000/a",
},
})
cits := Citations(entries)
if len(cits) != 1 {
t.Fatalf("citations = %v", cits)
}
c := cits[0]
if c["@type"] != "ScholarlyArticle" || c["identifier"] != "https://doi.org/10.1000/a" {
t.Fatalf("citation shape wrong: %v", c)
}
persons, ok := c["author"].([]map[string]any)
if !ok || persons[0]["identifier"] != "https://orcid.org/0000-0002-1825-0097" {
t.Fatalf("author orcid missing: %v", c["author"])
}
}
func TestInternalCrossLinks(t *testing.T) {
entries := Parse([]map[string]any{
{"num": int64(1), "title": "Teorie deterministického substrátu", "doi": "10.5555/tdssc.2026"},
{"num": int64(2), "raw": "Balvín, P.: TDSSC, viz doi:10.5555/tdssc.2026; externí doi:10.9999/other (2020)."},
{"num": int64(3), "title": "Externí práce", "doi": "10.9999/other"},
{"num": int64(4), "title": "Bez identifikátoru"},
})
entries[0].Internal = "/api/volumen/posts/tdssc"
entries[1].Internal = "/api/volumen/posts/tdssc"
list := ListHTML(entries)
for _, want := range []string{
`href="/api/volumen/posts/tdssc">doi:10.5555/tdssc.2026<`,
`href="https://doi.org/10.9999/other">doi:10.9999/other<`,
} {
if !strings.Contains(list, want) {
t.Fatalf("list missing %q:\n%s", want, list)
}
}
if strings.Count(list, `href="/api/volumen/posts/tdssc"`) != 2 {
t.Fatalf("internal link count wrong:\n%s", list)
}
cits := Citations(entries)
if cits[0]["url"] != "/api/volumen/posts/tdssc" {
t.Fatalf("citation url not internal: %v", cits[0])
}
// The canonical identifier survives unchanged next to the internal link.
if cits[0]["identifier"] != "https://doi.org/10.5555/tdssc.2026" {
t.Fatalf("citation identifier changed: %v", cits[0])
}
if _, ok := cits[2]["url"]; ok {
t.Fatalf("external citation gained a url: %v", cits[2])
}
if _, ok := cits[3]["identifier"]; ok {
t.Fatalf("bare citation gained an identifier: %v", cits[3])
}
}
// A raw entry's internal link is escaped like every other href: the slug
// it names comes from the store, and a hand-edited file could carry a
// quote that must not open an attribute in the rendered list.
func TestRawEntryEscapesInternalLink(t *testing.T) {
entries := Parse([]map[string]any{
{"raw": "Viz doi:10.5555/hostile.2026 (2020).", "doi": "10.5555/hostile.2026"},
})
entries[0].Internal = `/api/volumen/posts/x" onmouseover="alert(1)`
out := entries[0].identifierLinks()
if strings.Contains(out, `" onmouseover=`) {
t.Fatalf("the internal link broke out of its attribute: %q", out)
}
if !strings.Contains(out, "href=\"/api/volumen/posts/x&#34; onmouseover=&#34;alert(1)\"") {
t.Fatalf("the internal link lost its target: %q", out)
}
}
+523
View File
@@ -0,0 +1,523 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package config reads config.toml into a typed value, applies the
// command-line overrides, and validates it. The commented template it
// ships, config.toml.example, is the file an operator copies.
//
// There is no configuration map: every key has a field, the decoder
// rejects a key whose TOML type does not match its field, and a key the
// decoder does not know is ignored, so a file written for a newer release
// still loads.
package config
import (
"errors"
"fmt"
"log/slog"
"net/netip"
"net/url"
"os"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/password"
)
// DefaultPath is the system-wide configuration file location.
const DefaultPath = "/etc/volumen/config.toml"
// UserConfigPath returns the per-user configuration file location,
// resolved from XDG_CONFIG_HOME or ~/.config. It is "" when neither can
// be named.
func UserConfigPath() string {
if d := os.Getenv("XDG_CONFIG_HOME"); d != "" {
return filepath.Join(d, "volumen", "config.toml")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "volumen", "config.toml")
}
// ResolveConfigPath picks the configuration file `serve` reads when
// --config was not given: the system path if it exists, otherwise the
// per-user path if it exists, otherwise the system path, which does not
// exist and so loads the built-in defaults. A plain `volumen serve` with
// no file anywhere therefore runs on per-user state paths.
func ResolveConfigPath() string {
if _, err := os.Stat(DefaultPath); err == nil {
return DefaultPath
}
if p := UserConfigPath(); p != "" {
if _, err := os.Stat(p); err == nil {
return p
}
}
return DefaultPath
}
// UserStatePaths returns the per-user content and users file paths used
// as the defaults when no configuration file exists, so the server can
// run and write its state under the user's home without root. They are
// resolved from XDG_DATA_HOME or ~/.local/share; ok is false when the
// platform cannot name one.
func UserStatePaths() (contentDir, usersFile string, ok bool) {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", "", false
}
base = filepath.Join(home, ".local", "share")
}
dir := filepath.Join(base, "volumen")
return filepath.Join(dir, "posts"), filepath.Join(dir, "users.toml"), true
}
// The built-in defaults, applied to every key the file leaves out.
const (
DefaultHost = "::"
DefaultPort = 9091
DefaultContentDir = "/var/lib/volumen/posts"
DefaultUsersFile = "/var/lib/volumen/users.toml"
DefaultSiteTitle = "Volumen"
DefaultSiteDescription = "Powered by Volumen."
DefaultBaseURL = "https://example.com"
DefaultLanguage = "en"
DefaultAuthor = "Anonymous"
DefaultSessionTTL = 86400
DefaultMinPasswordLength = 10
DefaultMaxPasswordLength = 1024
DefaultMaxUploadBytes = 10 * 1024 * 1024
DefaultAPIRateLimit = 60
DefaultAPIRateLimitWindow = 60
DefaultRevisionLimit = 10
DefaultSchedulerInterval = 300
// MaxSessionTTL bounds [admin].session_ttl so that it cannot overflow
// a time.Duration when converted to seconds, and so that a session
// cannot outlive a year.
MaxSessionTTL = 365 * 24 * 60 * 60
// MaxRateLimitWindow bounds [api].rate_limit_window for the same
// reason.
MaxRateLimitWindow = 24 * 60 * 60
// MaxUploadBytesCeiling bounds [admin].max_upload_bytes, so that a
// mistyped value cannot be read into memory in one piece.
MaxUploadBytesCeiling = 1 << 30
// PortUnset is the Overrides.Port sentinel meaning "do not override".
PortUnset = -1
EnvProduction = "production"
EnvDevelopment = "development"
LogFormatText = "text"
LogFormatJSON = "json"
)
// ConfigError reports a configuration value the program refuses to run
// with. Validate returns it, and the caller prints it and exits.
type ConfigError struct {
msg string
}
func (e *ConfigError) Error() string { return e.msg }
func errorf(format string, args ...any) *ConfigError {
return &ConfigError{msg: fmt.Sprintf(format, args...)}
}
// Server is the [server] table.
type Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
Env string `toml:"env"`
TrustProxy bool `toml:"trust_proxy"`
// TrustedProxies lists the addresses whose X-Forwarded-For may be
// believed, as addresses or CIDR prefixes. An empty list means the
// header is never read and the connection address is always used;
// list the proxy so its clients each rate-limit under their own
// address.
TrustedProxies []string `toml:"trusted_proxies"`
CookieSecure bool `toml:"cookie_secure"`
LogFormat string `toml:"log_format"`
}
// Site is the [site] table.
type Site struct {
Title string `toml:"title"`
Description string `toml:"description"`
BaseURL string `toml:"base_url"`
Language string `toml:"language"`
Author string `toml:"author"`
FediverseCreator string `toml:"fediverse_creator"`
}
// Admin is the [admin] table.
type Admin struct {
SessionKey string `toml:"session_key"`
SessionTTL int `toml:"session_ttl"`
MinPasswordLength int `toml:"min_password_length"`
MaxPasswordLength int `toml:"max_password_length"`
MaxUploadBytes int `toml:"max_upload_bytes"`
}
// API is the [api] table.
type API struct {
RateLimit int `toml:"rate_limit"`
RateLimitWindow int `toml:"rate_limit_window"`
}
// Scheduler is the [scheduler] table.
type Scheduler struct {
Enabled bool `toml:"enabled"`
Interval int `toml:"interval"`
}
// Webhook is one [[webhooks]] entry.
type Webhook struct {
URL string `toml:"url"`
Secret string `toml:"secret"`
Events []string `toml:"events"`
// Enabled is a pointer so that an omitted key means enabled: a hook
// written without the key is one the operator wants delivered, and
// only an explicit false turns it off.
Enabled *bool `toml:"enabled"`
}
// Delivers reports whether the hook is on.
func (w Webhook) Delivers() bool { return w.Enabled == nil || *w.Enabled }
// Config is the merged configuration: the built-in defaults with the file
// decoded over them and the command-line overrides applied.
type Config struct {
Server Server `toml:"server"`
Site Site `toml:"site"`
Admin Admin `toml:"admin"`
API API `toml:"api"`
Scheduler Scheduler `toml:"scheduler"`
ContentDir string `toml:"content_dir"`
UsersFile string `toml:"users_file"`
RevisionLimit int `toml:"revision_limit"`
AuditLog string `toml:"audit_log"`
Webhooks []Webhook `toml:"webhooks"`
}
// Overrides carries the command-line overrides of the `serve` subcommand.
// An empty string means unset; Port uses PortUnset rather than zero,
// because port 0 is a value a caller could mean to set.
type Overrides struct {
Host string
Port int
ContentDir string
UsersFile string
}
// Defaults returns the built-in configuration.
func Defaults() *Config {
return &Config{
Server: Server{
Host: DefaultHost,
Port: DefaultPort,
Env: EnvDevelopment,
LogFormat: LogFormatText,
},
Site: Site{
Title: DefaultSiteTitle,
Description: DefaultSiteDescription,
BaseURL: DefaultBaseURL,
Language: DefaultLanguage,
Author: DefaultAuthor,
},
Admin: Admin{
SessionTTL: DefaultSessionTTL,
MinPasswordLength: DefaultMinPasswordLength,
MaxPasswordLength: DefaultMaxPasswordLength,
MaxUploadBytes: DefaultMaxUploadBytes,
},
API: API{
RateLimit: DefaultAPIRateLimit,
RateLimitWindow: DefaultAPIRateLimitWindow,
},
Scheduler: Scheduler{
Interval: DefaultSchedulerInterval,
},
ContentDir: DefaultContentDir,
UsersFile: DefaultUsersFile,
RevisionLimit: DefaultRevisionLimit,
}
}
// Load reads path, decodes it over the built-in defaults, applies the
// overrides, and returns the configuration. A missing file is not an
// error: the defaults are used and one line says so. With no file, the
// data paths move under the user's home so a server started without any
// configuration can still write its state and run the first-run wizard;
// an explicit --content or --users-file override wins over that.
func Load(path string, ov Overrides) (*Config, error) {
cfg := Defaults()
raw, err := os.ReadFile(path)
switch {
case err == nil:
if err := decode(raw, cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
case errors.Is(err, os.ErrNotExist):
if content, users, ok := UserStatePaths(); ok {
cfg.ContentDir = content
cfg.UsersFile = users
slog.Info("volumen: configuration file not found, using built-in defaults",
"path", path, "example", "config.toml.example", "content_dir", content)
} else {
slog.Info("volumen: configuration file not found, using built-in defaults",
"path", path, "example", "config.toml.example")
}
default:
return nil, fmt.Errorf("read config %s: %w", path, err)
}
cfg.apply(ov)
return cfg, nil
}
// decode fills cfg from a TOML document, and reports a root key that a
// table header swallowed: TOML puts a key written below [site] inside
// that table, where nothing reads it.
func decode(raw []byte, cfg *Config) error {
tree, err := interpres.ParseMap(raw)
if err != nil {
return err
}
for name, value := range tree {
// Only tables can swallow a root key; [[webhooks]] is an array
// of tables and parses as a slice, so the type check skips it.
sub, ok := value.(map[string]any)
if !ok {
continue
}
for _, key := range foldedKeys {
if _, present := sub[key]; present {
return fmt.Errorf(
"%s is written below the [%s] header, so it belongs to that table; move it above the first [table] header",
key, name)
}
}
}
return interpres.Unmarshal(raw, cfg)
}
// foldedKeys are the keys that sit at the root of the document and are
// silently captured by a preceding table header if they are written below
// one.
var foldedKeys = []string{"content_dir", "users_file", "revision_limit", "audit_log"}
func (c *Config) apply(ov Overrides) {
if ov.Host != "" {
c.Server.Host = ov.Host
}
if ov.Port != PortUnset {
c.Server.Port = ov.Port
}
if ov.ContentDir != "" {
c.ContentDir = ov.ContentDir
}
if ov.UsersFile != "" {
c.UsersFile = ov.UsersFile
}
}
// TemplatesFile returns the templates.toml path, next to users_file.
func (c *Config) TemplatesFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "templates.toml")
}
// TokensFile returns the tokens.toml path, next to users_file.
func (c *Config) TokensFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "tokens.toml")
}
// IsProduction reports whether the environment label is production.
func (c *Config) IsProduction() bool { return c.Server.Env == EnvProduction }
// ListenAddr returns the address the server binds, as host:port.
func (c *Config) ListenAddr() (netip.AddrPort, error) {
addr, err := netip.ParseAddr(c.Server.Host)
if err != nil {
return netip.AddrPort{}, errorf("[server].host must be an IP address (got %q)", c.Server.Host)
}
return netip.AddrPortFrom(addr, uint16(c.Server.Port)), nil
}
// TrustedProxyPrefixes parses [server].trusted_proxies. An entry may be a
// single address, which is read as a /32 or /128 prefix.
func (c *Config) TrustedProxyPrefixes() ([]netip.Prefix, error) {
out := make([]netip.Prefix, 0, len(c.Server.TrustedProxies))
for _, entry := range c.Server.TrustedProxies {
if prefix, err := netip.ParsePrefix(entry); err == nil {
out = append(out, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return nil, errorf("[server].trusted_proxies entry %q is not an address or a CIDR prefix", entry)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
// Validate checks the configuration and returns a *ConfigError naming the
// first problem. A key the decoder could not read is already an error by
// then, so this covers the values a wrong type cannot catch.
func (c *Config) Validate() error {
if c.Server.Host == "" {
return errorf("[server].host must be a non-empty string")
}
if _, err := netip.ParseAddr(c.Server.Host); err != nil {
return errorf("[server].host must be an IP address (got %q)", c.Server.Host)
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return errorf("[server].port must be an integer in 1..65535 (got %d)", c.Server.Port)
}
if c.Server.Env != EnvDevelopment && c.Server.Env != EnvProduction {
return errorf("[server].env must be one of [development production] (got %q)", c.Server.Env)
}
if c.Server.LogFormat != LogFormatText && c.Server.LogFormat != LogFormatJSON {
return errorf("[server].log_format must be one of [text json] (got %q)", c.Server.LogFormat)
}
if _, err := c.TrustedProxyPrefixes(); err != nil {
return err
}
if c.Server.TrustProxy && !c.IsProduction() {
slog.Warn("config: [server].trust_proxy is on outside production; " +
"only a proxy you control must be able to reach the listener")
}
if c.RevisionLimit < 0 {
return errorf("revision_limit must be >= 0 (got %d)", c.RevisionLimit)
}
if c.Admin.SessionTTL <= 0 {
return errorf("[admin].session_ttl must be > 0 (got %d)", c.Admin.SessionTTL)
}
if c.Admin.SessionTTL > MaxSessionTTL {
return errorf("[admin].session_ttl must be <= %d seconds (got %d)", MaxSessionTTL, c.Admin.SessionTTL)
}
if c.Admin.MinPasswordLength < 1 || c.Admin.MinPasswordLength > c.Admin.MaxPasswordLength {
return errorf("[admin].min_password_length must be >= 1 and <= max_password_length")
}
// The hashing layer refuses anything longer whatever this value
// says; catching it here turns a password-change 500 into a startup
// error the operator can read.
if c.Admin.MaxPasswordLength > password.MaxPasswordLength {
return errorf("[admin].max_password_length must be <= %d (got %d)",
password.MaxPasswordLength, c.Admin.MaxPasswordLength)
}
if c.Admin.MaxUploadBytes <= 0 {
return errorf("[admin].max_upload_bytes must be > 0")
}
if c.Admin.MaxUploadBytes > MaxUploadBytesCeiling {
return errorf("[admin].max_upload_bytes must be <= %d (got %d)", MaxUploadBytesCeiling, c.Admin.MaxUploadBytes)
}
if c.API.RateLimit < 0 {
return errorf("[api].rate_limit must be >= 0 (got %d)", c.API.RateLimit)
}
if c.API.RateLimit > 0 {
if c.API.RateLimitWindow <= 0 {
return errorf("[api].rate_limit_window must be > 0 when rate limiting is enabled")
}
if c.API.RateLimitWindow > MaxRateLimitWindow {
return errorf("[api].rate_limit_window must be <= %d seconds (got %d)", MaxRateLimitWindow, c.API.RateLimitWindow)
}
}
if c.Scheduler.Enabled && c.Scheduler.Interval < 1 {
return errorf("[scheduler].interval must be >= 1 second (got %d)", c.Scheduler.Interval)
}
for _, hook := range c.Webhooks {
parsed, err := url.Parse(hook.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return errorf("[[webhooks]].url must be an http(s) URL (got %q)", hook.URL)
}
}
if c.Site.BaseURL == "" {
return errorf("[site].base_url must be a non-empty string")
}
parsed, err := url.Parse(c.Site.BaseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return errorf("[site].base_url must be an absolute URL (got %q)", c.Site.BaseURL)
}
if c.Site.FediverseCreator != "" && !fediverse.Valid(c.Site.FediverseCreator) {
return errorf("[site].fediverse_creator must look like @user@host when set")
}
if err := probeWritable(c.ContentDir, "[content_dir]"); err != nil {
return err
}
if err := probeWritable(c.UsersFile, "[users_file]"); err != nil {
return err
}
return nil
}
// probeWritable reports whether the directory holding target can be
// written to. It never creates anything: a read-only command validates a
// configuration, and turning a mistyped path into a directory would make
// the mistake harder to see. The directory itself is created when the
// first post or account is written.
func probeWritable(target, label string) error {
parent := filepath.Dir(target)
probeDir := nearestExisting(parent)
probe, err := os.CreateTemp(probeDir, ".volumen-write-probe-*")
if err != nil {
return errorf("%s is not writable at %q: %v", label, target, err)
}
name := probe.Name()
probe.Close()
os.Remove(name)
return nil
}
// nearestExisting returns the closest existing ancestor of path, which is
// where writability is probed.
func nearestExisting(path string) string {
for dir := path; ; {
if info, err := os.Stat(dir); err == nil && info.IsDir() {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return dir
}
dir = parent
}
}
// TemplatesDir returns the directory that holds templates.toml and
// tokens.toml, beside users_file.
func (c *Config) TemplatesDir() string {
return filepath.Dir(c.UsersFile)
}
// SecretKeyFile returns the path of the session secret the server
// generates for itself, kept beside the state files. [admin].session_key
// overrides it.
func (c *Config) SecretKeyFile() string {
return filepath.Join(filepath.Dir(c.UsersFile), "secret.key")
}
// TrimmedBaseURL returns [site].base_url without a trailing slash.
func (c *Config) TrimmedBaseURL() string {
return strings.TrimRight(c.Site.BaseURL, "/")
}
+388
View File
@@ -0,0 +1,388 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package config
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func writableConfig(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
content := filepath.Join(dir, "posts")
users := filepath.Join(dir, "users.toml")
body := "content_dir = \"" + content + "\"\n" +
"users_file = \"" + users + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"port = 8080\n" +
"env = \"production\"\n" +
"trust_proxy = true\n" +
"\n[site]\n" +
"base_url = \"https://site.example\"\n" +
"language = \"cs\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func TestLoadMergesDefaults(t *testing.T) {
cfg, err := Load(writableConfig(t), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != "::1" || cfg.Server.Port != 8080 || cfg.Server.Env != EnvProduction {
t.Fatalf("server section wrong: %s %d %s", cfg.Server.Host, cfg.Server.Port, cfg.Server.Env)
}
if !cfg.Server.TrustProxy {
t.Fatal("trust_proxy not loaded")
}
if cfg.Site.Title != DefaultSiteTitle {
t.Fatalf("default title missing: %q", cfg.Site.Title)
}
if cfg.Site.Language != "cs" {
t.Fatalf("language = %q", cfg.Site.Language)
}
if cfg.Admin.SessionTTL != DefaultSessionTTL {
t.Fatalf("session_ttl = %d", cfg.Admin.SessionTTL)
}
if cfg.RevisionLimit != DefaultRevisionLimit {
t.Fatalf("revision_limit = %d", cfg.RevisionLimit)
}
}
func TestLoadMissingFileUsesDefaults(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "nope.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != DefaultHost || cfg.Server.Port != DefaultPort {
t.Fatalf("defaults not applied: %s %d", cfg.Server.Host, cfg.Server.Port)
}
}
func TestLoadRejectsInvalidToml(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("not = valid = toml"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := Load(path, Overrides{Port: -1}); err == nil {
t.Fatal("want parse error")
}
}
// A root key written below [server] belongs to that table, where nothing
// reads it. The loader names the key and the fix rather than silently
// falling back to the default path.
func TestLoadRejectsAFoldedRootKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
body := "[server]\nhost = \"::1\"\ncontent_dir = \"" + filepath.Join(dir, "posts") + "\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
_, err := Load(path, Overrides{Port: -1})
if err == nil {
t.Fatal("want an error for a root key below a table header")
}
if !strings.Contains(err.Error(), "content_dir") ||
!strings.Contains(err.Error(), "[table] header") {
t.Fatalf("error does not name the key and the fix: %v", err)
}
}
// The shipped template is a valid configuration as written: loading it
// and validating it (with the data paths pointed at a writable directory)
// is what every deployment does after copying config.toml.example.
func TestShippedTemplateLoads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte(Template), 0o600); err != nil {
t.Fatalf("write template: %v", err)
}
cfg, err := Load(path, Overrides{
Port: -1,
ContentDir: filepath.Join(dir, "posts"),
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
// The template's site block must carry the same defaults the code
// serves, so a deployment with no config file and one that copies the
// example present the same site.
if cfg.Site.Title != DefaultSiteTitle || cfg.Site.Description != DefaultSiteDescription {
t.Fatalf("template defaults = %q, %q; want %q, %q",
cfg.Site.Title, cfg.Site.Description, DefaultSiteTitle, DefaultSiteDescription)
}
// A fresh template leaves the session key empty: the server generates
// its own secret rather than the config carrying one.
if cfg.Admin.SessionKey != "" {
t.Fatalf("template session_key should default empty, got %q", cfg.Admin.SessionKey)
}
}
// A missing configuration file moves the state under the user's home:
// that is what lets a plain `volumen serve` on a fresh machine run
// without root, and the overrides still win over it.
func TestMissingFileUsesUserStatePaths(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
cfg, err := Load(filepath.Join(home, "nope", "config.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := filepath.Join(home, ".local", "share", "volumen")
if cfg.ContentDir != filepath.Join(want, "posts") || cfg.UsersFile != filepath.Join(want, "users.toml") {
t.Fatalf("paths = %q %q, want under %q", cfg.ContentDir, cfg.UsersFile, want)
}
// XDG_DATA_HOME wins over ~/.local/share when set.
data := t.TempDir()
t.Setenv("XDG_DATA_HOME", data)
cfg, err = Load(filepath.Join(home, "nope", "config.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ContentDir != filepath.Join(data, "volumen", "posts") {
t.Fatalf("XDG_DATA_HOME ignored: %q", cfg.ContentDir)
}
// An explicit override beats the user default.
cfg, err = Load(filepath.Join(home, "nope", "config.toml"), Overrides{
Port: -1,
ContentDir: "/srv/posts",
UsersFile: "/srv/users.toml",
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.ContentDir != "/srv/posts" || cfg.UsersFile != "/srv/users.toml" {
t.Fatalf("override lost: %q %q", cfg.ContentDir, cfg.UsersFile)
}
}
// Without --config the file is chosen in order: /etc, then the per-user
// path, then /etc again so a fresh machine loads the defaults.
func TestResolveConfigPathOrder(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", "")
userCfg := filepath.Join(home, ".config", "volumen", "config.toml")
// Nothing exists: the system path is named so Load reports defaults.
if got := ResolveConfigPath(); got != DefaultPath {
t.Fatalf("with no files = %q, want %q", got, DefaultPath)
}
if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(userCfg, []byte("port = 9091\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if got := ResolveConfigPath(); got != userCfg {
t.Fatalf("user config not found: %q", got)
}
}
func TestOverrides(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "x.toml"), Overrides{
Host: "127.0.0.1",
Port: 1234,
ContentDir: "/srv/posts",
UsersFile: "/srv/users.toml",
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Host != "127.0.0.1" || cfg.Server.Port != 1234 {
t.Fatalf("host/port override failed: %s %d", cfg.Server.Host, cfg.Server.Port)
}
if cfg.ContentDir != "/srv/posts" || cfg.UsersFile != "/srv/users.toml" {
t.Fatal("path overrides failed")
}
}
func TestValidateOK(t *testing.T) {
cfg, err := Load(writableConfig(t), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if err := cfg.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
}
func TestValidateFailures(t *testing.T) {
dir := t.TempDir()
base := func(mutate func(*Config)) *Config {
cfg, err := Load(filepath.Join(dir, "none.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if mutate != nil {
mutate(cfg)
}
return cfg
}
cases := []struct {
name string
cfg *Config
frag string
}{
{"host", base(func(c *Config) { c.Server.Host = "" }), "[server].host"},
{"host_not_an_address", base(func(c *Config) { c.Server.Host = "localhost" }), "[server].host"},
{"port", base(func(c *Config) { c.Server.Port = 0 }), "[server].port"},
{"env", base(func(c *Config) { c.Server.Env = "staging" }), "[server].env"},
{"log_format", base(func(c *Config) { c.Server.LogFormat = "xml" }), "log_format"},
{"trusted_proxies", base(func(c *Config) { c.Server.TrustedProxies = []string{"not a prefix"} }), "trusted_proxies"},
{"base_url", base(func(c *Config) { c.Site.BaseURL = "notaurl" }), "base_url"},
{"fediverse", base(func(c *Config) { c.Site.FediverseCreator = "nope" }), "fediverse_creator"},
{"webhook", base(func(c *Config) { c.Webhooks = []Webhook{{URL: "ftp://x"}} }), "webhooks"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.cfg.Validate()
if err == nil {
t.Fatal("want error")
}
if !strings.Contains(err.Error(), tc.frag) {
t.Fatalf("error %q does not mention %q", err, tc.frag)
}
})
}
t.Run("session_ttl", func(t *testing.T) {
cfg := base(func(c *Config) { c.Admin.SessionTTL = 0 })
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "session_ttl") {
t.Fatalf("err = %v", err)
}
})
t.Run("password lengths", func(t *testing.T) {
cfg := base(func(c *Config) {
c.Admin.MinPasswordLength = 20
c.Admin.MaxPasswordLength = 10
})
if err := cfg.Validate(); err == nil {
t.Fatal("want error")
}
})
t.Run("rate limit", func(t *testing.T) {
cfg := base(func(c *Config) { c.API.RateLimit = -1 })
if err := cfg.Validate(); err == nil {
t.Fatal("want error")
}
})
}
// The config file an operator writes for a reverse proxy decodes its
// trusted proxy list, empty list included: this is the shape documented
// in the template and what a production deployment relies on.
func TestTrustedProxiesParse(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
body := "content_dir = \"" + filepath.Join(dir, "posts") + "\"\n" +
"users_file = \"" + filepath.Join(dir, "users.toml") + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"trust_proxy = true\n" +
"trusted_proxies = [\"::1\", \"127.0.0.1\"]\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := Load(path, Overrides{Port: PortUnset})
if err != nil {
t.Fatalf("Load: %v", err)
}
want := []string{"::1", "127.0.0.1"}
if !slices.Equal(cfg.Server.TrustedProxies, want) {
t.Fatalf("trusted_proxies = %v, want %v", cfg.Server.TrustedProxies, want)
}
emptyPath := filepath.Join(dir, "empty.toml")
emptyBody := "content_dir = \"" + filepath.Join(dir, "posts") + "\"\n" +
"users_file = \"" + filepath.Join(dir, "users.toml") + "\"\n" +
"\n[server]\n" +
"host = \"::1\"\n" +
"trusted_proxies = []\n"
if err := os.WriteFile(emptyPath, []byte(emptyBody), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
empty, err := Load(emptyPath, Overrides{Port: PortUnset})
if err != nil {
t.Fatalf("Load empty: %v", err)
}
if len(empty.Server.TrustedProxies) != 0 {
t.Fatalf("empty list parsed as %v", empty.Server.TrustedProxies)
}
}
// The example shipped in the repository is the embedded template, so the
// two cannot drift: the documentation points at both.
func TestExampleMatchesTemplate(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "config.toml.example"))
if err != nil {
t.Fatalf("read config.toml.example: %v", err)
}
if string(raw) != Template {
t.Fatal("config.toml.example differs from config.Template")
}
}
func TestAuditLogPathAndScheduler(t *testing.T) {
cfg, err := Load(filepath.Join(t.TempDir(), "x.toml"), Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AuditLog != "" {
t.Fatal("audit log should default to disabled")
}
if cfg.Scheduler.Enabled || cfg.Scheduler.Interval != DefaultSchedulerInterval {
t.Fatal("scheduler defaults wrong")
}
cfg.AuditLog = "/var/log/volumen-audit.log"
cfg.Scheduler = Scheduler{Enabled: true, Interval: 60}
if cfg.AuditLog != "/var/log/volumen-audit.log" {
t.Fatalf("audit log = %q", cfg.AuditLog)
}
if !cfg.Scheduler.Enabled || cfg.Scheduler.Interval != 60 {
t.Fatal("scheduler config wrong")
}
}
// A hook is on unless the file turns it off, which is the reason the
// field is a pointer: an omitted key must not read as false.
func TestWebhookDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "hooks.toml")
body := "[[webhooks]]\nurl = \"https://example.com/one\"\n\n" +
"[[webhooks]]\nurl = \"https://example.com/two\"\nenabled = false\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := Load(path, Overrides{Port: -1})
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Webhooks) != 2 {
t.Fatalf("webhooks = %v", cfg.Webhooks)
}
if !cfg.Webhooks[0].Delivers() {
t.Fatal("an omitted enabled key disabled the hook")
}
if cfg.Webhooks[1].Delivers() {
t.Fatal("an explicit false left the hook enabled")
}
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package config
// Template is the commented configuration file every deployment starts
// from: an operator copies it to the config path and edits it. The
// committed config.toml.example is this constant, unchanged, and a test
// asserts that; the release pipeline ships that file as an asset.
//
// The root keys come first, before any table header: a key written below
// a header belongs to that table, and the loader refuses a root key that
// a header swallowed rather than silently falling back to the default.
// Every key this file accepts is listed here, and docs/CONFIGURATION.md
// is the reference for its type, default and rules.
const Template = `# volumen configuration.
#
# Every key this file accepts is listed here. Copy it to
# /etc/volumen/config.toml (or ~/.config/volumen/config.toml for a
# per-user installation), then edit.
#
# Keys that belong to no table come first, because a key written below a
# [table] header belongs to that table.
# Directory of the Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File holding the admin accounts (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
# How many previous versions of each post to keep in .revisions/
# (0 keeps none, which also makes deleting a post permanent).
revision_limit = 10
# Where the audit log is appended, or "" to disable auditing. Records
# who changed what, and when, in JSON lines.
audit_log = ""
[server]
# Address to bind, as an IP address: "::" is every interface, "::1" is
# loopback only, which is what a reverse proxy needs.
host = "::"
port = 9091
# Environment label: "development" or "production". It decides the
# startup safety checks (session key length, cookie flags, password
# policy).
env = "development"
# Set true ONLY when a trusted reverse proxy terminates TLS in front of
# volumen. Client addresses are then taken from X-Forwarded-For and
# cookies are marked Secure.
trust_proxy = false
# Addresses whose X-Forwarded-For may be believed, as addresses or CIDR
# prefixes. An empty list never reads the header and always uses the
# connection address; list the proxy so its clients each rate-limit
# under their own address.
trusted_proxies = []
# Set true in production to force the Secure flag on session cookies.
cookie_secure = false
# Log output format: "text" (human readable) or "json" (structured).
log_format = "text"
[site]
title = "Volumen"
description = "Powered by Volumen."
# Absolute URL of the public site, without a trailing slash.
base_url = "https://example.com"
language = "en"
author = "Anonymous"
# Fediverse handle surfaced as the author in feeds and meta tags.
# Leave empty to disable.
fediverse_creator = ""
[admin]
# Secret that signs session cookies (at least 64 bytes in production).
# Leave empty: the server generates one and keeps it in secret.key next
# to users.toml. A value here overrides that file.
session_key = ""
# Session lifetime in seconds (24 hours by default).
session_ttl = 86400
# Minimum password length enforced when a password is set in the admin UI.
min_password_length = 10
# Maximum password length, to bound the scrypt work.
max_password_length = 1024
# Maximum upload size in bytes (10 MB by default).
max_upload_bytes = 10485760
[api]
# Public API rate limit: requests allowed per window per client address.
# 0 disables rate limiting.
rate_limit = 60
# Rate-limit window length in seconds.
rate_limit_window = 60
# Scheduled publishing, for a post whose frontmatter carries publish_at.
# [scheduler]
# enabled = false
# interval = 300
# Outgoing webhooks: POST a signed JSON payload on post changes so a
# front-end can rebuild its cache or static pages. Repeat the block for
# more endpoints; events may be omitted to receive every event.
# [[webhooks]]
# url = "https://example.com/hooks/rebuild"
# secret = "a-long-random-string" # HMAC-SHA256 signing key
# events = ["post.created", "post.updated", "post.deleted", "post.published"]
# enabled = true
`
+192
View File
@@ -0,0 +1,192 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package diff renders a line-based difference between two texts. The
// classic longest-common-subsequence dynamic program is enough here:
// post-sized inputs are small, and inputs beyond the cell budget fall
// back to an honest full replacement rather than a slow or hungry walk.
package diff
import "strings"
// Op classifies one line of the result.
type Op uint8
const (
// Equal marks a line both texts share.
Equal Op = iota
// Removed marks a line only the old text carries.
Removed
// Added marks a line only the new text carries.
Added
// Skipped marks equal lines the context collapse hid.
Skipped
)
// Is reports whether the operation matches the name a template asks
// for: "equal", "removed", "added" or "skipped".
func (o Op) Is(name string) bool {
switch name {
case "equal":
return o == Equal
case "removed":
return o == Removed
case "added":
return o == Added
case "skipped":
return o == Skipped
}
return false
}
// Chunk is a run of consecutive lines with one operation.
type Chunk struct {
Op Op
Lines []string
Skipped int // Skipped only: how many equal lines the collapse hid
}
// maxCells bounds the dynamic-programming table. Past it, the diff
// degrades to a whole-text replacement: rare, and never wrong.
const maxCells = 4_000_000
// Chunks diffs the old text against the new one line by line and
// collapses long equal runs to context lines around each change. The
// texts are split on newlines; a trailing newline does not create an
// empty final line.
func Chunks(oldText, newText string, context int) []Chunk {
oldLines := split(oldText)
newLines := split(newText)
ops := make([]Op, 0, len(oldLines)+len(newLines))
if len(oldLines)*len(newLines) > maxCells {
ops = appendAll(ops, Removed, oldLines)
ops = appendAll(ops, Added, newLines)
} else {
ops = lcs(oldLines, newLines)
}
return collapse(ops, oldLines, newLines, context)
}
// split breaks a text into lines without a phantom empty line for the
// trailing newline.
func split(text string) []string {
text = strings.TrimSuffix(text, "\n")
if text == "" {
return nil
}
return strings.Split(text, "\n")
}
func appendAll(ops []Op, op Op, lines []string) []Op {
for range lines {
ops = append(ops, op)
}
return ops
}
// lcs walks the dynamic-programming table backwards and yields the
// per-line operations.
func lcs(a, b []string) []Op {
n, m := len(a), len(b)
// table[i][j] holds the LCS length of a[i:] and b[j:].
table := make([]int32, (n+1)*(m+1))
at := func(i, j int) *int32 { return &table[i*(m+1)+j] }
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
*at(i, j) = *at(i+1, j+1) + 1
} else {
down, right := *at(i+1, j), *at(i, j+1)
*at(i, j) = max(down, right)
}
}
}
ops := make([]Op, 0, n+m)
i, j := 0, 0
for i < n && j < m {
switch {
case a[i] == b[j]:
ops = append(ops, Equal)
i++
j++
case *at(i+1, j) >= *at(i, j+1):
ops = append(ops, Removed)
i++
default:
ops = append(ops, Added)
j++
}
}
ops = appendAll(ops, Removed, a[i:])
ops = appendAll(ops, Added, b[j:])
return ops
}
// collapse groups the operations into chunks and trims equal runs to
// context lines around the changes.
func collapse(ops []Op, a, b []string, context int) []Chunk {
// Position each operation over its source line.
type line struct {
op Op
text string
}
out := make([]line, 0, len(ops))
ai, bi := 0, 0
for _, op := range ops {
switch op {
case Removed:
out = append(out, line{Removed, a[ai]})
ai++
case Added:
out = append(out, line{Added, b[bi]})
bi++
default:
out = append(out, line{Equal, a[ai]})
ai++
bi++
}
}
var chunks []Chunk
for start := 0; start < len(out); {
op := out[start].op
end := start
for end < len(out) && out[end].op == op {
end++
}
lines := make([]string, 0, end-start)
for _, l := range out[start:end] {
lines = append(lines, l.text)
}
chunks = append(chunks, Chunk{Op: op, Lines: lines})
start = end
}
// Collapse an equal chunk only when another change follows it; the
// leading context of the first change stays whole.
if context < 0 {
context = 0
}
final := make([]Chunk, 0, len(chunks))
for _, chunk := range chunks {
final = append(final, chunk)
}
for i := 0; i < len(final)-1; i++ {
c := &final[i]
if c.Op != Equal || len(c.Lines) <= 2*context+4 {
continue
}
hidden := len(c.Lines) - 2*context
collapsed := Chunk{Op: Skipped, Skipped: hidden}
with := make([]Chunk, 0, len(final)+1)
with = append(with, final[:i]...)
with = append(with, Chunk{Op: Equal, Lines: c.Lines[:context]})
with = append(with, collapsed)
with = append(with, Chunk{Op: Equal, Lines: c.Lines[len(c.Lines)-context:]})
with = append(with, final[i+1:]...)
final = with
i++
}
return final
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package diff
import (
"strings"
"testing"
)
func render(chunks []Chunk) string {
var b strings.Builder
for _, c := range chunks {
switch c.Op {
case Equal:
for _, l := range c.Lines {
b.WriteString(" " + l + "\n")
}
case Removed:
for _, l := range c.Lines {
b.WriteString("- " + l + "\n")
}
case Added:
for _, l := range c.Lines {
b.WriteString("+ " + l + "\n")
}
case Skipped:
b.WriteString("… " + strings.Repeat("#", c.Skipped) + "\n")
}
}
return b.String()
}
func TestIdentical(t *testing.T) {
got := render(Chunks("alpha\nbeta\n", "alpha\nbeta\n", 3))
if got != " alpha\n beta\n" {
t.Errorf("identical diff = %q", got)
}
}
func TestSingleChange(t *testing.T) {
got := render(Chunks("alpha\nbeta\ngamma\n", "alpha\nbeta+\ngamma\n", 3))
want := " alpha\n- beta\n+ beta+\n gamma\n"
if got != want {
t.Errorf("change diff = %q, want %q", got, want)
}
}
func TestInsertAndDelete(t *testing.T) {
got := render(Chunks("one\ntwo\n", "one\nthree\nfour\ntwo\n", 3))
want := " one\n+ three\n+ four\n two\n"
if got != want {
t.Errorf("insert diff = %q, want %q", got, want)
}
got = render(Chunks("one\nthree\ntwo\n", "one\ntwo\n", 3))
want = " one\n- three\n two\n"
if got != want {
t.Errorf("delete diff = %q, want %q", got, want)
}
}
func TestEmptySides(t *testing.T) {
got := render(Chunks("", "alpha\n", 3))
if got != "+ alpha\n" {
t.Errorf("empty old = %q", got)
}
got = render(Chunks("alpha\n", "", 3))
if got != "- alpha\n" {
t.Errorf("empty new = %q", got)
}
got = render(Chunks("", "", 3))
if got != "" {
t.Errorf("both empty = %q", got)
}
}
func TestContextCollapse(t *testing.T) {
// Twelve equal lines between two changes: with context 3 the middle
// six collapse into one skipped chunk.
old := "x1\nx2\nx3\nx4\nx5\nx6\nx7\nx8\nx9\nx10\nx11\nx12\nx13\nx14\n"
new := "A1\nx2\nx3\nx4\nx5\nx6\nx7\nx8\nx9\nx10\nx11\nx12\nx13\nA14\n"
got := render(Chunks(old, new, 3))
want := "- x1\n+ A1\n" +
" x2\n x3\n x4\n" +
"… ######\n" +
" x11\n x12\n x13\n" +
"- x14\n+ A14\n"
if got != want {
t.Errorf("collapse diff = %q, want %q", got, want)
}
}
func TestTrailingNewlineIsNotALine(t *testing.T) {
with := render(Chunks("alpha\n", "alpha\n", 3))
without := render(Chunks("alpha", "alpha", 3))
if with != without {
t.Errorf("trailing newline changed the diff: %q vs %q", with, without)
}
}
func TestCellBudgetFallsBackToReplace(t *testing.T) {
// 2500 equal lines on both sides exceeds the cell budget; the diff
// must degrade to a full replacement instead of hanging.
var b strings.Builder
for range 2500 {
b.WriteString(strings.Repeat("x", 40) + "\n")
}
big := b.String()
chunks := Chunks(big, big, 3)
var removed, added bool
for _, c := range chunks {
switch c.Op {
case Removed:
removed = true
case Added:
added = true
}
}
if !removed || !added {
t.Errorf("oversized input did not degrade to replacement (removed=%v added=%v)", removed, added)
}
}
func TestOpIs(t *testing.T) {
if !Removed.Is("removed") || Added.Is("removed") || Skipped.Is("skipped") == false {
t.Error("Op.Is misclassifies")
}
}
+17
View File
@@ -0,0 +1,17 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package fediverse validates the fediverse handle that posts, users and
// the site configuration carry. It is a leaf so that the configuration
// and the domain object can share one rule without either importing the
// other.
package fediverse
import "regexp"
var handleRe = regexp.MustCompile(`\A@[^@\s]+@[^@\s]+\z`)
// Valid reports whether handle looks like @user@host.
func Valid(handle string) bool {
return handleRe.MatchString(handle)
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package fediverse
import "testing"
func TestValid(t *testing.T) {
good := []string{"@user@host.social", "@a@b", "@petr@social.example.org"}
for _, handle := range good {
if !Valid(handle) {
t.Errorf("Valid(%q) = false, want true", handle)
}
}
bad := []string{"", "user@host", "@user", "@@host", "@user@", "@user @host", "@user@ho st", "u@h"}
for _, handle := range bad {
if Valid(handle) {
t.Errorf("Valid(%q) = true, want false", handle)
}
}
}
+300
View File
@@ -0,0 +1,300 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package feeds renders the RSS 2.0, Atom 1.0, JSON Feed 1.1 and
// sitemap documents for the public API.
package feeds
import (
"bytes"
json "encoding/json/v2"
"fmt"
"html"
"log/slog"
"os"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
// FeedItemLimit caps the number of items in a feed.
const FeedItemLimit = 20
// SitemapURLLimit caps the URLs in one sitemap document: the sitemaps.org
// protocol defines 50 000 as the maximum a single document may carry, so
// a larger site truncates to the newest posts rather than shipping a
// document consumers may refuse whole.
const SitemapURLLimit = 50_000
// language returns the site language, defaulting to English.
func language(site config.Site) string {
if site.Language != "" {
return site.Language
}
return DefaultLanguage
}
// DefaultLanguage is the feed language when [site].language is empty.
const DefaultLanguage = "en"
// RenderRSSFeed renders an RSS 2.0 XML feed for the given posts.
// selfPath is the path of the feed being rendered, so a reader can see
// which document it fetched.
func RenderRSSFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) string {
items := make([]string, 0, min(len(posts), FeedItemLimit))
for _, p := range posts[:min(len(posts), FeedItemLimit)] {
items = append(items, rssItem(p, baseURL))
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>%s</title>
<link>%s</link>
<description>%s</description>
<language>%s</language>
<atom:link rel="self" type="application/rss+xml" href="%s"/>
%s
</channel>
</rss>`,
html.EscapeString(site.Title),
html.EscapeString(baseURL),
html.EscapeString(site.Description),
html.EscapeString(language(site)),
html.EscapeString(baseURL+selfPath),
strings.Join(items, "\n"))
}
func rssItem(p *post.Post, baseURL string) string {
permalink := baseURL + "/" + p.Slug()
var extra strings.Builder
if dateStr := p.DateString(); dateStr != "" {
fmt.Fprintf(&extra, "\n <pubDate>%s</pubDate>", html.EscapeString(rfc822Date(dateStr)))
}
if creator := p.FediverseCreator(); creator != "" {
fmt.Fprintf(&extra, "\n <dc:creator>%s</dc:creator>", html.EscapeString(creator))
}
if lang := p.Lang(); lang != "" {
fmt.Fprintf(&extra, "\n <dc:language>%s</dc:language>", html.EscapeString(lang))
}
return fmt.Sprintf(` <item>
<title>%s</title>
<link>%s</link>
<guid>%s</guid>
<description>%s</description>%s
</item>`,
html.EscapeString(p.Title()),
html.EscapeString(permalink),
html.EscapeString(permalink),
html.EscapeString(p.Excerpt()),
extra.String())
}
// rfc822Date formats a YYYY-MM-DD string as an RFC 822 timestamp, or
// returns it unchanged when unparseable.
func rfc822Date(value string) string {
t, err := time.Parse("2006-01-02", value)
if err != nil {
return value
}
return t.UTC().Format("Mon, 02 Jan 2006 15:04:05") + " GMT"
}
// rfc3339Date formats a YYYY-MM-DD string as RFC 3339 at UTC midnight,
// or returns it unchanged when it cannot be parsed.
func rfc3339Date(value string) string {
t, err := time.Parse("2006-01-02", value)
if err != nil {
return value
}
return t.UTC().Format("2006-01-02T15:04:05-07:00")
}
// RenderAtomFeed renders an Atom 1.0 XML feed for the given posts.
// selfPath is the path of the feed being rendered, so the rel=self link
// names the document the client actually fetched rather than always the
// site-wide feed.
func RenderAtomFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) string {
title := html.EscapeString(site.Title)
link := html.EscapeString(baseURL)
feedURL := html.EscapeString(baseURL + selfPath)
description := html.EscapeString(site.Description)
updated := atomUpdated(posts)
items := make([]string, 0, min(len(posts), FeedItemLimit))
for _, p := range posts[:min(len(posts), FeedItemLimit)] {
items = append(items, atomEntry(p, baseURL))
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>%s</title>
<link rel="alternate" type="text/html" href="%s"/>
<link rel="self" type="application/atom+xml" href="%s"/>
<id>%s/</id>
<updated>%s</updated>
<subtitle>%s</subtitle>
%s
</feed>`,
title, link, feedURL, link,
html.EscapeString(updated), description,
strings.Join(items, "\n"))
}
func atomUpdated(posts []*post.Post) string {
for _, p := range posts {
if dateStr := p.DateString(); dateStr != "" {
return rfc3339Date(dateStr)
}
}
// No post carries a date, so the feed has no update time of its own.
// The epoch is used rather than the current time, because a document
// that changes on every request defeats every cache in front of it.
return "1970-01-01T00:00:00Z"
}
func atomEntry(p *post.Post, baseURL string) string {
permalink := baseURL + "/" + p.Slug()
updated, published := "", ""
if dateStr := p.DateString(); dateStr != "" {
updated = rfc3339Date(dateStr)
published = updated
}
authorTag := ""
if creator := p.FediverseCreator(); creator != "" {
authorTag = fmt.Sprintf("\n <author><name>%s</name></author>", html.EscapeString(creator))
}
langAttr := ""
if lang := p.Lang(); lang != "" {
langAttr = fmt.Sprintf(` xml:lang="%s"`, html.EscapeString(lang))
}
return fmt.Sprintf(` <entry>
<title%s>%s</title>
<link rel="alternate" type="text/html" href="%s"/>
<id>%s</id>
<updated>%s</updated>
<published>%s</published>
<summary>%s</summary>%s
</entry>`,
langAttr, html.EscapeString(p.Title()),
html.EscapeString(permalink), html.EscapeString(permalink),
updated, published,
html.EscapeString(p.Excerpt()), authorTag)
}
// JSONFeed is a JSON Feed 1.1 document. Empty optional fields are
// omitted, matching the feed specification.
type JSONFeed struct {
Version string `json:"version"`
Title string `json:"title"`
HomePageURL string `json:"home_page_url"`
FeedURL string `json:"feed_url"`
Description string `json:"description"`
Language string `json:"language"`
Items []JSONItem `json:"items,omitempty"`
}
// JSONItem is one JSON Feed item. Empty optional fields are omitted.
type JSONItem struct {
ID string `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
ContentHTML string `json:"content_html"`
Summary string `json:"summary,omitempty"`
DatePublished string `json:"date_published,omitempty"`
Tags []string `json:"tags,omitempty"`
Authors []JSONAuthor `json:"authors,omitempty"`
}
// JSONAuthor is the author entry of a JSON Feed item.
type JSONAuthor struct {
Name string `json:"name"`
}
// RenderJSONFeed builds a JSON Feed 1.1 document for the given posts.
// selfPath is the path of the feed being rendered, for feed_url.
func RenderJSONFeed(posts []*post.Post, site config.Site, baseURL, selfPath string) JSONFeed {
limit := min(len(posts), FeedItemLimit)
items := make([]JSONItem, 0, limit)
for _, p := range posts[:limit] {
items = append(items, jsonFeedItem(p, baseURL, site))
}
return JSONFeed{
Version: "https://jsonfeed.org/version/1.1",
Title: site.Title,
HomePageURL: baseURL,
FeedURL: baseURL + selfPath,
Description: site.Description,
Language: language(site),
Items: items,
}
}
func jsonFeedItem(p *post.Post, baseURL string, site config.Site) JSONItem {
permalink := baseURL + "/" + p.Slug()
htmlOut, err := p.HTML()
if err != nil {
// The item still goes out (a feed with a body-less entry beats a
// feed that 500s for one bad post), but not silently: the detail
// endpoint fails loudly for the same post, and the feed should
// leave the same trace.
slog.Warn("feeds: cannot render post for the JSON feed", "slug", p.Slug(), "error", err)
htmlOut = ""
}
item := JSONItem{
ID: permalink,
URL: permalink,
Title: p.Title(),
ContentHTML: htmlOut,
Summary: p.Excerpt(),
DatePublished: p.DateString(),
Tags: p.Tags(),
}
author := p.FediverseCreator()
if author == "" {
author = site.FediverseCreator
}
if author != "" {
item.Authors = []JSONAuthor{{Name: author}}
}
return item
}
// MarshalJSONFeed serialises a feed. encoding/json/v2 escapes only what
// JSON requires, so the HTML inside content_html reaches the client as the
// post was rendered rather than with every angle bracket escaped.
func MarshalJSONFeed(feed JSONFeed) ([]byte, error) {
var buf bytes.Buffer
if err := json.MarshalWrite(&buf, feed, json.Deterministic(true)); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// RenderSitemap renders an XML sitemap, preferring the file
// modification time for <lastmod>. At most SitemapURLLimit URLs are
// included, newest posts first (the caller lists them that way).
func RenderSitemap(posts []*post.Post, baseURL string) string {
var urls []string
for _, p := range posts[:min(len(posts), SitemapURLLimit)] {
entry := " <url><loc>" + html.EscapeString(baseURL+"/"+p.Slug()) + "</loc>"
if lastmod := lastmodFor(p); lastmod != "" {
entry += "<lastmod>" + html.EscapeString(lastmod) + "</lastmod>"
}
entry += "</url>"
urls = append(urls, entry)
}
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
` + strings.Join(urls, "\n") + `
</urlset>`
}
func lastmodFor(p *post.Post) string {
if p.Path != "" {
if info, err := os.Stat(p.Path); err == nil {
return info.ModTime().UTC().Format("2006-01-02")
}
}
return p.DateString()
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package feeds
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
var testSite = config.Site{
Title: "Můj blog",
Description: "Testovací blog",
BaseURL: "https://site.example",
Language: "cs",
Author: "Petr",
}
func samplePosts(t *testing.T) []*post.Post {
t.Helper()
p1 := parsePost(t, "+++\ntitle = \"První & <pos>\"\nslug = \"prvni\"\ndate = 2026-08-18\nlang = \"cs\"\ntags = [\"go\"]\nfediverse_creator = \"@petr@social\"\n+++\nTělo **jedna**.\n")
p2 := parsePost(t, "+++\ntitle = \"Druhý\"\nslug = \"druhy\"\n+++\nTělo dva.\n")
p1.Path = filepath.Join(t.TempDir(), "prvni.md")
return []*post.Post{p1, p2}
}
func parsePost(t *testing.T, content string) *post.Post {
t.Helper()
p, err := post.Parse(content)
if err != nil {
t.Fatalf("parse: %v", err)
}
return p
}
func TestRenderRSSFeed(t *testing.T) {
posts := samplePosts(t)
out := RenderRSSFeed(posts, testSite, "https://site.example", "/api/volumen/feed.xml")
for _, want := range []string{
`<?xml version="1.0" encoding="UTF-8"?>`,
`<rss version="2.0"`,
`<title>Můj blog</title>`,
`<title>První &amp; &lt;pos&gt;</title>`,
`<link>https://site.example/prvni</link>`,
`<guid>https://site.example/prvni</guid>`,
`<pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>`,
`<dc:creator>@petr@social</dc:creator>`,
`<dc:language>cs</dc:language>`,
`<language>cs</language>`,
} {
if !strings.Contains(out, want) {
t.Fatalf("rss missing %q:\n%s", want, out)
}
}
// Post without a date has no pubDate.
if strings.Contains(out, "druhý</title>") && strings.Count(out, "<pubDate>") != 1 {
t.Fatalf("unexpected pubDate count:\n%s", out)
}
}
func TestRenderRSSFeedItemLimit(t *testing.T) {
var posts []*post.Post
for range FeedItemLimit + 5 {
p := parsePost(t, "+++\nslug = \"p\"\ntitle = \"t\"\n+++\nx\n")
posts = append(posts, p)
}
out := RenderRSSFeed(posts, testSite, "https://site.example", "/api/volumen/feed.xml")
if got := strings.Count(out, "<item>"); got != FeedItemLimit {
t.Fatalf("items = %d, want %d", got, FeedItemLimit)
}
}
func TestRenderAtomFeed(t *testing.T) {
posts := samplePosts(t)
out := RenderAtomFeed(posts, testSite, "https://site.example", "/api/volumen/feed.atom")
for _, want := range []string{
`<feed xmlns="http://www.w3.org/2005/Atom">`,
`<link rel="self" type="application/atom+xml" href="https://site.example/api/volumen/feed.atom"/>`,
`<id>https://site.example/</id>`,
`<updated>2026-08-18T00:00:00+00:00</updated>`,
`<title xml:lang="cs">První &amp; &lt;pos&gt;</title>`,
`<published>2026-08-18T00:00:00+00:00</published>`,
`<author><name>@petr@social</name></author>`,
`<summary>Tělo jedna.</summary>`,
} {
if !strings.Contains(out, want) {
t.Fatalf("atom missing %q:\n%s", want, out)
}
}
}
func TestRenderAtomFeedWithoutDates(t *testing.T) {
p := parsePost(t, "+++\nslug = \"x\"\ntitle = \"X\"\n+++\nb\n")
out := RenderAtomFeed([]*post.Post{p}, testSite, "https://site.example", "/api/volumen/feed.atom")
// An entry without a date emits empty updated and published
// elements, which consumers rely on.
if !strings.Contains(out, "<updated></updated>") {
t.Fatalf("empty updated element missing:\n%s", out)
}
if !strings.Contains(out, "<published></published>") {
t.Fatalf("empty published element missing:\n%s", out)
}
}
func TestRenderJSONFeed(t *testing.T) {
posts := samplePosts(t)
out := RenderJSONFeed(posts, testSite, "https://site.example", "/api/volumen/feed.json")
if out.Version != "https://jsonfeed.org/version/1.1" {
t.Fatalf("version = %v", out.Version)
}
if out.Title != "Můj blog" || out.Language != "cs" {
t.Fatalf("feed = %+v", out)
}
if out.FeedURL != "https://site.example/api/volumen/feed.json" {
t.Fatalf("feed_url = %v", out.FeedURL)
}
if len(out.Items) != 2 {
t.Fatalf("items = %v", out.Items)
}
first := out.Items[0]
if first.ID != "https://site.example/prvni" {
t.Fatalf("item = %+v", first)
}
if !strings.Contains(first.ContentHTML, "<strong>jedna</strong>") {
t.Fatalf("content_html = %v", first.ContentHTML)
}
if first.DatePublished != "2026-08-18" {
t.Fatalf("date_published = %v", first.DatePublished)
}
if len(first.Authors) != 1 || first.Authors[0].Name != "@petr@social" {
t.Fatalf("authors = %v", first.Authors)
}
if second := out.Items[1]; second.DatePublished != "" {
t.Fatalf("date_published should be empty: %+v", second)
}
}
func TestRenderJSONFeedSiteAuthorFallback(t *testing.T) {
p := parsePost(t, "+++\nslug = \"x\"\ntitle = \"X\"\n+++\nb\n")
site := config.Site{Title: "T", FediverseCreator: "@site@host"}
out := RenderJSONFeed([]*post.Post{p}, site, "https://site.example", "/api/volumen/feed.json")
if len(out.Items) != 1 || out.Items[0].Authors[0].Name != "@site@host" {
t.Fatalf("authors = %v", out.Items)
}
}
func TestRenderSitemap(t *testing.T) {
posts := samplePosts(t)
out := RenderSitemap(posts, "https://site.example")
for _, want := range []string{
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
`<loc>https://site.example/prvni</loc>`,
`<lastmod>`,
`<loc>https://site.example/druhy</loc>`,
} {
if !strings.Contains(out, want) {
t.Fatalf("sitemap missing %q:\n%s", want, out)
}
}
}
func TestSitemapLastmodFallsBackToDate(t *testing.T) {
p := parsePost(t, "+++\nslug = \"x\"\ndate = 2026-01-02\n+++\nb\n")
out := RenderSitemap([]*post.Post{p}, "https://site.example")
if !strings.Contains(out, "<lastmod>2026-01-02</lastmod>") {
t.Fatalf("sitemap = %s", out)
}
}
func TestUnparseableDatePassesThrough(t *testing.T) {
if got := rfc822Date("not-a-date"); got != "not-a-date" {
t.Fatalf("rfc822Date = %q", got)
}
if got := rfc3339Date("not-a-date"); got != "not-a-date" {
t.Fatalf("rfc3339Date = %q", got)
}
}
func TestSitemapLastmodFromRealFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "x.md")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
p := parsePost(t, "+++\nslug = \"x\"\n+++\nb\n")
p.Path = path
out := RenderSitemap([]*post.Post{p}, "https://site.example")
if !strings.Contains(out, "<lastmod>") {
t.Fatalf("sitemap = %s", out)
}
}
func TestMarshalJSONFeedNoHTMLEscaping(t *testing.T) {
posts := samplePosts(t)
feed := RenderJSONFeed(posts, testSite, "https://site.example", "/api/volumen/feed.json")
out, err := MarshalJSONFeed(feed)
if err != nil {
t.Fatalf("MarshalJSONFeed: %v", err)
}
if strings.Contains(string(out), `\u0026`) || strings.Contains(string(out), `\u003c`) {
t.Fatalf("HTML escaping leaked in: %s", out)
}
if !strings.Contains(string(out), `"title":"První & <pos>"`) {
t.Fatalf("literal characters missing: %s", out)
}
}
// One sitemap document carries at most SitemapURLLimit URLs, the
// sitemaps.org protocol ceiling.
func TestRenderSitemapCapsURLs(t *testing.T) {
posts := make([]*post.Post, SitemapURLLimit+250)
for i := range posts {
meta := frontmatter.NewMeta()
meta.Set("slug", fmt.Sprintf("post-%d", i))
posts[i] = post.New(meta, "body")
}
out := RenderSitemap(posts, "https://site.example")
if got := strings.Count(out, "<url>"); got != SitemapURLLimit {
t.Fatalf("sitemap holds %d URLs, want %d", got, SitemapURLLimit)
}
}
+316
View File
@@ -0,0 +1,316 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package frontmatter parses and writes TOML frontmatter in Markdown
// post files.
//
// Post files start with a TOML table between "+++" delimiter lines. The
// TOML is carried by an interpres Document, so a save keeps what the
// author wrote: the key order at every level, the comments, and whether
// a table was written as a header section or an inline table.
package frontmatter
import (
"bytes"
"fmt"
"maps"
"slices"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
)
// Delimiter fences the TOML frontmatter block.
const Delimiter = "+++"
// Meta is an order-preserving TOML table. Keys iterate in written
// order; values keep their interpres-decoded Go types (map[string]any,
// []any, string, int64, float64, bool, interpres.LocalDate and
// friends). A Meta parsed from a file keeps the file's comments and
// nested table shapes through a save.
type Meta struct {
doc *interpres.Document
}
// NewMeta returns an empty Meta.
func NewMeta() *Meta {
// Parsing nothing always succeeds and yields a document with a
// root table, which is what an empty Meta needs; a nil doc (if the
// parser ever failed on nothing) leaves an inert Meta whose methods
// are all no-ops.
doc, _ := interpres.Parse(nil)
return &Meta{doc: doc}
}
// MetaFromMap builds a Meta from a plain map with keys in the given
// order. Keys missing from order are appended sorted, so output stays
// deterministic.
func MetaFromMap(m map[string]any, order []string) *Meta {
meta := NewMeta()
seen := map[string]bool{}
for _, k := range order {
if v, ok := m[k]; ok {
meta.Set(k, v)
seen[k] = true
}
}
rest := make([]string, 0, len(m))
for k := range m {
if !seen[k] {
rest = append(rest, k)
}
}
slices.Sort(rest)
for _, k := range rest {
meta.Set(k, m[k])
}
return meta
}
// Len returns the number of keys.
func (m *Meta) Len() int { return len(m.Keys()) }
// Keys returns the keys in written order.
func (m *Meta) Keys() []string {
if m.doc == nil {
return nil
}
return m.doc.Root().Keys()
}
// Get returns the value for key.
func (m *Meta) Get(key string) (any, bool) {
if m.doc == nil {
return nil, false
}
entry, ok := m.doc.Get(key)
if !ok {
return nil, false
}
return entry.Value(), true
}
// Set assigns key, appending it when new so the written order is
// stable. A []string is stored as the []any the parser produces, so a
// set value and a parsed one leave a save in the same shape; a map
// value becomes a sub-table whose keys are written sorted, because a
// plain map carries no order to keep.
func (m *Meta) Set(key string, value any) {
if list, ok := value.([]string); ok {
anyList := make([]any, len(list))
for i, s := range list {
anyList[i] = s
}
value = anyList
}
m.doc.Set(key, value)
}
// Delete removes key and its position in the order.
func (m *Meta) Delete(key string) {
m.doc.Delete(key)
}
// Map returns a plain copy of the metadata.
func (m *Meta) Map() map[string]any {
if m.doc == nil {
return nil
}
out := make(map[string]any, len(m.doc.Map()))
maps.Copy(out, m.doc.Map())
return out
}
// Clone returns a deep copy that shares no value with the original.
// The document is re-marshalled and re-parsed, which copies every value
// and carries the comments, the key order and the table shapes with
// them. A Meta holding a value no parse could produce (a nil set by
// hand) falls back to a plain value copy without comments.
func (m *Meta) Clone() *Meta {
if m.doc != nil {
if raw, err := interpres.Marshal(m.doc); err == nil {
if doc, err := interpres.Parse(raw); err == nil {
return &Meta{doc: doc}
}
}
}
out := NewMeta()
for _, key := range m.Keys() {
value, _ := m.Get(key)
out.Set(key, deepCopyValue(value))
}
return out
}
// deepCopyValue copies the containers so a clone shares no mutable
// value with its original.
func deepCopyValue(value any) any {
switch v := value.(type) {
case map[string]any:
out := make(map[string]any, len(v))
for key, item := range v {
out[key] = deepCopyValue(item)
}
return out
case []any:
out := make([]any, len(v))
for i, item := range v {
out[i] = deepCopyValue(item)
}
return out
case []map[string]any:
out := make([]map[string]any, len(v))
for i, item := range v {
out[i] = deepCopyValue(item).(map[string]any)
}
return out
case []string:
return slices.Clone(v)
}
return value
}
// Parse splits content into metadata and body. Without frontmatter it
// returns empty metadata and the full content as body. Line endings are
// normalised to \n. Invalid frontmatter TOML is an error.
func Parse(content string) (*Meta, string, error) {
text := normaliseFile(content)
meta, bodyStart, err := ParseMetadata(text)
if err != nil {
return nil, "", err
}
if bodyStart < 0 {
return meta, text, nil
}
return meta, text[bodyStart:], nil
}
// normaliseFile strips a leading byte-order mark and normalises line
// endings. A BOM before the opening delimiter would otherwise make the
// whole frontmatter (draft and publish_at included) silently count as
// body.
func normaliseFile(content string) string {
text := strings.TrimPrefix(content, "\ufeff")
return strings.ReplaceAll(text, "\r\n", "\n")
}
// ParseMetadata parses only the frontmatter, returning the metadata and
// the byte offset where the body begins, or -1 when there is no
// frontmatter. Line endings are normalised to \n before parsing.
// Invalid frontmatter TOML is an error.
func ParseMetadata(content string) (*Meta, int, error) {
text := normaliseFile(content)
lines := strings.Split(text, "\n")
if len(lines) == 0 || !isDelimiter(lines[0]) {
return NewMeta(), -1, nil
}
closing := closingIndex(lines)
if closing < 0 {
return NewMeta(), -1, nil
}
tomlText := strings.Join(lines[1:closing], "\n")
meta := NewMeta()
if strings.TrimSpace(tomlText) != "" {
doc, err := interpres.Parse([]byte(tomlText))
if err != nil {
return nil, -1, fmt.Errorf("frontmatter: %w", err)
}
meta = &Meta{doc: doc}
}
bodyStart := 0
for i := 0; i <= closing; i++ {
bodyStart += len(lines[i]) + 1
}
// A file that ends exactly on the closing delimiter has no trailing
// newline; clamp so the slice below can never run past the text.
if bodyStart > len(text) {
bodyStart = len(text)
}
if bodyStart < len(text) && text[bodyStart] == '\n' {
bodyStart++
}
return meta, bodyStart, nil
}
// Dump serialises metadata and body back into a post file string. The
// body is written verbatim so indented code blocks and leading blank
// lines survive a save round-trip; only a trailing newline is added.
// The frontmatter is written from the parsed document, so the author's
// comments, key order and table shapes survive a save.
func Dump(meta *Meta, body string) (string, error) {
var b strings.Builder
b.WriteString(Delimiter)
b.WriteByte('\n')
if meta.Len() > 0 {
tomlText, err := interpres.Marshal(meta.doc)
if err != nil {
return "", err
}
b.Write(tomlText)
if !bytes.HasSuffix(tomlText, []byte{'\n'}) {
b.WriteByte('\n')
}
}
b.WriteString(Delimiter)
b.WriteString("\n\n")
b.WriteString(body)
if !strings.HasSuffix(body, "\n") {
b.WriteByte('\n')
}
return b.String(), nil
}
func isDelimiter(line string) bool {
return strings.TrimSpace(line) == Delimiter
}
// multiline tracks whether the TOML scanner sits inside a multi-line
// basic string (three double quotes) or a literal one (three single
// quotes), where a line reading "+++" is content, not a delimiter, and
// a line reading "key =" is not a key.
type multiline struct {
basic bool
literal bool
}
// step consumes one line and reports whether that line sits inside a
// multi-line string.
func (m *multiline) step(line string) bool {
if m.basic || m.literal {
closer := `"""`
if m.literal {
closer = "'''"
}
if strings.Contains(line, closer) {
m.basic, m.literal = false, false
}
return true
}
if eq := strings.Index(line, "="); eq > 0 {
rest := strings.TrimSpace(line[eq+1:])
switch {
case rest == `"""`:
m.basic = true
case rest == `'''`:
m.literal = true
case strings.HasPrefix(rest, `"""`) && len(rest) > 5 && !strings.HasSuffix(rest, `"""`):
m.basic = true
case strings.HasPrefix(rest, `'''`) && len(rest) > 5 && !strings.HasSuffix(rest, `'''`):
m.literal = true
}
}
return false
}
func closingIndex(lines []string) int {
var state multiline
for i := 1; i < len(lines); i++ {
if state.step(lines[i]) {
continue
}
if isDelimiter(lines[i]) {
return i
}
}
return -1
}
+426
View File
@@ -0,0 +1,426 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package frontmatter
import (
"fmt"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/tomlfile"
)
const sample = `+++
title = "Ahoj"
slug = "ahoj"
tags = ["go", "blog"]
draft = false
date = 2026-08-18
[translations]
en = "hello"
+++
# Nadpis
Tělo článku.
`
func TestParse(t *testing.T) {
meta, body, _ := Parse(sample)
if got, _ := meta.Get("title"); got != "Ahoj" {
t.Fatalf("title = %v", got)
}
if got, _ := meta.Get("draft"); got != false {
t.Fatalf("draft = %v", got)
}
if !strings.HasPrefix(body, "# Nadpis") {
t.Fatalf("body = %q", body)
}
trans, ok := meta.Get("translations")
if !ok {
t.Fatal("translations missing")
}
tr := trans.(map[string]any)
if tr["en"] != "hello" {
t.Fatalf("translations = %v", tr)
}
}
func TestParseKeyOrderPreserved(t *testing.T) {
meta, _, _ := Parse(sample)
want := []string{"title", "slug", "tags", "draft", "date", "translations"}
got := meta.Keys()
if len(got) != len(want) {
t.Fatalf("keys = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("keys = %v, want %v", got, want)
}
}
}
func TestParseWithoutFrontmatter(t *testing.T) {
meta, body, _ := Parse("just text\n")
if meta.Len() != 0 {
t.Fatalf("metadata = %v, want empty", meta.Map())
}
if body != "just text\n" {
t.Fatalf("body = %q", body)
}
}
func TestParseUnclosedFrontmatter(t *testing.T) {
meta, body, _ := Parse("+++\ntitle = \"x\"\nno closing\n")
if meta.Len() != 0 {
t.Fatalf("metadata = %v, want empty", meta.Map())
}
if body != "+++\ntitle = \"x\"\nno closing\n" {
t.Fatalf("body = %q", body)
}
}
func TestParseInvalidToml(t *testing.T) {
meta, body, err := Parse("+++\nnot valid = = =\n+++\nbody\n")
if err == nil {
t.Fatalf("want error for invalid TOML, got metadata=%v body=%q", meta.Map(), body)
}
}
func TestParseCRLF(t *testing.T) {
crlf := strings.ReplaceAll(sample, "\n", "\r\n")
meta, body, _ := Parse(crlf)
if got, _ := meta.Get("title"); got != "Ahoj" {
t.Fatalf("title = %v", got)
}
if !strings.HasPrefix(body, "# Nadpis") {
t.Fatalf("body = %q", body)
}
}
func TestParseMetadataBodyOffset(t *testing.T) {
_, bodyStart, _ := ParseMetadata("+++\ntitle = \"x\"\n+++\n\ntext")
if bodyStart < 0 {
t.Fatal("bodyStart < 0")
}
rest := ("+++\ntitle = \"x\"\n+++\n\ntext")[bodyStart:]
if rest != "text" {
t.Fatalf("rest = %q, want %q", rest, "text")
}
}
func TestDumpRoundTrip(t *testing.T) {
meta, body, _ := Parse(sample)
out, err := Dump(meta, body)
if err != nil {
t.Fatalf("Dump: %v", err)
}
meta2, body2, _ := Parse(out)
if body2 != body {
t.Fatalf("body changed:\n%q\nvs\n%q", body2, body)
}
for _, key := range []string{"title", "slug", "draft"} {
a, _ := meta.Get(key)
b, _ := meta2.Get(key)
if a != b {
t.Fatalf("key %q: %v -> %v", key, a, b)
}
}
tags1, _ := meta.Get("tags")
tags2, _ := meta2.Get("tags")
if len(tags1.([]any)) != len(tags2.([]any)) {
t.Fatalf("tags changed: %v -> %v", tags1, tags2)
}
tr1 := meta.Map()["translations"].(map[string]any)
tr2 := meta2.Map()["translations"].(map[string]any)
if tr1["en"] != tr2["en"] {
t.Fatalf("translations changed: %v -> %v", tr1, tr2)
}
// Round-trip must be stable: dumping again yields identical bytes.
out2, err := Dump(meta2, body2)
if err != nil {
t.Fatalf("Dump 2: %v", err)
}
if out != out2 {
t.Fatalf("round-trip not stable:\n%s\nvs\n%s", out, out2)
}
}
func TestDumpEmptyMetadata(t *testing.T) {
out, err := Dump(NewMeta(), "body")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if out != "+++\n+++\n\nbody\n" {
t.Fatalf("out = %q", out)
}
}
func TestDumpAddsTrailingNewline(t *testing.T) {
out, err := Dump(NewMeta(), "body without newline")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.HasSuffix(out, "body without newline\n") {
t.Fatalf("out = %q", out)
}
}
func TestDumpValueTypes(t *testing.T) {
meta := NewMeta()
meta.Set("s", "řetězec s \"")
meta.Set("n", int64(42))
meta.Set("f", 3.5)
meta.Set("fint", 3.0)
meta.Set("b", true)
meta.Set("d", interpres.LocalDate{Time: time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC)})
meta.Set("arr", []any{"a", "b"})
out, err := Dump(meta, "x")
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, want := range []string{
`s = "řetězec s \""`,
"n = 42",
"f = 3.5",
"fint = 3.0",
"b = true",
"d = 2026-08-18",
`arr = ["a", "b"]`,
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
}
func TestDumpRejectsNil(t *testing.T) {
meta := NewMeta()
meta.Set("bad", nil)
if _, err := Dump(meta, "x"); err == nil {
t.Fatal("want error for nil value")
}
}
func TestDumpQuotedKeys(t *testing.T) {
meta := NewMeta()
meta.Set("with space", "v")
out, err := Dump(meta, "x")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.Contains(out, `"with space" = "v"`) {
t.Fatalf("out = %q", out)
}
}
func TestMetaOperations(t *testing.T) {
meta := NewMeta()
meta.Set("a", int64(1))
meta.Set("b", int64(2))
meta.Set("a", int64(3))
if meta.Len() != 2 {
t.Fatalf("Len = %d", meta.Len())
}
if got, _ := meta.Get("a"); got != int64(3) {
t.Fatalf("a = %v", got)
}
if _, ok := meta.Get("b"); !ok {
t.Fatal("Has(b) = false")
}
meta.Delete("a")
meta.Delete("missing")
if keys := meta.Keys(); len(keys) != 1 || keys[0] != "b" {
t.Fatalf("keys = %v", keys)
}
}
func TestMetaFromMapUnknownOrderKeysSorted(t *testing.T) {
m := map[string]any{"b": int64(2), "a": int64(1), "c": int64(3)}
meta := MetaFromMap(m, []string{"c"})
keys := meta.Keys()
if keys[0] != "c" || keys[1] != "a" || keys[2] != "b" {
t.Fatalf("keys = %v", keys)
}
}
func TestParseClosingDelimiterWithoutTrailingNewline(t *testing.T) {
// A file ending exactly on the closing delimiter must not panic.
meta, body, err := Parse("+++\nslug = \"x\"\n+++")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, _ := meta.Get("slug"); got != "x" {
t.Fatalf("slug = %v", got)
}
if body != "" {
t.Fatalf("body = %q, want empty", body)
}
// The metadata-only variant behaves the same.
if _, offset, err := ParseMetadata("+++\n+++"); err != nil || offset > len("+++\n+++") {
t.Fatalf("offset = %d, err = %v", offset, err)
}
}
// A byte-order mark must not demote the frontmatter to body: draft and
// publish_at live there, and a file saved as UTF-8 with BOM keeps its
// meaning.
func TestParseStripsByteOrderMark(t *testing.T) {
meta, body, err := Parse("\ufeff+++\ntitle = \"BOM\"\nslug = \"bom\"\ndraft = true\n+++\n\nbody text\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if title, _ := meta.Get("title"); title != "BOM" {
t.Fatalf("title = %v, want BOM", title)
}
if !strings.Contains(body, "body text") || strings.Contains(body, "+++") {
t.Fatalf("body = %q", body)
}
if v, present := meta.Get("draft"); !present || v != true {
t.Fatal("draft flag lost behind the BOM")
}
}
// An array of tables in the frontmatter survives a save: it parses, so
// it must also serialise, or the post can never be edited again.
func TestDumpArraysOfTables(t *testing.T) {
src := "+++\ntitle = \"T\"\nslug = \"t\"\ninline = [{a = \"b\", n = 3}]\n\n[[chapters]]\nx = 1\n\n[[chapters]]\nx = 2\n+++\n\nbody\n"
meta, _, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
out, err := Dump(meta, "body\n")
if err != nil {
t.Fatalf("Dump rejected an array of tables: %v", err)
}
// The array of tables keeps its header form instead of being
// flattened into inline tables.
if !strings.Contains(out, "[[chapters]]") {
t.Fatalf("chapters header form lost:\n%s", out)
}
meta2, body2, err := Parse(out)
if err != nil {
t.Fatalf("round-trip parse: %v\n%s", err, out)
}
if body2 != "body\n" {
t.Fatalf("body = %q", body2)
}
chapters, _ := meta2.Get("chapters")
if got := len(tomlfile.Tables(chapters)); got != 2 {
t.Fatalf("chapters hold %d tables, want 2:\n%s", got, out)
}
inline, _ := meta2.Get("inline")
if got := len(tomlfile.Tables(inline)); got != 1 {
t.Fatalf("inline holds %d tables, want 1:\n%s", got, out)
}
}
// Comments in the frontmatter survive a save: they sit above the key
// the author explained, and a save has no business deleting them.
func TestDumpKeepsComments(t *testing.T) {
src := "+++\n# the visible name\ntitle = \"T\"\nslug = \"c\"\n\n# where it also lives\n[translations]\nen = \"hello\"\n+++\n\nbody\n"
meta, body, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
out, err := Dump(meta, body)
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, want := range []string{"# the visible name", "# where it also lives"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in:\n%s", want, out)
}
}
// Round-trip must be stable: dumping again yields identical bytes.
meta2, _, err := Parse(out)
if err != nil {
t.Fatalf("round-trip parse: %v\n%s", err, out)
}
out2, err := Dump(meta2, body)
if err != nil {
t.Fatalf("Dump 2: %v", err)
}
if out != out2 {
t.Fatalf("round-trip not stable:\n%s\nvs\n%s", out, out2)
}
}
// A nested table keeps the order its keys were written in, not the
// sorted order a map would give.
func TestDumpKeepsNestedKeyOrder(t *testing.T) {
src := "+++\ntitle = \"T\"\nslug = \"o\"\n[translations]\nzz = \"last\"\naa = \"first\"\n+++\n\nbody\n"
meta, body, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
out, err := Dump(meta, body)
if err != nil {
t.Fatalf("Dump: %v", err)
}
zz := strings.Index(out, "zz = ")
aa := strings.Index(out, "aa = ")
if zz < 0 || aa < 0 || zz > aa {
t.Fatalf("nested order re-sorted:\n%s", out)
}
}
// A clone is a deep copy: it carries the comments with it, and mutating
// it leaves the original untouched.
func TestCloneKeepsCommentsAndIsolates(t *testing.T) {
src := "+++\ntitle = \"T\"\nslug = \"c\"\n\n# the visible name\ntitle_note = \"x\"\n[translations]\nen = \"hello\"\n+++\n\nbody\n"
meta, _, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
clone := meta.Clone()
clone.Set("title", "Changed")
tr, _ := clone.Get("translations")
tr.(map[string]any)["en"] = "mutated"
orig, _ := meta.Get("title")
if orig != "T" {
t.Fatalf("original title = %v, want T", orig)
}
origTr, _ := meta.Get("translations")
if origTr.(map[string]any)["en"] != "hello" {
t.Fatalf("original translations mutated: %v", origTr)
}
out, err := Dump(clone, "body\n")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.Contains(out, "# the visible name") || !strings.Contains(out, `title = "Changed"`) {
t.Fatalf("clone lost a comment or the new value:\n%s", out)
}
}
// A line reading "+++" inside a multi-line string is content, not the
// closing delimiter; the frontmatter ends at the real one.
func TestParseDelimiterInsideMultilineString(t *testing.T) {
src := "+++\ntitle = \"T\"\nslug = \"ml\"\nbody = \"\"\"\n+++\nnot the end\n\"\"\"\n+++\nreal body\n"
meta, body, err := Parse(src)
if err != nil {
t.Fatalf("Parse: %v", err)
}
value, _ := meta.Get("body")
if got := fmt.Sprintf("%v", value); !strings.Contains(got, "not the end") {
t.Fatalf("multiline value = %q", got)
}
if !strings.HasPrefix(body, "real body") {
t.Fatalf("body = %q", body)
}
literal := "+++\ntitle = \"T\"\nslug = \"ml\"\nnote = '''\n+++\nliteral content\n'''\n+++\nreal body\n"
_, body2, err := Parse(literal)
if err != nil {
t.Fatalf("Parse literal: %v", err)
}
if !strings.HasPrefix(body2, "real body") {
t.Fatalf("literal body = %q", body2)
}
}
+937
View File
@@ -0,0 +1,937 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package httpapi serves the public JSON API under /api/volumen:
// site metadata, paginated posts, tags, series, feeds, the sitemap,
// and token-authenticated write endpoints.
package httpapi
import (
"crypto/sha256"
"encoding/hex"
json "encoding/json/v2"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/feeds"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// Content is the content surface the API uses.
type Content interface {
All() []*post.Post
Find(slug, lang string) *post.Post
ResolveAlias(alias string) string
Save(p *post.Post) (*post.Post, error)
Delete(slug, lang string) (*post.Post, bool, error)
CacheKey() string
}
// Deps are the shared services the API needs.
type Deps struct {
Config *config.Config
Store Content
Tokens *tokens.Store
OnEvent func(event string, payload map[string]any)
// PreviewKey verifies the shareable preview links: the session
// secret the app layer resolved, from [admin].session_key or from
// the secret.key file it generated, which is the same key the admin
// signs the links with. Empty refuses every token.
PreviewKey string
}
// API routes /api/volumen requests.
type API struct {
deps Deps
sitemapMu sync.Mutex
sitemapKey string
sitemapXML string
}
// New builds the API handler.
func New(deps Deps) http.Handler {
api := &API{deps: deps}
mux := http.NewServeMux()
mux.HandleFunc("OPTIONS /api/volumen/{rest...}", api.handleOptions)
mux.HandleFunc("GET /api/volumen/site", api.handleSite)
mux.HandleFunc("GET /api/volumen/posts", api.handlePosts)
mux.HandleFunc("GET /api/volumen/posts/batch", api.handleBatch)
mux.HandleFunc("GET /api/volumen/posts/{slug}", api.handleSingle)
mux.HandleFunc("POST /api/volumen/posts", api.handleCreatePost)
mux.HandleFunc("PUT /api/volumen/posts/{slug}", api.handleUpdatePost)
mux.HandleFunc("DELETE /api/volumen/posts/{slug}", api.handleDeletePost)
mux.HandleFunc("GET /api/volumen/tags", api.handleTags)
mux.HandleFunc("GET /api/volumen/tags/{tag}", api.handleTagPosts)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.xml", api.handleTagRSS)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.atom", api.handleTagAtom)
mux.HandleFunc("GET /api/volumen/tags/{tag}/feed.json", api.handleTagJSON)
mux.HandleFunc("GET /api/volumen/series", api.handleSeries)
mux.HandleFunc("GET /api/volumen/series/{name}", api.handleSeriesDetail)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.xml", api.handleSeriesRSS)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.atom", api.handleSeriesAtom)
mux.HandleFunc("GET /api/volumen/series/{name}/feed.json", api.handleSeriesJSON)
mux.HandleFunc("GET /api/volumen/feed.xml", api.handleRSS)
mux.HandleFunc("GET /api/volumen/feed.atom", api.handleAtom)
mux.HandleFunc("GET /api/volumen/feed.json", api.handleJSONFeed)
mux.HandleFunc("GET /api/volumen/sitemap.xml", api.handleSitemap)
return mux
}
var corsHeaders = map[string]string{
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
const cacheHeader = "public, max-age=60, stale-while-revalidate=21600"
// pageSizeLimit bounds the page size: the documented limit of the list
// endpoints, used both by the clamp and by the error message so the two
// cannot drift.
const pageSizeLimit = 100
// maxWriteBody bounds a write payload.
const maxWriteBody = 10 << 20
// maxPage bounds the page number so offset arithmetic stays far inside
// 32-bit int range.
const maxPage = 1_000_000
func (a *API) fire(event string, payload map[string]any) {
if a.deps.OnEvent != nil {
a.deps.OnEvent(event, payload)
}
}
func writeCORS(w http.ResponseWriter) {
for key, value := range corsHeaders {
w.Header().Set(key, value)
}
w.Header().Set("Cache-Control", cacheHeader)
}
// writeJSON writes a JSON response with CORS and cache headers.
func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
writeJSONWithHeaders(w, r, status, v, nil)
}
// writeJSONWithHeaders applies extra headers last, so write endpoints
// can override the public CORS defaults.
func writeJSONWithHeaders(w http.ResponseWriter, r *http.Request, status int, v any, extra map[string]string) {
writeCORS(w)
for key, value := range extra {
w.Header().Set(key, value)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
writeJSONBody(w, r, v, "cannot encode response")
}
// writeJSONBody writes one JSON document and the newline a line-oriented
// client expects. encoding/json/v2 escapes only what JSON requires, so a
// body keeps the characters the author wrote.
func writeJSONBody(w io.Writer, r *http.Request, v any, what string) {
if err := json.MarshalWrite(w, v, json.Deterministic(true)); err != nil {
web.Logger(r.Context()).Warn("httpapi: "+what, "error", err)
return
}
if _, err := io.WriteString(w, "\n"); err != nil {
web.Logger(r.Context()).Warn("httpapi: "+what, "error", err)
}
}
// writeError writes the uniform error envelope:
//
// {"error": "<code>", "message": "<human text>", …extras}
//
// Every failure, in the API and in the middleware, uses this shape with
// CORS headers so cross-origin clients can read it. An error is never
// publicly cacheable.
func writeError(w http.ResponseWriter, r *http.Request, status int, body map[string]any) {
writeCORS(w)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
writeJSONBody(w, r, body, "cannot encode error")
}
func writeXML(w http.ResponseWriter, r *http.Request, contentType, body string) {
writeCORS(w)
w.Header().Set("Content-Type", contentType)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
}
// etagFor hashes the canonical serialisation of a payload, so two equal
// payloads always produce one tag: without Deterministic a map inside the
// payload could serialise in a different order on the next request and
// change the tag for the same content.
func etagFor(v any) string {
raw, err := json.Marshal(v, json.Deterministic(true))
if err != nil {
return `""`
}
sum := sha256.Sum256(raw)
return `"` + hex.EncodeToString(sum[:8]) + `"`
}
func etagMatches(header, etag string) bool {
opaque := func(tag string) string {
tag = strings.TrimSpace(tag)
tag = strings.TrimPrefix(tag, "W/")
return strings.Trim(tag, `"`)
}
stored := opaque(etag)
for tag := range strings.SplitSeq(header, ",") {
if strings.TrimSpace(tag) == "*" || opaque(tag) == stored {
return true
}
}
return false
}
func maybeNotModified(w http.ResponseWriter, r *http.Request, etag string) bool {
inm := r.Header.Get("If-None-Match")
if inm == "" || !etagMatches(inm, etag) {
return false
}
writeCORS(w)
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNotModified)
return true
}
func writeJSONWithETag(w http.ResponseWriter, r *http.Request, v any) {
etag := etagFor(v)
if maybeNotModified(w, r, etag) {
return
}
w.Header().Set("ETag", etag)
writeJSON(w, r, http.StatusOK, v)
}
func (a *API) baseURL() string {
return strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
}
func (a *API) handleOptions(w http.ResponseWriter, r *http.Request) {
// The preflight answers for the whole subtree, so it advertises the
// write methods as well; a browser preflight for a cross-origin POST
// fails when the response lists only GET.
for key, value := range writeCORSHeaders(r, a.deps.Config) {
w.Header().Set(key, value)
}
w.Header().Set("Access-Control-Max-Age", "600")
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
}
func (a *API) handleSite(w http.ResponseWriter, r *http.Request) {
writeJSONWithETag(w, r, payloads.BuildSite(a.deps.Config))
}
func queryInt(r *http.Request, name string, def, lo, hi int) (int, bool) {
raw := r.URL.Query().Get(name)
if raw == "" {
return def, true
}
n, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
if n < lo || n > hi {
return 0, false
}
return n, true
}
func writeQueryValidationError(w http.ResponseWriter, r *http.Request, name, msg string) {
writeError(w, r, http.StatusUnprocessableEntity, map[string]any{
"error": "validation",
"message": msg,
"field": name,
})
}
func (a *API) handlePosts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page, ok := queryInt(r, "page", 1, 1, maxPage)
if !ok {
writeQueryValidationError(w, r, "page", "Input should be between 1 and "+strconv.Itoa(maxPage))
return
}
limit, ok := queryInt(r, "limit", 20, 1, pageSizeLimit)
if !ok {
writeQueryValidationError(w, r, "limit", "Input should be between 1 and "+strconv.Itoa(pageSizeLimit))
return
}
payload := payloads.PostsPayload(
a.deps.Store,
q.Get("lang"), q.Get("tag"), q.Get("q"),
page, limit, q.Get("cursor"),
)
writeJSONWithETag(w, r, payload)
}
func (a *API) handleBatch(w http.ResponseWriter, r *http.Request) {
slugsParam := r.URL.Query().Get("slugs")
if slugsParam == "" {
writeJSON(w, r, http.StatusOK, map[string]any{"posts": []any{}})
return
}
var slugs []string
for slug := range strings.SplitSeq(slugsParam, ",") {
if trimmed := strings.TrimSpace(slug); trimmed != "" {
slugs = append(slugs, trimmed)
}
}
if len(slugs) > pageSizeLimit {
slugs = slugs[:pageSizeLimit]
}
// One listing serves the whole batch: Find re-walks the content
// directory per call, so a hundred slugs would walk it a hundred
// times. The map keeps Find(slug, "") semantics: the first post in
// listing order that carries the slug.
posts := a.deps.Store.All()
first := make(map[string]*post.Post, len(posts))
for _, p := range posts {
if _, ok := first[p.Slug()]; !ok {
first[p.Slug()] = p
}
}
base := a.baseURL()
results := make([]payloads.Detail, 0, len(slugs))
for _, slug := range slugs {
p := first[slug]
if p == nil || !p.Published() {
continue
}
detail, err := payloads.BuildDetail(p, base)
if err != nil {
web.Logger(r.Context()).Warn("httpapi: cannot render post", "slug", slug, "error", err)
continue
}
results = append(results, detail)
}
etag := etagFor(results)
if maybeNotModified(w, r, etag) {
return
}
w.Header().Set("ETag", etag)
writeJSON(w, r, http.StatusOK, payloads.Batch{Posts: results})
}
func (a *API) validPreviewToken(token, slug string) bool {
return preview.Valid(token, slug, a.deps.PreviewKey, time.Now())
}
func (a *API) handleSingle(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
lang := r.URL.Query().Get("lang")
p := a.deps.Store.Find(slug, lang)
if p == nil {
if canonical := a.deps.Store.ResolveAlias(slug); canonical != "" {
w.Header().Set("Location", "/api/volumen/posts/"+canonical)
w.WriteHeader(http.StatusMovedPermanently)
return
}
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !p.Published() && !a.validPreviewToken(r.URL.Query().Get("preview_token"), slug) {
// A draft and a scheduled post are distinguishable on purpose:
// the client knows the slug already, and the two states need
// different handling on the other side.
if p.Draft() {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "draft"})
return
}
writeError(w, r, http.StatusNotFound, map[string]any{"error": "scheduled"})
return
}
detail, err := payloads.BuildDetail(p, a.baseURL())
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return
}
if !p.Published() {
// The URL is only valid with the preview token, but a shared cache
// would still be allowed to hold the unpublished content for the
// header's lifetime; a draft or a scheduled post is not
// publicly cacheable.
writeJSONWithHeaders(w, r, http.StatusOK, detail,
map[string]string{"Cache-Control": "no-store"})
return
}
writeJSONWithETag(w, r, detail)
}
func (a *API) handleTags(w http.ResponseWriter, r *http.Request) {
writeJSONWithETag(w, r, payloads.TagList{Tags: payloads.BuildTagCounts(a.publishedPosts())})
}
func (a *API) handleTagPosts(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
q := r.URL.Query()
page, ok := queryInt(r, "page", 1, 1, maxPage)
if !ok {
writeQueryValidationError(w, r, "page", "Input should be between 1 and "+strconv.Itoa(maxPage))
return
}
limit, ok := queryInt(r, "limit", 20, 1, 100)
if !ok {
writeQueryValidationError(w, r, "limit", "Input should be between 1 and "+strconv.Itoa(pageSizeLimit))
return
}
payload := payloads.PostsPayload(a.deps.Store, q.Get("lang"), tag, "", page, limit, q.Get("cursor"))
if payloads.IsEmpty(payload) {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeJSONWithETag(w, r, payload)
}
func (a *API) handleSeries(w http.ResponseWriter, r *http.Request) {
writeJSON(w, r, http.StatusOK, payloads.SeriesList{Series: payloads.BuildSeriesList(a.deps.Store)})
}
func (a *API) handleSeriesDetail(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
summaries := make([]payloads.Summary, 0, len(posts))
for _, p := range posts {
summaries = append(summaries, payloads.BuildSummary(p))
}
writeJSON(w, r, http.StatusOK, payloads.SeriesDetail{
Name: name,
Count: len(posts),
Posts: summaries,
})
}
func (a *API) publishedPosts() []*post.Post {
return payloads.PublishedPosts(a.deps.Store)
}
func (a *API) postsWithTag(tag string) []*post.Post {
var out []*post.Post
for _, p := range a.publishedPosts() {
if slices.Contains(p.Tags(), tag) {
out = append(out, p)
}
}
return out
}
func (a *API) handleTagRSS(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(posts, a.deps.Config.Site, a.baseURL(), tagFeedPath(tag, "xml")))
}
func (a *API) handleTagAtom(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(posts, a.deps.Config.Site, a.baseURL(), tagFeedPath(tag, "atom")))
}
func (a *API) handleTagJSON(w http.ResponseWriter, r *http.Request) {
tag := r.PathValue("tag")
posts := a.postsWithTag(tag)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.writeJSONFeed(w, r, posts, tagFeedPath(tag, "json"))
}
func (a *API) handleSeriesRSS(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(posts, a.deps.Config.Site, a.baseURL(), seriesFeedPath(name, "xml")))
}
func (a *API) handleSeriesAtom(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(posts, a.deps.Config.Site, a.baseURL(), seriesFeedPath(name, "atom")))
}
func (a *API) handleSeriesJSON(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
posts := payloads.SeriesPosts(a.deps.Store, name)
if len(posts) == 0 {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.writeJSONFeed(w, r, posts, seriesFeedPath(name, "json"))
}
func (a *API) handleRSS(w http.ResponseWriter, r *http.Request) {
writeXML(w, r, "application/rss+xml",
feeds.RenderRSSFeed(a.publishedPosts(), a.deps.Config.Site, a.baseURL(), siteFeedPath("xml")))
}
func (a *API) handleAtom(w http.ResponseWriter, r *http.Request) {
writeXML(w, r, "application/atom+xml",
feeds.RenderAtomFeed(a.publishedPosts(), a.deps.Config.Site, a.baseURL(), siteFeedPath("atom")))
}
func (a *API) handleJSONFeed(w http.ResponseWriter, r *http.Request) {
a.writeJSONFeed(w, r, a.publishedPosts(), siteFeedPath("json"))
}
// Feed paths, used for the self link and feed_url of each document.
func siteFeedPath(format string) string { return "/api/volumen/feed." + format }
func tagFeedPath(tag, format string) string {
return "/api/volumen/tags/" + url.PathEscape(tag) + "/feed." + format
}
func seriesFeedPath(name, format string) string {
return "/api/volumen/series/" + url.PathEscape(name) + "/feed." + format
}
func (a *API) writeJSONFeed(w http.ResponseWriter, r *http.Request, posts []*post.Post, selfPath string) {
body, err := feeds.MarshalJSONFeed(feeds.RenderJSONFeed(posts, a.deps.Config.Site, a.baseURL(), selfPath))
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return
}
writeCORS(w)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(append(body, '\n'))
}
func (a *API) handleSitemap(w http.ResponseWriter, r *http.Request) {
// The key is the store's snapshot, which changes whenever a post file
// is added, edited, touched or removed: the sitemap's lastmod comes
// from the file's modification time, so keying on the path and date
// alone would serve a stale lastmod for the life of the process.
// The key is taken before the posts: content changing between the two
// reads would pin XML rendered from the older snapshot under the newer
// key, and the cache would serve it until the next change. Key-first,
// the worst case is XML newer than its key, which recomputes.
key := a.deps.Store.CacheKey()
posts := a.publishedPosts()
a.sitemapMu.Lock()
if a.sitemapXML != "" && a.sitemapKey == key {
xml := a.sitemapXML
a.sitemapMu.Unlock()
writeXML(w, r, "application/xml", xml)
return
}
a.sitemapMu.Unlock()
xml := feeds.RenderSitemap(posts, a.baseURL())
a.sitemapMu.Lock()
a.sitemapKey = key
a.sitemapXML = xml
a.sitemapMu.Unlock()
writeXML(w, r, "application/xml", xml)
}
// --- token-authenticated writes ---------------------------------------------
var writeFields = []string{
"title", "slug", "lang", "author", "fediverse_creator",
"excerpt", "cover", "cover_alt", "cover_caption", "series",
}
func (a *API) requireToken(w http.ResponseWriter, r *http.Request, scope string) (*tokens.Token, bool) {
header := r.Header.Get("Authorization")
scheme, raw, found := strings.Cut(header, " ")
if !found || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(raw) == "" {
writeUnauthorized(w, r)
return nil, false
}
token := a.deps.Tokens.Authenticate(strings.TrimSpace(raw))
if token == nil {
writeUnauthorized(w, r)
return nil, false
}
a.deps.Tokens.Touch(token.Name)
if !token.HasScope(scope) {
writeError(w, r, http.StatusForbidden, map[string]any{
"error": "forbidden",
"message": fmt.Sprintf("Token lacks '%s' scope", scope),
})
return nil, false
}
return token, true
}
// writeUnauthorized answers a missing or invalid token. The challenge
// goes out with the status line: a header set after WriteHeader never
// reaches the client.
func writeUnauthorized(w http.ResponseWriter, r *http.Request) {
writeJSONWithHeaders(w, r, http.StatusUnauthorized,
map[string]any{"error": "unauthorized"},
map[string]string{"WWW-Authenticate": "Bearer", "Cache-Control": "no-store"})
}
// writeCORSHeaders builds the restrictive CORS header set for
// token-authenticated write endpoints; only the configured base_url is
// allowed as an origin (with a wildcard fallback for setups without
// one).
func writeCORSHeaders(r *http.Request, cfg *config.Config) map[string]string {
base := strings.TrimRight(cfg.Site.BaseURL, "/")
origin := r.Header.Get("Origin")
allowed := "*"
if base != "" && origin != "" {
allowed = base
}
return map[string]string{
"Access-Control-Allow-Origin": allowed,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Cache-Control": "no-store",
}
}
// readJSONBody decodes a write payload. The decoder rejects a duplicate
// object member and invalid UTF-8, so a body that a JSON parser could
// read two ways is a 400 rather than a silent choice. A body over the
// limit is a 413, not a parse failure.
func readJSONBody(w http.ResponseWriter, r *http.Request) (map[string]any, bool) {
var data map[string]any
if err := json.UnmarshalRead(http.MaxBytesReader(w, r.Body, maxWriteBody), &data); err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
writeError(w, r, http.StatusRequestEntityTooLarge, map[string]any{"error": "payload_too_large"})
return nil, false
}
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "invalid_json"})
return nil, false
}
return data, true
}
// postFromJSON merges a JSON write payload into a post: omitted fields
// keep their values on update, an explicit null or empty string clears a
// field, and a malformed type is rejected with a validation message
// rather than coerced.
func postFromJSON(data map[string]any, existing *post.Post) (*post.Post, error) {
meta := frontmatterMetaFrom(existing)
body := ""
if existing != nil {
body = existing.Body
}
if rawBody, present := data["body"]; present {
// An explicit null clears the body, as it does every metadata
// field; keeping the stored text would contradict the merge
// contract the comment above documents.
if rawBody == nil {
body = ""
} else {
text, ok := rawBody.(string)
if !ok {
return nil, &payloads.ValidationError{Message: "body must be a string"}
}
body = text
}
}
bad := func(message string) (*post.Post, error) { return nil, &payloads.ValidationError{Message: message} }
for _, field := range writeFields {
value, present := data[field]
if !present {
continue
}
switch v := value.(type) {
case string:
if strings.TrimSpace(v) != "" {
meta.Set(field, strings.TrimSpace(v))
} else {
meta.Delete(field)
}
case nil:
meta.Delete(field)
default:
return bad(fmt.Sprintf("%s must be a string", field))
}
}
for _, dateField := range []string{"date", "publish_at"} {
value, present := data[dateField]
if !present {
continue
}
if parsed, ok := payloads.ParseDate(value); ok {
meta.Set(dateField, interpres.LocalDate{Time: parsed})
} else if value == nil || value == "" {
meta.Delete(dateField)
} else {
return bad(fmt.Sprintf("%s must be an ISO 8601 date", dateField))
}
}
if value, present := data["tags"]; present {
switch v := value.(type) {
case []any:
var cleaned []string
for _, item := range v {
// A malformed item is rejected, not coerced: fmt.Sprintf
// would turn null into the tag "<nil>".
s, ok := item.(string)
if !ok {
return bad("tags must be a list of strings")
}
if trimmed := strings.TrimSpace(s); trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
if len(cleaned) > 0 {
meta.Set("tags", cleaned)
} else {
meta.Delete("tags")
}
case string:
if strings.TrimSpace(v) != "" {
meta.Set("tags", payloads.ParseTags(v))
} else {
meta.Delete("tags")
}
case nil:
meta.Delete("tags")
default:
return bad("tags must be a list of strings")
}
}
for _, boolField := range []string{"draft", "all_langs"} {
value, present := data[boolField]
if !present {
continue
}
b, ok := value.(bool)
if !ok {
return bad(fmt.Sprintf("%s must be a boolean", boolField))
}
if b {
meta.Set(boolField, true)
} else {
meta.Delete(boolField)
}
}
if value, present := data["series_order"]; present {
switch v := value.(type) {
case nil:
meta.Delete("series_order")
case bool:
return bad("series_order must be an integer")
case float64:
if v != float64(int64(v)) {
return bad("series_order must be an integer")
}
meta.Set("series_order", int64(v))
case string:
if strings.TrimSpace(v) == "" {
meta.Delete("series_order")
break
}
n, ok := payloads.ParseInt(v)
if !ok {
return bad("series_order must be an integer")
}
meta.Set("series_order", int64(n))
default:
return bad("series_order must be an integer")
}
}
p := post.New(meta, body)
if existing != nil {
p.Path = existing.Path
}
return p, nil
}
func frontmatterMetaFrom(existing *post.Post) *frontmatter.Meta {
meta := frontmatter.NewMeta()
if existing != nil {
for _, key := range existing.Metadata.Keys() {
value, _ := existing.Metadata.Get(key)
meta.Set(key, value)
}
}
return meta
}
func (a *API) handleCreatePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "write"); !ok {
return
}
data, ok := readJSONBody(w, r)
if !ok {
return
}
p, err := postFromJSON(data, nil)
if err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
saved, err := a.deps.Store.Save(p)
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "save_failed"})
return
}
summary := payloads.BuildSummary(saved)
a.fire("post.created", map[string]any{"post": summary})
headers := writeCORSHeaders(r, a.deps.Config)
// The ETag names the resource state the single-post GET serves, so a
// client can chain the create straight into an If-Match write.
if detail, err := payloads.BuildDetail(saved, a.baseURL()); err == nil {
headers["ETag"] = etagFor(detail)
}
writeJSONWithHeaders(w, r, http.StatusCreated, summary, headers)
}
// preconditionHolds checks the request's If-Match against the ETag the
// single-post GET serves for the same resource, so a client that read
// the post, edited it and writes it back fails instead of overwriting a
// change it never saw. No header is unconditional; `*` demands the post
// exists, which the caller has already established.
func (a *API) preconditionHolds(w http.ResponseWriter, r *http.Request, existing *post.Post) bool {
header := r.Header.Get("If-Match")
if header == "" {
return true
}
detail, err := payloads.BuildDetail(existing, a.baseURL())
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "render_failed"})
return false
}
if etagMatches(header, etagFor(detail)) {
return true
}
writeError(w, r, http.StatusPreconditionFailed, map[string]any{"error": "precondition_failed"})
return false
}
func (a *API) handleUpdatePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "write"); !ok {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !a.preconditionHolds(w, r, existing) {
return
}
data, ok := readJSONBody(w, r)
if !ok {
return
}
p, err := postFromJSON(data, existing)
if err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
if p.Slug() == "" {
p.Metadata.Set("slug", slug)
}
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
writeError(w, r, http.StatusBadRequest, map[string]any{"error": "validation", "message": err.Error()})
return
}
// A post whose language came from its directory (not the frontmatter)
// keeps it through a rename: the payload set no lang, so the new
// default path would otherwise drop the language subdirectory and
// silently move the post into the default language.
if p.Lang() == "" {
p.SetFileLocation(existing.Slug(), existing.Lang())
}
// SavePost moves the file when the slug changed and archives the old
// one under its own language, the same way the admin editor does, so
// a rename behaves alike from either entry point.
saved, err := payloads.SavePost(a.deps.Store, p, existing)
if err != nil {
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "save_failed"})
return
}
summary := payloads.BuildSummary(saved)
a.fire("post.updated", map[string]any{"post": summary})
headers := writeCORSHeaders(r, a.deps.Config)
if detail, err := payloads.BuildDetail(saved, a.baseURL()); err == nil {
headers["ETag"] = etagFor(detail)
}
writeJSONWithHeaders(w, r, http.StatusOK, summary, headers)
}
func (a *API) handleDeletePost(w http.ResponseWriter, r *http.Request) {
if _, ok := a.requireToken(w, r, "delete"); !ok {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
if !a.preconditionHolds(w, r, existing) {
return
}
deleted, _, err := a.deps.Store.Delete(slug, "")
if err != nil {
web.Logger(r.Context()).Error("httpapi: cannot delete post", "slug", slug, "error", err)
writeError(w, r, http.StatusInternalServerError, map[string]any{"error": "delete_failed"})
return
}
if deleted == nil {
writeError(w, r, http.StatusNotFound, map[string]any{"error": "not_found"})
return
}
a.fire("post.deleted", map[string]any{"post": map[string]any{
"slug": deleted.Slug(), "title": deleted.Title(),
}})
for key, value := range writeCORSHeaders(r, a.deps.Config) {
w.Header().Set(key, value)
}
w.WriteHeader(http.StatusNoContent)
}
+951
View File
@@ -0,0 +1,951 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package httpapi
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
)
type fixture struct {
handler http.Handler
store *store.Store
tokens *tokens.Store
events []string
}
func newFixture(t *testing.T, files map[string]string) *fixture {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
for name, body := range files {
path := filepath.Join(content, name)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Host: "",
Port: -1,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
if err != nil {
t.Fatalf("config: %v", err)
}
cfg.Site.BaseURL = "https://site.example"
cfg.Admin.SessionKey = strings.Repeat("k", 64)
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
f := &fixture{store: st, tokens: tokens.New(filepath.Join(dir, "tokens.toml"))}
f.handler = New(Deps{
Config: cfg,
Store: st,
Tokens: f.tokens,
PreviewKey: cfg.Admin.SessionKey,
OnEvent: func(event string, _ map[string]any) {
f.events = append(f.events, event)
},
})
return f
}
func (f *fixture) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
f.handler.ServeHTTP(rec, req)
return rec
}
func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("invalid JSON %q: %v", rec.Body.String(), err)
}
return out
}
const helloFile = `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "cs"
tags = ["go"]
+++
Hello **body**.
`
const draftFile = `+++
title = "Draft"
slug = "draft"
draft = true
+++
draft body
`
const scheduledFile = `+++
title = "Future"
slug = "future"
publish_at = 2999-01-01
+++
future body
`
func TestSiteAndETag(t *testing.T) {
f := newFixture(t, nil)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
if body["title"] != config.DefaultSiteTitle || body["base_url"] != "https://site.example" {
t.Fatalf("site = %v", body)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Fatal("CORS missing")
}
if !strings.Contains(rec.Header().Get("Cache-Control"), "max-age=60") {
t.Fatalf("cache-control = %q", rec.Header().Get("Cache-Control"))
}
etag := rec.Header().Get("ETag")
if etag == "" {
t.Fatal("ETag missing")
}
req2 := httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)
req2.Header.Set("If-None-Match", etag)
rec2 := f.do(t, req2)
if rec2.Code != http.StatusNotModified {
t.Fatalf("code = %d, want 304", rec2.Code)
}
if rec2.Header().Get("ETag") != etag {
t.Fatal("ETag lost on 304")
}
// Weak comparison: W/ prefix and lists still match.
req3 := httptest.NewRequest(http.MethodGet, "/api/volumen/site", nil)
req3.Header.Set("If-None-Match", "W/"+etag+", \"other\"")
if rec3 := f.do(t, req3); rec3.Code != http.StatusNotModified {
t.Fatalf("weak compare failed: %d", rec3.Code)
}
}
func TestPostsPaginationAndFilters(t *testing.T) {
f := newFixture(t, map[string]string{
"hello.md": helloFile,
"draft.md": draftFile,
"scheduled.md": scheduledFile,
})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts", nil))
body := decodeJSON(t, rec)
if body["total"] != float64(1) {
t.Fatalf("total = %v", body["total"])
}
posts := body["posts"].([]any)
first := posts[0].(map[string]any)
if first["slug"] != "hello" {
t.Fatalf("post = %v", first)
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts?lang=cs&tag=go&q=hello", nil))
if decodeJSON(t, rec)["total"] != float64(1) {
t.Fatal("filters wrong")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts?lang=en", nil))
if decodeJSON(t, rec)["total"] != float64(0) {
t.Fatal("lang filter wrong")
}
}
func TestPostsQueryValidation(t *testing.T) {
f := newFixture(t, nil)
for _, path := range []string{
"/api/volumen/posts?page=0",
"/api/volumen/posts?page=abc",
"/api/volumen/posts?limit=0",
"/api/volumen/posts?limit=101",
} {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("%s: code = %d, want 422", path, rec.Code)
}
body := decodeJSON(t, rec)
if body["error"] != "validation" || body["field"] == nil {
t.Fatalf("%s: body = %v", path, body)
}
}
}
func TestBatch(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile, "draft.md": draftFile})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/batch", nil))
if decodeJSON(t, rec)["posts"] == nil {
t.Fatal("empty batch wrong")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/batch?slugs=hello,draft,missing", nil))
body := decodeJSON(t, rec)
posts := body["posts"].([]any)
if len(posts) != 1 {
t.Fatalf("posts = %v", posts)
}
first := posts[0].(map[string]any)
if first["slug"] != "hello" || first["html"] == nil || first["meta"] == nil {
t.Fatalf("detail = %v", first)
}
if rec.Header().Get("ETag") == "" {
t.Fatal("ETag missing on batch")
}
}
func TestSinglePostAndAliases(t *testing.T) {
f := newFixture(t, map[string]string{
"hello.md": "+++\ntitle = \"Hi\"\nslug = \"new\"\naliases = [\"old\"]\n+++\nbody\n",
})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/new", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if decodeJSON(t, rec)["title"] != "Hi" {
t.Fatal("wrong post")
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/old", nil))
if rec.Code != http.StatusMovedPermanently ||
rec.Header().Get("Location") != "/api/volumen/posts/new" {
t.Fatalf("alias redirect: %d %q", rec.Code, rec.Header().Get("Location"))
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/nope", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if decodeJSON(t, rec)["error"] != "not_found" {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestDraftAndScheduledHiddenUnlessPreview(t *testing.T) {
f := newFixture(t, map[string]string{"draft.md": draftFile, "scheduled.md": scheduledFile})
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("draft visible: %d", rec.Code)
}
if decodeJSON(t, rec)["error"] != "draft" {
t.Fatalf("body = %s", rec.Body.String())
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/future", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("scheduled visible: %d", rec.Code)
}
// Valid preview token reveals the draft.
token := preview.Token("draft", strings.Repeat("k", 64), time.Now())
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token="+token, nil))
if rec.Code != http.StatusOK {
t.Fatalf("preview failed: %d %s", rec.Code, rec.Body.String())
}
// Garbage token does not.
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token=bogus", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("bogus token accepted: %d", rec.Code)
}
}
func TestTagsAndSeries(t *testing.T) {
f := newFixture(t, map[string]string{
"a.md": "+++\nslug = \"a\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 1\ndate = 2026-01-01\n+++\nx\n",
"b.md": "+++\nslug = \"b\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 2\ndate = 2026-01-02\n+++\nx\n",
})
body := decodeJSON(t, f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags", nil)))
tags := body["tags"].([]any)
if len(tags) != 1 || tags[0].(map[string]any)["name"] != "go" {
t.Fatalf("tags = %v", tags)
}
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go", nil))
if decodeJSON(t, rec)["total"] != float64(2) {
t.Fatal("tag posts wrong")
}
if rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/none", nil)); rec.Code != http.StatusNotFound {
t.Fatalf("unknown tag: %d", rec.Code)
}
body = decodeJSON(t, f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series", nil)))
series := body["series"].([]any)
if len(series) != 1 || series[0].(map[string]any)["name"] != "S" {
t.Fatalf("series = %v", series)
}
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series/S", nil))
body = decodeJSON(t, rec)
if body["count"] != float64(2) {
t.Fatalf("series detail = %v", body)
}
if rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/series/none", nil)); rec.Code != http.StatusNotFound {
t.Fatalf("unknown series: %d", rec.Code)
}
}
const seriesFile = `+++
title = "Second"
slug = "second"
date = 2026-08-19
series = "S"
series_order = 1
tags = ["go"]
+++
Second body.`
func TestFeedsAndSitemap(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile, "second.md": seriesFile})
cases := map[string]string{
"/api/volumen/feed.xml": "application/rss+xml",
"/api/volumen/feed.atom": "application/atom+xml",
"/api/volumen/feed.json": "application/json",
"/api/volumen/sitemap.xml": "application/xml",
"/api/volumen/tags/go/feed.xml": "application/rss+xml",
"/api/volumen/tags/go/feed.atom": "application/atom+xml",
"/api/volumen/tags/go/feed.json": "application/json",
}
for path, contentType := range cases {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != contentType {
t.Fatalf("%s: content-type = %q, want %q", path, got, contentType)
}
}
// JSON feed keeps literal characters.
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/feed.json", nil))
if strings.Contains(rec.Body.String(), `&`) {
t.Fatal("JSON feed HTML-escaped")
}
// Series feeds render the series, not the whole site, and name
// themselves in the self link.
for path, contentType := range map[string]string{
"/api/volumen/series/S/feed.xml": "application/rss+xml",
"/api/volumen/series/S/feed.atom": "application/atom+xml",
"/api/volumen/series/S/feed.json": "application/json",
} {
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != contentType {
t.Fatalf("%s: content-type = %q, want %q", path, got, contentType)
}
body := rec.Body.String()
if !strings.Contains(body, "second") {
t.Fatalf("%s does not carry the series post:\n%s", path, body)
}
if strings.Contains(body, "hello") {
t.Fatalf("%s carries a post outside the series:\n%s", path, body)
}
if !strings.Contains(body, "/api/volumen/series/S/feed.") {
t.Fatalf("%s does not name itself:\n%s", path, body)
}
}
// A tag feed carries the tagged posts and names itself; a tag feed
// for an unknown tag is a 404.
tagFeed := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go/feed.json", nil))
if body := tagFeed.Body.String(); !strings.Contains(body, "hello") ||
!strings.Contains(body, "/api/volumen/tags/go/feed.json") {
t.Fatalf("tag json feed = %s", body)
}
tagAtom := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/tags/go/feed.atom", nil))
if body := tagAtom.Body.String(); !strings.Contains(body, "/api/volumen/tags/go/feed.atom") {
t.Fatalf("tag atom feed does not name itself: %s", body)
}
for _, path := range []string{
"/api/volumen/series/none/feed.xml",
"/api/volumen/series/none/feed.atom",
"/api/volumen/series/none/feed.json",
"/api/volumen/tags/none/feed.json",
"/api/volumen/tags/none/feed.atom",
} {
if rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil)); rec.Code != http.StatusNotFound {
t.Fatalf("%s: code = %d", path, rec.Code)
}
}
}
func TestOptionsPreflight(t *testing.T) {
f := newFixture(t, nil)
rec := f.do(t, httptest.NewRequest(http.MethodOptions, "/api/volumen/posts", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "*" ||
!strings.Contains(rec.Header().Get("Access-Control-Allow-Methods"), "GET") {
t.Fatalf("headers = %v", rec.Header())
}
}
// An If-Match write is refused with 412 when the etag the client holds
// no longer names the stored state, and accepted when it does. The
// guard is opt-in: a write without the header stays unconditional.
// Frontmatter keys the engine does not consume pass through to the
// detail payload's fields object; a post without any omits the member.
func TestCustomFieldsPassThrough(t *testing.T) {
files := map[string]string{
"hello.md": helloFile,
"custom.md": `+++
title = "Custom"
slug = "custom"
date = 2026-08-18
[colour]
accent = "#0f0"
depths = [1, 2, 3]
[ratings]
good = 5
[[items]]
n = 1
+++`,
}
f := newFixture(t, files)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/custom", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
fields, ok := body["fields"].(map[string]any)
if !ok {
t.Fatalf("fields missing: %s", rec.Body.String())
}
colour, ok := fields["colour"].(map[string]any)
if !ok || colour["accent"] != "#0f0" {
t.Fatalf("colour = %v", fields["colour"])
}
if depths, _ := colour["depths"].([]any); len(depths) != 3 {
t.Fatalf("depths = %v", colour["depths"])
}
if ratings, _ := fields["ratings"].(map[string]any); ratings["good"] != float64(5) {
t.Fatalf("ratings = %v", fields["ratings"])
}
if items, _ := fields["items"].([]any); len(items) != 1 {
t.Fatalf("items = %v", fields["items"])
}
// A known key is never duplicated into fields.
if _, present := fields["title"]; present {
t.Fatalf("known key leaked into fields: %v", fields)
}
// A post without custom frontmatter carries no fields member.
rec = f.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil))
if strings.Contains(rec.Body.String(), `"fields"`) {
t.Fatalf("empty fields leaked: %s", rec.Body.String())
}
}
// A search ranks by relevance: a title hit leads, a tag that contains
// the query is now found at all, and a body-only mention trails; the
// date order alone would read the other way round.
func TestSearchRanksByRelevance(t *testing.T) {
searchPost := func(slug, title, tags, date, body string) string {
return fmt.Sprintf(`+++
title = %q
slug = %q
lang = "en"
date = %s
tags = [%q]
+++
%s
`, title, slug, date, tags, body)
}
files := map[string]string{
"title-hit.md": searchPost("title-hit", "WebP guide", "images", "2026-08-01", "nothing relevant here"),
"tag-hit.md": searchPost("tag-hit", "Unrelated one", "webp", "2026-08-02", "nothing relevant here"),
"body-hit.md": searchPost("body-hit", "Unrelated two", "images", "2026-08-03", "the webp format is lovely"),
}
f := newFixture(t, files)
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts?q=webp", nil)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
posts, ok := decodeJSON(t, rec)["posts"].([]any)
if !ok || len(posts) != 3 {
t.Fatalf("posts = %v", posts)
}
want := []string{"title-hit", "tag-hit", "body-hit"}
for i, slug := range want {
if got := posts[i].(map[string]any)["slug"]; got != slug {
t.Fatalf("rank %d = %v, want %q", i, got, slug)
}
}
}
func TestIfMatchGuardsWrites(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
auth := "Bearer " + raw
servedETag := func() string {
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil)
return f.do(t, req).Header().Get("ETag")
}
fresh := servedETag()
if fresh == "" {
t.Fatal("GET served no ETag")
}
put := func(ifMatch string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/hello", strings.NewReader(`{"title":"Fresh"}`))
req.Header.Set("Authorization", auth)
if ifMatch != "" {
req.Header.Set("If-Match", ifMatch)
}
return f.do(t, req)
}
if rec := put(`"0000000000000000"`); rec.Code != http.StatusPreconditionFailed {
t.Fatalf("stale etag: code = %d body = %s", rec.Code, rec.Body.String())
}
if body := decodeJSON(t, put(`"0000000000000000"`)); body["error"] != "precondition_failed" {
t.Fatalf("body = %v", body)
}
rec := put(fresh)
if rec.Code != http.StatusOK {
t.Fatalf("fresh etag: code = %d body = %s", rec.Code, rec.Body.String())
}
stored := servedETag()
if rec.Header().Get("ETag") != stored {
t.Fatalf("response ETag %q does not name the stored state %q", rec.Header().Get("ETag"), stored)
}
if rec.Header().Get("ETag") == fresh {
t.Fatal("the etag survived an edit")
}
if rec := put("*"); rec.Code != http.StatusOK {
t.Fatalf("star etag: code = %d", rec.Code)
}
if rec := put(""); rec.Code != http.StatusOK {
t.Fatalf("no header: code = %d", rec.Code)
}
}
func TestIfMatchGuardsDelete(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
auth := "Bearer " + raw
req := httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/hello", nil)
req.Header.Set("Authorization", auth)
req.Header.Set("If-Match", `"0000000000000000"`)
if rec := f.do(t, req); rec.Code != http.StatusPreconditionFailed {
t.Fatalf("stale etag: code = %d", rec.Code)
}
get := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil)
fresh := f.do(t, get).Header().Get("ETag")
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/hello", nil)
req.Header.Set("Authorization", auth)
req.Header.Set("If-Match", fresh)
if rec := f.do(t, req); rec.Code != http.StatusNoContent {
t.Fatalf("fresh etag: code = %d", rec.Code)
}
}
func TestWriteEndpointsRequireToken(t *testing.T) {
f := newFixture(t, map[string]string{"hello.md": helloFile})
rec := f.do(t, httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`)))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
if rec.Header().Get("WWW-Authenticate") != "Bearer" {
t.Fatal("WWW-Authenticate missing")
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer vol_bogus")
if rec := f.do(t, req); rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
}
func TestWriteEndpointsScopeEnforced(t *testing.T) {
f := newFixture(t, nil)
// A token that carries only the delete scope may not write.
_, raw, err := f.tokens.Create("scoped", []string{"delete"})
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`{}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("code = %d", rec.Code)
}
body := decodeJSON(t, rec)
if message, _ := body["message"].(string); !strings.Contains(message, "write") {
t.Fatalf("body = %v", body)
}
}
func TestCreateUpdateDeletePost(t *testing.T) {
f := newFixture(t, nil)
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
// Invalid payload rejected.
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "Upper"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
// Valid create.
req = httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "created", "title": "Created", "body": "hello", "tags": ["go", "blog"]}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if decodeJSON(t, rec)["title"] != "Created" {
t.Fatal("wrong payload")
}
if len(f.events) != 1 || f.events[0] != "post.created" {
t.Fatalf("events = %v", f.events)
}
if f.store.Find("created", "") == nil {
t.Fatal("post not saved")
}
// Partial update keeps omitted fields.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"title": "Updated"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
p := f.store.Find("created", "")
// The body keeps the trailing newline the writer normalises, exactly
// a second round-trip must produce the same document.
if p.Title() != "Updated" || len(p.Tags()) != 2 || p.Body != "hello\n" {
t.Fatalf("title=%q tags=%v body=%q", p.Title(), p.Tags(), p.Body)
}
// Clearing a field with null.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"title": null}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
if f.store.Find("created", "").Title() != "" {
t.Fatal("title not cleared")
}
// Malformed types rejected.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/created",
strings.NewReader(`{"draft": "yes"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
// Unknown slug.
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/ghost",
strings.NewReader(`{"title": "x"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
// Invalid JSON body.
req = httptest.NewRequest(http.MethodPost, "/api/volumen/posts", strings.NewReader(`not json`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
// Delete.
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/created", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec = f.do(t, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("code = %d", rec.Code)
}
if f.store.Find("created", "") != nil {
t.Fatal("post not deleted")
}
if f.events[len(f.events)-1] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
req = httptest.NewRequest(http.MethodDelete, "/api/volumen/posts/created", nil)
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestUpdateRenameSoftDeletesOldFile(t *testing.T) {
f := newFixture(t, map[string]string{
"old.md": "+++\ntitle = \"Old\"\nslug = \"old\"\n+++\nbody\n",
})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/old",
strings.NewReader(`{"slug": "new-name"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if f.store.Find("new-name", "") == nil {
t.Fatal("renamed post missing")
}
if f.store.Find("old", "") != nil {
t.Fatal("old slug still resolves")
}
if f.store.TombstonePath("old") == "" {
t.Fatal("old file not soft-deleted")
}
if restored := f.store.Undelete("old"); restored == nil {
t.Fatal("rename not undoable")
}
}
func TestPreviewTokenShape(t *testing.T) {
now := time.Now()
// 32 hex characters plus an expiry stamp, stable for the same slug,
// secret and day.
token := preview.Token("hello", "secret", now)
if len(token) != 32+1+10 {
t.Fatalf("token = %q", token)
}
if token != preview.Token("hello", "secret", now) {
t.Fatal("token not stable")
}
if token == preview.Token("other", "secret", now) {
t.Fatal("token ignores slug")
}
if !preview.Valid(token, "hello", "secret", now) {
t.Fatal("fresh token rejected")
}
if preview.Valid(token, "hello", "secret", now.Add(preview.TTL+time.Hour)) {
t.Fatal("expired token accepted")
}
if preview.Valid(token, "hello", "other", now) {
t.Fatal("token accepted with the wrong key")
}
if preview.Token("hello", "", now) != "" {
t.Fatal("a token was minted without a session key")
}
}
func TestSeriesOrderBooleanRejected(t *testing.T) {
f := newFixture(t, map[string]string{"a.md": "+++\nslug = \"a\"\n+++\nx\n"})
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/a",
strings.NewReader(`{"series_order": true}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusBadRequest {
t.Fatalf("code = %d", rec.Code)
}
}
func TestWriteEndpointsKeepRestrictiveCORS(t *testing.T) {
f := newFixture(t, nil)
_, raw, err := f.tokens.Create("full", nil)
if err != nil {
t.Fatalf("Create: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "cors-check", "title": "T"}`))
req.Header.Set("Authorization", "Bearer "+raw)
req.Header.Set("Origin", "https://evil.example")
rec := f.do(t, req)
if rec.Code != http.StatusCreated {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://site.example" {
t.Fatalf("allow-origin = %q, want the configured base_url", got)
}
if methods := rec.Header().Get("Access-Control-Allow-Methods"); !strings.Contains(methods, "POST") {
t.Fatalf("allow-methods = %q", methods)
}
}
// An explicit null body clears the stored text, matching the merge
// contract every other field follows.
func TestUpdateClearsBodyWithNull(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "body-test", "title": "B", "body": "text"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusCreated {
t.Fatalf("create code = %d, body = %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/body-test",
strings.NewReader(`{"body": null}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("update code = %d, body = %s", rec.Code, rec.Body.String())
}
// The writer always ends the file with one newline, so an empty
// body reads back as exactly that.
if body := f.store.Find("body-test", "").Body; body != "\n" {
t.Fatalf("body = %q, want cleared", body)
}
}
// A tag list item that is not a string is rejected rather than coerced
// into a made-up tag such as "<nil>".
func TestUpdateRejectsNonStringTags(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "tag-test", "title": "T", "body": "x"}`))
req.Header.Set("Authorization", "Bearer "+raw)
if rec := f.do(t, req); rec.Code != http.StatusCreated {
t.Fatalf("create code = %d, body = %s", rec.Code, rec.Body.String())
}
for _, body := range []string{`{"tags": [null]}`, `{"tags": [3]}`, `{"tags": [true]}`} {
req = httptest.NewRequest(http.MethodPut, "/api/volumen/posts/tag-test", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("body %s: code = %d", body, rec.Code)
}
if decodeJSON(t, rec)["error"] != "validation" {
t.Fatalf("body %s: envelope = %s", body, rec.Body.String())
}
}
if tags := f.store.Find("tag-test", "").Tags(); len(tags) != 0 {
t.Fatalf("tags = %v, want untouched", tags)
}
}
// A body over the limit is a 413, not a parse failure.
func TestWriteBodyOverTheLimitIs413(t *testing.T) {
f := newFixture(t, nil)
_, raw, _ := f.tokens.Create("full", nil)
big := strings.Repeat("x", maxWriteBody+1)
req := httptest.NewRequest(http.MethodPost, "/api/volumen/posts",
strings.NewReader(`{"slug": "big", "body": "`+big+`"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if decodeJSON(t, rec)["error"] != "payload_too_large" {
t.Fatalf("envelope = %s", rec.Body.String())
}
}
// A preview response is not publicly cacheable: the URL is only valid
// with the token, and a shared cache must not keep unpublished content.
func TestPreviewResponseIsNotPubliclyCacheable(t *testing.T) {
f := newFixture(t, map[string]string{"draft.md": draftFile})
token := preview.Token("draft", strings.Repeat("k", 64), time.Now())
req := httptest.NewRequest(http.MethodGet, "/api/volumen/posts/draft?preview_token="+token, nil)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("preview failed: %d %s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
// The published detail stays publicly cacheable.
f2 := newFixture(t, map[string]string{"hello.md": helloFile})
rec = f2.do(t, httptest.NewRequest(http.MethodGet, "/api/volumen/posts/hello", nil))
if got := rec2CacheControl(rec); got == "no-store" {
t.Fatalf("published detail carries %q", got)
}
}
func rec2CacheControl(rec *httptest.ResponseRecorder) string {
return rec.Header().Get("Cache-Control")
}
// Renaming through the API keeps a language that came from the file's
// directory: the new file lands in the same language subtree rather
// than in the content root.
func TestRenameKeepsDirectoryLanguage(t *testing.T) {
f := newFixture(t, map[string]string{
// No lang in the frontmatter: cs comes from the directory.
"cs/hello.md": "+++\ntitle = \"Hello\"\nslug = \"hello\"\n+++\nbody\n",
})
_, raw, _ := f.tokens.Create("full", nil)
req := httptest.NewRequest(http.MethodPut, "/api/volumen/posts/hello",
strings.NewReader(`{"slug": "hi"}`))
req.Header.Set("Authorization", "Bearer "+raw)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("rename code = %d, body = %s", rec.Code, rec.Body.String())
}
renamed := f.store.Find("hi", "")
if renamed == nil {
t.Fatal("renamed post missing")
}
if renamed.Lang() != "cs" {
t.Fatalf("lang = %q, want cs", renamed.Lang())
}
if want := filepath.Join(f.store.ContentDir, "cs", "hi.md"); renamed.Path != want {
t.Fatalf("path = %q, want %q", renamed.Path, want)
}
}
+145
View File
@@ -0,0 +1,145 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package httpapi
import (
"encoding/json"
"flag"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
)
var updateContract = flag.Bool("update-contract", false, "rewrite the API contract goldens")
// contractRequests pin the public JSON API contract. Any change to a
// response body shows up as a golden diff, which is exactly the point:
// the API serves the front-end, so its shape is a contract.
var contractRequests = map[string]string{
"site": "/api/volumen/site",
"posts": "/api/volumen/posts",
"posts_page2": "/api/volumen/posts?limit=1&page=2",
"posts_cursor": "/api/volumen/posts?cursor=alpha&limit=1",
"posts_filtered": "/api/volumen/posts?tag=go&q=alpha&lang=cs",
"posts_batch": "/api/volumen/posts/batch?slugs=alpha,draft,missing",
"post_detail": "/api/volumen/posts/alpha",
"post_not_found": "/api/volumen/posts/ghost",
"post_draft": "/api/volumen/posts/draft",
"tags": "/api/volumen/tags",
"tag_posts": "/api/volumen/tags/go",
"series": "/api/volumen/series",
"series_detail": "/api/volumen/series/Series",
"feed_json": "/api/volumen/feed.json",
}
const contractPost = `+++
title = "Alpha"
slug = "alpha"
date = 2026-08-18
lang = "cs"
author = "Petr"
tags = ["go", "research"]
series = "Series"
series_order = 1
fediverse_creator = "@petr@social"
cover = "/media/c.webp"
cover_alt = "alt"
cover_caption = "caption"
aliases = ["old-alpha"]
[translations]
en = "alpha-en"
+++
Alpha **body** with a [link](https://example.com).
`
const contractSecond = `+++
title = "Beta"
slug = "beta"
date = 2026-07-01
tags = ["go"]
+++
Beta body.
`
// TestAPIContract snapshots every public JSON response. Regenerate with
// `go test ./internal/httpapi -update-contract` after an intentional
// contract change, and record it in the CHANGELOG.
func TestAPIContract(t *testing.T) {
f := newFixture(t, map[string]string{
"alpha.md": contractPost,
"beta.md": contractSecond,
"draft.md": "+++\nslug = \"draft\"\ntitle = \"Draft\"\ndraft = true\n+++\nDraft body.\n",
})
for name, path := range contractRequests {
t.Run(name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := f.do(t, req)
got := rec.Body.Bytes()
golden := filepath.Join("testdata", "contract", name+".json")
if *updateContract {
if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(golden, got, 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
return
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden (run with -update-contract): %v", err)
}
// JSON objects are unordered, so compare parsed values
// rather than bytes; keys and values must match exactly.
var gotJSON, wantJSON any
if err := json.Unmarshal(got, &gotJSON); err != nil {
t.Fatalf("response is not JSON: %v\n%s", err, got)
}
if err := json.Unmarshal(want, &wantJSON); err != nil {
t.Fatalf("golden is not JSON: %v", err)
}
if !reflect.DeepEqual(gotJSON, wantJSON) {
t.Fatalf("contract changed for %s:\n--- got ---\n%s\n--- want ---\n%s",
path, got, want)
}
})
}
}
// TestContractStatusCodes pins the status codes that accompany the
// bodies above.
func TestContractStatusCodes(t *testing.T) {
f := newFixture(t, map[string]string{
"alpha.md": contractPost,
"draft.md": "+++\nslug = \"draft\"\ndraft = true\n+++\nx\n",
})
want := map[string]int{
"/api/volumen/site": http.StatusOK,
"/api/volumen/posts": http.StatusOK,
"/api/volumen/posts/alpha": http.StatusOK,
"/api/volumen/posts/ghost": http.StatusNotFound,
"/api/volumen/posts/draft": http.StatusNotFound,
"/api/volumen/posts/old-alpha": http.StatusMovedPermanently,
"/api/volumen/tags/go": http.StatusOK,
"/api/volumen/tags/none": http.StatusNotFound,
"/api/volumen/series/None": http.StatusNotFound,
"/api/volumen/posts?page=0": http.StatusUnprocessableEntity,
"/api/volumen/posts?limit=101": http.StatusUnprocessableEntity,
"/api/volumen/posts?page=1000001": http.StatusUnprocessableEntity,
}
for path, code := range want {
req := httptest.NewRequest(http.MethodGet, path, nil)
if rec := f.do(t, req); rec.Code != code {
t.Fatalf("%s: code = %d, want %d", path, rec.Code, code)
}
}
}
+1
View File
@@ -0,0 +1 @@
{"version":"https://jsonfeed.org/version/1.1","title":"Volumen","home_page_url":"https://site.example","feed_url":"https://site.example/api/volumen/feed.json","description":"Powered by Volumen.","language":"en","items":[{"id":"https://site.example/alpha","url":"https://site.example/alpha","title":"Alpha","content_html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","summary":"Alpha body with a [link](https://example.com).","date_published":"2026-08-18","tags":["go","research"],"authors":[{"name":"@petr@social"}]},{"id":"https://site.example/beta","url":"https://site.example/beta","title":"Beta","content_html":"<p>Beta body.</p>\n","summary":"Beta body.","date_published":"2026-07-01","tags":["go"]}]}
+1
View File
@@ -0,0 +1 @@
{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha","body":"Alpha **body** with a [link](https://example.com).\n","html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","toc":"<div class=\"toc\">\n<ul></ul>\n</div>\n","meta":{"url":"https://site.example/alpha","json_ld":"{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://site.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://site.example/alpha\"}","og":{"article:author":"Petr","article:published_time":"2026-08-18","article:tag":["go","research"],"og:description":"Alpha body with a [link](https://example.com).","og:image":"/media/c.webp","og:locale":"cs","og:title":"Alpha","og:type":"article","og:url":"https://site.example/alpha"},"twitter":{"twitter:card":"summary_large_image","twitter:creator":"@petr@social","twitter:description":"Alpha body with a [link](https://example.com).","twitter:image":"/media/c.webp","twitter:title":"Alpha"}}}
+1
View File
@@ -0,0 +1 @@
{"error":"draft"}
@@ -0,0 +1 @@
{"error":"not_found"}
+1
View File
@@ -0,0 +1 @@
{"page_size":20,"total":2,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"},{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":1,"has_next":false,"has_prev":false}
+1
View File
@@ -0,0 +1 @@
{"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha","body":"Alpha **body** with a [link](https://example.com).\n","html":"<p>Alpha <strong>body</strong> with a <a rel=\"noopener noreferrer\" href=\"https://example.com\">link</a>.</p>\n","toc":"<div class=\"toc\">\n<ul></ul>\n</div>\n","meta":{"url":"https://site.example/alpha","json_ld":"{\"@context\":\"https://schema.org\",\"@type\":\"Article\",\"author\":{\"@type\":\"Person\",\"name\":\"Petr\"},\"creator\":{\"@type\":\"Person\",\"name\":\"@petr@social\"},\"dateModified\":\"2026-08-18\",\"datePublished\":\"2026-08-18\",\"description\":\"Alpha body with a [link](https://example.com).\",\"headline\":\"Alpha\",\"image\":[\"/media/c.webp\"],\"inLanguage\":\"cs\",\"keywords\":[\"go\",\"research\"],\"mainEntityOfPage\":{\"@id\":\"https://site.example/alpha\",\"@type\":\"WebPage\"},\"url\":\"https://site.example/alpha\"}","og":{"article:author":"Petr","article:published_time":"2026-08-18","article:tag":["go","research"],"og:description":"Alpha body with a [link](https://example.com).","og:image":"/media/c.webp","og:locale":"cs","og:title":"Alpha","og:type":"article","og:url":"https://site.example/alpha"},"twitter":{"twitter:card":"summary_large_image","twitter:creator":"@petr@social","twitter:description":"Alpha body with a [link](https://example.com).","twitter:image":"/media/c.webp","twitter:title":"Alpha"}}}]}
+1
View File
@@ -0,0 +1 @@
{"page_size":1,"total":2,"posts":[{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"next_cursor":null}
@@ -0,0 +1 @@
{"page_size":20,"total":1,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"}],"page":1,"has_next":false,"has_prev":false}
+1
View File
@@ -0,0 +1 @@
{"page_size":1,"total":2,"posts":[{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":2,"has_next":false,"has_prev":true}
+1
View File
@@ -0,0 +1 @@
{"series":[{"name":"Series","count":1}]}
+1
View File
@@ -0,0 +1 @@
{"name":"Series","count":1,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"}]}
+1
View File
@@ -0,0 +1 @@
{"title":"Volumen","description":"Powered by Volumen.","base_url":"https://site.example","language":"en","author":"Anonymous","fediverse_creator":""}
+1
View File
@@ -0,0 +1 @@
{"page_size":20,"total":2,"posts":[{"slug":"alpha","title":"Alpha","excerpt":"Alpha body with a [link](https://example.com).","date":"2026-08-18","lang":"cs","tags":["go","research"],"author":"Petr","fediverse_creator":"@petr@social","cover":"/media/c.webp","cover_alt":"alt","cover_caption":"caption","reading_time":1,"translations":{"en":"alpha-en"},"series":"Series","series_order":1,"url":"/api/volumen/posts/alpha"},{"slug":"beta","title":"Beta","excerpt":"Beta body.","date":"2026-07-01","lang":"en","tags":["go"],"reading_time":1,"url":"/api/volumen/posts/beta"}],"page":1,"has_next":false,"has_prev":false}
+1
View File
@@ -0,0 +1 @@
{"tags":[{"name":"go","count":2},{"name":"research","count":1}]}
+831
View File
@@ -0,0 +1,831 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package i18n holds the admin interface strings in every language the
// UI ships. Simple messages are keyed by their English text, so the
// template keeps its meaning and an untranslated key falls back to
// English rather than an identifier. Messages with a numeral use an
// explicit id and per-language plural forms, because Czech declines
// the counted noun where English does not.
//
// The catalogue never stores markup: sentences with <code>, <strong> and
// friends stay in the template, split into fragments, so every string
// the funcs return can take the template engine's escaping. The one
// exception is trh, for static sentences that only make sense whole.
//
// Some keys never appear literally outside this file: the editor
// matches payloads validation errors against the catalogue at runtime
// ("Slug is required." and friends), and the page scripts read their
// strings from the JSON dump, so a key being absent from the templates
// does not mean it is dead.
package i18n
import (
"slices"
"strconv"
"strings"
)
// Languages the admin interface ships, in the order the settings
// switch lists them.
var Languages = []string{"en", "cs"}
// Cookie carries the interface language across the login screen, where
// no account is known yet.
const Cookie = "volumen_admin_lang"
// Valid reports whether the value is a shipped language.
func Valid(lang string) bool {
return slices.Contains(Languages, lang)
}
// Normalize maps a language tag, which may carry a region ("cs-CZ"),
// onto a shipped language, or "" when nothing matches.
func Normalize(tag string) string {
tag = strings.ToLower(strings.TrimSpace(tag))
for _, l := range Languages {
if tag == l || strings.HasPrefix(tag, l+"-") {
return l
}
}
return ""
}
// pluralSet holds the counted forms of one message: one for exactly
// one, few for the Czech "2 to 4" class (22 to 24 included), other for
// the rest. English never uses few.
type pluralSet struct {
one, few, other string
}
// pluralForms are the messages that carry a numeral. Every language
// defines one and other; few exists for Czech alone.
var pluralForms = map[string]map[string]pluralSet{
"posts.total": {
"en": {one: "%d post in total", other: "%d posts in total"},
"cs": {one: "Celkem %d publikace", few: "Celkem %d publikace", other: "Celkem %d publikací"},
},
"media.count": {
"en": {one: "%d file uploaded", other: "%d files uploaded"},
"cs": {one: "Nahraný %d soubor", few: "Nahrané %d soubory", other: "Nahraných %d souborů"},
},
"revisions.count": {
"en": {one: "%d archived version", other: "%d archived versions"},
"cs": {one: "Archivovaná %d verze", few: "Archivované %d verze", other: "Archivovaných %d verzí"},
},
"posts.published": {
"en": {one: "%d post published.", other: "%d posts published."},
"cs": {one: "Zveřejněna %d publikace.", few: "Zveřejněny %d publikace.", other: "Zveřejněno %d publikací."},
},
"posts.drafted": {
"en": {one: "%d post moved to draft.", other: "%d posts moved to draft."},
"cs": {one: "Přesunuta %d publikace do konceptu.", few: "Přesunuty %d publikace do konceptu.", other: "Přesunuto %d publikací do konceptu."},
},
"posts.deleted": {
"en": {one: "%d post deleted.", other: "%d posts deleted."},
"cs": {one: "Smazána %d publikace.", few: "Smazány %d publikace.", other: "Smazáno %d publikací."},
},
"diff.unchanged": {
"en": {one: "%d unchanged line", other: "%d unchanged lines"},
"cs": {one: "%d nezměněný řádek", few: "%d nezměněné řádky", other: "%d nezměněných řádků"},
},
"password.min": {
"en": {one: "Password must be at least %d character long.", other: "Password must be at least %d characters long."},
"cs": {one: "Heslo musí mít nejméně %d znak.", few: "Heslo musí mít nejméně %d znaky.", other: "Heslo musí mít nejméně %d znaků."},
},
"password.max": {
"en": {one: "Password must be at most %d character long.", other: "Password must be at most %d characters long."},
"cs": {one: "Heslo musí mít nejvýše %d znak.", few: "Heslo musí mít nejvýše %d znaky.", other: "Heslo musí mít nejvýše %d znaků."},
},
"editor.words": {
"en": {one: "%d word", other: "%d words"},
"cs": {one: "%d slovo", few: "%d slova", other: "%d slov"},
},
"refs.count": {
"en": {one: "%d reference", other: "%d references"},
"cs": {one: "%d reference", few: "%d reference", other: "%d referencí"},
},
"editor.chars": {
"en": {one: "%d char", other: "%d chars"},
"cs": {one: "%d znak", few: "%d znaky", other: "%d znaků"},
},
"editor.reading": {
"en": {one: "%d min read", other: "%d min read"},
"cs": {one: "%d min čtení", few: "%d min čtení", other: "%d min čtení"},
},
"bulk.selected": {
"en": {one: "%d selected", other: "%d selected"},
"cs": {one: "Vybráno %d", few: "Vybráno %d", other: "Vybráno %d"},
},
"login.seconds": {
"en": {one: "Too many login attempts. Try again in %d second.", other: "Too many login attempts. Try again in %d seconds."},
"cs": {one: "Příliš mnoho pokusů o přihlášení. Zkuste to znovu za %d s.", few: "Příliš mnoho pokusů o přihlášení. Zkuste to znovu za %d s.", other: "Příliš mnoho pokusů o přihlášení. Zkuste to znovu za %d s."},
},
"deliveries.attempts": {
"en": {one: "failed (%d attempt)", other: "failed (%d attempts)"},
"cs": {one: "selhalo (%d pokus)", few: "selhalo (%d pokusy)", other: "selhalo (%d pokusů)"},
},
"posts.count": {
"en": {one: "%d post", other: "%d posts"},
"cs": {one: "%d publikace", few: "%d publikace", other: "%d publikací"},
},
"backup.files": {
"en": {one: "Backup restored (%d file).", other: "Backup restored (%d files)."},
"cs": {one: "Záloha obnovena (%d soubor).", few: "Záloha obnovena (%d soubory).", other: "Záloha obnovena (%d souborů)."},
},
}
// cs translates the simple messages, keyed by the English text the
// templates and handlers use. Markup stays out: where the template
// carries <code> or <strong>, the sentence is split into fragments.
var cs = map[string]string{
// Layout: sidebar, topbar, mobile sheet, shared dialogs.
"Content": "Obsah",
"Configure": "Nastavení",
"Posts": "Publikace",
"New post": "Nová publikace",
"Import": "Import",
"Media": "Média",
"Settings": "Nastavení",
"Log out": "Odhlásit se",
"Menu": "Nabídka",
"Main menu": "Hlavní nabídka",
"Collapse sidebar": "Sbalit postranní panel",
"Expand sidebar": "Rozbalit postranní panel",
"administration": "administrace",
"Skip to content": "Přejít na obsah",
"Breadcrumb": "Navigace",
"Colour mode": "Režim barev",
"Follow system": "Podle systému",
"Light": "Světlý",
"Dark": "Tmavý",
"Update now": "Aktualizovat",
"Details": "Podrobnosti",
"is available; you are running": "je k dispozici; provozujete",
"Update to %s now?": "Aktualizovat na %s hned?",
"The server will restart.": "Server se restartuje.",
"Are you sure?": "Opravdu?",
"Continue": "Pokračovat",
"URL": "URL",
"OK": "Budiž",
// The admin 404 page.
"Page not found": "Stránka nenalezena",
"The address does not exist; it may have been moved or deleted.": "Tato adresa neexistuje; možná byla přesunuta nebo zrušena.",
"Back to dashboard": "Zpět na přehled",
// Login page.
"Volumen · administration": "Volumen · administrace",
"Sign in": "Přihlásit se",
"Welcome back. Sign in to manage your posts.": "Vítejte zpět. Přihlaste se ke správě publikací.",
"Username": "Uživatel",
"Password": "Heslo",
"Show password": "Zobrazit heslo",
"Hide password": "Skrýt heslo",
"Invalid username or password.": "Neplatné uživatelské jméno nebo heslo.",
"Auto-unlock in {} s.": "Odemčení za {} s.",
" You can try again now.": " Můžete to zkusit znovu.",
// First-run wizard.
"Welcome to Volumen": "Vítejte ve Volumenu",
"Set up Volumen": "Nastavení Volumenu",
"Set up the administrator account to open this installation.": "Nastavte účet správce, tím se instalace otevře.",
"The page takes the colours as you choose.": "Stránka mění barvy podle vaší volby.",
"Create account": "Vytvořit účet",
"The users file cannot be read; repair it before setting up.": "Soubor uživatelů nelze přečíst; před nastavením ho opravte.",
"The account could not be created: %s": "Účet se nepodařilo vytvořit: %s",
"Weak": "Slabé",
"Fair": "Dostatečné",
"Good": "Dobré",
"Strong": "Silné",
// Posts list.
"Published": "Publikované",
"Drafts": "Koncepty",
"Scheduled": "Naplánované",
"Total": "Celkem",
"Recent published": "Nedávno publikované",
"Search title, slug, tag...": "Hledat v titulku, slagu, značce…",
"Select all posts": "Vybrat všechny publikace",
"All": "Vše",
"Filter by status": "Filtrovat podle stavu",
"Filter by tag": "Filtrovat podle značky",
"Publish": "Publikovat",
"Move to draft": "Přesunout do konceptu",
"Delete": "Smazat",
"Cancel": "Zrušit",
"Two-factor authentication": "Dvoufázové přihlášení",
"Two-factor code": "Kód dvoufázového přihlášení",
"The password is accepted; answer the second question.": "Heslo přijato; odpovězte na druhou otázku.",
"Verification code": "Ověřovací kód",
"six digits, or a recovery code": "šest číslic, nebo záložní kód",
"Six digits from your application, or one of your recovery codes.": "Šest číslic z vaší aplikace, nebo jeden ze záložních kódů.",
"Back to sign in": "Zpět na přihlášení",
"Your sign-in asks for a code from your application after the password.": "Po přihlášení heslem se žádá ještě kód z vaší aplikace.",
"Current code": "Současný kód",
"Regenerate recovery codes": "Vygenerovat záložní kódy znovu",
"Turn two-factor off? Your application will no longer be asked.": "Vypnout dvoufázové přihlášení? Vaše aplikace se už nebude dotazovat.",
"Turn off": "Vypnout",
"Scan the code with your authenticator application, or enter the secret by hand, then confirm with the code it shows.": "Naskenujte kód svou aplikací pro ověřování, nebo zadejte tajný klíč ručně, a potvrďte kódem, který ukáže.",
"Secret": "Tajný klíč",
"Verify and enable": "Ověřit a zapnout",
"Ask for a code from your authenticator application after the password. Voluntary: nothing changes until you finish the setup.": "Po hesle se bude žádat o kód z vaší ověřovací aplikace. Dobrovolné: nic se nezmění, dokud nastavení nedokončíte.",
"Set up two-factor": "Nastavit dvoufázové přihlášení",
"Wrong or expired code.": "Nesprávný nebo propadlý kód.",
"That code did not match; start again.": "Kód nesouhlasil; začněte znovu.",
"Two-factor is already on.": "Dvoufázové přihlášení už je zapnuté.",
"Two-factor could not be enabled: %s": "Dvoufázové přihlášení nešlo zapnout: %s",
"Two-factor could not be disabled: %s": "Dvoufázové přihlášení nešlo vypnout: %s",
"The codes could not be replaced: %s": "Záložní kódy nešlo vyměnit: %s",
"Two-factor is off.": "Dvoufázové přihlášení je vypnuté.",
"Two-factor is on. Store these recovery codes now; they will not be shown again.": "Dvoufázové přihlášení je zapnuté. Uschovejte si tyto záložní kódy; znovu se neukážou.",
"New recovery codes. Store them now; they will not be shown again.": "Nové záložní kódy. Uschovejte si je; znovu se neukážou.",
"No posts yet": "Zatím žádné publikace",
"Create your first post to get started.": "Začněte vytvořením první publikace.",
"Create post": "Vytvořit publikaci",
"No posts match your search": "Žádná publikace neodpovídá hledání",
"Try a different search term or status filter.": "Zkuste jiné hledané slovo nebo jiný filtr stavu.",
"Clear filters": "Vymazat filtry",
"Draft": "Koncept",
"Edit": "Upravit",
"Delete post": "Smazat publikaci",
"Delete %s?": "Smazat %s?",
"Post created.": "Publikace vytvořena.",
"Post saved.": "Publikace uložena.",
"Post deleted.": "Publikace smazána.",
"Post duplicated.": "Publikace zkopírována.",
"Post restored.": "Publikace obnovena.",
"Could not restore the post; it may already be back.": "Publikaci se nepodařilo obnovit; možná už je zpět.",
"The post could not be duplicated.": "Publikaci se nepodařilo zdvojit.",
"Undo": "Zpět",
"Undo failed.": "Obnovení se nezdařilo.",
"Delete the selected posts?": "Smazat vybrané publikace?",
"Delete this post?": "Smazat tuto publikaci?",
// Editor.
"Draft a new article in Markdown.": "Napište nový článek v Markdownu.",
"Edit post": "Úprava publikace",
"Update and publish this post.": "Upravte a zveřejněte tuto publikaci.",
"Draft, not visible to the public": "Koncept, veřejnosti nezobrazovaný",
"Scheduled for %s": "Naplánováno na %s",
"Scheduled for later": "Naplánováno na později",
"Preview": "Náhled",
"Download": "Stáhnout",
"History": "Historie",
"Duplicate": "Zkopírovat",
"Duplicate as draft": "Zkopírovat jako koncept",
"Duplicate this post as a draft?": "Zkopírovat tuto publikaci jako koncept?",
"Save": "Uložit",
"Save (Ctrl+S)": "Uložit (Ctrl+S)",
"Revision restored. The previous content was archived; see": "Verze obnovena. Předchozí obsah byl archivován; viz",
"Duplicated as draft. The new slug is": "Zkopírováno jako koncept. Nový slug je",
", adjust the title and body, then publish.": ", upravte titulek a obsah a publikujte.",
"Template": "Šablona",
"Blank": "Prázdná",
"Restore": "Obnovit",
"Discard": "Zahodit",
"Title, slug, and publishing options": "Titulek, slug a volby publikování",
"Title": "Titulek",
"A clear, descriptive title": "Výstižný, popisný titulek",
"Slug": "Slug",
"my-post-slug": "moje-publikace",
"Language": "Jazyk",
"Author": "Autor",
"Author name": "Jméno autora",
"Fediverse creator": "Autor na fediverse",
"@user@instance.tld": "@uzivatel@instance.tld",
"DOI": "DOI",
"Author ORCID": "ORCID autora",
"10.5281/zenodo.1234567": "10.5281/zenodo.1234567",
"0000-0002-1825-0097": "0000-0002-1825-0097",
"Digital Object Identifier; a doi.org URL is stored as the bare 10.… form.": "Digital Object Identifier; URL na doi.org se uloží jako holý tvar 10.…",
"The author's Open Researcher and Contributor ID, checked against its own digit.": "Open Researcher and Contributor ID autora, kontroluje se i kontrolní číslice.",
"DOI must look like 10.xxxx/suffix.": "DOI musí mít tvar 10.xxxx/sufix.",
"Bibliography": "Bibliografie",
"The numbered reference list; cite entries as [n] in the body": "Číslovaný seznam literatury; v textu citujte jako [n]",
"No references yet.": "Zatím žádné reference.",
"Add reference": "Přidat referenci",
"Insert [[refs]] marker into the body": "Vložit do textu značku [[refs]]",
"Fill the verbatim citation line, or the structured fields; the marker places the list in the body and without it the list is appended.": "Vyplňte doslovný citovaný řádek nebo strukturovaná pole; značka umístí seznam do textu a bez něj se seznam připojí na konec.",
"Move up": "Posunout výš",
"Move down": "Posunout níž",
"Remove reference": "Odebrat referenci",
"Verbatim citation": "Doslovná citace",
"Structured fields": "Strukturovaná pole",
"One author per line, optionally with [orcid:0000-0002-1825-0097]": "Každý autor na vlastním řádku, volitelně s [orcid:0000-0002-1825-0097]",
"Authors": "Autoři",
"Journal or proceedings": "Časopis nebo sborník",
"Venue": "Místo publikace",
"Year": "Rok",
"Volume": "Ročník",
"Pages": "Strany",
"arXiv": "arXiv",
"Author, title, venue, year": "Autor, titul, kde vyšlo, rok",
"Publish date": "Datum publikace",
"Schedule for": "Naplánovat na",
"Pick a date": "Vyberte datum",
"Leave empty to publish immediately. Date is interpreted as your local timezone.": "Necháte-li prázdné, publikuje se hned. Datum se interpretuje v místním časovém pásmu.",
"Markdown": "Markdown",
"Visual": "Vizuální",
"Tags": "Značky",
"comma, separated": "čárkami, oddělené",
"Series": "Série",
"e.g. rust-tutorial": "např. rust-tutorial",
"Group multi-part posts; leave empty for standalone posts": "Sdružuje díly publikací; pro samostatné nechte prázdné",
"Part number": "Číslo dílu",
"Order within the series": "Pořadí v sérii",
"Excerpt": "Perex",
"Auto-generated from the first paragraph if left empty": "Nevyplněno: vygeneruje se z prvního odstavce",
"Available in all languages": "Dostupné ve všech jazycích",
"Cover image": "Úvodní obrázek",
"Header image shown on the article page": "Obrázek v záhlaví stránky článku",
"/media/… or https://…": "/media/… nebo https://…",
"Upload": "Nahrát",
"Clear": "Vymazat",
"Today": "Dnes",
"ALT text (for accessibility)": "ALT text (pro přístupnost)",
"Caption (e.g. 'Generated by AI')": "Popisek (např. „Vygenerováno AI“)",
"Write your post in Markdown…": "Pište publikaci v Markdownu…",
"Drop image to upload": "Pusťte obrázek a nahrajte",
"Bold (Ctrl+B)": "Tučně (Ctrl+B)",
"Italic (Ctrl+I)": "Kurzíva (Ctrl+I)",
"Heading 2": "Nadpis 2",
"Heading 3": "Nadpis 3",
"Link (Ctrl+K)": "Odkaz (Ctrl+K)",
"Bullet list": "Odrážky",
"Numbered list": "Číslovaný seznam",
"Quote": "Citace",
"Code": "Kód",
"Image": "Obrázek",
"Toggle preview": "Přepnout náhled",
"✓ saved": "✓ uloženo",
"Keyboard shortcuts": "Klávesové zkratky",
"Bold": "Tučně",
"Italic": "Kurzíva",
"Link": "Odkaz",
"Save post": "Uložit publikaci",
"Show this help": "Zobrazit tuto nápovědu",
"Close modal": "Zavřít okno",
"Editor": "Editor",
"Unsaved changes from %s are available.": "K dispozici jsou neuložené změny z %s.",
"Slug is available.": "Slug je volný.",
"Already used by “%s”.": "Už ho používá „%s“.",
"Will be published immediately (date is today or earlier).": "Publikuje se okamžitě (datum je dnešní nebo dřívější).",
"Link URL": "Adresa odkazu",
"Only WebP, AVIF and SVG images are accepted (got “%s”).": "Přijímají se jen obrázky WebP, AVIF a SVG (obdrženo „%s“).",
"Upload failed:\n\n%s": "Nahrání selhalo:\n\n%s",
// History.
"Back to editor": "Zpět do editoru",
"No revisions yet": "Zatím žádné verze",
"Every time this post is saved, the previous version is archived here.": "Při každém uložení publikace se předchozí verze archivuje tady.",
"Saved": "Uloženo",
"Size": "Velikost",
"Actions": "Akce",
"Restore this revision? The current content will be archived first.": "Obnovit tuto verzi? Současný obsah se nejdřív archivuje.",
"Compare": "Porovnat",
"Back to history": "Zpět do historie",
"Changes": "Změny",
"Archived version": "Archivovaná verze",
"compared with the current content.": "srovnáno se současným obsahem.",
"No differences from the current content.": "Žádné rozdíly oproti současnému obsahu.",
"removed": "odebráno",
"added": "přidáno",
"Upcoming": "Nadcházející",
// Import.
"Import post": "Import publikace",
"frontmatter": "hlavička",
"Bring a post written anywhere: a Markdown file with optional TOML frontmatter.": "Přineste publikaci psanou kdekoliv: soubor Markdown s volitelnou TOML hlavičkou.",
"Drop your post here": "Pusťte sem svou publikaci",
"or click to browse": "nebo klikněte a vyberte",
"Remove": "Odebrat",
"No frontmatter found; the slug will be derived from the file name.": "Hlavička nenalezena; slug se odvodí z názvu souboru.",
"Only .md files are accepted, got “%s”": "Přijímají se jen soubory .md, obdrženo „%s“",
// Media.
"No media yet": "Zatím žádná média",
"Upload an image to use as a cover or inline content. WebP, AVIF and SVG are supported.": "Nahrajte obrázek jako úvodní nebo do obsahu. Podporovány jsou WebP, AVIF a SVG.",
"You can also paste or drop an image directly into the editor body.": "Obrázek můžete také vložit nebo přetáhnout přímo do těla editoru.",
"Upload images": "Nahrát obrázky",
"Drop images here, or click to browse": "Pusťte sem obrázky, nebo klikněte a vyberte",
"WebP, AVIF or SVG, up to 10 MB each.": "WebP, AVIF nebo SVG, každý nejvýše 10 MB.",
"Search files...": "Hledat soubory…",
"No files match your search.": "Žádný soubor neodpovídá hledání.",
"Copy link": "Kopírovat odkaz",
"Open in new tab": "Otevřít v nové kartě",
"Link copied.": "Odkaz zkopírován.",
"Copy the link:": "Zkopírujte odkaz:",
"Upload failed: %s": "Nahrání selhalo: %s",
"Upload complete.": "Nahrání dokončeno.",
"Uploading %s…": "Nahrávám %s…",
// Settings, shared.
"Manage your account and users": "Spravujte svůj účet a uživatele",
"Account": "Účet",
"Users": "Uživatelé",
"Templates": "Šablony",
"Backup": "Záloha",
"Version": "Verze",
"Webhooks": "Webhooky",
"API tokens": "API tokeny",
"Signed in as": "Přihlášen jako",
// Account section.
"Profile photo": "Profilová fotka",
"WebP, AVIF or SVG, max 10 MB. Shown in the sidebar.": "WebP, AVIF nebo SVG, nejvýše 10 MB. Zobrazuje se v postranní liště.",
"Identity": "Identita",
"Display name": "Zobrazované jméno",
"Your real name": "Vaše skutečné jméno",
"Save name": "Uložit jméno",
"Fediverse handle": "Fediverse účet",
"Save handle": "Uložit účet",
"Security": "Zabezpečení",
"Change your password and username. You can also lock yourself out.": "Změňte heslo i uživatelské jméno. Můžete si i sami zablokovat přístup.",
"Current password": "Současné heslo",
"New password": "Nové heslo",
"Update password": "Změnit heslo",
"Update username": "Změnit uživatele",
"Language version": "Jazyková verze",
"The interface language of your account. The login screen follows your last choice.": "Jazyk rozhraní vašeho účtu. Přihlašovací stránka se řídí poslední volbou.",
"Unsupported language.": "Nepodporovaný jazyk.",
"The language could not be saved: %s": "Jazyk nešlo uložit: %s",
"Colour scheme": "Barevné schéma",
"The colour scheme of your account. The login screen follows your last choice.": "Barevné schéma vašeho účtu. Přihlašovací stránka se řídí poslední volbou.",
"Unsupported colour scheme.": "Nepodporované barevné schéma.",
"The colour scheme could not be saved: %s": "Barevné schéma nešlo uložit: %s",
"The colour scheme is set.": "Barevné schéma je nastaveno.",
// Users section.
"Add or remove admin and editor accounts": "Přidávejte a odstraňujte účty správců a autorů",
"you": "vy",
"Remove this user?": "Odebrat tohoto uživatele?",
"Add user": "Přidat uživatele",
"Role": "Role",
"jane-doe": "jana-novakova",
// Templates section.
"Post templates": "Šablony publikací",
"Pre-fill new posts with a reusable structure": "Předvyplňují nové publikace opakovaně použitelnou strukturou",
"No templates yet. Templates pre-fill the new-post form so you can keep your favourite structure on hand.": "Zatím žádné šablony. Předvyplňují formulář nové publikace, abyste měli svou oblíbenou strukturu po ruce.",
"Name": "Název",
"Fields to pre-fill": "Předvyplněná pole",
"Extra editor inputs as TOML key = value lines, one per line, strings quoted: author, lang, doi, orcid, series, series_order, cover, excerpt.": "Doplňující hodnoty editoru jako řádky TOML key = value, jeden na řádek, řetězce v uvozovkách: author, lang, doi, orcid, series, series_order, cover, excerpt.",
"Template fields must be key = value TOML lines.": "Pole šablony musí být řádky TOML key = value.",
"Unknown template field %s.": "Neznámé pole šablony %s.",
"Default title": "Výchozí titulek",
"Optional": "Volitelné",
"Default slug": "Výchozí slug",
"Tags (comma-separated)": "Značky (oddělené čárkou)",
"Body template (Markdown)": "Šablona obsahu (Markdown)",
"e.g. Review": "např. Recenze",
"Add template": "Přidat šablonu",
// Backup section.
"Backup & restore": "Záloha a obnova",
"Export or import all posts, media, users, and templates": "Exportujte nebo importujte všechny publikace, média, uživatele i šablony",
"Export": "Export",
"Download backup": "Stáhnout zálohu",
"Restore from a previously exported archive. Overwrites existing data.": "Obnova z dříve exportovaného archivu. Přepíše existující data.",
"This will overwrite existing posts and users. Continue?": "Tím se přepíšou existující publikace i uživatelé. Pokračovat?",
"Restore backup": "Obnovit ze zálohy",
// Version section.
"Keep Volumen up to date": "Udržujte Volumen aktuální",
"Installed version": "Nainstalovaná verze",
"Running": "Provozuji",
"latest": "nejnovější",
"Update to %s": "Aktualizovat na %s",
"Check for updates": "Zkontrolovat aktualizace",
// Webhooks section.
"Notify external services when posts change": "Upozorněte externí služby na změny publikací",
"Endpoints": "Koncové body",
"Signed JSON payloads are POSTed on post changes. Changes apply without a restart.": "Podepsané zprávy JSON se posílají při změnách publikací. Změny se projeví bez restartu.",
"Events": "Události",
"Send test": "Poslat test",
"disabled": "vypnuto",
"signed": "podepsáno",
"from config": "z konfigurace",
"Add webhook": "Přidat webhook",
"Endpoint URL": "URL koncového bodu",
"Signing secret (optional)": "Podpisový tajný klíč (nepovinný)",
"Events (comma-separated, empty for all)": "Události (oddělené čárkou, prázdné znamená všechny)",
"Enabled": "Zapnuto",
"Add": "Přidat",
"Enable": "Zapnout",
"Disable": "Vypnout",
"Remove this webhook?": "Odebrat tento webhook?",
"Webhook added.": "Webhook přidán.",
"Webhook updated.": "Webhook uložen.",
"Webhook removed.": "Webhook odebrán.",
"That URL is not a valid http(s) endpoint.": "Tato URL není platný koncový bod http(s).",
"That URL is already configured.": "Tato URL už je nastavená.",
"The webhook store could not be read: %s": "Soubor webhooků se nepodařilo přečíst: %s",
"The webhook could not be saved: %s": "Webhook se nepodařilo uložit: %s",
"Recent deliveries": "Nedávná doručení",
"Kept in memory; cleared on restart.": "Uchovaná v paměti; po restartu smazána.",
"When": "Kdy",
"Event": "Událost",
"Endpoint": "Kam",
"Result": "Výsledek",
// Tokens section.
"Programmatic write access to the public API": "Programový zápis do veřejného API",
"Access tokens": "Přístupové tokeny",
"Copy this token now; it will never be shown again.": "Token si zkopírujte hned; znovu se nezobrazí.",
"No tokens yet. Create one to publish posts from scripts or CI.": "Zatím žádné tokeny. Vytvořte si jeden pro publikování ze skriptů nebo CI.",
"Created": "Vytvořeno",
"Last used": "Naposledy",
"Revoke token “%s”? Scripts using it will stop working.": "Odvolat token „%s“? Skripty, které ho používají, přestanou fungovat.",
"Revoke": "Odvolat",
"Token name": "Název tokenu",
"e.g. deploy-script": "např. deploy-script",
"Scopes": "Oprávnění",
"Write": "Zápis",
"Write creates and edits posts; delete removes them. Read endpoints are public, so no scope is needed for them. Selecting neither grants full access.": "Zápis vytváří a upravuje publikace; mazání je odstraňuje. Čtení je veřejné, oprávnění nepotřebuje. Bez výběru platí plný přístup.",
"Create token": "Vytvořit token",
// Update page.
"The server is restarting. This page will reload automatically.": "Server se restartuje. Tato stránka se obnoví sama.",
"Server is back; reloading.": "Server je zpět; obnovuji.",
"The server did not come back within 3 minutes. Check the service manually.": "Server se do 3 minut nevrátil. Zkontrolujte službu ručně.",
// Date picker.
"Previous month": "Předchozí měsíc",
"Next month": "Další měsíc",
// Editor page actions and crumbs.
"Select %s": "Vybrat: %s",
"Updated to": "Aktualizováno na",
"Short summary for listings and previews": "Krátké shrnutí pro výčty a náhledy",
"Volumen admin": "Volumen administrace",
// Editor crumbs and roles.
"Untitled": "Bez názvu",
"%d of %d": "%d z %d",
"admin": "administrátor",
"author": "autor",
"date must be an ISO 8601 date.": "Datum musí být ve tvaru ISO 8601 (RRRR-MM-DD).",
"publish_at must be an ISO 8601 date.": "Datum publikace musí být ve tvaru ISO 8601 (RRRR-MM-DD).",
// Go handler messages: posts.
"No file selected.": "Nevybrán žádný soubor.",
"No file was uploaded.": "Nevybrán žádný soubor.",
"The upload could not be stored.": "Nahraný soubor nešlo uložit.",
"Only .md files are accepted.": "Přijímají se jen soubory .md.",
"File is too large.": "Soubor je příliš velký.",
"The file could not be read as a post: %s": "Soubor nešlo přečíst jako publikaci: %s",
"Import failed.": "Import selhal.",
"The file could not be read (limit %s bytes).": "Soubor nešlo přečíst (limit %s bajtů).",
"Only WebP, AVIF and SVG images are supported.": "Podporovány jsou jen obrázky WebP, AVIF a SVG.",
"The post could not be saved: %s": "Publikaci nešlo uložit: %s",
"The post could not be deleted: %s": "Publikaci nešlo smazat: %s",
"Slug is required.": "Slug je povinný.",
"Invalid slug.": "Neplatný slug.",
"Invalid language.": "Neplatný jazyk.",
"A post with that slug already exists.": "Publikace s tímto slugem už existuje.",
"Fediverse creator must look like @user@host.": "Autor fediverse musí mít tvar @uzivatel@hostitel.",
// Go handler messages: login and security.
"Invalid CSRF token": "Neplatný CSRF token",
// Go handler messages: settings, account.
"Current password is incorrect.": "Současné heslo není správné.",
"New password cannot be empty.": "Nové heslo nesmí být prázdné.",
"The new password could not be saved: %s": "Nové heslo nešlo uložit: %s",
"Password updated.": "Heslo změněno.",
"Username cannot be empty.": "Uživatelské jméno nesmí být prázdné.",
"Username may use letters, numbers, dot, dash, underscore.": "Uživatelské jméno smí používat písmena, čísla, tečku, pomlčku a podtržítko.",
"Username unchanged.": "Uživatelské jméno nezměněno.",
"The username could not be changed: %s": "Uživatelské jméno nešlo změnit: %s",
"Username updated.": "Uživatelské jméno změněno.",
"The display name could not be saved: %s": "Zobrazované jméno nešlo uložit: %s",
"Display name cleared.": "Zobrazované jméno vymazáno.",
"Display name updated.": "Zobrazované jméno uloženo.",
"The handle could not be saved: %s": "Fediverse účet nešlo uložit: %s",
"Fediverse handle cleared.": "Fediverse účet vymazán.",
"Fediverse handle must look like @user@host.": "Fediverse účet musí mít tvar @uzivatel@hostitel.",
"Fediverse handle updated.": "Fediverse účet uložen.",
"ORCID iD": "ORCID iD",
"Save ORCID": "Uložit ORCID",
"ORCID updated.": "ORCID uložen.",
"ORCID cleared.": "ORCID vymazán.",
"The ORCID could not be saved: %s": "ORCID nešlo uložit: %s",
"ORCID must look like 0000-0002-1825-0097.": "ORCID musí mít tvar 0000-0002-1825-0097.",
"Your Open Researcher and Contributor ID; it pre-fills the author field of new publications.": "Váš Open Researcher and Contributor ID; předvyplňuje pole autora nových publikací.",
"Display name pre-fills the author field. Fediverse handle pre-fills the creator, and the ORCID pre-fills the author identifier of new publications.": "Zobrazované jméno předvyplňuje pole autora, fediverse účet tvůrce a ORCID identifikátor autora nových publikací.",
"The photo could not be stored.": "Fotku nešlo uložit.",
"The profile photo could not be saved: %s": "Profilovou fotku nešlo uložit: %s",
"Profile photo updated.": "Profilová fotka uložena.",
"The profile photo could not be removed: %s": "Profilovou fotku nešlo odstranit: %s",
"Profile photo removed.": "Profilová fotka odstraněna.",
"The interface language is set.": "Jazyk rozhraní je nastaven.",
// Go handler messages: settings, users.
"Username and password are required.": "Uživatelské jméno a heslo jsou povinné.",
"That user could not be added: %s": "Uživatele nešlo přidat: %s",
"User added.": "Uživatel přidán.",
"You cannot change your own role.": "Vlastní roli nelze změnit.",
"The role could not be changed: %s": "Roli nešlo změnit: %s",
"Role updated.": "Role uložena.",
"You cannot delete your own account.": "Vlastní účet nelze smazat.",
"The user could not be removed: %s": "Uživatele nešlo odebrat: %s",
"User removed.": "Uživatel odebrán.",
"Reset password": "Resetovat heslo",
"You cannot reset your own password here.": "Vlastní heslo tady resetovat nemůžete.",
"That user was not found.": "Takový uživatel nebyl nalezen.",
"Password reset; that user's sessions were signed out.": "Heslo resetováno; relace toho uživatele byly odhlášeny.",
"Reset this user's password? Their sessions will be signed out.": "Resetovat heslo tohoto uživatele? Jeho relace budou odhlášeny.",
// Go handler messages: settings, templates and tokens.
"Template name is required.": "Název šablony je povinný.",
"That template could not be added: %s": "Šablonu nešlo přidat: %s",
"Template added.": "Šablona přidána.",
"The template could not be deleted: %s": "Šablonu nešlo smazat: %s",
"Template deleted.": "Šablona smazána.",
"The token could not be created: %s": "Token nešlo vytvořit: %s",
"Token revoked.": "Token odvolán.",
"That token was not found.": "Token nenalezen.",
// Go handler messages: settings, webhooks, backup and version.
"No webhooks configured.": "Žádné webhooky nenastaveny.",
"Webhook not found.": "Webhook nenalezen.",
"Test delivery sent.": "Testovací doručení odesláno.",
"Test delivery failed.": "Testovací doručení selhalo.",
"No backup file selected.": "Nevybrán žádný soubor zálohy.",
"Could not restore backup: %s": "Zálohu nešlo obnovit: %s",
"The archive holds no files this deployment recognises.": "Archiv neobsahuje žádné soubory, které by tahle instalace poznala.",
"Update checks are not available in this build.": "Kontrola aktualizací není v tomto buildu dostupná.",
"Update check failed: %s": "Kontrola aktualizací selhala: %s",
"volumen %s is already the latest release.": "Volumen %s je už nejnovější vydání.",
"volumen %s is available.": "Volumen %s je k dispozici.",
"Self-update is not available in this build.": "Automatická aktualizace není v tomto buildu dostupná.",
"The upgrade failed: %s": "Aktualizace selhala: %s",
"Upgrade to %s failed: %s": "Aktualizace na %s selhala: %s",
}
// csHTML translates the static sentences that only make sense with
// their markup whole. They never carry an interpolated value, so
// returning them unescaped is safe.
var csHTML = map[string]string{
"On macOS use <kbd>Cmd</kbd> instead of <kbd>Ctrl</kbd>.": "Na macOS použijte <kbd>Cmd</kbd> místo <kbd>Ctrl</kbd>.",
"Download a <code>.tar.gz</code> archive of your entire site.": "Stáhněte <code>.tar.gz</code> archiv celého webu.",
"No webhooks yet. Add an endpoint below, or declare <code>[[webhooks]]</code> blocks in <code>config.toml</code> (those are read-only here):": "Žádné webhooky. Přidejte koncový bod níže, nebo deklarujte bloky <code>[[webhooks]]</code> v <code>config.toml</code> (ty tady jsou jen ke čtení):",
"Send a token as <code>Authorization: Bearer &lt;token&gt;</code> on <code>POST/PUT/DELETE /api/volumen/posts</code>.": "Token posílejte jako <code>Authorization: Bearer &lt;token&gt;</code> na <code>POST/PUT/DELETE /api/volumen/posts</code>.",
}
// Catalog translates the admin interface.
type Catalog struct{}
// Admin is the catalogue the binary ships.
var Admin = Catalog{}
// T translates a simple message. The key is the English text; an
// unknown key or the "en" language returns the key itself, so English
// needs no table and a missing translation degrades to English rather
// than to an identifier.
func (Catalog) T(lang, s string) string {
if lang != "cs" {
return s
}
if out, ok := cs[s]; ok && out != "" {
return out
}
return s
}
// TH translates a static sentence that carries its own markup. The
// sentences are authored here, never interpolated, so returning them
// unescaped cannot smuggle request data into the page.
func (Catalog) TH(lang, s string) string {
if lang != "cs" {
return s
}
if out, ok := csHTML[s]; ok {
return out
}
return s
}
// Tf translates a simple message with one %s value. The result stays a
// plain string, so the template engine escapes the interpolated value.
func (c Catalog) Tf(lang, s, arg string) string {
t := c.T(lang, s)
if before, after, ok := strings.Cut(t, "%s"); ok {
return before + arg + after
}
return t
}
// Tf2 translates a simple message with two %s values.
func (c Catalog) Tf2(lang, s, first, second string) string {
t := c.T(lang, s)
for _, arg := range []string{first, second} {
if i := strings.Index(t, "%s"); i >= 0 {
t = t[:i] + arg + t[i+2:]
}
}
return t
}
// N translates a numbered message, picking the form the language's
// cardinal rule asks for. Czech follows CLDR: one for 1, few for the
// 2 to 4 class except 12 to 14, other otherwise.
func (c Catalog) N(lang, id string, n int) string {
forms, ok := pluralForms[id]
if !ok {
return id
}
set := forms["en"]
if lang != "en" {
if l := forms[lang]; l.one != "" || l.other != "" {
set = l
}
}
var form string
switch {
case n == 1:
form = set.one
case lang == "cs" && n%10 >= 2 && n%10 <= 4 && (n%100 < 12 || n%100 > 14):
form = set.few
default:
form = set.other
}
// A plural set without a few form (an id added with English forms
// only) would otherwise render an empty string for the 2 to 4 class.
if form == "" {
form = set.other
}
num := strconv.Itoa(n)
if i := strings.Index(form, "%d"); i >= 0 {
return form[:i] + num + form[i+2:]
}
return form
}
// JS returns the strings the page scripts need, as a map ready for
// JSON encoding into the page. Plural messages carry their three
// forms; the client picks one with the same cardinal rule as N.
func (c Catalog) JS(lang string) map[string]any {
if lang == "" {
lang = "en"
}
out := map[string]any{}
for _, id := range []string{
"editor.words", "editor.chars", "editor.reading",
"bulk.selected", "posts.count", "refs.count",
} {
set := pluralForms[id]["en"]
if lang != "en" {
if l := pluralForms[id][lang]; l.one != "" || l.other != "" {
set = l
}
}
out[id] = []string{set.one, set.few, set.other}
}
plain := map[string]string{
"post.created": c.T(lang, "Post created."),
"post.updated": c.T(lang, "Post saved."),
"post.deleted": c.T(lang, "Post deleted."),
"post.duplicated": c.T(lang, "Post duplicated."),
"post.undone": c.T(lang, "Post restored."),
"post.undelete_failed": c.T(lang, "Could not restore the post; it may already be back."),
"post.duplicate_failed": c.T(lang, "The post could not be duplicated."),
"undo": c.T(lang, "Undo"),
"undo.failed": c.T(lang, "Undo failed."),
"autosave.available": c.T(lang, "Unsaved changes from %s are available."),
"slug.ok": c.T(lang, "Slug is available."),
"slug.taken": c.T(lang, "Already used by “%s”."),
"upload.prefix": c.T(lang, "Upload failed:\n\n%s"),
"upload.rejected": c.T(lang, "Only WebP, AVIF and SVG images are accepted (got “%s”)."),
"link.prompt": c.T(lang, "Link URL"),
"Show password": c.T(lang, "Show password"),
"Hide password": c.T(lang, "Hide password"),
"Collapse sidebar": c.T(lang, "Collapse sidebar"),
"Expand sidebar": c.T(lang, "Expand sidebar"),
"login.unlock": c.T(lang, "Auto-unlock in {} s."),
"login.retry": c.T(lang, " You can try again now."),
"update.reload": c.T(lang, "Server is back; reloading."),
"update.gaveup": c.T(lang, "The server did not come back within 3 minutes. Check the service manually."),
"datepicker.prev": c.T(lang, "Previous month"),
"datepicker.next": c.T(lang, "Next month"),
"Pick a date": c.T(lang, "Pick a date"),
"Clear": c.T(lang, "Clear"),
"Today": c.T(lang, "Today"),
"filter.of": c.T(lang, "%d of %d"),
"import.rejected": c.T(lang, "Only .md files are accepted, got “%s”"),
"media.uploading": c.T(lang, "Uploading %s…"),
"media.upload_failed": c.T(lang, "Upload failed: %s"),
"media.copied": c.T(lang, "Link copied."),
"media.copy_prompt": c.T(lang, "Copy the link:"),
"upload.complete": c.T(lang, "Upload complete."),
"pw.weak": c.T(lang, "Weak"),
"pw.fair": c.T(lang, "Fair"),
"pw.good": c.T(lang, "Good"),
"pw.strong": c.T(lang, "Strong"),
}
for id, v := range plain {
out[id] = v
}
return out
}
+144
View File
@@ -0,0 +1,144 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package i18n
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestNormalize(t *testing.T) {
cases := map[string]string{
"cs": "cs",
"cs-CZ": "cs",
"cs-CZ-x-praha": "cs",
"EN": "en",
"en-GB": "en",
"de": "",
"": "",
}
for in, want := range cases {
if got := Normalize(in); got != want {
t.Errorf("Normalize(%q) = %q, want %q", in, got, want)
}
}
}
func TestSimpleFallback(t *testing.T) {
// English is the key: it always renders as itself.
if got := Admin.T("en", "New post"); got != "New post" {
t.Errorf("en simple = %q", got)
}
// A key the Czech table misses falls back to English, not to noise.
if got := Admin.T("cs", "No such string in the catalogue"); got != "No such string in the catalogue" {
t.Errorf("cs fallback = %q", got)
}
if got := Admin.T("cs", "New post"); got != "Nová publikace" {
t.Errorf("cs New post = %q", got)
}
}
func TestPlural(t *testing.T) {
csCases := []struct {
n int
want string
}{
{1, "Celkem 1 publikace"},
{2, "Celkem 2 publikace"},
{4, "Celkem 4 publikace"},
{5, "Celkem 5 publikací"},
{11, "Celkem 11 publikací"},
{12, "Celkem 12 publikací"},
{14, "Celkem 14 publikací"},
{22, "Celkem 22 publikace"},
{24, "Celkem 24 publikace"},
{25, "Celkem 25 publikací"},
{111, "Celkem 111 publikací"},
{124, "Celkem 124 publikace"},
}
for _, c := range csCases {
if got := Admin.N("cs", "posts.total", c.n); got != c.want {
t.Errorf("cs posts.total(%d) = %q, want %q", c.n, got, c.want)
}
}
if got := Admin.N("en", "posts.total", 1); got != "1 post in total" {
t.Errorf("en posts.total(1) = %q", got)
}
if got := Admin.N("en", "posts.total", 3); got != "3 posts in total" {
t.Errorf("en posts.total(3) = %q", got)
}
if got := Admin.N("cs", "no.such.id", 2); got != "no.such.id" {
t.Errorf("missing plural id = %q", got)
}
}
func TestFormat(t *testing.T) {
if got := Admin.Tf("cs", "Scheduled for %s", "2026-09-19"); got != "Naplánováno na 2026-09-19" {
t.Errorf("cs Tf = %q", got)
}
if got := Admin.Tf("en", "No such string %s", "x"); got != "No such string x" {
t.Errorf("en Tf fallback = %q", got)
}
if got := Admin.Tf2("cs", "Upgrade to %s failed: %s", "v1.1", "boom"); got != "Aktualizace na v1.1 selhala: boom" {
t.Errorf("cs Tf2 = %q", got)
}
}
func TestHTML(t *testing.T) {
en := Admin.TH("en", "Download a <code>.tar.gz</code> archive of your entire site.")
if en != "Download a <code>.tar.gz</code> archive of your entire site." {
t.Errorf("en TH = %q", en)
}
csOut := Admin.TH("cs", "Download a <code>.tar.gz</code> archive of your entire site.")
if !strings.Contains(csOut, "<code>.tar.gz</code>") {
t.Errorf("cs TH lost the markup: %q", csOut)
}
}
// TestTemplatesAreTranslated walks the admin templates and asserts every
// tr, trh and trf key exists in the Czech catalogue, and every trn id is a
// known plural message. A string edited in a template without its Czech
// counterpart fails here rather than silently rendering English.
func TestTemplatesAreTranslated(t *testing.T) {
root := filepath.Join("..", "..", "internal", "web", "templates")
entries, err := os.ReadDir(root)
if err != nil {
t.Fatalf("read templates: %v", err)
}
callRe := regexp.MustCompile(`\b(tr|trh|trf)\s+"((?:[^"\\]|\\.)*)"`)
// trn takes the count first: "trn .Count \"id\"" or
// "trn (len .Posts) \"id\"", so the id is the first quoted string
// after a run of non-quote characters on the same line.
idRe := regexp.MustCompile(`\btrn\s+[^\n"]*"((?:[^"\\]|\\.)*)"`)
for _, entry := range entries {
if !strings.HasSuffix(entry.Name(), ".html") {
continue
}
src, err := os.ReadFile(filepath.Join(root, entry.Name()))
if err != nil {
t.Fatalf("read %s: %v", entry.Name(), err)
}
for _, m := range callRe.FindAllStringSubmatch(string(src), -1) {
fn, key := m[1], m[2]
switch fn {
case "tr", "trf":
if _, ok := cs[key]; !ok {
t.Errorf("%s: tr key %q has no Czech entry", entry.Name(), key)
}
case "trh":
if _, ok := csHTML[key]; !ok {
t.Errorf("%s: trh key %q has no Czech entry", entry.Name(), key)
}
}
}
for _, m := range idRe.FindAllStringSubmatch(string(src), -1) {
if _, ok := pluralForms[m[1]]; !ok {
t.Errorf("%s: trn id %q is not a plural message", entry.Name(), m[1])
}
}
}
}
+94
View File
@@ -0,0 +1,94 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package identifiers validates the scholarly identifiers a post or an
// account can carry: a DOI names the work, an ORCID names an author. It
// is a leaf so the configuration, the domain object, the payload
// builders and the admin share one rule without importing one another.
//
// The rules are syntactic on purpose. A DOI resolves through doi.org and
// an ORCID through orcid.org, and both publish registries; asking those
// services here would tie a save to the network, leak who is writing to
// a third party, and make the offline binary wait on someone else's
// uptime. Syntax plus the ORCID check digit catches every realistic
// typo; resolution stays the consumer's job.
package identifiers
import (
"regexp"
"strings"
)
var doiRe = regexp.MustCompile(`\A10\.[0-9]{4,9}/\S+\z`)
// orcidRe is the display form: four groups of four digits, the last
// character a digit or the uppercase X that stands for a checksum of 10.
var orcidRe = regexp.MustCompile(`\A[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]\z`)
// NormalizeDOI accepts the bare form, a doi: scheme and a doi.org URL,
// and returns the bare identifier; an empty result means the input
// carries no DOI at all.
func NormalizeDOI(text string) string {
text = strings.TrimSpace(text)
for _, prefix := range []string{
"https://doi.org/", "http://doi.org/", "doi.org/", "doi:",
} {
if len(text) > len(prefix) && strings.EqualFold(text[:len(prefix)], prefix) {
text = strings.TrimSpace(text[len(prefix):])
break
}
}
return text
}
// ValidDOI reports whether text is a syntactically valid bare DOI: the
// 10. prefix, a registrant needle of four to nine digits, a slash, and
// a non-empty suffix without spaces.
func ValidDOI(text string) bool {
return doiRe.MatchString(NormalizeDOI(text))
}
// NormalizeORCID trims and lower-cases to upper; it fixes no body.
func NormalizeORCID(text string) string {
return strings.ToUpper(strings.TrimSpace(text))
}
// ValidORCID reports whether text is an ORCID iD in display form with a
// correct ISO 7064 (MOD 11-2) check digit.
func ValidORCID(text string) bool {
id := NormalizeORCID(text)
if !orcidRe.MatchString(id) {
return false
}
digits := strings.ReplaceAll(id, "-", "")
total := 0
for _, r := range digits[:15] {
total = (total + int(r-'0')) * 2
}
remainder := (12 - total%11) % 11
want := byte('0' + remainder)
if remainder == 10 {
want = 'X'
}
return digits[15] == want
}
// DOIURL names the resolver for a bare or already-prefixed DOI; an
// invalid input yields "".
func DOIURL(text string) string {
doi := NormalizeDOI(text)
if !doiRe.MatchString(doi) {
return ""
}
return "https://doi.org/" + doi
}
// ORCIDURL names the public record for a valid iD; an invalid input
// yields "".
func ORCIDURL(text string) string {
id := NormalizeORCID(text)
if !ValidORCID(id) {
return ""
}
return "https://orcid.org/" + id
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package identifiers
import "testing"
func TestNormalizeDOI(t *testing.T) {
cases := map[string]string{
"10.5281/zenodo.1234567": "10.5281/zenodo.1234567",
" https://doi.org/10.5281/zenodo.1234567": "10.5281/zenodo.1234567",
"DOI: 10.1234/abcd": "10.1234/abcd",
"http://dx.doi.org/10.1/x": "http://dx.doi.org/10.1/x", // unknown host stays as it is
"": "",
}
for in, want := range cases {
if got := NormalizeDOI(in); got != want {
t.Errorf("NormalizeDOI(%q) = %q, want %q", in, got, want)
}
}
}
func TestValidDOI(t *testing.T) {
for _, ok := range []string{
"10.1234/x", "10.5281/zenodo.1234567", "https://doi.org/10.1000/18.2011.01",
"10.1109/5.77101",
} {
if !ValidDOI(ok) {
t.Errorf("ValidDOI(%q) = false, want true", ok)
}
}
for _, bad := range []string{
"", "10.123/x", // needle too short
"10./x", // empty needle
"20.1234/x", // not the 10. prefix
"10.1234", // no suffix
"10.1234/ spaced suffix", // whitespace in the suffix
"doi:10.1234/", // empty suffix after the slash
} {
if ValidDOI(bad) {
t.Errorf("ValidDOI(%q) = true, want false", bad)
}
}
}
func TestValidORCID(t *testing.T) {
// A real public iD, and its computed X-check sibling.
for _, ok := range []string{
"0000-0002-1825-0097",
"0000-0000-0000-001X",
"0000-0000-0000-001x", // lower-case x is upper-cased first
" 0000-0002-1825-0097 ",
} {
if !ValidORCID(ok) {
t.Errorf("ValidORCID(%q) = false, want true", ok)
}
}
for _, bad := range []string{
"",
"0000-0002-1825-0098", // wrong check digit
"0000-0002-1825-009x", // right length, wrong check digit
"0000-0002-1825-009", // too short
"0000000218250097", // dashes missing
} {
if ValidORCID(bad) {
t.Errorf("ValidORCID(%q) = true, want false", bad)
}
}
}
func TestURLs(t *testing.T) {
if got := DOIURL("https://doi.org/10.1234/x"); got != "https://doi.org/10.1234/x" {
t.Errorf("DOIURL = %q", got)
}
if got := DOIURL("nonsense"); got != "" {
t.Errorf("DOIURL(nonsense) = %q, want empty", got)
}
if got := ORCIDURL("0000-0002-1825-0097"); got != "https://orcid.org/0000-0002-1825-0097" {
t.Errorf("ORCIDURL = %q", got)
}
if got := ORCIDURL("0000-0002-1825-0098"); got != "" {
t.Errorf("ORCIDURL(bad checksum) = %q, want empty", got)
}
}
+463
View File
@@ -0,0 +1,463 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package imagefile identifies the image formats volumen accepts, from
// the bytes rather than from a name or a declared type.
//
// Only the three formats below are stored, and the media route serves
// nothing else: a browser is never handed a document from a directory
// that an archive or an upload can write to. An SVG is a document of
// sorts, so the media route serves it sandboxed and the signature test
// here demands the root element really be <svg>.
package imagefile
import (
"bytes"
"encoding/binary"
"path/filepath"
"strconv"
"strings"
)
// The canonical extensions and the Content-Type each is served with.
const (
ExtWebP = ".webp"
ExtAVIF = ".avif"
ExtSVG = ".svg"
MIMEWebP = "image/webp"
MIMEAVIF = "image/avif"
MIMESVG = "image/svg+xml"
)
var mimeTypes = map[string]string{
ExtWebP: MIMEWebP,
ExtAVIF: MIMEAVIF,
ExtSVG: MIMESVG,
}
// ContentType returns the Content-Type for an allowed image name, or ""
// when the name does not carry an allowed extension.
func ContentType(name string) string {
return mimeTypes[strings.ToLower(filepath.Ext(filepath.Base(name)))]
}
// Allowed reports whether name carries an allowed extension.
func Allowed(name string) bool { return ContentType(name) != "" }
// SignatureMatches reports whether data carries the signature of the
// format the extension names.
func SignatureMatches(data []byte, ext string) bool {
switch strings.ToLower(ext) {
case ExtWebP:
// RIFF....WEBP, twelve bytes of magic.
return len(data) >= 12 &&
bytes.Equal(data[:4], []byte("RIFF")) &&
bytes.Equal(data[8:12], []byte("WEBP"))
case ExtAVIF:
// ISO BMFF: bytes 4..7 are the box size, 8..11 are "ftyp",
// followed by the major brand.
if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) {
return false
}
brand := data[8:12]
return bytes.Equal(brand, []byte("avif")) || bytes.Equal(brand, []byte("avis"))
case ExtSVG:
// The root element must be <svg>: XML text that opens with
// another document (an XHTML page, an SVGZ masquerading as a
// plain .svg) is not an accepted image.
return svgRootTag(data) != nil
}
return false
}
// Detect returns the canonical extension for data, or "" when the bytes
// carry no accepted signature.
func Detect(data []byte) string {
if SignatureMatches(data, ExtWebP) {
return ExtWebP
}
if SignatureMatches(data, ExtAVIF) {
return ExtAVIF
}
if SignatureMatches(data, ExtSVG) {
return ExtSVG
}
return ""
}
// Dimensions reports the pixel size of a WebP, AVIF or SVG image, reading
// only the container headers or the root element, and ok=false when the
// bytes carry no size it can trust. A caller that holds a whole file can
// pass it whole; a 64 KiB prefix of a media file carries every header
// this reads.
func Dimensions(data []byte) (width, height int, ok bool) {
if w, h, ok := webpDimensions(data); ok {
return w, h, true
}
if w, h, ok := avifDimensions(data); ok {
return w, h, true
}
return svgDimensions(data)
}
// webpDimensions reads the size out of the three chunk shapes WebP
// uses: VP8 (lossy), VP8L (lossless) and VP8X (extended canvas).
func webpDimensions(data []byte) (int, int, bool) {
if len(data) < 20 || !bytes.Equal(data[:4], []byte("RIFF")) ||
!bytes.Equal(data[8:12], []byte("WEBP")) {
return 0, 0, false
}
switch string(data[12:16]) {
case "VP8 ":
// After the frame tag sit the three sync bytes 0x9d 0x01 0x2a,
// then the width and the height as 16-bit little-endian values
// whose top two bits carry a scale code.
if len(data) < 30 || data[23] != 0x9d || data[24] != 0x01 || data[25] != 0x2a {
return 0, 0, false
}
w := int(binary.LittleEndian.Uint16(data[26:28]) & 0x3fff)
h := int(binary.LittleEndian.Uint16(data[28:30]) & 0x3fff)
return w, h, w > 0 && h > 0
case "VP8L":
// The payload opens with 0x2f and packs width-1 into 14 bits
// followed by height-1 into 14 more, least significant first.
if len(data) < 25 || data[20] != 0x2f {
return 0, 0, false
}
bits := uint32(data[21]) | uint32(data[22])<<8 | uint32(data[23])<<16 | uint32(data[24])<<24
w := int(bits&0x3fff) + 1
h := int((bits>>14)&0x3fff) + 1
return w, h, true
case "VP8X":
// The canvas size sits as two 24-bit little-endian minus-one
// values after the flags and three reserved bytes.
if len(data) < 30 {
return 0, 0, false
}
w := int(uint32(data[24])|uint32(data[25])<<8|uint32(data[26])<<16) + 1
h := int(uint32(data[27])|uint32(data[28])<<8|uint32(data[29])<<16) + 1
return w, h, true
}
return 0, 0, false
}
// avifDimensions walks the ISO-BMFF boxes of an AVIF: the meta box
// names the primary item (pitm) and associates it with properties
// (ipma inside iprp); the ispe property it points at carries the image
// extent. Anything the walk cannot certify is reported as unknown
// rather than guessed.
func avifDimensions(data []byte) (int, int, bool) {
if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) {
return 0, 0, false
}
var meta []byte
for _, b := range readBoxes(data) {
if b.typ == "meta" {
meta = b.body
break
}
}
if meta == nil || len(meta) < 4 {
return 0, 0, false
}
// meta is a full box: four bytes of version and flags precede the
// children.
children := readBoxes(meta[4:])
var primary uint64
var properties []box
var associations []box
for _, b := range children {
switch b.typ {
case "pitm":
if len(b.body) < 1 {
return 0, 0, false
}
// The bounds name the whole item id: a box whose body stops
// short of it is refused rather than read past, because a
// truncated box at the end of the buffer has no bytes left
// to read and the slice would run out of range.
if b.body[0] == 0 {
if len(b.body) < 6 {
return 0, 0, false
}
primary = uint64(binary.BigEndian.Uint16(b.body[4:6]))
} else {
if len(b.body) < 8 {
return 0, 0, false
}
primary = uint64(binary.BigEndian.Uint32(b.body[4:8]))
}
case "iprp":
for _, inner := range readBoxes(b.body) {
switch inner.typ {
case "ipco":
properties = readBoxes(inner.body)
case "ipma":
associations = append(associations, inner)
}
}
}
}
if primary == 0 || properties == nil {
return 0, 0, false
}
for _, assoc := range associations {
for _, propertyIndex := range readAssociations(assoc) {
if propertyIndex.item != primary {
continue
}
// Property indices are one-based over ipco's children.
if propertyIndex.index == 0 || propertyIndex.index > len(properties) {
continue
}
boxed := properties[propertyIndex.index-1]
// ispe is a full box: version and flags, then the width and
// the height as big-endian 32-bit values.
if boxed.typ != "ispe" || len(boxed.body) < 12 {
continue
}
w := int(binary.BigEndian.Uint32(boxed.body[4:8]))
h := int(binary.BigEndian.Uint32(boxed.body[8:12]))
return w, h, w > 0 && h > 0
}
}
return 0, 0, false
}
// boxAssociation pairs an item id with the one-based property index one
// of its associations names.
type boxAssociation struct {
item uint64
index int
}
// readAssociations decodes an ipma box's entries into item/property
// pairs, honouring both the 16- and 32-bit item id sizes and the
// 7- and 15-bit index sizes the flags select.
func readAssociations(b box) []boxAssociation {
if len(b.body) < 12 {
return nil
}
flags := uint32(b.body[1])<<16 | uint32(b.body[2])<<8 | uint32(b.body[3])
wideItems := flags&0b10 != 0
wideIndexes := flags&0b01 != 0
idSize, indexSize := 2, 1
if wideItems {
idSize = 4
}
if wideIndexes {
indexSize = 2
}
count := int(binary.BigEndian.Uint32(b.body[4:8]))
out := make([]boxAssociation, 0, count)
pos := 8
for range count {
if pos+idSize+1 > len(b.body) {
return out
}
var item uint64
if wideItems {
item = uint64(binary.BigEndian.Uint32(b.body[pos : pos+4]))
} else {
item = uint64(binary.BigEndian.Uint16(b.body[pos : pos+2]))
}
pos += idSize
assocCount := int(b.body[pos])
pos++
for range assocCount {
if pos+indexSize > len(b.body) {
return out
}
var index int
if wideIndexes {
// The essential bit rides the top bit of a 15-bit index.
index = int(binary.BigEndian.Uint16(b.body[pos:pos+2]) & 0x7fff)
} else {
index = int(b.body[pos] & 0x7f)
}
pos += indexSize
out = append(out, boxAssociation{item: item, index: index})
}
}
return out
}
// box is one ISO-BMFF box: its type and the body after the header.
type box struct {
typ string
body []byte
}
// readBoxes splits a run of sibling boxes. A size of zero means "to the
// end of the input" and a size of one promotes to a 64-bit size; both
// are honoured, and a truncated or empty box stops the walk.
func readBoxes(data []byte) []box {
var out []box
pos := 0
for pos+8 <= len(data) {
size := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
header := 8
if size == 1 {
if pos+16 > len(data) {
break
}
size = binary.BigEndian.Uint64(data[pos+8 : pos+16])
header = 16
}
if size < uint64(header) {
break
}
end := min(uint64(pos)+size, uint64(len(data)))
out = append(out, box{typ: typ, body: data[pos+header : end]})
if end <= uint64(pos)+8 {
break
}
pos = int(end)
}
return out
}
// svgRootTag returns the start tag of the root <svg> element, or nil when
// the bytes are not an SVG document. The XML prolog, comments and any
// leading declaration are stepped over on the way to it; anything that
// opens the document with another root element is refused, so an XHTML
// page never takes the .svg extension it is served under.
func svgRootTag(data []byte) []byte {
s := bytes.TrimPrefix(data, []byte("\ufeff"))
for {
s = bytes.TrimLeft(s, " \t\r\n")
switch {
case bytes.HasPrefix(s, []byte("<?xml")):
end := bytes.Index(s, []byte("?>"))
if end < 0 {
return nil
}
s = s[end+2:]
case bytes.HasPrefix(s, []byte("<!--")):
end := bytes.Index(s, []byte("-->"))
if end < 0 {
return nil
}
s = s[end+3:]
case bytes.HasPrefix(s, []byte("<!")):
end := bytes.IndexByte(s, '>')
if end < 0 {
return nil
}
s = s[end+1:]
case bytes.HasPrefix(s, []byte("<svg")):
if len(s) > 4 {
switch s[4] {
case ' ', '\t', '\n', '\r', '>', '/':
default:
return nil // <svgrect and friends are not a root
}
}
end := bytes.IndexByte(s, '>')
if end < 0 {
return nil
}
return s[:end+1]
default:
return nil
}
}
}
// svgDimensions reads the size off the root element: the width and height
// attributes when both are bare lengths, or the viewBox box otherwise. A
// percentage length carries no size the media library could show.
func svgDimensions(data []byte) (int, int, bool) {
tag := svgRootTag(data)
if tag == nil {
return 0, 0, false
}
if width, ok := svgLength(tag, "width"); ok {
if height, ok := svgLength(tag, "height"); ok {
return width, height, width > 0 && height > 0
}
}
if box, ok := svgAttr(tag, "viewBox"); ok {
parts := strings.FieldsFunc(box, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(parts) == 4 {
w, errW := strconv.ParseFloat(parts[2], 64)
h, errH := strconv.ParseFloat(parts[3], 64)
if errW == nil && errH == nil && w >= 1 && h >= 1 {
return int(w), int(h), true
}
}
}
return 0, 0, false
}
// svgLength reads one of the root element's size attributes as a pixel
// length: a plain number or one written in px.
func svgLength(tag []byte, name string) (int, bool) {
value, ok := svgAttr(tag, name)
if !ok {
return 0, false
}
value = strings.TrimSuffix(strings.TrimSuffix(value, "px"), "PX")
if strings.TrimLeftFunc(value, func(r rune) bool {
return (r >= '0' && r <= '9') || r == '.'
}) != "" {
return 0, false // a unit the media library does not convert
}
n, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, false
}
return int(n), n >= 0
}
// svgAttr returns the quoted value of one attribute of a start tag. The
// scan is deliberately shallow: it reads the plain attributes the size
// of a figure is written with and gives up on anything else.
func svgAttr(tag []byte, name string) (string, bool) {
s := string(tag)
i := strings.IndexByte(s, ' ') // past "<svg"
if i < 0 {
return "", false
}
for i < len(s) {
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
i++
}
start := i
for i < len(s) && s[i] != '=' && !isSVGTagSpace(s[i]) && s[i] != '>' && s[i] != '/' {
i++
}
key := s[start:i]
if i >= len(s) || s[i] != '=' {
return "", false
}
i++
if i >= len(s) || (s[i] != '"' && s[i] != '\'') {
return "", false
}
quote := s[i]
i++
valueStart := i
for i < len(s) && s[i] != quote {
i++
}
if i >= len(s) {
return "", false
}
value := s[valueStart:i]
i++
if key == name {
return value, true
}
}
return "", false
}
func isSVGTagSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}
+238
View File
@@ -0,0 +1,238 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package imagefile
import "testing"
// webpBytes is a minimal RIFF/WEBP header, enough for the signature
// check.
func webpBytes() []byte {
data := append([]byte("RIFF"), 0, 0, 0, 0)
return append(data, []byte("WEBPVP8 ")...)
}
// avifBytes is a minimal ISO BMFF header carrying the avif brand.
func avifBytes() []byte {
return []byte{0, 0, 0, 24, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
}
func TestSignatureMatches(t *testing.T) {
if !SignatureMatches(webpBytes(), ExtWebP) {
t.Fatal("webp signature rejected")
}
if !SignatureMatches(avifBytes(), ExtAVIF) {
t.Fatal("avif signature rejected")
}
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" width="8" height="6"><rect/></svg>`)
if !SignatureMatches(svg, ExtSVG) {
t.Fatal("svg signature rejected")
}
prologed := []byte("\ufeff<?xml version=\"1.0\"?>\n<!-- figure --><svg></svg>")
if !SignatureMatches(prologed, ExtSVG) {
t.Fatal("svg with prolog and comment rejected")
}
if SignatureMatches([]byte("<html><body>hi</body></html>"), ExtSVG) {
t.Fatal("an html document accepted as svg")
}
if SignatureMatches([]byte("<svgrect/>"), ExtSVG) {
t.Fatal("a lookalike element name accepted as svg")
}
// The avis brand is a valid AVIF image sequence.
avis := append([]byte{}, avifBytes()...)
copy(avis[8:12], "avis")
if !SignatureMatches(avis, ExtAVIF) {
t.Fatal("avis brand rejected")
}
if SignatureMatches([]byte("short"), ExtWebP) {
t.Fatal("short data accepted as webp")
}
if SignatureMatches([]byte("plain text here!!!"), ExtAVIF) {
t.Fatal("garbage accepted as avif")
}
if SignatureMatches(webpBytes(), ".png") {
t.Fatal("unknown extension accepted")
}
if SignatureMatches(avifBytes(), ExtWebP) {
t.Fatal("avif bytes accepted as webp")
}
}
func TestDetect(t *testing.T) {
if got := Detect(webpBytes()); got != ExtWebP {
t.Fatalf("Detect(webp) = %q", got)
}
if got := Detect(avifBytes()); got != ExtAVIF {
t.Fatalf("Detect(avif) = %q", got)
}
if got := Detect([]byte("<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")); got != ExtSVG {
t.Fatalf("Detect(svg) = %q", got)
}
for _, data := range [][]byte{
nil, []byte("RIFF"), []byte("GIF89a and then padding"),
[]byte("not an image at all, but long enough"),
[]byte("<!DOCTYPE html>\n<html></html>"),
} {
if got := Detect(data); got != "" {
t.Fatalf("Detect(%q) = %q, want empty", data, got)
}
}
}
func TestContentType(t *testing.T) {
cases := map[string]string{
"photo.webp": MIMEWebP,
"photo.AVIF": MIMEAVIF,
"figure.svg": MIMESVG,
"nested/dir/photo.webp": MIMEWebP,
"photo.png": "",
"photo": "",
"": "",
"photo.webp.html": "",
}
for name, want := range cases {
if got := ContentType(name); got != want {
t.Errorf("ContentType(%q) = %q, want %q", name, got, want)
}
if Allowed(name) != (want != "") {
t.Errorf("Allowed(%q) = %v, want %v", name, Allowed(name), want != "")
}
}
}
// mkbox assembles one ISO-BMFF box: a big-endian size, the four-byte type
// and the body.
func mkbox(typ string, body []byte) []byte {
size := len(body) + 8
out := make([]byte, 8, size)
out[0] = byte(size >> 24)
out[1] = byte(size >> 16)
out[2] = byte(size >> 8)
out[3] = byte(size)
copy(out[4:], typ)
return append(out, body...)
}
// avifWithDimensions assembles a minimal but structurally honest AVIF:
// ftyp, then meta naming item 1 as primary and associating its ispe.
func avifWithDimensions(width, height int) []byte {
ispe := []byte{0, 0, 0, 0} // full box: version and flags
ispe = append(ispe,
byte(width>>24), byte(width>>16), byte(width>>8), byte(width),
byte(height>>24), byte(height>>16), byte(height>>8), byte(height))
ipco := mkbox("ipco", mkbox("ispe", ispe))
// ipma v0, flags 0: one entry, item 1, one association naming
// property 1 (the ispe).
ipma := []byte{0, 0, 0, 0,
0, 0, 0, 1, // entry count
0, 1, // item id 1
1, // one association
1, // property index 1
}
pitm := []byte{0, 0, 0, 0, 0, 1} // v0, item id 1
iprp := append(ipco, mkbox("ipma", ipma)...)
metaBody := append([]byte{0, 0, 0, 0}, mkbox("pitm", pitm)...)
metaBody = append(metaBody, mkbox("iprp", iprp)...)
meta := mkbox("meta", metaBody)
ftyp := []byte{0, 0, 0, 16, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
return append(ftyp, meta...)
}
func TestDimensions(t *testing.T) {
// webpChunk builds a RIFF/WEBP file whose first chunk carries the
// payload: fourcc, little-endian size, then the bytes.
webpChunk := func(chunk string, payload ...byte) []byte {
out := append([]byte("RIFF"), 0, 0, 0, 0)
out = append(out, []byte("WEBP")...)
out = append(out, chunk...)
out = append(out, byte(len(payload)), 0, 0, 0)
return append(out, payload...)
}
// VP8L opens with 0x2f and packs width-1 then height-1 into 14-bit
// little-endian fields.
bits := uint32(1919) | uint32(1079)<<14
vp8l := webpChunk("VP8L", 0x2f, byte(bits), byte(bits>>8), byte(bits>>16), byte(bits>>24))
// VP8X carries two 24-bit minus-one canvas values after the flags
// and three reserved bytes: 999 and 599, little-endian.
vp8x := webpChunk("VP8X", 0, 0, 0, 0, 0xE7, 0x03, 0x00, 0x57, 0x02, 0x00)
// VP8 lossy: frame tag, the sync bytes, then two scaled 14-bit
// little-endian values.
vp8 := webpChunk("VP8 ", 0, 0, 0, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01)
// The AVIF structure assembled above.
avif := avifWithDimensions(800, 600)
cases := []struct {
name string
data []byte
w, h int
ok bool
}{
{"webp lossless", vp8l, 1920, 1080, true},
{"webp extended", vp8x, 1000, 600, true},
{"webp lossy", vp8, 640, 480, true},
{"avif primary item", avif, 800, 600, true},
{"empty", nil, 0, 0, false},
{"truncated webp", vp8x[:18], 0, 0, false},
{"text", []byte("plainly not an image at all"), 0, 0, false},
{"svg attributes",
[]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="800px" height="600px"><g/></svg>`), 800, 600, true},
{"svg viewbox",
[]byte("<?xml version=\"1.0\"?>\n<svg viewBox=\"0 -10 1200 900\"></svg>"), 1200, 900, true},
{"svg percent length",
[]byte(`<svg width="100%" height="100%"></svg>`), 0, 0, false},
{"svg no size",
[]byte(`<svg xmlns="http://www.w3.org/2000/svg"><circle/></svg>`), 0, 0, false},
}
for _, tc := range cases {
w, h, ok := Dimensions(tc.data)
if ok != tc.ok || (ok && (w != tc.w || h != tc.h)) {
t.Errorf("%s: Dimensions = %d, %d, %v; want %d, %d, %v",
tc.name, w, h, ok, tc.w, tc.h, tc.ok)
}
}
}
// A box whose declared content is cut short must be refused, not panic:
// the walk may only read bytes the box really holds, and a truncated
// pitm or meta sits at the end of the buffer, where a read past the box
// runs past the whole input.
func TestDimensionsTruncatedBoxes(t *testing.T) {
ftyp := []byte{0, 0, 0, 16, 'f', 't', 'y', 'p', 'a', 'v', 'i', 'f', 0, 0, 0, 0}
fullBox := func(typ string, body []byte) []byte {
return append(ftyp, mkbox(typ, body)...)
}
// A v0 pitm carries a two-byte item id; only one byte of it survives.
shortPitm := []byte{0, 0, 0, 0, 0}
// A v1 pitm carries a four-byte item id; three bytes survive.
widePitm := []byte{1, 0, 0, 0, 1, 2, 3}
// A meta box whose full-box header itself is cut in half.
for _, tc := range []struct {
name string
data []byte
}{
{"truncated pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", shortPitm)...))},
{"truncated wide pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", widePitm)...))},
{"truncated meta header", fullBox("meta", []byte{0, 0})},
{"header-only pitm", fullBox("meta", append([]byte{0, 0, 0, 0}, mkbox("pitm", nil)...))},
} {
if _, _, ok := Dimensions(tc.data); ok {
t.Errorf("%s: Dimensions reported a size", tc.name)
}
}
}
// A prefix is enough: the media listing reads only the head of a file,
// so the sizes must come from there too.
func TestDimensionsFromPrefix(t *testing.T) {
full := avifWithDimensions(123, 45)
head := make([]byte, 64<<10)
copy(head, full)
if w, h, ok := Dimensions(head[:len(full)+1024]); !ok || w != 123 || h != 45 {
t.Fatalf("prefix Dimensions = %d, %d, %v", w, h, ok)
}
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
stdhtml "html"
"regexp"
"sourcedock.dev/petrbalvin/scriptorium"
)
// mermaidBlockRe matches the fenced mermaid blocks the renderer writes.
// The diagram source arrives HTML-escaped, as code content always does.
var mermaidBlockRe = regexp.MustCompile(`(?s)<pre><code class="language-mermaid">(.*?)</code></pre>`)
// renderDiagrams replaces every fenced mermaid block with the SVG
// scriptorium draws from it, wrapped so a style sheet can tell diagrams
// from prose. It runs on sanitised HTML: the SVG is machine-drawn output
// whose vocabulary the library itself bounds, and the sanitiser's
// HTML-only parser would corrupt its case-sensitive attributes. A
// diagram the library does not carry, or a line it cannot parse, keeps
// its code block: the author sees the source that was not understood,
// and the reader never a half-drawn figure.
func renderDiagrams(html string) string {
return mermaidBlockRe.ReplaceAllStringFunc(html, func(block string) string {
src := stdhtml.UnescapeString(mermaidBlockRe.FindStringSubmatch(block)[1])
svg, err := scriptorium.RenderDiagram([]byte(src))
if err != nil {
return block
}
return `<div class="diagram">` + "\n" + string(svg) + "\n</div>"
})
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"flag"
"os"
"path/filepath"
"strings"
"testing"
)
var update = flag.Bool("update", false, "rewrite golden files")
// TestGoldenCorpus renders every corpus file and compares against the
// committed golden output. Run `go test ./internal/markdown -update` to
// regenerate after an intentional rendering change.
func TestGoldenCorpus(t *testing.T) {
corpusDir := filepath.Join("testdata", "corpus")
entries, err := os.ReadDir(corpusDir)
if err != nil {
t.Fatalf("read corpus: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".md")
t.Run(name, func(t *testing.T) {
src, err := os.ReadFile(filepath.Join(corpusDir, entry.Name()))
if err != nil {
t.Fatalf("read corpus file: %v", err)
}
htmlOut, toc, err := RenderWithTOC(string(src))
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
got := "=== HTML ===\n" + htmlOut + "=== TOC ===\n" + toc
goldenPath := filepath.Join("testdata", name+".html")
if *update {
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (run with -update): %v", err)
}
if got != string(want) {
t.Fatalf("output differs from golden:\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
})
}
}
+341
View File
@@ -0,0 +1,341 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package markdown renders Markdown posts to sanitised HTML.
//
// scriptorium renders the body (CommonMark with the GFM extensions,
// footnotes and definition lists). Mathematics and Mermaid diagrams, which
// scriptorium renders only when a consumer asks, are recognised here: a
// pre-render scan lifts $$…$$ and $…$ runs out of the source into
// placeholders and splices the MathML back after rendering, and fenced
// mermaid blocks are replaced by their SVG. Images titled with a caption
// are wrapped in <figure>/<figcaption>, headings gain id attributes and a
// table of contents, and bluemonday strips everything outside a narrow
// tag/attribute allowlist.
package markdown
import (
"fmt"
stdhtml "html"
"regexp"
"strconv"
"strings"
"sync"
"github.com/microcosm-cc/bluemonday"
"sourcedock.dev/petrbalvin/scriptorium"
)
// MaxBodyLength caps the Markdown source size (1 MiB).
const MaxBodyLength = 1_048_576
// MaxQuoteDepth bounds how deeply one line may nest blockquote markers.
// Measured: rendering cost grows superlinearly with depth (100 000
// levels take seconds, and a 1 MiB body of nothing but markers could
// reach hundreds of thousands), while legitimate prose never approaches
// the bound. It turns the worst case from an unbounded CPU burn into a
// rejection.
const MaxQuoteDepth = 100
var (
policyOnce sync.Once
sanitizer *bluemonday.Policy
)
// tocHeading is one entry of the rendered table of contents.
type tocHeading struct {
level int
id string
text string
}
func getPolicy() *bluemonday.Policy {
policyOnce.Do(func() {
sanitizer = bluemonday.NewPolicy()
sanitizer.AllowElements(
"a", "abbr", "blockquote", "br", "caption", "code", "del",
"dd", "div", "dl", "dt", "em", "figcaption", "figure",
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "img", "input",
"li", "ol", "p", "pre", "section", "span", "strong", "sub", "sup",
"table", "tbody", "td", "th", "thead", "tr", "ul",
// SVG, the output of the Mermaid diagram renderer.
"svg", "line", "path", "polygon", "rect", "text",
// MathML Core, the output of the mathematics renderer.
"math", "mi", "mn", "mo", "ms", "mtext", "mspace", "mrow",
"mfrac", "msqrt", "mroot", "msub", "msup", "msubsup",
"munder", "mover", "munderover", "merror", "mpadded",
"mphantom", "mstyle", "mtable", "mtr", "mtd",
)
// MathML leaves most elements bare: an <mi> carries no attribute
// at all, and without this the policy admits only elements that
// do.
sanitizer.AllowNoAttrs().OnElements(
"math", "mi", "mn", "mo", "ms", "mtext", "mspace", "mrow",
"mfrac", "msqrt", "mroot", "msub", "msup", "msubsup",
"munder", "mover", "munderover", "merror", "mpadded",
"mphantom", "mstyle", "mtable", "mtr", "mtd",
)
sanitizer.AllowAttrs("href", "title", "class", "id", "aria-label", "data-footnote-ref").OnElements("a")
sanitizer.AllowAttrs("src", "alt", "title", "width", "height", "loading").OnElements("img")
sanitizer.AllowAttrs("class").OnElements("div", "span", "code", "pre", "figure", "figcaption", "section", "li", "sup")
sanitizer.AllowAttrs("id").OnElements("h1", "h2", "h3", "h4", "h5", "h6", "section", "li")
sanitizer.AllowAttrs("align").OnElements("th", "td")
sanitizer.AllowAttrs("type", "checked", "disabled").OnElements("input")
sanitizer.AllowAttrs("display", "xmlns").OnElements("math")
sanitizer.AllowAttrs(
"mathvariant", "stretchy", "accent", "accentunder", "separator",
"form", "fence", "lspace", "rspace", "mathcolor",
).OnElements("mi", "mn", "mo", "ms", "mtext")
sanitizer.AllowAttrs("width").OnElements("mspace")
sanitizer.AllowAttrs("displaystyle", "scriptlevel", "mathcolor").OnElements("mstyle")
sanitizer.AllowAttrs("columnalign").OnElements("mtable")
sanitizer.AllowAttrs("data-footnotes").OnElements("section")
// The attributes of the diagram SVG: geometry and paint, the
// shapes the renderer draws and the styles a classDef or a
// linkStyle statement asks for.
sanitizer.AllowAttrs("xmlns", "viewBox", "width", "height", "font-family").OnElements("svg")
sanitizer.AllowAttrs("x", "y", "width", "height", "rx").OnElements("rect")
sanitizer.AllowAttrs("x1", "y1", "x2", "y2").OnElements("line")
sanitizer.AllowAttrs("d").OnElements("path")
sanitizer.AllowAttrs("x", "y", "text-anchor").OnElements("text")
sanitizer.AllowAttrs("points").OnElements("polygon")
sanitizer.AllowAttrs(
"fill", "stroke", "stroke-width", "stroke-dasharray",
"font-size", "opacity", "style",
).OnElements("svg", "line", "path", "polygon", "rect", "text")
sanitizer.AllowURLSchemes("http", "https", "mailto")
sanitizer.AllowRelativeURLs(true)
})
return sanitizer
}
// Render renders Markdown to sanitised HTML.
func Render(text string) (string, error) {
out, _, err := RenderWithTOC(text)
return out, err
}
// RenderWithTOC renders Markdown to sanitised HTML and returns
// (html, toc_html). toc_html is the sanitised table-of-contents markup,
// or an empty string when the body has no headings.
func RenderWithTOC(src string) (string, string, error) {
if src == "" {
return "", "", nil
}
if len(src) > MaxBodyLength {
return "", "", fmt.Errorf("body exceeds %d bytes", MaxBodyLength)
}
if quoteDepthTooDeep(src) {
return "", "", fmt.Errorf("body nests blockquotes deeper than %d levels", MaxQuoteDepth)
}
rewritten, spans := extractMath(src)
html := string(scriptorium.Render([]byte(rewritten)))
html = spliceMath(html, spans)
html, toc := addHeadingIDs(html)
raw := unwrapFigureParagraphs(wrapFigures(html))
out := sanitize(raw)
// The diagrams are drawn after sanitisation: the SVG is scriptorium's
// own output, not authored markup, and the HTML policy's parser
// rewrites the case-sensitive viewBox attribute into a form no browser
// reads. A diagram the library refuses keeps its code block, which the
// sanitiser above has already cleaned like every other one.
out = renderDiagrams(out)
return out, sanitize(toc), nil
}
// quoteDepthTooDeep reports whether any line nests blockquote markers
// beyond MaxQuoteDepth. The markers may be written with or without
// spaces between them, so both spellings are counted.
func quoteDepthTooDeep(src string) bool {
for line := range strings.SplitSeq(src, "\n") {
rest := strings.TrimLeft(line, " \t")
depth := 0
for strings.HasPrefix(rest, ">") {
depth++
if depth > MaxQuoteDepth {
return true
}
rest = strings.TrimLeft(rest[1:], " \t")
}
}
return false
}
var figureParaRe = regexp.MustCompile(`(?s)<p>(<figure>.*?</figure>)</p>`)
// unwrapFigureParagraphs lifts figures out of their wrapping paragraph
// (<p> cannot contain <figure>).
func unwrapFigureParagraphs(in string) string {
return figureParaRe.ReplaceAllString(in, "<p></p>$1<p></p>")
}
func sanitize(in string) string {
if in == "" {
return in
}
out := getPolicy().Sanitize(in)
return addLinkRel(out)
}
// linkTagRe matches anchor start tags in sanitised output.
var linkTagRe = regexp.MustCompile(`<a\s[^>]*>`)
// addLinkRel forces rel="noopener noreferrer" onto every link, matching
// the shape consumers expect.
func addLinkRel(in string) string {
return linkTagRe.ReplaceAllStringFunc(in, func(tag string) string {
if strings.Contains(tag, "rel=") {
return tag
}
return `<a rel="noopener noreferrer" ` + strings.TrimPrefix(tag, "<a ")
})
}
var (
imgTitleRe = regexp.MustCompile(`(?is)<img\s[^>]*\stitle=(?:"[^"]*"|'[^']*')[^>]*/?>`)
titleAttrRe = regexp.MustCompile(`(?is)\s+title=(?:"[^"]*"|'[^']*')`)
titleValRe = regexp.MustCompile(`(?is)^<img\s[^>]*\stitle=(?:"([^"]*)"|'([^']*)')`)
)
// wrapFigures wraps every <img title="…"> in a <figure> with a
// <figcaption>, on the raw pre-sanitisation output so the title
// attribute is still present.
func wrapFigures(in string) string {
return imgTitleRe.ReplaceAllStringFunc(in, func(imgTag string) string {
title := ""
if m := titleValRe.FindStringSubmatch(imgTag); m != nil {
title = m[1]
if title == "" {
title = m[2]
}
}
// The captured attribute value is HTML-escaped (the renderer
// escapes what it writes), so it is unescaped first: escaping it
// again would show "&amp;amp;" for a title containing "&".
// Unescaping leaves a raw-HTML title the author wrote verbatim,
// and the re-escape puts both forms back in canonical shape.
caption := stdhtml.EscapeString(stdhtml.UnescapeString(title))
cleanImg := titleAttrRe.ReplaceAllString(imgTag, "")
return "<figure>" +
cleanImg +
`<div class="fig-info" aria-hidden="true">i</div>` +
"<figcaption>" + caption + "</figcaption>" +
"</figure>"
})
}
// headingTagRe matches the headings the renderer writes: scriptorium
// emits them without attributes, so the pass below is the only source of
// their id attributes. RE2 has no backreference, so the func below
// checks that the opening and closing levels agree.
var headingTagRe = regexp.MustCompile(`(?s)<h([1-6])>(.*?)</h([1-6])>`)
// innerTagRe strips the inline markup of a heading, leaving its text.
var innerTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
// addHeadingIDs gives every heading an id attribute derived from its
// text and returns the table of contents built from the same walk.
func addHeadingIDs(in string) (string, string) {
used := make(map[string]int)
var headings []tocHeading
out := headingTagRe.ReplaceAllStringFunc(in, func(m string) string {
sub := headingTagRe.FindStringSubmatch(m)
level, inner, closeLevel := sub[1], sub[2], sub[3]
if level != closeLevel {
return m
}
label := strings.TrimSpace(stdhtml.UnescapeString(innerTagRe.ReplaceAllString(inner, "")))
if label == "" {
return m
}
id := headingSlug(label, used)
levelNum, _ := strconv.Atoi(level)
headings = append(headings, tocHeading{level: levelNum, id: id, text: label})
return "<h" + level + ` id="` + id + `">` + inner + "</h" + level + ">"
})
return out, renderTOC(headings)
}
// headingSlug turns a heading label into a unique id by the rule the
// previous renderer established, so the anchors the published pages
// already carry keep resolving: ASCII letters and digits, lowercased;
// spaces, dashes and underscores as dashes; every other byte, the
// diacritics of Czech prose included, dropped. Collisions are told
// apart by a numeric suffix.
func headingSlug(label string, used map[string]int) string {
var b strings.Builder
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 0x80:
// A multi-byte rune is dropped whole.
continue
case 'A' <= c && c <= 'Z':
b.WriteByte(c + 'a' - 'A')
case 'a' <= c && c <= 'z' || '0' <= c && c <= '9':
b.WriteByte(c)
case c == ' ' || c == '\t' || c == '-' || c == '_':
b.WriteByte('-')
}
}
id := b.String()
if id == "" {
id = "heading"
}
if n, ok := used[id]; ok {
used[id] = n + 1
id = id + "-" + strconv.Itoa(n)
}
used[id] = 1
return id
}
// renderTOC renders the headings as a table of contents in the shape the
// API documents:
// <div class="toc"><ul><li><a href="#id">Title</a></li></ul></div>.
// The wrapper is emitted even with an empty list, so a consumer can rely
// on its presence.
func renderTOC(headings []tocHeading) string {
var b strings.Builder
b.WriteString(`<div class="toc">` + "\n")
if len(headings) == 0 {
b.WriteString("<ul></ul>\n")
} else {
b.WriteString(renderTOCList(headings))
}
b.WriteString("</div>\n")
return b.String()
}
// renderTOCList renders the headings as nested lists. A run of deeper
// headings becomes a sub-list of the heading above it, and a heading
// that returns to a shallower level closes the lists it left behind and
// continues as a sibling, so a body that opens with a second-level
// heading and later uses a first-level one keeps both in the list.
func renderTOCList(headings []tocHeading) string {
var b strings.Builder
b.WriteString("<ul>\n")
for i := 0; i < len(headings); i++ {
h := headings[i]
b.WriteString(`<li><a href="#` + stdhtml.EscapeString(h.id) + `">` +
stdhtml.EscapeString(h.text) + "</a>")
if j := deeperRun(headings, i+1, h.level); j > i+1 {
b.WriteString("\n")
b.WriteString(renderTOCList(headings[i+1 : j]))
i = j - 1
}
b.WriteString("</li>\n")
}
b.WriteString("</ul>\n")
return b.String()
}
// deeperRun returns the end index of the contiguous run of headings
// deeper than level, starting at start.
func deeperRun(headings []tocHeading, start, level int) int {
end := start
for end < len(headings) && headings[end].level > level {
end++
}
return end
}
+583
View File
@@ -0,0 +1,583 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"regexp"
"strings"
"testing"
)
func TestRenderEmpty(t *testing.T) {
out, err := Render("")
if err != nil || out != "" {
t.Fatalf("Render(\"\") = %q, %v", out, err)
}
}
func TestRenderRejectsOversizedBody(t *testing.T) {
huge := strings.Repeat("a", MaxBodyLength+1)
if _, err := Render(huge); err == nil {
t.Fatal("want error for oversized body")
}
}
func TestRenderBasicParagraph(t *testing.T) {
out, err := Render("Hello *world*")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<em>world</em>") {
t.Fatalf("out = %q", out)
}
}
func TestRenderStripsScript(t *testing.T) {
out, err := Render(`<script>alert("x")</script>`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "<script") || strings.Contains(out, "alert") {
t.Fatalf("script survived: %q", out)
}
}
func TestRenderStripsEventHandlers(t *testing.T) {
out, err := Render(`<img src="/media/x.webp" onerror="alert(1)">`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "onerror") {
t.Fatalf("onerror survived: %q", out)
}
}
func TestRenderBlocksJavascriptURL(t *testing.T) {
out, err := Render(`[click](javascript:alert(1))`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "javascript:") {
t.Fatalf("javascript: URL survived: %q", out)
}
}
func TestRenderAddsLinkRel(t *testing.T) {
out, err := Render(`[link](https://example.com)`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `rel="noopener noreferrer"`) {
t.Fatalf("rel missing: %q", out)
}
}
func TestRenderCodeBlockLanguageClass(t *testing.T) {
out, err := Render("```go\nfmt.Println(1)\n```\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `class="language-go"`) {
t.Fatalf("language class missing: %q", out)
}
}
func TestRenderTaskList(t *testing.T) {
out, err := Render("- [x] done\n- [ ] todo\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `type="checkbox"`) {
t.Fatalf("checkbox missing: %q", out)
}
if strings.Count(out, "checked") < 1 {
t.Fatalf("checked state missing: %q", out)
}
}
func TestRenderStrikethrough(t *testing.T) {
out, err := Render("~~gone~~")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<del>gone</del>") {
t.Fatalf("out = %q", out)
}
}
func TestRenderTable(t *testing.T) {
out, err := Render("| a | b |\n|---|---|\n| 1 | 2 |\n")
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, "<table>") || !strings.Contains(out, "<td") {
t.Fatalf("table missing: %q", out)
}
}
func TestWrapFigures(t *testing.T) {
out, err := Render(`![alt](/media/pic.webp "Popisek")`)
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{"<figure>", "<figcaption>Popisek</figcaption>", `class="fig-info"`} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
if strings.Contains(out, "title=") {
t.Fatalf("title attribute survived on figure image: %q", out)
}
}
func TestWrapFiguresEscapesCaption(t *testing.T) {
out, err := Render(`![alt](/media/pic.webp "<b>x</b>")`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(out, "<figcaption><b>") {
t.Fatalf("caption not escaped: %q", out)
}
}
func TestRenderWithTOC(t *testing.T) {
src := "# First\n\n## Second\n\n### Third\n\n## Another\n"
out, toc, err := RenderWithTOC(src)
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
if !strings.Contains(out, `id="first"`) {
t.Fatalf("heading id missing: %q", out)
}
for _, want := range []string{`<div class="toc">`, `href="#first"`, `href="#second"`, `href="#third"`, `href="#another"`} {
if !strings.Contains(toc, want) {
t.Fatalf("toc missing %q: %q", want, toc)
}
}
// "Third" nests under "Second".
secondAt := strings.Index(toc, `href="#second"`)
thirdAt := strings.Index(toc, `href="#third"`)
anotherAt := strings.Index(toc, `href="#another"`)
if !(secondAt < thirdAt && thirdAt < anotherAt) {
t.Fatalf("toc order wrong: %q", toc)
}
if strings.Count(toc, "<ul>") < 2 {
t.Fatalf("nested list missing: %q", toc)
}
}
func TestRenderWithTOCNoHeadings(t *testing.T) {
_, toc, err := RenderWithTOC("just text")
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
// The wrapper is present even with an empty list.
want := `<div class="toc">` + "\n<ul></ul>\n</div>\n"
if toc != want {
t.Fatalf("toc = %q, want %q", toc, want)
}
}
func TestRenderAllowsRelativeImage(t *testing.T) {
out, err := Render(`![](/media/pic.webp)`)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(out, `src="/media/pic.webp"`) {
t.Fatalf("relative image stripped: %q", out)
}
}
// BenchmarkRender measures the rendering pipeline for a medium body
// (2.4 KB) and a large one (43 KB), which bracket the posts the engine is
// built for.
func BenchmarkRender(b *testing.B) {
medium := strings.Repeat("Some **markdown** text with a [link](https://example.com).\n\n", 40)
large := strings.Repeat(medium, 18)
for name, src := range map[string]string{"medium": medium, "large": large} {
b.Run(name, func(b *testing.B) {
b.SetBytes(int64(len(src)))
for b.Loop() {
if _, _, err := RenderWithTOC(src); err != nil {
b.Fatal(err)
}
}
})
}
}
// A body that opens with a second-level heading and later uses a
// first-level one must keep both in the table of contents: the shallower
// heading closes the list it was nested in rather than ending the walk.
func TestTOCKeepsShallowerHeadings(t *testing.T) {
_, toc, err := RenderWithTOC("## Intro\n\n### Detail\n\n# Later\n\ntext\n")
if err != nil {
t.Fatalf("RenderWithTOC: %v", err)
}
for _, want := range []string{"Intro", "Detail", "Later"} {
if !strings.Contains(toc, want) {
t.Fatalf("toc is missing %q:\n%s", want, toc)
}
}
if got := strings.Count(toc, "<li>"); got != 3 {
t.Fatalf("toc lists %d headings, want 3:\n%s", got, toc)
}
// Detail is nested one list deeper than Intro, and Later sits beside
// Intro rather than inside it.
nested := strings.Index(toc, `href="#detail"`)
intro := strings.Index(toc, `href="#intro"`)
later := strings.Index(toc, `href="#later"`)
if !(intro < nested && nested < later) {
t.Fatalf("headings are out of order in the toc:\n%s", toc)
}
if strings.Count(toc, "<ul>") != 2 {
t.Fatalf("want one nested list, got %d lists:\n%s", strings.Count(toc, "<ul>"), toc)
}
}
// A caption containing "&" is escaped once: goldmark writes the title
// attribute HTML-escaped, so escaping the captured value again would
// publish "&amp;amp;".
func TestFigureCaptionEscapesOnce(t *testing.T) {
out, _, err := RenderWithTOC(`![alt](/media/p.webp "Tom & Jerry")`)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<figcaption>Tom &amp; Jerry</figcaption>") {
t.Fatalf("caption = %s", out)
}
if strings.Contains(out, "&amp;amp;") {
t.Fatalf("caption double-escaped: %s", out)
}
// The raw-HTML form (author-written, unescaped by goldmark) produces
// the same caption.
raw, _, err := RenderWithTOC(`<img src="/media/p.webp" title="Tom & Jerry">`)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(raw, "<figcaption>Tom &amp; Jerry</figcaption>") {
t.Fatalf("raw caption = %s", raw)
}
}
// A heading written with an entity reference and the TOC entry for it
// agree: the TOC shows the decoded text, as the rendered heading does.
func TestTOCResolvesEntityReferences(t *testing.T) {
_, toc, err := RenderWithTOC("## Caf&eacute;\n\ntext")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(toc, ">Café</a>") {
t.Fatalf("toc = %s", toc)
}
if strings.Contains(toc, "&amp;eacute;") {
t.Fatalf("toc double-escaped: %s", toc)
}
}
// A body of nothing but blockquote markers is bounded: rendering cost
// grows superlinearly with depth, so an absurd nest is rejected instead
// of rendered.
func TestQuoteDepthIsBounded(t *testing.T) {
tooDeep := strings.Repeat(">", MaxQuoteDepth+1) + " text"
if _, _, err := RenderWithTOC(tooDeep); err == nil {
t.Fatal("an absurdly nested body was rendered")
}
// Spelled with spaces it is the same nest.
spaced := strings.Repeat("> ", MaxQuoteDepth+1) + "text"
if _, _, err := RenderWithTOC(spaced); err == nil {
t.Fatal("the spaced form slipped through")
}
// Legitimate nesting still renders, and a quoted block that merely
// mentions the marker in prose is not counted.
deep := strings.Repeat("> ", 50) + "text"
if _, _, err := RenderWithTOC(deep); err != nil {
t.Fatalf("legitimate nesting rejected: %v", err)
}
if _, _, err := RenderWithTOC("The `>` in `>>> /dev/null` is code."); err != nil {
t.Fatalf("prose with markers rejected: %v", err)
}
}
// An inline equation the author broke across lines does not swallow the
// prose after it into mathematics: the run that spans the break contains
// a dollar inside, and such a run is refused, so the broken fragments
// stay the text they look like and the next whole equation still
// renders.
func TestBrokenInlineRunKeepsProseOut(t *testing.T) {
src := "5. Čtení nese **rovnici** $\\nabla^2 h =\n (8\\pi/\\kappa a)u$: vazba je pružná síla buňky $\\kappa a = c^4/G$,\n zdroj je energie.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<merror>") {
t.Fatalf("the broken run degraded inside math: %q", out)
}
// The prose never becomes mathematics: ž occurs only in prose.
if strings.Contains(out, "<mi>ž</mi>") {
t.Fatalf("prose was swallowed into math: %q", out)
}
// The whole equation after the prose still renders.
if !strings.Contains(out, "<mi>κ</mi>") {
t.Fatalf("the clean equation did not render: %q", out)
}
// The broken fragments stay visible as their source.
if !strings.Contains(out, `$\nabla^2 h =`) {
t.Fatalf("the opening fragment did not stay text: %q", out)
}
}
// A display equation may span lines: the $$ opens on the line that
// starts the mathematics and closes on a later one, the shape the
// papers in the corpus are written in.
func TestMultiLineDisplayMath(t *testing.T) {
src := "Text above.\n\n$$r_h = \\frac{\\sigma}{\\sqrt{2\\pi G \\rho_{\\text{amb}}}},\n\\qquad M_h = \\frac{2\\sigma^2 r_h}{G}. \\quad (7)$$\n\ntext below\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "$$") {
t.Fatalf("the markers survived: %q", out)
}
for _, want := range []string{
"<p>Text above.</p>",
`<div class="math math-display">`, `display="block"`,
"<mi>σ</mi>", "<mi>M</mi>", "<mn>7</mn>",
"<p>text below</p>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// A block that opens on a line of its own collects until a closing $$.
func TestMultiLineDisplayMathBareCloser(t *testing.T) {
src := "$$\nE = mc^2\n$$\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "$$") || !strings.Contains(out, `display="block"`) {
t.Fatalf("out = %q", out)
}
}
// An unclosed $$ stays text: the search for the closer stops at a blank
// line or the line bound, so a stray marker cannot swallow the body.
func TestUnclosedDisplayMathStaysText(t *testing.T) {
src := "$$x = 1,\nstill prose\n\nmore prose\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("an unclosed block became mathematics: %q", out)
}
if !strings.Contains(out, "$$x = 1,") {
t.Fatalf("the stray marker did not survive as text: %q", out)
}
}
// A display equation set right below the sentence that introduces it,
// without a blank line, becomes its own block: the paragraph above ends,
// the equation stands alone, and the sentence after it opens a new
// paragraph.
func TestDisplayMathInterruptsParagraph(t *testing.T) {
src := "The metric reads\n$$g_{tt} = 1$$\nand continues.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
"<p>The metric reads</p>",
`<div class="math math-display">`,
`display="block"`,
"<p>and continues.</p>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// Mathematics is a prose construct: a dollar inside a fenced block, an
// indented one or a code span stays the literal byte it is, and a
// backslash-escaped dollar never opens a run. The escape itself the
// renderer consumes, so the escaped dollar reaches the reader as a bare
// one that still opens no mathematics.
func TestMathSkipsCode(t *testing.T) {
src := "```tex\n$x^2$\n```\n\n $x^2$\n\nInline `$x$` code, and \\$x\\$ escaped.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("code was turned into mathematics: %q", out)
}
for _, want := range []string{"$x^2$", "$x$"} {
if !strings.Contains(out, want) {
t.Fatalf("literal %q missing in %q", want, out)
}
}
}
// Two dollar amounts in a sentence are not a phantom equation. A $$…$$
// run that shares its line with text keeps the historical shape: the
// first dollar stays text, the inner $…$ is inline mathematics and the
// closing dollar follows it, exactly as the engine this pass replaces
// rendered it.
func TestCurrencyGuards(t *testing.T) {
out, err := Render("Costs $5 and $10 per group.\n\nSplit $$x$$ mid line.\n")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<p>Costs $5 and $10 per group.</p>") {
t.Fatalf("amounts became mathematics: %q", out)
}
if !strings.Contains(out, "Split $<math") || !strings.Contains(out, "</math>$ mid line.") {
t.Fatalf("the mid-line run changed shape: %q", out)
}
}
// A construct outside the mappable surface is not refused: it degrades
// in place, its source visible in an merror element.
func TestMathDegradesInPlace(t *testing.T) {
out, err := Render("$$\\raisebox{1em}{E}$$\n")
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, "<merror>") || !strings.Contains(out, `\raisebox`) {
t.Fatalf("no honest degradation: %q", out)
}
}
// A body that already carries the private-use sentinel runes is left
// alone rather than spliced into the wrong place.
func TestSentinelRunesDisableMath(t *testing.T) {
src := "Text \uE000" + "0" + "\uE001 with $x$ inside.\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if strings.Contains(out, "<math") {
t.Fatalf("a sentinel-bearing body was spliced: %q", out)
}
}
// A flowchart or a sequence diagram renders to an inline SVG in a
// wrapper a style sheet can address.
func TestMermaidRendersDiagram(t *testing.T) {
out, err := Render("```mermaid\nflowchart LR\n A --> B\n```\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
`<div class="diagram">`, "<svg", `viewBox=`, "</svg>",
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
if strings.Contains(out, "<pre") {
t.Fatalf("the code block survived the rendered diagram: %q", out)
}
}
// A diagram type outside the two families the library carries keeps its
// code block, so the author sees the source that was refused.
func TestMermaidRefusalKeepsCode(t *testing.T) {
src := "```mermaid\nstateDiagram-v2\n [*] --> calm\n```\n"
out, err := Render(src)
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(out, `class="language-mermaid"`) || strings.Contains(out, "<svg") {
t.Fatalf("the refused diagram did not stay source: %q", out)
}
}
// The diagram SVG is spliced in after the sanitiser, so the diagram
// source must not be able to smuggle markup the policy would have
// refused: a hostile label or interaction line may at worst break the
// drawing, never open an element, name an event handler inside a tag or
// plant a javascript: URL. Label text itself is escaped by the renderer
// and stays inert, so the assertions look at markup positions, not at
// the mere presence of a word.
func TestMermaidSourceCannotInjectMarkup(t *testing.T) {
// handlerInTag matches an event handler attribute inside a tag, and
// scriptURL an attribute whose URL is a javascript: one.
handlerInTag := regexp.MustCompile(`(?is)<[a-z][^>]*\bon[a-z]+\s*=`)
scriptURL := regexp.MustCompile(`(?is)<[a-z][^>]*(?:href|src)\s*=\s*["']\s*javascript:`)
sources := []string{
"flowchart LR\n A[\"<img src=x onerror=alert(1)>\"] --> B\n",
"flowchart LR\n A[\"<script>alert(1)</script>\"] --> B\n",
"flowchart LR\n A[\"x\" onmouseover=\"alert(1)\"] --> B\n",
"flowchart LR\n A --> B\n click B \"javascript:alert(1)\"\n",
}
for _, src := range sources {
out, err := Render("```mermaid\n" + src + "```\n")
if err != nil {
t.Fatalf("render %q: %v", src, err)
}
lower := strings.ToLower(out)
for _, banned := range []string{"<img", "<script"} {
if strings.Contains(lower, banned) {
t.Fatalf("%q reached the page through the diagram: %q", banned, out)
}
}
if loc := handlerInTag.FindString(lower); loc != "" {
t.Fatalf("an event handler reached a tag through the diagram (%q): %q", loc, out)
}
if loc := scriptURL.FindString(lower); loc != "" {
t.Fatalf("a javascript URL reached a tag through the diagram (%q): %q", loc, out)
}
}
}
// Definition lists survive sanitisation as themselves: the terms stay
// dt, the definitions dd.
func TestDefinitionListSurvives(t *testing.T) {
out, err := Render("Term\n: definition\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{"<dl>", "<dt>Term</dt>", "<dd>definition</dd>", "</dl>"} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// The footnote round trip keeps its ids and markers: the reference
// carries the id the back reference points back to.
func TestFootnoteMarkersSurvive(t *testing.T) {
out, err := Render("Text[^1].\n\n[^1]: The note.\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{
`href="#fn-1"`, `id="fnref-1"`, `data-footnote-ref`, `id="fn-1"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
// Two headings of the same text are told apart by a numeric suffix. A
// heading of Czech prose keeps the id the previous renderer gave it:
// the diacritics are dropped, exactly as the anchors already published
// spell them.
func TestHeadingSlugs(t *testing.T) {
out, _, err := RenderWithTOC("## Same\n\ntext\n\n## Same\n\n## Čeština pro vědce\n")
if err != nil {
t.Fatalf("render: %v", err)
}
for _, want := range []string{`id="same"`, `id="same-1"`, `id="etina-pro-vdce"`} {
if !strings.Contains(out, want) {
t.Fatalf("missing %q in %q", want, out)
}
}
}
+538
View File
@@ -0,0 +1,538 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package markdown
import (
"regexp"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/scriptorium"
)
// The mathematics pass turns TeX runs into MathML: $$…$$ occupying a
// whole line becomes a display block, $…$ within one line becomes an
// inline element. scriptorium's Markdown grammar knows nothing about
// dollars, so the runs are lifted out of the source before rendering,
// each replaced by a placeholder of two private-use runes around its
// index, and the MathML is spliced back into the rendered HTML in the
// placeholder's place. A run outside the mappable surface is not
// refused: scriptorium degrades it in place, its verbatim source inside
// an merror element, so nothing is silently mistranslated.
// mathSpan is one equation lifted out of the source.
type mathSpan struct {
display bool
src string
}
// The placeholder runes come from Unicode's private-use area, so no
// authored body contains them; a body that somehow does is left without
// mathematics rather than spliced into the wrong place.
const (
sentinelOpen = '\uE000'
sentinelClose = '\uE001'
)
func mathToken(i int) string {
return string(sentinelOpen) + strconv.Itoa(i) + string(sentinelClose)
}
// lineKind tells how the previous emitted line ended, which is what the
// display rules need: an equation that follows text needs a separating
// blank line, or the renderer keeps it inside the paragraph above it.
type lineKind int
const (
prevBlank lineKind = iota
prevText
prevDisplay
)
// extractMath rewrites the source with placeholders and returns the
// equations in the order their placeholders appear.
func extractMath(src string) (string, []mathSpan) {
if strings.ContainsRune(src, sentinelOpen) || strings.ContainsRune(src, sentinelClose) {
return src, nil
}
var spans []mathSpan
next := func(display bool, src string) string {
spans = append(spans, mathSpan{display: display, src: src})
return mathToken(len(spans) - 1)
}
var out strings.Builder
emit := func(line string) {
out.WriteString(line)
out.WriteByte('\n')
}
fenceChar := byte(0)
fenceLen := 0
inCode := false
inHTML := false
pendingTick := 0
prev := prevBlank
// The lines are indexed rather than streamed: the multi-line display
// collector looks ahead from the line it stands on.
lines := strings.Split(src, "\n")
for i := 0; i < len(lines); i++ {
line := lines[i]
indent := len(line) - len(strings.TrimLeft(line, " \t"))
blank := strings.TrimSpace(line) == ""
// A fenced code block passes every line through verbatim.
if fenceChar != 0 {
emit(line)
if isClosingFence(line, fenceChar, fenceLen) {
fenceChar = 0
prev = prevBlank
}
continue
}
// The code-span cover of the line, carrying any run an earlier
// line left open. A span that has not closed keeps the whole
// line out of every other rule.
covered, open := codeSpans(line, pendingTick)
if open > 0 {
emit(line)
pendingTick = open
prev = prevText
continue
}
pendingTick = 0
startsCovered := len(covered) > 0 && covered[0]
if !startsCovered {
if c, n, ok := openingFence(line); ok {
fenceChar, fenceLen = c, n
emit(line)
continue
}
}
// An indented code block: entered from a blank line, left by the
// first line that is blank or carries less indentation.
if inCode {
if blank || indent >= 4 {
emit(line)
continue
}
inCode = false
} else if indent >= 4 && prev == prevBlank && !blank {
inCode = true
emit(line)
continue
}
// A raw HTML block runs to the next blank line, and its dollars
// are markup, not mathematics.
if inHTML {
emit(line)
if blank {
inHTML = false
prev = prevBlank
}
continue
}
if !startsCovered && startsHTMLBlock(line) {
inHTML = true
emit(line)
continue
}
if blank {
emit(line)
prev = prevBlank
continue
}
quotePrefix, rest := stripQuoteMarkers(line)
listPrefix, item, inList := stripListMarker(rest)
if inner, ok := displayMath(item); ok {
if !inList && prev != prevBlank {
emit(strings.TrimRight(quotePrefix, " \t"))
}
emit(quotePrefix + listPrefix + next(true, inner))
prev = prevDisplay
continue
}
if parts, end, ok := collectDisplayBlock(lines, i, item); ok {
if !inList && prev != prevBlank {
emit("")
}
emit(quotePrefix + listPrefix + next(true, strings.Join(parts, "\n")))
prev = prevDisplay
i = end
continue
}
if prev == prevDisplay && !inList {
emit(strings.TrimRight(quotePrefix, " \t"))
}
off := len(quotePrefix) + len(listPrefix)
emit(quotePrefix + listPrefix + renderInlineMath(item, covered, off, next))
prev = prevText
}
return out.String(), spans
}
// maxMathBlockLines bounds how far a multi-line display block may reach
// for its closing $$. A block the author never closed then falls back to
// literal text instead of swallowing the rest of the body.
const maxMathBlockLines = 64
// collectDisplayBlock looks ahead from lines[i], whose content opens a
// $$ block it does not close on the same line, for the line that closes
// it. The content lines between become the parts of one display
// equation. The collection stays inside one paragraph: a blank line, a
// code fence, a blockquote marker or the line bound ends the search and
// the block is refused, so a stray $$ stays the text it looks like.
func collectDisplayBlock(lines []string, i int, item string) (parts []string, end int, ok bool) {
if !strings.HasPrefix(item, "$$") {
return nil, 0, false
}
first := item[2:]
if strings.Contains(first, "$$") {
return nil, 0, false
}
if strings.TrimSpace(first) != "" {
parts = append(parts, first)
}
for j := i + 1; j < len(lines) && j-i <= maxMathBlockLines; j++ {
t := strings.TrimSpace(lines[j])
if t == "" || strings.HasPrefix(t, ">") {
return nil, 0, false
}
if _, _, fenced := openingFence(lines[j]); fenced {
return nil, 0, false
}
if strings.HasSuffix(t, "$$") && backslashRun(t, len(t)-2)%2 == 0 {
tail := t[:len(t)-2]
if strings.Contains(tail, "$$") {
return nil, 0, false
}
if strings.TrimSpace(tail) != "" {
parts = append(parts, tail)
}
if len(parts) == 0 || strings.TrimSpace(strings.Join(parts, "")) == "" {
return nil, 0, false
}
return parts, j, true
}
parts = append(parts, t)
}
return nil, 0, false
}
// renderInlineMath replaces the $…$ runs of one line with placeholders,
// leaving the bytes inside backtick code spans and the dollars written
// \$ alone. The cover was computed for the whole source line, so off
// tells where the line's remaining content begins in it.
func renderInlineMath(line string, covered []bool, off int, next func(bool, string) string) string {
var b strings.Builder
i := 0
for i < len(line) {
if i+off < len(covered) && covered[i+off] {
b.WriteByte(line[i])
i++
continue
}
if line[i] == '$' && backslashRun(line, i)%2 == 0 {
if value, consumed, ok := matchInlineMath(line[i:]); ok {
b.WriteString(next(false, value))
i += consumed
continue
}
}
b.WriteByte(line[i])
i++
}
return b.String()
}
// displayMath reports whether the whole of a line's content is one
// $$…$$ run, and returns the mathematics between the fences. A run that
// is empty, that hides another $$ or that shares its line with anything
// else is not a display equation.
func displayMath(t string) (string, bool) {
if len(t) < 5 || !strings.HasPrefix(t, "$$") || !strings.HasSuffix(t, "$$") {
return "", false
}
inner := t[2 : len(t)-2]
if strings.Contains(inner, "$$") || strings.TrimSpace(inner) == "" {
return "", false
}
return inner, true
}
// matchInlineMath matches one $…$ run at the head of line. The guards
// mirror the shape of real prose: the run must not be empty, may not
// start or end with a space, may not close before a digit, and may not
// contain a dollar inside, so a sentence with two dollar amounts does
// not become a phantom equation, and a run whose opening dollar is a
// closer of an equation broken across lines never swallows the prose
// around it into mathematics.
func matchInlineMath(line string) (value string, consumed int, ok bool) {
if len(line) < 3 || line[0] != '$' || line[1] == '$' || line[1] == ' ' {
return "", 0, false
}
for i := 1; i < len(line); i++ {
if line[i] == '\\' {
i++ // an escaped character is never the closer
continue
}
if line[i] != '$' || i == 1 {
continue
}
if line[i-1] == ' ' {
continue
}
if i+1 < len(line) && isDigit(line[i+1]) {
continue // currency: the next run of digits belongs outside
}
value := line[1:i]
if strings.ContainsRune(value, '$') {
return "", 0, false
}
return value, i + 1, true
}
return "", 0, false
}
func isDigit(b byte) bool { return '0' <= b && b <= '9' }
// backslashRun counts the backslashes immediately before line[i]; an
// odd count means the byte is escaped.
func backslashRun(line string, i int) int {
n := 0
for j := i - 1; j >= 0 && line[j] == '\\'; j-- {
n++
}
return n
}
// codeSpans marks the bytes of line that sit inside a backtick code
// span and reports the length of a run the line leaves open. A span
// opens with a run of n backticks and closes with the next run of
// exactly n; a run left open is carried to the next line by the
// pending state, and while it is open no dollar on the line starts
// mathematics.
func codeSpans(line string, pending int) (covered []bool, open int) {
covered = make([]bool, len(line))
open = pending
i := 0
if pending > 0 {
end := findTickRun(line, pending)
if end < 0 {
for j := range covered {
covered[j] = true
}
return covered, pending
}
for j := 0; j < end+pending; j++ {
covered[j] = true
}
i = end + pending
open = 0
}
for i < len(line) {
if line[i] != '`' {
i++
continue
}
n := 0
for i+n < len(line) && line[i+n] == '`' {
n++
}
end := findTickRun(line[i+n:], n)
if end < 0 {
if open == 0 {
open = n
}
i += n
continue
}
closeAt := i + n + end
for j := i; j < closeAt+n; j++ {
covered[j] = true
}
i = closeAt + n
}
return covered, open
}
// findTickRun returns the index in line where a run of exactly n
// backticks begins, or -1 when there is none.
func findTickRun(line string, n int) int {
for i := 0; i < len(line); {
if line[i] != '`' {
i++
continue
}
run := 0
for i+run < len(line) && line[i+run] == '`' {
run++
}
if run == n {
return i
}
i += run
}
return -1
}
// openingFence reports whether the line opens a fenced code block, and
// with which character and length.
func openingFence(line string) (byte, int, bool) {
t := strings.TrimLeft(line, " \t")
if len(line)-len(t) > 3 {
return 0, 0, false
}
if n := fenceRun(t, '`'); n > 0 {
return '`', n, true
}
if n := fenceRun(t, '~'); n > 0 {
return '~', n, true
}
return 0, 0, false
}
// fenceRun returns the length of a fence of c at the head of t, which
// the rest of the line may follow only with spaces, or 0 when this is
// not a fence.
func fenceRun(t string, c byte) int {
n := 0
for n < len(t) && t[n] == c {
n++
}
if n < 3 {
return 0
}
for _, r := range t[n:] {
if r != ' ' && r != '\t' {
return 0
}
}
return n
}
// isClosingFence reports whether the line closes an open fence.
func isClosingFence(line string, c byte, n int) bool {
t := strings.TrimLeft(line, " \t")
if len(line)-len(t) > 3 {
return false
}
run := 0
for run < len(t) && t[run] == c {
run++
}
if run < n {
return false
}
for _, r := range t[run:] {
if r != ' ' && r != '\t' {
return false
}
}
return true
}
// startsHTMLBlock approximates the CommonMark HTML block: a line that
// opens with a tag, a closing tag, a comment or a declaration starts
// one, and the block then runs to the next blank line.
func startsHTMLBlock(line string) bool {
t := strings.TrimLeft(line, " \t")
if len(t) < 2 || t[0] != '<' {
return false
}
c := t[1]
return c == '/' || c == '!' || c == '?' ||
('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z')
}
// stripQuoteMarkers removes the blockquote markers from the head of the
// line and returns everything consumed with what remains.
func stripQuoteMarkers(line string) (prefix, rest string) {
rest = line
for {
j := 0
for j < len(rest) && (rest[j] == ' ' || rest[j] == '\t') {
j++
}
if j >= len(rest) || rest[j] != '>' {
break
}
rest = rest[j+1:]
}
return line[:len(line)-len(rest)], rest
}
// stripListMarker removes one list marker from the head of the line, so
// an equation that is a list item's whole content is still recognised
// as display mathematics inside the item.
func stripListMarker(line string) (prefix, rest string, ok bool) {
j := 0
for j < len(line) && (line[j] == ' ' || line[j] == '\t') {
j++
}
rest = line[j:]
if strings.HasPrefix(rest, "- ") || strings.HasPrefix(rest, "* ") || strings.HasPrefix(rest, "+ ") {
rest = rest[2:]
} else {
digits := 0
for digits < len(rest) && digits < 9 && isDigit(rest[digits]) {
digits++
}
if digits == 0 || digits+1 >= len(rest) {
return "", line, false
}
if (rest[digits] == '.' || rest[digits] == ')') && rest[digits+1] == ' ' {
rest = rest[digits+2:]
} else {
return "", line, false
}
}
rest = strings.TrimLeft(rest, " \t")
if rest == "" {
return "", line, false
}
return line[:len(line)-len(rest)], rest, true
}
// spliceMath puts the rendered MathML into the HTML in each
// placeholder's place. A placeholder alone in its paragraph becomes the
// display wrapper; any other position takes the math element as it
// stands, which keeps the HTML valid where a display equation shares
// its paragraph with text or sits in a tight list item.
func spliceMath(html string, spans []mathSpan) string {
for i, span := range spans {
math := renderMathSpan(span)
alone := regexp.MustCompile(`(?s)<p>\s*` + mathToken(i) + `\s*</p>`)
if alone.MatchString(html) {
html = alone.ReplaceAllString(html, regexpEscapeRepl(math))
continue
}
html = strings.Replace(html, mathToken(i), math, 1)
}
return html
}
// regexpEscapeRepl guards the replacement text of ReplaceAllString,
// where a dollar sign would otherwise read as a capture group.
func regexpEscapeRepl(s string) string {
return strings.ReplaceAll(s, "$", "$$")
}
// renderMathSpan renders one equation through scriptorium. Rendering
// never fails: a construct outside the mappable surface degrades to its
// verbatim source inside an merror element, in place.
func renderMathSpan(span mathSpan) string {
if span.display {
return `<div class="math math-display">` + "\n" +
string(scriptorium.RenderMathDisplay([]byte(span.src))) + "\n</div>"
}
return string(scriptorium.RenderMath([]byte(span.src)))
}
+6
View File
@@ -0,0 +1,6 @@
=== HTML ===
<p>Hello <em>world</em> and <strong>bold</strong></p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+11
View File
@@ -0,0 +1,11 @@
=== HTML ===
<p>Prose before.</p>
<pre><code class="language-go">fmt.Println(&#34;hi&#34;)
</code></pre>
<pre><code>indented code
</code></pre>
<p>Inline <code>code</code> too.</p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+1
View File
@@ -0,0 +1 @@
Hello *world* and **bold**
+9
View File
@@ -0,0 +1,9 @@
Prose before.
```go
fmt.Println("hi")
```
indented code
Inline `code` too.
+3
View File
@@ -0,0 +1,3 @@
![alt text](/media/pic.webp "Caption here")
![plain](/media/other.webp)
+9
View File
@@ -0,0 +1,9 @@
# Title One
Some [link](https://example.com) here.
## Sub & "head"
### Deep
Text.
+7
View File
@@ -0,0 +1,7 @@
<script>alert(1)</script>
<img src="/media/x.webp" onerror="alert(1)">
[click](javascript:alert(1))
<iframe src="https://evil"></iframe>
+28
View File
@@ -0,0 +1,28 @@
# Math
Inline radiation: $E = h\nu$ redshifted by $\sqrt{g_{tt}}$.
$$E = h\nu\,\sqrt{g_{tt}(r)} = \text{konst.} \quad (1)$$
The metric reads $ds^2 = -g_{tt}(r)c^2dt^2 + dr^2 + r^2d\Omega^2$, and a
photon loses energy as $\frac{E(t)}{E_0} = e^{-Ht}$.
$$1+z = \frac{\nu(r_1)}{\nu(r_2)} = \sqrt{\frac{g_{tt}(r_1)}{g_{tt}(r_2)}}. \quad (2)$$
Prices are not math: the ticket costs $5 and the guide $10 per group.
The metric in prose right above its display form
$$g_{tt}(r) = 1 - \frac{r_s}{r}$$
continues in a sentence after it.
$$r_h = \frac{\sigma}{\sqrt{2\pi G \rho_{\text{amb}}}},
\qquad M_h = \frac{2\sigma^2 r_h}{G}. \quad (7)$$
An equation broken across lines keeps its prose out of mathematics
$\nabla^2 h =
(8\pi/\kappa a)u$: the binding is the force of a cell $\kappa a = c^4/G$,
and the source is the energy.
$$\raisebox{1em}{E} = h\nu$$
Inline degradation too: $\raisebox{1em}{E}$ stays visible where it stands.
+28
View File
@@ -0,0 +1,28 @@
# Diagrams
A flowchart of the observed cycle:
```mermaid
flowchart LR
A[Observation] --> B{Hypothesis}
B -->|confirmed| C[Theory]
B -->|refuted| D[Revised model]
```
A sequence of a measurement:
```mermaid
sequenceDiagram
participant O as Observer
participant S as Source
O->>S: request spectrum
S-->>O: redshifted light
Note over S: proper time runs slow
```
A type the renderer does not carry stays source:
```mermaid
stateDiagram-v2
[*] --> calm
```
+10
View File
@@ -0,0 +1,10 @@
~~struck~~ text
> quote
Footnote[^1]
[^1]: note text
Term
: definition
+3
View File
@@ -0,0 +1,3 @@
| a | b |
|---|---|
| 1 | 2 |
+5
View File
@@ -0,0 +1,5 @@
- [x] done
- [ ] todo
1. ordered
2. items
+7
View File
@@ -0,0 +1,7 @@
=== HTML ===
<p></p><figure><img src="/media/pic.webp" alt="alt text"/><div class="fig-info">i</div><figcaption>Caption here</figcaption></figure><p></p>
<p><img src="/media/other.webp" alt="plain"/></p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+20
View File
@@ -0,0 +1,20 @@
=== HTML ===
<h1 id="title-one">Title One</h1>
<p>Some <a rel="noopener noreferrer" href="https://example.com">link</a> here.</p>
<h2 id="sub--head">Sub &amp; &#34;head&#34;</h2>
<h3 id="deep">Deep</h3>
<p>Text.</p>
=== TOC ===
<div class="toc">
<ul>
<li><a rel="noopener noreferrer" href="#title-one">Title One</a>
<ul>
<li><a rel="noopener noreferrer" href="#sub--head">Sub &amp; &#34;head&#34;</a>
<ul>
<li><a rel="noopener noreferrer" href="#deep">Deep</a></li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
+9
View File
@@ -0,0 +1,9 @@
=== HTML ===
<img src="/media/x.webp">
<p>click</p>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+34
View File
@@ -0,0 +1,34 @@
=== HTML ===
<h1 id="math">Math</h1>
<p>Inline radiation: <math xmlns="http://www.w3.org/1998/Math/MathML"><mi>E</mi><mo>=</mo><mi>h</mi><mi>ν</mi></math> redshifted by <math xmlns="http://www.w3.org/1998/Math/MathML"><msqrt><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub></msqrt></math>.</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mi>E</mi><mo>=</mo><mi>h</mi><mi>ν</mi><mspace width="0.1667em"></mspace><msqrt><mrow><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><mi>r</mi><mo>)</mo></mrow></msqrt><mo>=</mo><mtext>konst.</mtext><mspace width="1em"></mspace><mo>(</mo><mn>1</mn><mo>)</mo></math>
</div>
<p>The metric reads <math xmlns="http://www.w3.org/1998/Math/MathML"><mi>d</mi><msup><mi>s</mi><mn>2</mn></msup><mo>=</mo><mo>-</mo><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><mi>r</mi><mo>)</mo><msup><mi>c</mi><mn>2</mn></msup><mi>d</mi><msup><mi>t</mi><mn>2</mn></msup><mo>+</mo><mi>d</mi><msup><mi>r</mi><mn>2</mn></msup><mo>+</mo><msup><mi>r</mi><mn>2</mn></msup><mi>d</mi><msup><mi>Ω</mi><mn>2</mn></msup></math>, and a
photon loses energy as <math xmlns="http://www.w3.org/1998/Math/MathML"><mfrac><mrow><mi>E</mi><mo>(</mo><mi>t</mi><mo>)</mo></mrow><msub><mi>E</mi><mn>0</mn></msub></mfrac><mo>=</mo><msup><mi>e</mi><mrow><mo>-</mo><mi>H</mi><mi>t</mi></mrow></msup></math>.</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mn>1</mn><mo>+</mo><mi>z</mi><mo>=</mo><mfrac><mrow><mi>ν</mi><mo>(</mo><msub><mi>r</mi><mn>1</mn></msub><mo>)</mo></mrow><mrow><mi>ν</mi><mo>(</mo><msub><mi>r</mi><mn>2</mn></msub><mo>)</mo></mrow></mfrac><mo>=</mo><msqrt><mfrac><mrow><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><msub><mi>r</mi><mn>1</mn></msub><mo>)</mo></mrow><mrow><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><msub><mi>r</mi><mn>2</mn></msub><mo>)</mo></mrow></mfrac></msqrt><mo>.</mo><mspace width="1em"></mspace><mo>(</mo><mn>2</mn><mo>)</mo></math>
</div>
<p>Prices are not math: the ticket costs $5 and the guide $10 per group.</p>
<p>The metric in prose right above its display form</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><msub><mi>g</mi><mrow><mi>t</mi><mi>t</mi></mrow></msub><mo>(</mo><mi>r</mi><mo>)</mo><mo>=</mo><mn>1</mn><mo>-</mo><mfrac><msub><mi>r</mi><mi>s</mi></msub><mi>r</mi></mfrac></math>
</div>
<p>continues in a sentence after it.</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><msub><mi>r</mi><mi>h</mi></msub><mo>=</mo><mfrac><mi>σ</mi><msqrt><mrow><mn>2</mn><mi>π</mi><mi>G</mi><msub><mi>ρ</mi><mtext>amb</mtext></msub></mrow></msqrt></mfrac><mo>,</mo><mspace width="2em"></mspace><msub><mi>M</mi><mi>h</mi></msub><mo>=</mo><mfrac><mrow><mn>2</mn><msup><mi>σ</mi><mn>2</mn></msup><msub><mi>r</mi><mi>h</mi></msub></mrow><mi>G</mi></mfrac><mo>.</mo><mspace width="1em"></mspace><mo>(</mo><mn>7</mn><mo>)</mo></math>
</div>
<p>An equation broken across lines keeps its prose out of mathematics
$\nabla^2 h =
(8\pi/\kappa a)u$: the binding is the force of a cell <math xmlns="http://www.w3.org/1998/Math/MathML"><mi>κ</mi><mi>a</mi><mo>=</mo><msup><mi>c</mi><mn>4</mn></msup><mo>/</mo><mi>G</mi></math>,
and the source is the energy.</p>
<div class="math math-display">
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><merror><mtext>\raisebox</mtext></merror><mrow><mn>1</mn><mi>e</mi><mi>m</mi></mrow><mi>E</mi><mo>=</mo><mi>h</mi><mi>ν</mi></math>
</div>
<p>Inline degradation too: <math xmlns="http://www.w3.org/1998/Math/MathML"><merror><mtext>\raisebox</mtext></merror><mrow><mn>1</mn><mi>e</mi><mi>m</mi></mrow><mi>E</mi></math> stays visible where it stands.</p>
=== TOC ===
<div class="toc">
<ul>
<li><a rel="noopener noreferrer" href="#math">Math</a></li>
</ul>
</div>
+20
View File
@@ -0,0 +1,20 @@
=== HTML ===
<h1 id="diagrams">Diagrams</h1>
<p>A flowchart of the observed cycle:</p>
<div class="diagram">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 388 340" font-family="sans-serif"><path d="M 76 194 C 108 194, 108 194, 140 194" fill="none" stroke="#555" stroke-width="2"/><path d="M 248 194 C 280 194, 280 104, 312 104" fill="none" stroke="#555" stroke-width="2"/><path d="M 248 194 C 280 194, 280 242, 312 242" fill="none" stroke="#555" stroke-width="2"/><polygon points="140,194 129,199 129,189" fill="#555"/><polygon points="312,104 301,109 301,99" fill="#555"/><polygon points="312,242 301,247 301,237" fill="#555"/><rect x="32" y="136" width="44" height="116" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="54" y="199" text-anchor="middle" font-size="14">Observation</text><polygon points="194,86 248,194 194,302 140,194" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="194" y="199" text-anchor="middle" font-size="14">Hypothesis</text><rect x="312" y="56" width="44" height="96" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="334" y="109" text-anchor="middle" font-size="14">Theory</text><rect x="312" y="176" width="44" height="132" fill="#ffffff" stroke="#333" stroke-width="1.5"/><text x="334" y="247" text-anchor="middle" font-size="14">Revised model</text><text x="280" y="142" text-anchor="middle" font-size="12">confirmed</text><text x="280" y="211" text-anchor="middle" font-size="12">refuted</text></svg>
</div>
<p>A sequence of a measurement:</p>
<div class="diagram">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 348 272" font-family="sans-serif"><line x1="99" y1="68" x2="99" y2="248" stroke="#999" stroke-dasharray="5 5"/><line x1="249" y1="68" x2="249" y2="248" stroke="#999" stroke-dasharray="5 5"/><rect x="36" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="99" y="51" text-anchor="middle" font-size="14" font-weight="bold">Observer</text><rect x="186" y="24" width="126" height="44" rx="8" fill="#eef2f7" stroke="#333" stroke-width="1.5"/><text x="249" y="51" text-anchor="middle" font-size="14" font-weight="bold">Source</text><line x1="99" y1="126" x2="249" y2="126" stroke="#333" stroke-width="1.8"/><polygon points="249,126 238,121 238,131" fill="#333"/><text x="174" y="119" text-anchor="middle" font-size="13">request spectrum</text><line x1="249" y1="174" x2="99" y2="174" stroke="#333" stroke-width="1.8" stroke-dasharray="7 5"/><polygon points="99,174 110,169 110,179" fill="#333"/><text x="174" y="167" text-anchor="middle" font-size="13">redshifted light</text><rect x="219" y="196" width="60" height="30" rx="4" fill="#fffbe0" stroke="#999"/><text x="249" y="216" text-anchor="middle" font-size="13">proper time runs slow</text></svg>
</div>
<p>A type the renderer does not carry stays source:</p>
<pre><code class="language-mermaid">stateDiagram-v2
[*] --&gt; calm
</code></pre>
=== TOC ===
<div class="toc">
<ul>
<li><a rel="noopener noreferrer" href="#diagrams">Diagrams</a></li>
</ul>
</div>
+21
View File
@@ -0,0 +1,21 @@
=== HTML ===
<p><del>struck</del> text</p>
<blockquote>
<p>quote</p>
</blockquote>
<p>Footnote<sup class="footnote-ref"><a rel="noopener noreferrer" href="#fn-1" id="fnref-1" data-footnote-ref="">1</a></sup></p>
<dl>
<dt>Term</dt>
<dd>definition</dd>
</dl>
<section class="footnotes" data-footnotes="">
<ol>
<li id="fn-1">
<p>note text <a rel="noopener noreferrer" href="#fnref-1" class="data-footnote-backref" aria-label="Back to reference 1">↩</a></p>
</li>
</ol>
</section>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+19
View File
@@ -0,0 +1,19 @@
=== HTML ===
<table>
<thead>
<tr>
<th>a</th>
<th>b</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>2</td>
</tr>
</tbody>
</table>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+13
View File
@@ -0,0 +1,13 @@
=== HTML ===
<ul>
<li><input checked="" disabled="" type="checkbox"> done</li>
<li><input disabled="" type="checkbox"> todo</li>
</ul>
<ol>
<li>ordered</li>
<li>items</li>
</ol>
=== TOC ===
<div class="toc">
<ul></ul>
</div>
+167
View File
@@ -0,0 +1,167 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package password hashes and verifies passwords with scrypt.
//
// A fixed set of scrypt parameters (N, r, p, dklen, salt length) is
// enforced so weaker configurations stored in older users.toml files are
// rejected. Stored hashes use scrypt$<N>$<r>$<p>$<saltB64>$<hashB64>,
// the encoding every released version has written, so an existing
// users.toml keeps working unchanged.
package password
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"golang.org/x/crypto/scrypt"
)
const (
// CostN, BlockR, ParallelP and KeyLength are the scrypt policy floor.
CostN = 16384
BlockR = 8
ParallelP = 1
KeyLength = 32
SaltBytes = 16
prefix = "scrypt"
maxMemBytes = 64 << 20 // bound scrypt memory for hostile stored hashes
maxWorkBits = 1 << 28 // bound the full 128*N*r*p work: p multiplies CPU, not memory
)
// ErrEmpty is returned when the password to hash is empty.
var ErrEmpty = errors.New("password must not be empty")
// MaxPasswordLength caps input size to bound scrypt work.
const MaxPasswordLength = 1024
// dummy is a hash of an unguessable value, derived on first use.
var dummy = sync.OnceValue(func() string {
salt := make([]byte, SaltBytes)
rand.Read(salt)
derived, err := scrypt.Key(salt, salt, CostN, BlockR, ParallelP, KeyLength)
if err != nil {
return ""
}
return fmt.Sprintf("%s$%d$%d$%d$%s$%s",
prefix, CostN, BlockR, ParallelP,
base64.StdEncoding.EncodeToString(salt),
base64.StdEncoding.EncodeToString(derived),
)
})
// Dummy returns a valid encoded hash of a value nobody knows, for
// verification against when the username does not exist: a caller can
// spend the same scrypt work either way, so the response time does not
// reveal whether an account exists.
func Dummy() string { return dummy() }
// Hash derives a scrypt hash and returns the encoded string.
func Hash(password string) (string, error) {
if password == "" {
return "", ErrEmpty
}
if len([]rune(password)) > MaxPasswordLength {
return "", fmt.Errorf("password longer than %d characters", MaxPasswordLength)
}
salt := make([]byte, SaltBytes)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
derived, err := scrypt.Key([]byte(password), salt, CostN, BlockR, ParallelP, KeyLength)
if err != nil {
return "", fmt.Errorf("scrypt hash: %w", err)
}
return fmt.Sprintf("%s$%d$%d$%d$%s$%s",
prefix, CostN, BlockR, ParallelP,
base64.StdEncoding.EncodeToString(salt),
base64.StdEncoding.EncodeToString(derived),
), nil
}
// Verify checks a password against an encoded scrypt hash in constant
// time. Hashes produced with parameters below the policy floor, or that
// are malformed, are rejected with false and a warning is logged.
func Verify(password, encoded string) bool {
n, r, p, salt, expected, ok := parse(encoded)
if !ok {
slog.Warn("password verify: malformed stored hash")
return false
}
if n < CostN || r < BlockR || p < ParallelP || len(expected) < KeyLength {
slog.Warn("password verify: stored hash uses weak scrypt parameters, rejecting",
"n", n, "r", r, "p", p, "dklen", len(expected))
return false
}
if scryptMem(n, r) > maxMemBytes {
slog.Warn("password verify: stored hash exceeds memory bound, rejecting",
"n", n, "r", r)
return false
}
// p multiplies the sequential work without touching the memory bound,
// so it needs its own ceiling: a hostile file with a huge p would
// otherwise burn hours of CPU inside a single verification.
if p < 1 || int64(p) > maxWorkBits/(128*int64(n)*int64(r)) {
slog.Warn("password verify: stored hash exceeds work bound, rejecting",
"n", n, "r", r, "p", p)
return false
}
derived, err := scrypt.Key([]byte(password), salt, n, r, p, len(expected))
if err != nil {
slog.Warn("password verify: scrypt failed", "error", err)
return false
}
return subtle.ConstantTimeCompare(derived, expected) == 1
}
// NeedsRehash reports whether stored uses parameters the policy floor no
// longer accepts: Verify rejects such a hash, so the account cannot sign
// in until its password is reset out of band (users.toml or a new hash
// from the operator). Re-hashing on login is not possible, because the
// weak verification that would allow it is exactly what the floor forbids.
func NeedsRehash(stored string) bool {
n, r, p, _, expected, ok := parse(stored)
if !ok {
return true
}
return n < CostN || r < BlockR || p < ParallelP || len(expected) < KeyLength
}
func parse(encoded string) (n, r, p int, salt, hash []byte, ok bool) {
parts := strings.Split(encoded, "$")
if len(parts) != 6 || parts[0] != prefix {
return 0, 0, 0, nil, nil, false
}
n, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, nil, nil, false
}
r, err = strconv.Atoi(parts[2])
if err != nil {
return 0, 0, 0, nil, nil, false
}
p, err = strconv.Atoi(parts[3])
if err != nil {
return 0, 0, 0, nil, nil, false
}
salt, err = base64.StdEncoding.DecodeString(parts[4])
if err != nil {
return 0, 0, 0, nil, nil, false
}
hash, err = base64.StdEncoding.DecodeString(parts[5])
if err != nil {
return 0, 0, 0, nil, nil, false
}
return n, r, p, salt, hash, true
}
func scryptMem(n, r int) int64 {
return 128 * int64(n) * int64(r)
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package password
import (
"strings"
"testing"
)
func TestHashAndVerifyRoundTrip(t *testing.T) {
encoded, err := Hash("correct horse battery staple")
if err != nil {
t.Fatalf("Hash: %v", err)
}
if !strings.HasPrefix(encoded, "scrypt$16384$8$1$") {
t.Fatalf("encoded = %q, want scrypt$16384$8$1$ prefix", encoded)
}
if !Verify("correct horse battery staple", encoded) {
t.Fatal("Verify returned false for the correct password")
}
if Verify("wrong password", encoded) {
t.Fatal("Verify returned true for a wrong password")
}
}
func TestHashRejectsEmpty(t *testing.T) {
if _, err := Hash(""); err != ErrEmpty {
t.Fatalf("err = %v, want ErrEmpty", err)
}
}
func TestHashRejectsTooLong(t *testing.T) {
if _, err := Hash(strings.Repeat("x", MaxPasswordLength+1)); err == nil {
t.Fatal("want error for over-long password")
}
}
func TestVerifyRejectsMalformed(t *testing.T) {
for _, encoded := range []string{
"",
"not-a-hash",
"bcrypt$16384$8$1$c2FsdA==$aGFzaA==",
"scrypt$16384$8$c2FsdA==$aGFzaA==",
"scrypt$abc$8$1$c2FsdA==$aGFzaA==",
"scrypt$16384$8$1$!!!notb64==$aGFzaA==",
} {
if Verify("secret", encoded) {
t.Fatalf("Verify(%q) = true, want false", encoded)
}
}
}
func TestVerifyRejectsWeakParameters(t *testing.T) {
// N=1024, r=8, p=1 with a well-formed 32-byte hash: below the floor.
encoded := "scrypt$1024$8$1$c2FsdHNhbHRzYWx0c2E=$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA=="
if Verify("secret", encoded) {
t.Fatal("Verify accepted weak scrypt parameters")
}
if !NeedsRehash(encoded) {
t.Fatal("NeedsRehash = false for weak parameters")
}
}
func TestVerifyRejectsExcessiveMemory(t *testing.T) {
// N and r parse and clear the floor, but 128*N*r exceeds the bound.
encoded := "scrypt$1048576$1024$1$c2FsdHNhbHRzYWx0c2E=$aGFzaGhhc2hoYXNoaGFzaGhhc2hoYXNoaGFzaA=="
if Verify("secret", encoded) {
t.Fatal("Verify accepted an excessive-memory hash")
}
}
func TestNeedsRehash(t *testing.T) {
encoded, err := Hash("password123")
if err != nil {
t.Fatalf("Hash: %v", err)
}
if NeedsRehash(encoded) {
t.Fatal("NeedsRehash = true for a current-policy hash")
}
if !NeedsRehash("garbage") {
t.Fatal("NeedsRehash = false for garbage")
}
}
+335
View File
@@ -0,0 +1,335 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
json "encoding/json/v2"
"fmt"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
// Site is the /api/volumen/site payload: the [site] table as configured.
type Site struct {
Title string `json:"title"`
Description string `json:"description"`
BaseURL string `json:"base_url"`
Language string `json:"language"`
Author string `json:"author"`
FediverseCreator string `json:"fediverse_creator"`
}
// BuildSite reads the site block from the configuration.
func BuildSite(cfg *config.Config) Site {
return Site{
Title: cfg.Site.Title,
Description: cfg.Site.Description,
BaseURL: cfg.Site.BaseURL,
Language: cfg.Site.Language,
Author: cfg.Site.Author,
FediverseCreator: cfg.Site.FediverseCreator,
}
}
// Summary is the post shape used in list endpoints. Empty optional
// fields are omitted rather than sent as null.
type Summary struct {
Slug string `json:"slug,omitempty"`
Title string `json:"title,omitempty"`
Excerpt string `json:"excerpt"`
Date string `json:"date,omitempty"`
Lang string `json:"lang,omitempty"`
Tags []string `json:"tags,omitempty"`
Author string `json:"author,omitempty"`
FediverseCreator string `json:"fediverse_creator,omitempty"`
// DOI and ORCID carry the scholarly identifiers when the post has
// them: the bare identifier, normalised away from any doi.org URL or
// doi: prefix. A stored value that fails the syntax rule passes
// through untouched rather than vanishing from the read API.
DOI string `json:"doi,omitempty"`
ORCID string `json:"orcid,omitempty"`
Cover string `json:"cover,omitempty"`
CoverAlt string `json:"cover_alt,omitempty"`
CoverCaption string `json:"cover_caption,omitempty"`
ReadingTime int `json:"reading_time"`
Translations map[string]string `json:"translations,omitempty"`
Series string `json:"series,omitempty"`
SeriesOrder *int `json:"series_order,omitempty"`
URL string `json:"url"`
}
// Meta is the SEO block attached to a post detail.
type Meta struct {
URL string `json:"url"`
JSONLD string `json:"json_ld"`
OG map[string]any `json:"og"`
Twitter map[string]any `json:"twitter"`
}
// Detail is Summary plus the body, rendered HTML, table of contents and
// SEO metadata.
type Detail struct {
Summary
Body string `json:"body"`
HTML string `json:"html"`
TOC string `json:"toc"`
Meta Meta `json:"meta"`
// Fields carries the frontmatter keys outside post.ReservedMetadata,
// the author's own. A post whose frontmatter holds only known keys
// omits the member.
Fields map[string]any `json:"fields,omitzero"`
// References is the structured bibliography from the post's refs
// frontmatter, resolved to identifiers; omitted when the post cites
// nothing.
References []biblio.Entry `json:"references,omitzero"`
}
// CountedName is one entry of the tag cloud and the series list.
type CountedName struct {
Name string `json:"name"`
Count int `json:"count"`
}
// TagList is the /api/volumen/tags payload.
type TagList struct {
Tags []CountedName `json:"tags"`
}
// SeriesList is the /api/volumen/series payload.
type SeriesList struct {
Series []CountedName `json:"series"`
}
// SeriesDetail is the /api/volumen/series/{name} payload.
type SeriesDetail struct {
Name string `json:"name"`
Count int `json:"count"`
Posts []Summary `json:"posts"`
}
// Batch is the /api/volumen/posts/batch payload.
type Batch struct {
Posts []Detail `json:"posts"`
}
// PostListBase carries the fields every paginated post list shares.
type PostListBase struct {
PageSize int `json:"page_size"`
Total int `json:"total"`
Posts []Summary `json:"posts"`
}
// PageList is a page-numbered post list: it carries the page number and
// the has_next/has_prev flags, and no cursor.
type PageList struct {
PostListBase
Page int `json:"page"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_prev"`
}
// CursorList is a cursor-paginated post list; next_cursor is always
// present and is null when the cursor reached the end.
type CursorList struct {
PostListBase
NextCursor *string `json:"next_cursor"`
}
// BuildSummary maps a post onto its API shape.
func BuildSummary(p *post.Post) Summary {
summary := Summary{
Slug: p.Slug(),
Title: p.Title(),
Excerpt: p.Excerpt(),
Date: p.DateString(),
Lang: p.Lang(),
Tags: p.Tags(),
Author: p.Author(),
FediverseCreator: p.FediverseCreator(),
DOI: displayDOI(p.DOI()),
ORCID: displayORCID(p.ORCID()),
Cover: p.Cover(),
CoverAlt: p.CoverAlt(),
CoverCaption: p.CoverCaption(),
ReadingTime: p.ReadingTime(),
Translations: p.Translations(),
Series: p.Series(),
URL: fmt.Sprintf("/api/volumen/posts/%s", p.Slug()),
}
if order, ok := p.SeriesOrder(); ok {
summary.SeriesOrder = &order
}
return summary
}
// displayDOI normalises a stored DOI for the API: the bare identifier
// when it is recognisable, the stored text trimmed when it is not, so a
// hand-edited value is never silently dropped.
func displayDOI(stored string) string {
bare := identifiers.NormalizeDOI(stored)
if bare == "" {
return ""
}
if !identifiers.ValidDOI(bare) {
return strings.TrimSpace(stored)
}
return bare
}
// displayORCID upper-cases a stored iD and keeps the raw text when the
// shape is not an iD at all.
func displayORCID(stored string) string {
id := identifiers.NormalizeORCID(stored)
if id == "" {
return ""
}
if !identifiers.ValidORCID(id) {
return strings.TrimSpace(stored)
}
return id
}
// BuildDetail maps a post onto its detail shape.
func BuildDetail(p *post.Post, baseURL string) (Detail, error) {
htmlOut, err := p.HTML()
if err != nil {
return Detail{}, err
}
toc, err := p.TOC()
if err != nil {
return Detail{}, err
}
meta, err := BuildMeta(p, baseURL)
if err != nil {
return Detail{}, err
}
return Detail{
Summary: BuildSummary(p),
Body: p.Body,
HTML: htmlOut,
TOC: toc,
Meta: meta,
Fields: p.CustomFields(),
References: p.RefsLinked(),
}, nil
}
// BuildMeta builds the SEO and discovery metadata: a Schema.org Article
// JSON-LD document plus OpenGraph and Twitter card fields.
func BuildMeta(p *post.Post, baseURL string) (Meta, error) {
htmlOut, err := p.HTML()
if err != nil {
return Meta{}, err
}
plain := post.PlainText(htmlOut, 200)
base := trimTrailingSlash(baseURL)
url := "/api/volumen/posts/" + p.Slug()
if base != "" {
url = base + "/" + p.Slug()
}
title := p.Title()
if title == "" {
title = p.Slug()
}
description := p.Excerpt()
if description == "" {
description = plain
}
lang := p.Lang()
if lang == "" {
lang = "en"
}
dateStr := p.DateString()
article := map[string]any{
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"inLanguage": lang,
"datePublished": dateStr,
"dateModified": dateStr,
"url": url,
"mainEntityOfPage": map[string]any{"@type": "WebPage", "@id": url},
}
if author := p.Author(); author != "" {
person := map[string]any{"@type": "Person", "name": author}
if orcid := identifiers.ORCIDURL(p.ORCID()); orcid != "" {
person["identifier"] = orcid
}
article["author"] = person
} else if orcid := identifiers.ORCIDURL(p.ORCID()); orcid != "" {
article["author"] = map[string]any{
"@type": "Person",
"identifier": orcid,
}
}
if doi := identifiers.DOIURL(p.DOI()); doi != "" {
article["identifier"] = doi
}
if citations := biblio.Citations(p.RefsLinked()); len(citations) > 0 {
article["citation"] = citations
}
if cover := p.Cover(); cover != "" {
article["image"] = []any{cover}
}
if tags := p.Tags(); len(tags) > 0 {
article["keywords"] = tags
}
if creator := p.FediverseCreator(); creator != "" {
article["creator"] = map[string]any{"@type": "Person", "name": creator}
}
og := map[string]any{
"og:type": "article",
"og:title": title,
"og:description": description,
"og:url": url,
"og:locale": lang,
"article:published_time": dateStr,
}
if cover := p.Cover(); cover != "" {
og["og:image"] = cover
}
if tags := p.Tags(); len(tags) > 0 {
og["article:tag"] = tags
}
if author := p.Author(); author != "" {
og["article:author"] = author
}
twitter := map[string]any{
"twitter:card": "summary_large_image",
"twitter:title": title,
"twitter:description": description,
}
if cover := p.Cover(); cover != "" {
twitter["twitter:image"] = cover
}
if creator := p.FediverseCreator(); creator != "" {
twitter["twitter:creator"] = creator
}
jsonLD, err := json.Marshal(article, json.Deterministic(true))
if err != nil {
return Meta{}, fmt.Errorf("encode json-ld: %w", err)
}
return Meta{
URL: url,
JSONLD: string(jsonLD),
OG: og,
Twitter: twitter,
}, nil
}
func trimTrailingSlash(value string) string { return strings.TrimRight(value, "/") }
+172
View File
@@ -0,0 +1,172 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
"encoding/json"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
const dtoSample = `+++
title = "Ahoj světe"
slug = "ahoj-svete"
lang = "cs"
author = "Petr"
tags = ["go", "blog"]
date = 2026-08-18
series = "Série"
series_order = 2
cover = "/media/cover.webp"
cover_alt = "alt"
cover_caption = "caption"
fediverse_creator = "@petr@mastodon.social"
[translations]
en = "hello-world"
+++
# Hlavička
Tělo.
`
func TestBuildSummary(t *testing.T) {
s := BuildSummary(parsePost(t, dtoSample))
if s.Slug != "ahoj-svete" {
t.Fatalf("slug = %q", s.Slug)
}
if s.URL != "/api/volumen/posts/ahoj-svete" {
t.Fatalf("url = %q", s.URL)
}
if s.ReadingTime != 1 {
t.Fatalf("reading_time = %d", s.ReadingTime)
}
if s.SeriesOrder == nil || *s.SeriesOrder != 2 {
t.Fatalf("series_order = %v", s.SeriesOrder)
}
if len(s.Tags) != 2 || s.Tags[0] != "go" {
t.Fatalf("tags = %v", s.Tags)
}
if s.Translations["en"] != "hello-world" {
t.Fatalf("translations = %v", s.Translations)
}
if s.CoverAlt != "alt" {
t.Fatalf("cover_alt = %q", s.CoverAlt)
}
}
func TestBuildSummaryEmptyPostOmitsOptionals(t *testing.T) {
s := BuildSummary(post.New(nil, "body"))
if s.Slug != "" || s.Title != "" || s.Date != "" || s.Series != "" {
t.Fatalf("optional fields should be empty: %+v", s)
}
if len(s.Tags) != 0 || len(s.Translations) != 0 {
t.Fatalf("collections should be empty: %+v", s)
}
if s.SeriesOrder != nil {
t.Fatalf("series_order = %v, want nil", s.SeriesOrder)
}
// Marshal and confirm the empty optionals are omitted entirely.
raw, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, key := range []string{`"slug"`, `"title"`, `"date"`, `"tags"`, `"translations"`, `"series_order"`, `"cover"`} {
if strings.Contains(string(raw), key) {
t.Fatalf("%s should be omitted: %s", key, raw)
}
}
if !strings.Contains(string(raw), `"excerpt":"body"`) {
t.Fatalf("excerpt missing: %s", raw)
}
}
func TestBuildDetail(t *testing.T) {
d, err := BuildDetail(parsePost(t, dtoSample), "https://example.com/")
if err != nil {
t.Fatalf("BuildDetail: %v", err)
}
if d.HTML == "" || d.TOC == "" || d.Body == "" {
t.Fatalf("detail = %+v", d)
}
if d.Meta.URL != "https://example.com/ahoj-svete" {
t.Fatalf("meta url = %q", d.Meta.URL)
}
for _, want := range []string{
`"@context":"https://schema.org"`, `"@type":"Article"`,
`"headline":"Ahoj světe"`, `"inLanguage":"cs"`, `"datePublished":"2026-08-18"`,
} {
if !strings.Contains(d.Meta.JSONLD, want) {
t.Fatalf("json_ld missing %s: %s", want, d.Meta.JSONLD)
}
}
if d.Meta.OG["og:type"] != "article" || d.Meta.OG["og:image"] != "/media/cover.webp" {
t.Fatalf("og = %v", d.Meta.OG)
}
if d.Meta.Twitter["twitter:card"] != "summary_large_image" {
t.Fatalf("twitter = %v", d.Meta.Twitter)
}
}
func TestBuildMetaDescriptionFallback(t *testing.T) {
p := post.New(nil, "Body without an excerpt and with enough words to fill the description.")
meta, err := BuildMeta(p, "")
if err != nil {
t.Fatalf("BuildMeta: %v", err)
}
if meta.OG["og:description"] == "" || meta.OG["og:description"] == nil {
t.Fatal("og:description empty")
}
if meta.URL != "/api/volumen/posts/" {
t.Fatalf("url = %q", meta.URL)
}
}
func TestBuildSiteShape(t *testing.T) {
cfg, err := config.Load(t.TempDir()+"/none.toml", config.Overrides{Port: -1})
if err != nil {
t.Fatalf("config: %v", err)
}
site := BuildSite(cfg)
if site.Title != config.DefaultSiteTitle || site.Language != "en" {
t.Fatalf("site = %+v", site)
}
if site.FediverseCreator != "" {
t.Fatalf("fediverse_creator = %q, want empty", site.FediverseCreator)
}
}
func TestPlainTextTruncation(t *testing.T) {
long := "<p>" + strings.Repeat("word ", 100) + "</p>"
plain := post.PlainText(long, 20)
// Trailing spaces are trimmed before the ellipsis, so the result is
// at most limit+1 runes long.
if len([]rune(plain)) > 21 || !strings.HasSuffix(plain, "…") {
t.Fatalf("plain = %q (%d runes)", plain, len([]rune(plain)))
}
if plain := post.PlainText("<b>short</b>", 200); plain != "short" {
t.Fatalf("plain = %q", plain)
}
}
func TestSeriesOrderPointer(t *testing.T) {
meta := frontmatter.NewMeta()
meta.Set("series_order", int64(3))
s := BuildSummary(post.New(meta, ""))
if s.SeriesOrder == nil || *s.SeriesOrder != 3 {
t.Fatalf("series_order = %v", s.SeriesOrder)
}
meta2 := frontmatter.NewMeta()
meta2.Set("date", interpres.LocalDate{Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)})
if got := BuildSummary(post.New(meta2, "")).Date; got != "2026-01-02" {
t.Fatalf("date = %q", got)
}
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
// The write form normalises identifiers as it stores them: a doi.org
// URL becomes the bare DOI, an iD gains its upper-case X.
func TestIdentifiersNormaliseOnWrite(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "A paper",
"slug": "a-paper",
"body": "b",
"doi": "https://doi.org/10.5281/zenodo.1234567",
"orcid": "0000-0000-0000-001x",
}, nil)
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if got := p.DOI(); got != "10.5281/zenodo.1234567" {
t.Fatalf("stored doi = %q", got)
}
if got := p.ORCID(); got != "0000-0000-0000-001X" {
t.Fatalf("stored orcid = %q", got)
}
// Identifiers are reserved: they never appear in the custom fields.
if fields := p.CustomFields(); fields != nil {
t.Fatalf("CustomFields leaked identifiers: %v", fields)
}
s := newStoreWithPosts(t, nil)
if err := CreationError(p, s, nil); err != nil {
t.Fatalf("valid identifiers refused: %v", err)
}
}
func TestIdentifierValidationErrors(t *testing.T) {
s := newStoreWithPosts(t, nil)
base := func(key, value string) *post.Post {
meta := frontmatter.NewMeta()
meta.Set("slug", "x")
meta.Set("title", "T")
meta.Set(key, value)
return post.New(meta, "body")
}
cases := []struct {
key, value, want string
}{
{"doi", "20.1234/x", "DOI must look like 10.xxxx/suffix."},
{"doi", "10.1", "DOI must look like 10.xxxx/suffix."},
{"orcid", "0000-0002-1825-0098", "ORCID must look like 0000-0002-1825-0097."},
{"orcid", "not-an-orcid", "ORCID must look like 0000-0002-1825-0097."},
}
for _, tc := range cases {
err := CreationError(base(tc.key, tc.value), s, nil)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s %q: error = %v, want %q", tc.key, tc.value, err, tc.want)
}
}
// Both empty stay empty: the fields remain optional.
if err := CreationError(base("doi", ""), s, nil); err != nil {
t.Errorf("empty doi refused: %v", err)
}
}
// The read shapes carry the identifiers, and the SEO block publishes
// them: an identifier property on the article, one on its author.
func TestIdentifiersInPayloads(t *testing.T) {
meta := frontmatter.NewMeta()
meta.Set("slug", "paper")
meta.Set("title", "Paper")
meta.Set("author", "Petr Balvín")
meta.Set("doi", "https://doi.org/10.1000/xyz")
meta.Set("orcid", "0000-0002-1825-0097")
p := post.New(meta, "body text here")
summary := BuildSummary(p)
if summary.DOI != "10.1000/xyz" {
t.Fatalf("summary doi = %q", summary.DOI)
}
if summary.ORCID != "0000-0002-1825-0097" {
t.Fatalf("summary orcid = %q", summary.ORCID)
}
m, err := BuildMeta(p, "https://example.com")
if err != nil {
t.Fatalf("BuildMeta: %v", err)
}
for _, want := range []string{
`"identifier":"https://doi.org/10.1000/xyz"`,
`"identifier":"https://orcid.org/0000-0002-1825-0097"`,
} {
if !strings.Contains(m.JSONLD, want) {
t.Fatalf("json_ld missing %s:\n%s", want, m.JSONLD)
}
}
// A stored value that is no identifier at all survives the read
// untouched rather than vanishing.
broken := post.New(func() *frontmatter.Meta {
m := frontmatter.NewMeta()
m.Set("slug", "broken")
m.Set("doi", "10.ONE_HUNDRED/x")
return m
}(), "b")
if got := BuildSummary(broken).DOI; got != "10.ONE_HUNDRED/x" {
t.Fatalf("invalid stored doi dropped on read: %q", got)
}
}
+798
View File
@@ -0,0 +1,798 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package payloads builds the public API responses: filtering,
// pagination, tag clouds, series, and post validation helpers.
package payloads
import (
json "encoding/json/v2"
"fmt"
"log/slog"
"maps"
"regexp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/markdown"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
var (
// SlugRegex accepts lowercase slugs with inner dots, dashes and
// underscores.
SlugRegex = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$`)
// LangRegex accepts simple language codes such as cs or pt-BR.
LangRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
// MaxSlugLength caps slug length.
const MaxSlugLength = 200
// MaxPageSize is the largest page the list endpoints return, and the
// documented limit.
const MaxPageSize = 100
// maxSeriesOrder is the sort key for a post that carries no
// series_order: it sorts after every explicit order.
const maxSeriesOrder = 1 << 30
// Source is what the payload builders read from a content store: the
// whole set for a listing, and one post by slug for a validation rule.
type Source interface {
All() []*post.Post
Find(slug, lang string) *post.Post
}
// PublishedPosts returns all published posts, newest first. A post that
// is a draft or still scheduled is withheld.
func PublishedPosts(s Source) []*post.Post {
var visible []*post.Post
for _, p := range s.All() {
if p.Published() {
visible = append(visible, p)
}
}
slices.SortStableFunc(visible, func(a, b *post.Post) int {
return strings.Compare(b.DateString(), a.DateString())
})
return visible
}
// FilterPosts filters published posts by lang, tag and query.
func FilterPosts(posts []*post.Post, lang, tag, query string) []*post.Post {
if lang != "" {
var out []*post.Post
for _, p := range posts {
if p.Lang() == lang || p.AllLangs() {
out = append(out, p)
}
}
posts = out
}
if tag != "" {
var out []*post.Post
for _, p := range posts {
if slices.Contains(p.Tags(), tag) {
out = append(out, p)
}
}
posts = out
}
query = strings.ToLower(strings.TrimSpace(query))
if query != "" {
scored := make([]scoredPost, 0, len(posts))
for _, p := range posts {
if score := searchScore(p, query); score > 0 {
scored = append(scored, scoredPost{post: p, score: score})
}
}
// Relevance first: the strongest match leads, and posts that
// score the same keep the date order they arrived in.
slices.SortStableFunc(scored, func(a, b scoredPost) int {
return b.score - a.score
})
posts = make([]*post.Post, len(scored))
for i, s := range scored {
posts[i] = s.post
}
}
return posts
}
// scoredPost pairs a post with the relevance it scored against the
// running query.
type scoredPost struct {
post *post.Post
score int
}
// searchScore ranks one post against the lowered query: a match higher
// in the post weighs more, so a title hit outranks a passing mention in
// the body. Zero means the post does not match at all and leaves the
// result list.
func searchScore(p *post.Post, query string) int {
score := 0
title := strings.ToLower(p.Title())
if strings.Contains(title, query) {
score += 100
if strings.HasPrefix(title, query) {
score += 50
}
}
for _, tag := range p.Tags() {
tag = strings.ToLower(tag)
switch {
case tag == query:
score += 40
case strings.Contains(tag, query):
score += 15
}
}
if strings.Contains(strings.ToLower(p.Excerpt()), query) {
score += 20
}
if hits := strings.Count(strings.ToLower(p.Body), query); hits > 0 {
score += 10 + min(hits-1, 4)*2
}
return score
}
// PostsPayload builds the paginated payload for /api/volumen/posts.
// With a cursor the list starts after that slug and page numbers are
// ignored; otherwise the list is page-numbered. The two shapes differ
// in which fields they carry, so they are distinct types. A cursor that
// names no post yields an empty page rather than silently rewinding to
// the first one, which would send a paging client posts it already has.
func PostsPayload(s Source, lang, tag, query string, page, limit int, cursor string) any {
posts := FilterPosts(PublishedPosts(s), lang, tag, query)
limit = min(max(limit, 1), MaxPageSize)
total := len(posts)
offset := 0
if cursor != "" {
offset = cursorOffset(posts, cursor)
} else {
page = max(page, 1)
offset = (page - 1) * limit
}
end := min(offset+limit, total)
var pagePosts []*post.Post
if offset < total {
pagePosts = posts[offset:end]
}
summaries := make([]Summary, 0, len(pagePosts))
for _, p := range pagePosts {
summaries = append(summaries, BuildSummary(p))
}
base := PostListBase{PageSize: limit, Total: total, Posts: summaries}
var nextCursor *string
if len(pagePosts) > 0 && offset+limit < total {
last := pagePosts[len(pagePosts)-1]
value := last.Slug()
if slugShared(posts, last.Slug()) {
// Two posts share the slug (translations do): name the exact
// post, or the next page resumes after the first variant and
// serves the second one twice.
value += "." + last.Lang()
}
nextCursor = &value
}
if cursor != "" {
return CursorList{PostListBase: base, NextCursor: nextCursor}
}
return PageList{
PostListBase: base,
Page: max(page, 1),
HasNext: offset+limit < total,
HasPrev: max(page, 1) > 1,
}
}
// cursorOffset resolves a cursor to the index the next page starts
// after. A cursor is "slug", or "slug.lang" when the slug is shared by
// translations: the composite form is matched first so an ambiguous
// slug cannot resume the walk at the wrong variant. A cursor that names
// no post yields the end, an empty page, rather than rewinding.
func cursorOffset(posts []*post.Post, cursor string) int {
for i, p := range posts {
if p.Slug()+"."+p.Lang() == cursor {
return i + 1
}
}
for i, p := range posts {
if p.Slug() == cursor {
return i + 1
}
}
return len(posts)
}
// slugShared reports whether more than one post in the list carries the
// slug.
func slugShared(posts []*post.Post, slug string) bool {
count := 0
for _, p := range posts {
if p.Slug() == slug {
count++
if count > 1 {
return true
}
}
}
return false
}
// IsEmpty reports whether a paginated payload carries no posts. A
// payload of an unknown type is not treated as empty, so a future shape
// cannot accidentally turn into a 404.
func IsEmpty(payload any) bool {
switch list := payload.(type) {
case PageList:
return len(list.Posts) == 0
case CursorList:
return len(list.Posts) == 0
}
return false
}
// BuildTagCounts counts the tags of the given posts, sorted by
// frequency then name. The caller chooses the post set, so the admin
// dashboard counts its own view (drafts included) and the API counts the
// published posts.
func BuildTagCounts(posts []*post.Post) []CountedName {
counts := map[string]int{}
for _, p := range posts {
for _, tag := range p.Tags() {
counts[tag]++
}
}
return orderedCounts(counts)
}
// BuildSeriesList lists series with their post counts, most posts
// first.
func BuildSeriesList(s Source) []CountedName {
counts := map[string]int{}
for _, p := range PublishedPosts(s) {
if p.Series() != "" {
counts[p.Series()]++
}
}
return orderedCounts(counts)
}
func orderedCounts(counts map[string]int) []CountedName {
// slices.SortedFunc takes the keys as an iterator, so the list is
// built and ordered in one step.
names := slices.SortedFunc(maps.Keys(counts), func(a, b string) int {
if counts[a] != counts[b] {
return counts[b] - counts[a]
}
return strings.Compare(a, b)
})
out := make([]CountedName, 0, len(names))
for _, name := range names {
out = append(out, CountedName{Name: name, Count: counts[name]})
}
return out
}
// SeriesPosts returns the published posts of one series, ordered by
// series_order then date then slug.
func SeriesPosts(s Source, name string) []*post.Post {
var posts []*post.Post
for _, p := range PublishedPosts(s) {
if p.Series() == name {
posts = append(posts, p)
}
}
slices.SortStableFunc(posts, func(a, b *post.Post) int {
orderA, okA := a.SeriesOrder()
if !okA {
orderA = maxSeriesOrder
}
orderB, okB := b.SeriesOrder()
if !okB {
orderB = maxSeriesOrder
}
if orderA != orderB {
return orderA - orderB
}
if c := strings.Compare(a.DateString(), b.DateString()); c != 0 {
return c
}
return strings.Compare(a.Slug(), b.Slug())
})
return posts
}
// Presence returns a stripped string, or "" when empty.
func Presence(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", value))
}
// ParseDate parses an ISO 8601 date string.
func ParseDate(value any) (time.Time, bool) {
text := Presence(value)
if text == "" {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", text)
if err != nil {
return time.Time{}, false
}
return t, true
}
// ParseInt parses an integer from form data.
func ParseInt(value any) (int, bool) {
text := Presence(value)
if text == "" {
return 0, false
}
n, err := strconv.Atoi(text)
if err != nil {
return 0, false
}
return n, true
}
// ParseTags parses a comma-separated tag string.
func ParseTags(value any) []string {
raw := Presence(value)
if raw == "" {
return nil
}
var out []string
for tag := range strings.SplitSeq(raw, ",") {
if trimmed := strings.TrimSpace(tag); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// PostFromParams builds a post from admin form data, carrying the
// existing post's path when editing. Metadata keys the form does not
// manage (aliases, translations, custom fields) are inherited from the
// existing post so an editor save never drops them. A date field the
// form carries but cannot parse is reported as a ValidationError on the
// built post: an unparseable value dropping the key would silently
// publish a scheduled post. The caller renders the returned post back
// into the form either way.
func PostFromParams(form map[string]string, existing *post.Post) (*post.Post, error) {
badDateField := ""
for _, field := range []string{"date", "publish_at"} {
if Presence(form[field]) != "" {
if _, ok := ParseDate(form[field]); !ok {
badDateField = field
}
}
}
badUTF8Field := ""
for _, field := range append(slices.Clone(metadataOrder), "body") {
if value, present := form[field]; present && !utf8.ValidString(value) {
badUTF8Field = field
break
}
}
m := frontmatter.NewMeta()
if existing != nil {
// The clone carries the comments and the nested shapes of the
// stored file: the form only rewrites its own fields, and every
// key it does not name round-trips untouched.
m = existing.Metadata.Clone()
}
cleaned := cleanMetadata(baseMetadata(form, existing))
for _, key := range metadataOrder {
if key == badDateField {
// The form value was rejected, so the field keeps its
// inherited value: the post goes back into the editor with
// the schedule it had, not with the bad input written into
// the metadata nor with the schedule silently dropped.
continue
}
value, present := cleaned[key]
if !present {
// Cleared in the form: drop any inherited value too.
m.Delete(key)
continue
}
m.Set(key, value)
}
p := post.New(m, form["body"])
if existing != nil {
p.Path = existing.Path
}
if badUTF8Field != "" {
// Saving would silently replace the invalid bytes with U+FFFD,
// so the form is rejected instead: the author sees the field that
// carries them and keeps control over the text.
return p, invalid(fmt.Sprintf("%s must be valid UTF-8 text.", badUTF8Field))
}
if badDateField != "" {
return p, invalid(fmt.Sprintf("%s must be an ISO 8601 date.", badDateField))
}
// The references arrive as one JSON field from the editor. An absent
// field means the form never carried one, and the stored list
// round-trips untouched; an empty list is an explicit deletion.
tables, present, err := refsFromForm(form)
if err != nil {
return p, err
}
if present {
if len(tables) == 0 {
m.Delete("refs")
} else {
m.Set("refs", tables)
}
}
return p, nil
}
var metadataOrder = []string{
"title", "slug", "lang", "author", "fediverse_creator",
"doi", "orcid",
"date", "publish_at", "tags", "excerpt", "cover", "cover_alt",
"cover_caption", "series", "series_order", "draft", "all_langs",
}
func baseMetadata(form map[string]string, existing *post.Post) map[string]any {
slug := Presence(form["slug"])
if slug == "" && existing != nil {
slug = existing.Slug()
}
meta := map[string]any{
"title": Presence(form["title"]),
"slug": slug,
"lang": Presence(form["lang"]),
"author": Presence(form["author"]),
"fediverse_creator": Presence(form["fediverse_creator"]),
"doi": identifiers.NormalizeDOI(form["doi"]),
"orcid": identifiers.NormalizeORCID(form["orcid"]),
"tags": ParseTags(form["tags"]),
"excerpt": Presence(form["excerpt"]),
"cover": Presence(form["cover"]),
"cover_alt": Presence(form["cover_alt"]),
"cover_caption": Presence(form["cover_caption"]),
"series": Presence(form["series"]),
}
if d, ok := ParseDate(form["date"]); ok {
meta["date"] = interpres.LocalDate{Time: d}
}
if d, ok := ParseDate(form["publish_at"]); ok {
meta["publish_at"] = interpres.LocalDate{Time: d}
}
if n, ok := ParseInt(form["series_order"]); ok {
meta["series_order"] = int64(n)
}
if form["draft"] == "on" {
meta["draft"] = true
}
if form["all_langs"] == "on" {
meta["all_langs"] = true
}
return meta
}
// cleanMetadata drops nil, empty and false entries, keeping the
// original key order.
func cleanMetadata(meta map[string]any) map[string]any {
out := make(map[string]any, len(meta))
for _, key := range metadataOrder {
value, ok := meta[key]
if !ok || value == nil {
continue
}
switch v := value.(type) {
case string:
if v == "" {
continue
}
case []string:
if len(v) == 0 {
continue
}
case bool:
if !v {
continue
}
}
out[key] = value
}
return out
}
// ValidationError is a user-facing validation failure. The message is
// shown verbatim in the API error envelope and in the admin forms.
type ValidationError struct {
Message string
}
func (e *ValidationError) Error() string { return e.Message }
func invalid(message string) error { return &ValidationError{Message: message} }
// The bounds of one reference list written from the editor. They exist so
// a runaway payload dies at a named rule rather than at the body limit.
const (
maxRefEntries = 500
maxRefField = 4000
maxRefAuthors = 200
)
// refsFromForm decodes the editor's reference list. The second return
// value reports whether the form carried the field at all: absent means
// the stored list survives untouched, present means the decoded list (or
// its deletion) is the author's explicit choice. A row that carries
// nothing at all is dropped rather than saved as an empty table. The
// tables come back as []map[string]any, the shape the frontmatter writer
// renders as [[refs]] blocks rather than one inline array.
func refsFromForm(form map[string]string) ([]map[string]any, bool, error) {
encoded, present := form["refs"]
if !present {
return nil, false, nil
}
if strings.TrimSpace(encoded) == "" {
return nil, true, nil
}
var entries []map[string]any
if err := json.Unmarshal([]byte(encoded), &entries); err != nil {
return nil, true, invalid("References must be a list of entries.")
}
if len(entries) > maxRefEntries {
return nil, true, invalid(fmt.Sprintf("References must hold at most %d entries.", maxRefEntries))
}
out := make([]map[string]any, 0, len(entries))
for i, entry := range entries {
table, err := cleanRefEntry(entry, i+1)
if err != nil {
return nil, true, err
}
if table != nil {
out = append(out, table)
}
}
return out, true, nil
}
// cleanRefEntry validates one reference table and returns it in the
// canonical shape the frontmatter writer accepts: empty strings dropped,
// identifiers normalised, authors as strings or name tables. A row whose
// every field is blank returns nil, meaning it is dropped.
func cleanRefEntry(entry map[string]any, position int) (map[string]any, error) {
which := fmt.Sprintf("Reference %d", position)
clip := func(value any, field string) (string, error) {
var text string
switch v := value.(type) {
case string:
text = v
case float64:
// A year or volume the client sent as a JSON number still
// belongs in the table, as the string the model stores.
if v == float64(int64(v)) {
text = strconv.FormatInt(int64(v), 10)
} else {
text = strconv.FormatFloat(v, 'f', -1, 64)
}
}
text = strings.TrimSpace(text)
if len(text) > maxRefField {
return "", invalid(fmt.Sprintf("%s: %s must be at most %d characters.", which, field, maxRefField))
}
return text, nil
}
out := map[string]any{}
for _, field := range []string{"raw", "title", "venue", "year", "volume", "pages", "arxiv", "url"} {
text, err := clip(entry[field], field)
if err != nil {
return nil, err
}
if text != "" {
out[field] = text
}
}
if doi, _ := entry["doi"].(string); strings.TrimSpace(doi) != "" {
doi = identifiers.NormalizeDOI(doi)
if !identifiers.ValidDOI(doi) {
return nil, invalid(fmt.Sprintf("%s: DOI must look like 10.xxxx/suffix.", which))
}
out["doi"] = doi
}
if arxiv, ok := out["arxiv"]; ok {
id := strings.TrimPrefix(strings.TrimPrefix(arxiv.(string), "https://arxiv.org/abs/"), "arXiv:")
if id == "" || strings.ContainsAny(id, " \t\"'<>") {
return nil, invalid(fmt.Sprintf("%s: arXiv must be the bare identifier, e.g. 2401.12345.", which))
}
out["arxiv"] = id
}
if urlField, ok := out["url"]; ok {
if !strings.HasPrefix(urlField.(string), "http://") && !strings.HasPrefix(urlField.(string), "https://") {
return nil, invalid(fmt.Sprintf("%s: URL must be an http(s) address.", which))
}
}
authors, err := cleanRefAuthors(entry["authors"], which)
if err != nil {
return nil, err
}
if len(authors) > 0 {
out["authors"] = authors
}
// An explicit number survives a round trip, so a hand-numbered list
// keeps its numbering through the editor.
switch n := entry["num"].(type) {
case float64:
if n == float64(int(n)) && n >= 1 && n <= 9999 {
out["num"] = int64(n)
}
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil && parsed >= 1 && parsed <= 9999 {
out["num"] = int64(parsed)
}
}
// A row with only identifiers and no citation of its own would render
// as an empty entry; an entirely blank row is simply dropped.
if _, cited := out["raw"]; !cited {
if _, ok := out["title"]; !ok {
if _, hasAuthors := out["authors"]; !hasAuthors {
if len(out) == 0 {
return nil, nil
}
return nil, invalid(fmt.Sprintf("%s needs the citation itself: the verbatim line, the title, or the authors.", which))
}
}
}
return out, nil
}
// cleanRefAuthors validates the author list of one reference: plain name
// strings, or name tables with an optional ORCID checked to its digit.
func cleanRefAuthors(value any, which string) ([]any, error) {
list, ok := value.([]any)
if !ok {
return nil, nil
}
if len(list) > maxRefAuthors {
return nil, invalid(fmt.Sprintf("%s: at most %d authors per reference.", which, maxRefAuthors))
}
out := make([]any, 0, len(list))
for _, item := range list {
switch author := item.(type) {
case string:
if name := strings.TrimSpace(author); name != "" {
out = append(out, name)
}
case map[string]any:
name, _ := author["name"].(string)
name = strings.TrimSpace(name)
if name == "" {
continue
}
orcid, _ := author["orcid"].(string)
orcid = identifiers.NormalizeORCID(strings.TrimSpace(orcid))
if orcid != "" && !identifiers.ValidORCID(orcid) {
return nil, invalid(fmt.Sprintf("%s: ORCID must look like 0000-0002-1825-0097.", which))
}
if orcid != "" {
out = append(out, map[string]any{"name": name, "orcid": orcid})
} else {
out = append(out, name)
}
}
}
return out, nil
}
// CreationError validates a post before saving; nil means valid.
func CreationError(p *post.Post, s Source, existing *post.Post) error {
if Presence(p.Slug()) == "" {
return invalid("Slug is required.")
}
slug := p.Slug()
if len([]rune(slug)) > MaxSlugLength {
return invalid(fmt.Sprintf("Slug must be at most %d characters.", MaxSlugLength))
}
if !SlugRegex.MatchString(slug) {
return invalid("Invalid slug.")
}
if lang := p.Lang(); lang != "" && !LangRegex.MatchString(lang) {
return invalid("Invalid language.")
}
if len(p.Body) > markdown.MaxBodyLength {
return invalid(fmt.Sprintf("Body must be at most %d bytes.", markdown.MaxBodyLength))
}
if found := s.Find(slug, ""); found != nil &&
(existing == nil || found.Path != existing.Path) {
return invalid("A post with that slug already exists.")
}
if err := FediverseCreatorError(p); err != nil {
return err
}
if err := DOIError(p); err != nil {
return err
}
return ORCIDError(p)
}
// FediverseCreatorError validates the optional fediverse handle.
func FediverseCreatorError(p *post.Post) error {
value, ok := p.Metadata.Get("fediverse_creator")
if !ok || value == nil {
return nil
}
if fediverse.Valid(fmt.Sprintf("%v", value)) {
return nil
}
return invalid("Fediverse creator must look like @user@host.")
}
// DOIError validates the optional DOI. The stored value is the bare
// form; a doi.org URL or doi: prefix normalises away before the rule.
func DOIError(p *post.Post) error {
value, ok := p.Metadata.Get("doi")
if !ok || value == nil {
return nil
}
text := fmt.Sprintf("%v", value)
if text == "" || identifiers.ValidDOI(text) {
return nil
}
return invalid("DOI must look like 10.xxxx/suffix.")
}
// ORCIDError validates the optional ORCID iD, check digit included.
func ORCIDError(p *post.Post) error {
value, ok := p.Metadata.Get("orcid")
if !ok || value == nil {
return nil
}
text := fmt.Sprintf("%v", value)
if text == "" || identifiers.ValidORCID(text) {
return nil
}
return invalid("ORCID must look like 0000-0002-1825-0097.")
}
// Repository is what the payload builders write through.
type Repository interface {
Source
Save(p *post.Post) (*post.Post, error)
Delete(slug, lang string) (*post.Post, bool, error)
}
// SavePost persists a post through the repository. When the slug changed
// since existing, the file moves to the path its new slug implies and
// the old file is soft-deleted, so a rename is one operation with one
// undo and one revision archive. The admin and the API both save through
// here, so the on-disk result never depends on which one asked.
func SavePost(st Repository, p, existing *post.Post) (*post.Post, error) {
renamed := existing != nil && existing.Path != "" && p.Slug() != "" && p.Slug() != existing.Slug()
if renamed {
p.Path = ""
}
saved, err := st.Save(p)
if err != nil {
return nil, err
}
if renamed {
if _, _, err := st.Delete(existing.Slug(), existing.Lang()); err != nil {
slog.Warn("payloads: could not archive the post under its old slug",
"slug", existing.Slug(), "error", err)
}
}
return saved, nil
}
+508
View File
@@ -0,0 +1,508 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// An edit through the form only rewrites the fields the form names: a
// key the form does not carry keeps the comment written above it.
func TestPostFromParamsKeepsUntouchedComments(t *testing.T) {
existing, _, err := frontmatter.Parse("+++\ntitle = \"Old\"\nslug = \"old\"\nlang = \"en\"\n\n# chapters live here\n[[chapters]]\nx = 1\n+++\n\nbody\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
p, err := PostFromParams(map[string]string{
"title": "New", "slug": "old", "lang": "en",
}, post.New(existing, "body\n"))
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if p.Title() != "New" {
t.Fatalf("title = %q, want New", p.Title())
}
out, err := frontmatter.Dump(p.Metadata, "body\n")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.Contains(out, "# chapters live here") || !strings.Contains(out, "[[chapters]]") {
t.Fatalf("untouched frontmatter lost its comment:\n%s", out)
}
}
// Every form field the editor saves is a key the engine consumes, so
// none of them may leak into the API's fields object: this guards the
// two lists against drifting apart.
func TestMetadataOrderStaysReserved(t *testing.T) {
for _, key := range metadataOrder {
if !post.ReservedMetadata[key] {
t.Fatalf("form key %q is not in post.ReservedMetadata", key)
}
}
}
func newStoreWithPosts(t *testing.T, files map[string]string) *store.Store {
t.Helper()
dir := filepath.Join(t.TempDir(), "posts")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
for name, body := range files {
target := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(target, []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
return store.New(store.Options{ContentDir: dir, DefaultLang: "en", RevisionLimit: 10})
}
const publishedFile = `+++
title = "Published"
slug = "published"
date = 2026-08-18
lang = "cs"
tags = ["go", "blog"]
series = "S"
series_order = 2
+++
published body
`
const draftFile = `+++
title = "Draft"
slug = "draft"
draft = true
+++
draft body
`
const scheduledFile = `+++
title = "Future"
slug = "future"
publish_at = 2999-01-01
+++
future body
`
func TestPublishedPostsExcludesDraftsAndScheduled(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"published.md": publishedFile,
"draft.md": draftFile,
"future.md": scheduledFile,
})
posts := PublishedPosts(s)
if len(posts) != 1 || posts[0].Slug() != "published" {
t.Fatalf("published = %v", posts)
}
}
func TestPublishedPostsSortsNewestFirst(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"old.md": "+++\nslug = \"old\"\ndate = 2020-01-01\n+++\nx\n",
"new.md": "+++\nslug = \"new\"\ndate = 2026-01-01\n+++\nx\n",
})
posts := PublishedPosts(s)
if len(posts) != 2 || posts[0].Slug() != "new" {
t.Fatalf("order = %v, %v", posts[0].Slug(), posts[1].Slug())
}
}
func TestFilterPosts(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"a.md": "+++\nslug = \"a\"\nlang = \"cs\"\ntags = [\"go\"]\ntitle = \"Go tipy\"\n+++\nbody here\n",
"b.md": "+++\nslug = \"b\"\nlang = \"en\"\ntags = [\"life\"]\ntitle = \"Hello\"\n+++\nother\n",
"c.md": "+++\nslug = \"c\"\nall_langs = true\nlang = \"cs\"\ntags = [\"go\"]\ntitle = \"CS GO\"\n+++\nxx\n",
})
all := PublishedPosts(s)
if got := FilterPosts(all, "en", "", ""); len(got) != 2 {
t.Fatalf("lang filter = %v", slugs(got))
}
if got := FilterPosts(all, "", "go", ""); len(got) != 2 {
t.Fatalf("tag filter = %v", slugs(got))
}
if got := FilterPosts(all, "", "", "body here"); len(got) != 1 || got[0].Slug() != "a" {
t.Fatalf("query filter = %v", slugs(got))
}
if got := FilterPosts(all, "", "", "HELLO"); len(got) != 1 || got[0].Slug() != "b" {
t.Fatalf("case-insensitive query = %v", slugs(got))
}
}
func parsePost(t *testing.T, content string) *post.Post {
t.Helper()
p, err := post.Parse(content)
if err != nil {
t.Fatalf("parse: %v", err)
}
return p
}
func slugs(posts []*post.Post) []string {
out := make([]string, len(posts))
for i, p := range posts {
out[i] = p.Slug()
}
return out
}
func TestPostsPayloadPagination(t *testing.T) {
files := map[string]string{}
for i, slug := range []string{"one", "two", "three", "four", "five"} {
files[slug+".md"] = "+++\nslug = \"" + slug + "\"\ntitle = \"" + slug +
"\"\ndate = 2026-01-0" + string(rune('1'+i)) + "\n+++\nx\n"
}
s := newStoreWithPosts(t, files)
first, ok := PostsPayload(s, "", "", "", 1, 2, "").(PageList)
if !ok {
t.Fatal("page mode must return a PageList")
}
if first.Total != 5 || first.Page != 1 || !first.HasNext || first.HasPrev {
t.Fatalf("first page = %+v", first)
}
if len(first.Posts) != 2 {
t.Fatalf("posts = %v", first.Posts)
}
if !first.HasNext {
t.Fatalf("first page must have a next page: %+v", first)
}
last, ok := PostsPayload(s, "", "", "", 3, 2, "").(PageList)
if !ok {
t.Fatal("page mode must return a PageList")
}
if last.HasNext || !last.HasPrev {
t.Fatalf("last page = %+v", last)
}
cursorPage, ok := PostsPayload(s, "", "", "", 1, 2, "five").(CursorList)
if !ok {
t.Fatal("cursor mode must return a CursorList")
}
if cursorPage.NextCursor == nil {
t.Fatalf("cursor page = %+v", cursorPage)
}
}
func TestPostsPayloadLimitClamping(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"a.md": "+++\nslug = \"a\"\n+++\nx\n",
})
huge, ok := PostsPayload(s, "", "", "", 1, 5000, "").(PageList)
if !ok || huge.PageSize != 100 {
t.Fatalf("page_size = %v, want 100", huge.PageSize)
}
zero, ok := PostsPayload(s, "", "", "", 1, 0, "").(PageList)
if !ok || zero.PageSize != 1 {
t.Fatalf("page_size = %v, want 1", zero.PageSize)
}
}
func TestPostsPayloadIsEmpty(t *testing.T) {
s := newStoreWithPosts(t, nil)
if !IsEmpty(PostsPayload(s, "", "", "", 1, 20, "")) {
t.Fatal("empty store must yield an empty payload")
}
if IsEmpty("not a payload") {
t.Fatal("unknown types count as empty")
}
}
func TestTagCountsAndSeries(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"a.md": "+++\nslug = \"a\"\ntags = [\"go\", \"blog\"]\nseries = \"S\"\nseries_order = 2\ndate = 2026-01-02\n+++\nx\n",
"b.md": "+++\nslug = \"b\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 1\ndate = 2026-01-03\n+++\nx\n",
"c.md": "+++\nslug = \"c\"\ntags = [\"zz\"]\nseries = \"T\"\ndate = 2026-01-04\n+++\nx\n",
})
tags := BuildTagCounts(PublishedPosts(s))
if len(tags) != 3 || tags[0].Name != "go" || tags[0].Count != 2 {
t.Fatalf("tags = %v", tags)
}
series := BuildSeriesList(s)
if len(series) != 2 || series[0].Name != "S" || series[0].Count != 2 {
t.Fatalf("series = %v", series)
}
posts := SeriesPosts(s, "S")
if len(posts) != 2 || posts[0].Slug() != "b" || posts[1].Slug() != "a" {
t.Fatalf("series posts = %v", slugs(posts))
}
}
func TestSeriesPostsOrderFallbacks(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"a.md": "+++\nslug = \"a\"\nseries = \"S\"\ndate = 2026-01-02\n+++\nx\n",
"b.md": "+++\nslug = \"b\"\nseries = \"S\"\ndate = 2026-01-01\n+++\nx\n",
})
posts := SeriesPosts(s, "S")
// No series_order: date ascending.
if posts[0].Slug() != "b" || posts[1].Slug() != "a" {
t.Fatalf("order = %v", slugs(posts))
}
}
func TestPresenceAndParsers(t *testing.T) {
if Presence(" x ") != "x" || Presence(nil) != "" || Presence(" ") != "" {
t.Fatal("Presence wrong")
}
if _, ok := ParseDate("2026-08-18"); !ok {
t.Fatal("ParseDate failed")
}
if _, ok := ParseDate("nonsense"); ok {
t.Fatal("ParseDate accepted nonsense")
}
if _, ok := ParseDate(""); ok {
t.Fatal("ParseDate accepted empty")
}
if n, ok := ParseInt(" 42 "); !ok || n != 42 {
t.Fatalf("ParseInt = %d, %v", n, ok)
}
if _, ok := ParseInt("x"); ok {
t.Fatal("ParseInt accepted nonsense")
}
if _, ok := ParseInt(""); ok {
t.Fatal("ParseInt accepted empty")
}
tags := ParseTags("go, blog ,,rust")
if len(tags) != 3 || tags[1] != "blog" {
t.Fatalf("ParseTags = %v", tags)
}
if ParseTags("") != nil {
t.Fatal("ParseTags empty should be nil")
}
}
func TestPostFromParams(t *testing.T) {
form := map[string]string{
"title": "Nadpis",
"slug": "nadpis",
"lang": "cs",
"author": "Petr",
"fediverse_creator": "@petr@social",
"date": "2026-08-18",
"publish_at": "",
"tags": "go, blog",
"excerpt": "Perex",
"cover": "/media/c.webp",
"cover_alt": "alt",
"cover_caption": "caption",
"series": "S",
"series_order": "3",
"draft": "on",
"all_langs": "on",
"body": "obsah",
}
p, err := PostFromParams(form, nil)
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if p.Slug() != "nadpis" || p.Title() != "Nadpis" || p.Body != "obsah" {
t.Fatalf("post = %v", p)
}
if !p.Draft() || !p.AllLangs() {
t.Fatal("flags not set")
}
if p.DateString() != "2026-08-18" {
t.Fatalf("date = %q", p.DateString())
}
if order, ok := p.SeriesOrder(); !ok || order != 3 {
t.Fatalf("series_order = %d, %v", order, ok)
}
if _, present := p.Metadata.Get("publish_at"); present {
t.Fatal("empty publish_at kept")
}
if len(p.Tags()) != 2 {
t.Fatalf("tags = %v", p.Tags())
}
// Cleaned metadata drops empties and keeps date as a bare TOML date.
out, err := p.ToFile()
if err != nil {
t.Fatalf("ToFile: %v", err)
}
if !strings.Contains(out, "date = 2026-08-18") {
t.Fatalf("date not written bare:\n%s", out)
}
if strings.Contains(out, "excerpt = \"\"") {
t.Fatalf("empty values kept:\n%s", out)
}
}
func TestPostFromParamsKeepsExistingPathAndSlug(t *testing.T) {
existing := parsePost(t, "+++\nslug = \"old\"\n+++\nx\n")
p, err := PostFromParams(map[string]string{"title": "T", "body": "b"}, existing)
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if p.Slug() != "old" {
t.Fatalf("slug = %q, want fallback to existing", p.Slug())
}
if p.Path != existing.Path {
t.Fatalf("path not carried: %q", p.Path)
}
}
func TestCreationError(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"taken.md": "+++\nslug = \"taken\"\n+++\nx\n",
})
newPost := func(slug, lang, fediverse string) *post.Post {
meta := frontmatter.NewMeta()
meta.Set("slug", slug)
if lang != "" {
meta.Set("lang", lang)
}
if fediverse != "" {
meta.Set("fediverse_creator", fediverse)
}
return post.New(meta, "x")
}
check := func(err error, want string) {
t.Helper()
if want == "" {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
return
}
validation, ok := errors.AsType[*ValidationError](err)
if err == nil || !ok {
t.Fatalf("err = %v, want a ValidationError", err)
}
if validation.Message != want {
t.Fatalf("message = %q, want %q", validation.Message, want)
}
}
check(CreationError(newPost("", "", ""), s, nil), "Slug is required.")
check(CreationError(newPost("Upper!", "", ""), s, nil), "Invalid slug.")
if err := CreationError(newPost(strings.Repeat("a", MaxSlugLength+1), "", ""), s, nil); err == nil {
t.Fatal("want slug length error")
}
check(CreationError(newPost("ok", "bad lang!", ""), s, nil), "Invalid language.")
check(CreationError(newPost("taken", "", ""), s, nil), "A post with that slug already exists.")
// Editing the same post keeps its own slug.
existing := s.Find("taken", "")
check(CreationError(newPost("taken", "", ""), s, existing), "")
check(CreationError(newPost("fresh", "", "not-a-handle"), s, nil),
"Fediverse creator must look like @user@host.")
check(CreationError(newPost("fresh", "cs", "@ok@host"), s, nil), "")
check(CreationError(newPost("a.b_c-d", "", ""), s, nil), "")
}
func TestSlugAndLangRegexes(t *testing.T) {
if SlugRegex.MatchString("-leading") || SlugRegex.MatchString("trailing-") || SlugRegex.MatchString("UPPER") {
t.Fatal("SlugRegex too permissive")
}
if !SlugRegex.MatchString("a") || !SlugRegex.MatchString("a.b-c_d") {
t.Fatal("SlugRegex too strict")
}
if LangRegex.MatchString("cs CS") || !LangRegex.MatchString("pt-BR") {
t.Fatal("LangRegex wrong")
}
}
func TestLocalDateDumpShape(t *testing.T) {
// Guard: form dates must serialise as bare TOML dates, not RFC3339.
d := time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC)
meta := frontmatter.NewMeta()
meta.Set("date", interpres.LocalDate{Time: d})
out, err := frontmatter.Dump(meta, "x")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.Contains(out, "date = 2026-03-04") {
t.Fatalf("out = %s", out)
}
}
// A malformed date in the form must be rejected rather than dropped:
// dropping it would delete an inherited schedule and publish the post.
func TestPostFromParamsRejectsAMalformedDate(t *testing.T) {
existing := parsePost(t, "+++\nslug = \"s\"\ntitle = \"T\"\npublish_at = 2999-01-01\n+++\nx\n")
p, err := PostFromParams(map[string]string{"title": "T", "publish_at": "not a date", "body": "b"}, existing)
if err == nil {
t.Fatal("a malformed publish_at was accepted")
}
if want := "publish_at must be an ISO 8601 date."; err.Error() != want {
t.Fatalf("error = %q, want %q", err.Error(), want)
}
// The post comes back for the form re-render; the schedule is kept
// because the form value never reached the metadata.
if _, ok := p.DueAt(); !ok {
t.Fatal("the inherited schedule was dropped on a rejected form")
}
}
// Two published posts may share a slug across languages; the cursor must
// then name the exact post or the walk serves the second variant twice
// and, with a tight limit, never advances.
func TestCursorPaginationAcrossSameSlugTranslations(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"en/shared.md": "+++\nslug = \"shared\"\ntitle = \"EN\"\nlang = \"en\"\ndate = 2026-01-03\n+++\nx\n",
"cs/shared.md": "+++\nslug = \"shared\"\ntitle = \"CS\"\nlang = \"cs\"\ndate = 2026-01-02\n+++\nx\n",
"other.md": "+++\nslug = \"other\"\ntitle = \"O\"\ndate = 2026-01-01\n+++\nx\n",
})
seen := map[string]bool{}
// The walk starts after the first variant, as it would for a client
// that paged one post at a time and just received the EN variant.
cursor := "shared"
pages := 0
for {
payload := PostsPayload(s, "", "", "", 1, 1, cursor).(CursorList)
for _, post := range payload.Posts {
key := post.Slug + "|" + post.Lang
if seen[key] {
t.Fatalf("post %s served twice", key)
}
seen[key] = true
}
pages++
if payload.NextCursor == nil {
break
}
cursor = *payload.NextCursor
if pages > 10 {
t.Fatal("the cursor walk does not terminate")
}
}
if len(seen) != 2 {
t.Fatalf("walked %d posts, want the CS variant and one more: %v", len(seen), seen)
}
}
// Invalid UTF-8 typed into a form field is rejected rather than saved:
// the writer would otherwise silently replace the bytes with U+FFFD.
func TestPostFromParamsRejectsInvalidUTF8(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "Bad \xff\xfe", "slug": "utf8-test", "body": "ok",
}, nil)
if err == nil {
t.Fatal("invalid UTF-8 was accepted")
}
if want := "title must be valid UTF-8 text."; err.Error() != want {
t.Fatalf("error = %q, want %q", err.Error(), want)
}
if p == nil {
t.Fatal("the post must come back for the form re-render")
}
// A clean body alone is fine.
if _, err := PostFromParams(map[string]string{"title": "Ok", "body": "ok"}, nil); err != nil {
t.Fatalf("clean form rejected: %v", err)
}
}
+231
View File
@@ -0,0 +1,231 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
const refsPost = `+++
title = "Cite"
slug = "cite"
date = 2026-08-18
[[refs]]
title = "Observational evidence from supernovae"
authors = [{ name = "Riess, A. G.", orcid = "0000-0002-1825-0097" }]
venue = "The Astronomical Journal"
year = 1998
doi = "10.1103/PhysRevD.59.103502"
+++
Tvrzení [1].
`
func TestDetailCarriesReferences(t *testing.T) {
p := parsePost(t, refsPost)
d, err := BuildDetail(p, "https://example.com")
if err != nil {
t.Fatalf("BuildDetail: %v", err)
}
if len(d.References) != 1 {
t.Fatalf("references = %d", len(d.References))
}
ref := d.References[0]
if ref.Title == "" || ref.DOI != "10.1103/PhysRevD.59.103502" ||
ref.Authors[0].ORCID != "0000-0002-1825-0097" {
t.Fatalf("reference = %+v", ref)
}
// The JSON-LD block cites the work back with author and identifier.
for _, want := range []string{
`"citation"`, `"ScholarlyArticle"`,
`https://doi.org/10.1103/PhysRevD.59.103502`,
`https://orcid.org/0000-0002-1825-0097`,
} {
if !strings.Contains(d.Meta.JSONLD, want) {
t.Fatalf("json-ld missing %q:\n%s", want, d.Meta.JSONLD)
}
}
// The HTML carries the linked citation too.
if !strings.Contains(d.HTML, `href="#ref-1"`) {
t.Fatalf("html missing the citation anchor:\n%s", d.HTML)
}
}
// A post without refs omits the member entirely: the wire shape of
// every ordinary post is unchanged.
func TestDetailOmitsEmptyReferences(t *testing.T) {
p := parsePost(t, "+++\ntitle = \"Plain\"\nslug = \"plain\"\n+++\nx\n")
d, err := BuildDetail(p, "https://example.com")
if err != nil {
t.Fatalf("BuildDetail: %v", err)
}
if len(d.References) != 0 {
t.Fatalf("references = %v", d.References)
}
if strings.Contains(d.Meta.JSONLD, "citation") {
t.Fatalf("plain post gained citations:\n%s", d.Meta.JSONLD)
}
}
// When the cited work is published in the same instance, the detail
// exposes the internal link in every shape the consumer reads: the
// rendered HTML, the references member, and the JSON-LD citation.
func TestReferencesCarryInternalLink(t *testing.T) {
p := parsePost(t, refsPost)
p.SetLinkIndex(map[string]string{"10.1103/physrevd.59.103502": "riess-1998"})
d, err := BuildDetail(p, "https://example.com")
if err != nil {
t.Fatalf("BuildDetail: %v", err)
}
if d.References[0].Internal != "/api/volumen/posts/riess-1998" {
t.Fatalf("references internal = %+v", d.References[0])
}
if !strings.Contains(d.HTML, `href="/api/volumen/posts/riess-1998"`) {
t.Fatalf("html missing the internal link:\n%s", d.HTML)
}
if !strings.Contains(d.Meta.JSONLD, "/api/volumen/posts/riess-1998") {
t.Fatalf("json-ld missing the internal url:\n%s", d.Meta.JSONLD)
}
// The canonical DOI identifier stays in the JSON-LD beside it.
if !strings.Contains(d.Meta.JSONLD, "https://doi.org/10.1103/PhysRevD.59.103502") {
t.Fatalf("canonical identifier lost:\n%s", d.Meta.JSONLD)
}
}
func existingWithRefs(t *testing.T) *post.Post {
t.Helper()
m := frontmatter.NewMeta()
m.Set("title", "Hello")
m.Set("slug", "hello")
// The shape a parsed file carries: an array of tables.
m.Set("refs", []map[string]any{
{"raw": "Riess, A. G. et al. 1998, AJ 116, 1009"},
})
return post.New(m, "body")
}
func refTables(t *testing.T, p *post.Post) []map[string]any {
t.Helper()
raw, ok := p.Metadata.Get("refs")
if !ok {
return nil
}
list, ok := raw.([]map[string]any)
if !ok {
t.Fatalf("refs = %T, want []map[string]any", raw)
}
return list
}
// A form that carries no refs field leaves the stored list untouched: a
// save from a page without its bibliography card still round-trips the
// file as it stands.
func TestRefsAbsentFromFormKeepsTheStoredList(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "Hello", "slug": "hello", "body": "new",
}, existingWithRefs(t))
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
tables := refTables(t, p)
if len(tables) != 1 || tables[0]["raw"] != "Riess, A. G. et al. 1998, AJ 116, 1009" {
t.Fatalf("refs = %v", tables)
}
}
// An empty list is an explicit deletion, not a lost field.
func TestRefsEmptyListDeletesTheKey(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "Hello", "slug": "hello", "body": "b", "refs": "[]",
}, existingWithRefs(t))
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if _, ok := p.Metadata.Get("refs"); ok {
t.Fatal("refs survived an explicit empty list")
}
}
// The editor's JSON becomes the frontmatter tables: values trimmed,
// identifiers normalised, author ORCIDs kept as name tables, an explicit
// number preserved, a blank row dropped.
func TestRefsFormWritesCanonicalTables(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "Hello", "slug": "hello", "body": "b",
"refs": `[
{"raw": " Trimmed raw line. "},
{},
{"num": 2,
"authors": [{"name": "Adam Riess", "orcid": "0000-0002-1825-0097"}, "plain author"],
"title": "Observational Evidence from Supernovae",
"venue": "The Astronomical Journal", "year": "1998",
"volume": "116", "pages": "1009",
"doi": "https://doi.org/10.1086/300499",
"arxiv": "arXiv:astro-ph/9805201",
"url": "https://example.com/paper"}
]`,
}, existingWithRefs(t))
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
tables := refTables(t, p)
if len(tables) != 2 {
t.Fatalf("tables = %v", tables)
}
if tables[0]["raw"] != "Trimmed raw line." {
t.Fatalf("raw = %q", tables[0]["raw"])
}
second := tables[1]
if second["num"] != int64(2) {
t.Fatalf("num = %v", second["num"])
}
if second["doi"] != "10.1086/300499" {
t.Fatalf("doi = %q", second["doi"])
}
if second["arxiv"] != "astro-ph/9805201" {
t.Fatalf("arxiv = %q", second["arxiv"])
}
authors, ok := second["authors"].([]any)
if !ok || len(authors) != 2 {
t.Fatalf("authors = %v", second["authors"])
}
first, ok := authors[0].(map[string]any)
if !ok || first["name"] != "Adam Riess" || first["orcid"] != "0000-0002-1825-0097" {
t.Fatalf("author = %v", authors[0])
}
if authors[1] != "plain author" {
t.Fatalf("second author = %v", authors[1])
}
}
func TestRefsFormRejectsBadPayloads(t *testing.T) {
cases := []struct {
name string
refs string
want string
}{
{"not a list", `{"raw": "x"}`, "References must be a list of entries."},
{"bad doi", `[{"raw": "x", "doi": "not-a-doi"}]`, "Reference 1: DOI must look like 10.xxxx/suffix."},
{"bad orcid", `[{"raw": "x", "authors": [{"name": "A", "orcid": "0000-0002-1825-0098"}]}]`,
"Reference 1: ORCID must look like 0000-0002-1825-0097."},
{"bad url", `[{"raw": "x", "url": "ftp://example.com"}]`, "Reference 1: URL must be an http(s) address."},
{"bad arxiv", `[{"raw": "x", "arxiv": "not an id"}]`, "Reference 1: arXiv must be the bare identifier"},
{"no citation", `[{"doi": "10.1086/300499"}]`, "Reference 1 needs the citation itself"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := PostFromParams(map[string]string{
"title": "Hello", "slug": "hello", "body": "b", "refs": tc.refs,
}, nil)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More