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
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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ------------------------------------------------
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 ------------------------------------------------------------
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ----------------------------------------------------------------
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ----------------------------------------------------
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}, ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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`))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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>"
|
||||
})
|
||||
}
|
||||
@@ -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" onmouseover="alert(1)\"") {
|
||||
t.Fatalf("the internal link lost its target: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -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, "/")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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í & <pos></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í & <pos></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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]}]}
|
||||
@@ -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"}}}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":"draft"}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":"not_found"}
|
||||
@@ -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}
|
||||
@@ -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"}}}]}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
{"series":[{"name":"Series","count":1}]}
|
||||
@@ -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"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"title":"Volumen","description":"Powered by Volumen.","base_url":"https://site.example","language":"en","author":"Anonymous","fediverse_creator":""}
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
{"tags":[{"name":"go","count":2},{"name":"research","count":1}]}
|
||||
@@ -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 <token></code> on <code>POST/PUT/DELETE /api/volumen/posts</code>.": "Token posílejte jako <code>Authorization: Bearer <token></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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>"
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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;" 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
|
||||
}
|
||||
@@ -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(``)
|
||||
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(``)
|
||||
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(``)
|
||||
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;".
|
||||
func TestFigureCaptionEscapesOnce(t *testing.T) {
|
||||
out, _, err := RenderWithTOC(``)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "<figcaption>Tom & Jerry</figcaption>") {
|
||||
t.Fatalf("caption = %s", out)
|
||||
}
|
||||
if strings.Contains(out, "&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 & 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é\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, "&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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,6 @@
|
||||
=== HTML ===
|
||||
<p>Hello <em>world</em> and <strong>bold</strong></p>
|
||||
=== TOC ===
|
||||
<div class="toc">
|
||||
<ul></ul>
|
||||
</div>
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
=== HTML ===
|
||||
<p>Prose before.</p>
|
||||
<pre><code class="language-go">fmt.Println("hi")
|
||||
</code></pre>
|
||||
<pre><code>indented code
|
||||
</code></pre>
|
||||
<p>Inline <code>code</code> too.</p>
|
||||
=== TOC ===
|
||||
<div class="toc">
|
||||
<ul></ul>
|
||||
</div>
|
||||
+1
@@ -0,0 +1 @@
|
||||
Hello *world* and **bold**
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
Prose before.
|
||||
|
||||
```go
|
||||
fmt.Println("hi")
|
||||
```
|
||||
|
||||
indented code
|
||||
|
||||
Inline `code` too.
|
||||
+3
@@ -0,0 +1,3 @@
|
||||

|
||||
|
||||

|
||||
@@ -0,0 +1,9 @@
|
||||
# Title One
|
||||
|
||||
Some [link](https://example.com) here.
|
||||
|
||||
## Sub & "head"
|
||||
|
||||
### Deep
|
||||
|
||||
Text.
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
|
||||
~~struck~~ text
|
||||
|
||||
> quote
|
||||
|
||||
Footnote[^1]
|
||||
|
||||
[^1]: note text
|
||||
|
||||
Term
|
||||
: definition
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
| a | b |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
@@ -0,0 +1,5 @@
|
||||
- [x] done
|
||||
- [ ] todo
|
||||
|
||||
1. ordered
|
||||
2. items
|
||||
+7
@@ -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
@@ -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 & "head"</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 & "head"</a>
|
||||
<ul>
|
||||
<li><a rel="noopener noreferrer" href="#deep">Deep</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
=== HTML ===
|
||||
|
||||
<img src="/media/x.webp">
|
||||
<p>click</p>
|
||||
|
||||
=== TOC ===
|
||||
<div class="toc">
|
||||
<ul></ul>
|
||||
</div>
|
||||
Vendored
+34
@@ -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
@@ -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
|
||||
[*] --> calm
|
||||
</code></pre>
|
||||
=== TOC ===
|
||||
<div class="toc">
|
||||
<ul>
|
||||
<li><a rel="noopener noreferrer" href="#diagrams">Diagrams</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
Vendored
+21
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, "/") }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user