Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+480
View File
@@ -0,0 +1,480 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"html"
"log/slog"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/audit"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// Content is the content surface the admin uses: every post, one post by
// slug, the write and delete paths, the revision archive and the media
// library. It is an interface rather than *store.Store so the handlers can
// be tested against a fake and a second backend stays conceivable.
type Content interface {
All() []*post.Post
Find(slug, lang string) *post.Post
Save(p *post.Post) (*post.Post, error)
Delete(slug, lang string) (*post.Post, bool, error)
Undelete(slug string) *post.Post
Revisions(slug string) []store.Revision
RevisionContent(slug, name string) string
RestoreRevision(p *post.Post, name string) *post.Post
InvalidateCache()
StoreUpload(originalName string, data []byte) (string, error)
MediaPath(name string) (string, error)
DeleteMedia(url string) bool
ListMedia() []store.Media
}
// Deps are the shared services the admin UI needs.
type Deps struct {
Config *config.Config
Store Content
Users *users.Users
Templates *templates.Store
Tokens *tokens.Store
Audit *audit.Log
LoginLim *ratelimit.LoginLimiter
Sessions *session.Store
Webhooks *webhooks.Manager
// PreviewKey signs the shareable preview links: the session secret
// the app layer resolved, from [admin].session_key or from the
// secret.key file it generated, so a default deployment offers
// preview links the way the configuration documents it. Empty means
// no link can be signed and none is offered.
PreviewKey string
// WebhooksFile is the admin-managed hook store the settings forms
// rewrite, and StaticWebhooks the hooks that came from config.toml
// and are read-only here. Together they are what the manager
// delivers; every mutation re-saves the file and refreshes the
// manager with the merge of the two.
WebhooksFile string
StaticWebhooks []webhooks.Webhook
Version string
OnEvent func(event string, payload map[string]any)
Backup backup.Options
// CheckUpdate returns the latest available version ("" when none),
// and SelfUpdate replaces the running binary; both are wired by the
// CLI layer and may be nil.
CheckUpdate func() (string, error)
SelfUpdate func() (target string, err error)
// UpdateInfo returns the newer version for the update banner, or ""
// when the running version is current.
UpdateInfo func() string
}
// Admin serves /admin routes.
type Admin struct {
deps Deps
renderer *Renderer
// trustedProxies are the peers whose X-Forwarded-For is believed.
// The configuration is validated before the admin is built, so a
// parse failure here cannot happen.
trustedProxies []netip.Prefix
}
// New builds the admin handler.
func New(deps Deps) (*Admin, error) {
renderer, err := NewRenderer()
if err != nil {
return nil, err
}
trusted, err := deps.Config.TrustedProxyPrefixes()
if err != nil {
return nil, err
}
if !deps.Config.Server.TrustProxy {
trusted = nil
}
return &Admin{deps: deps, renderer: renderer, trustedProxies: trusted}, nil
}
// SetUpdateHooks wires the version-check callbacks after construction.
func (a *Admin) SetUpdateHooks(check func() (string, error), selfUpdate func() (string, error)) {
a.deps.CheckUpdate = check
a.deps.SelfUpdate = selfUpdate
a.deps.UpdateInfo = func() string {
latest, err := check()
if err != nil || latest == "" || latest == a.deps.Version {
return ""
}
return latest
}
}
// Handler returns the admin route tree.
func (a *Admin) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
})
// The interface sheets and fonts: public by design (the login page
// needs them before any session), confined to the embedded set.
mux.HandleFunc("GET /admin/assets/", web.AssetHandler)
mux.HandleFunc("GET /admin/login", a.handleLoginForm)
mux.HandleFunc("POST /admin/login", a.handleLogin)
mux.HandleFunc("GET /admin/twofactor", a.handleTwofactorForm)
mux.HandleFunc("POST /admin/twofactor", a.handleTwofactor)
mux.HandleFunc("POST /admin/logout", a.handleLogout)
a.registerSetupRoutes(mux)
a.registerPostRoutes(mux)
a.registerSettingsRoutes(mux)
a.registerMediaRoutes(mux)
// The subtree catch-all answers any /admin path no route above claims,
// so a mistyped URL meets the shell's own 404 page rather than the
// engine's bare text. It is the least specific pattern, so every
// registered route still wins, and it sits behind requireLogin so an
// anonymous visitor is sent to the sign-in screen first.
mux.HandleFunc("/admin/", a.requireLogin(a.handleNotFound))
return mux
}
// handleNotFound renders the admin 404 page inside the shell. Nothing
// about the request reaches the page beyond the interface strings, so the
// answer leaks nothing and carries the honest status.
func (a *Admin) handleNotFound(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
a.renderPage(w, r, "notfound.html", a.pageData(r), http.StatusNotFound)
}
// record appends an audit entry for one request, filling in the acting
// user and the client address so the log can answer "who, from where".
func (a *Admin) record(r *http.Request, action, resource string, detail map[string]any) {
a.deps.Audit.Record(audit.Entry{
User: a.currentUser(r),
Action: action,
Resource: resource,
Detail: detail,
IP: a.clientIP(r),
})
}
// clientIP resolves the request origin with proxy awareness.
func (a *Admin) clientIP(r *http.Request) string {
return web.ClientIP(r, a.trustedProxies)
}
// lang resolves the interface language for one request: the account's
// choice first, then the language cookie the choice set for the login
// screen, then the site language when it is one the UI ships, and
// English otherwise.
func (a *Admin) lang(r *http.Request, record *users.User) string {
if record != nil && record.Language != "" {
return record.Language
}
if c, err := r.Cookie(i18n.Cookie); err == nil {
if lang := i18n.Normalize(c.Value); lang != "" {
return lang
}
}
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
return lang
}
return "en"
}
// theme resolves the colour scheme for one request: the account's
// choice first, then the theme cookie the choice set for the login
// screen, then the default scheme.
func (a *Admin) theme(r *http.Request, record *users.User) string {
if record != nil && record.Theme != "" {
if web.ValidTheme(record.Theme) {
return record.Theme
}
}
if c, err := r.Cookie(web.ThemeCookie); err == nil && web.ValidTheme(c.Value) {
return c.Value
}
return web.DefaultTheme
}
// langFor resolves the interface language from the request alone,
// without a page context: the signed-in account's choice, the language
// cookie, the site language, then English.
func (a *Admin) langFor(r *http.Request) string {
return a.lang(r, a.deps.Users.Find(session.FromContext(r.Context()).Get("user")))
}
// tr translates an admin interface message in the request's language.
func (a *Admin) tr(r *http.Request, s string) string {
return i18n.Admin.T(a.langFor(r), s)
}
// trf translates an admin interface message with one value.
func (a *Admin) trf(r *http.Request, s, arg string) string {
return i18n.Admin.Tf(a.langFor(r), s, arg)
}
// trf2 translates an admin interface message with two values.
func (a *Admin) trf2(r *http.Request, s, first, second string) string {
return i18n.Admin.Tf2(a.langFor(r), s, first, second)
}
// pageData builds the shared template context for one request.
func (a *Admin) pageData(r *http.Request) *PageData {
sess := session.FromContext(r.Context())
username := sess.Get("user")
record := a.deps.Users.Find(username)
data := &PageData{
Config: a.deps.Config,
Path: r.URL.Path,
CSPNonce: web.Nonce(r.Context()),
Version: a.deps.Version,
Lang: a.lang(r, record),
Theme: a.theme(r, record),
CurrentUser: username,
CurrentUserRecord: record,
UsersExist: a.deps.Users.Any(),
CSRFToken: CSRFToken(sess),
IsLogin: strings.HasPrefix(r.URL.Path, "/admin/login") || r.URL.Path == "/admin/twofactor",
IsSetup: r.URL.Path == "/admin/setup",
NavPosts: r.URL.Path == "/admin/" || r.URL.Path == "/admin",
NavNew: r.URL.Path == "/admin/posts/new",
NavImport: r.URL.Path == "/admin/posts/import",
NavMedia: strings.HasPrefix(r.URL.Path, "/admin/media"),
NavSettings: strings.HasPrefix(r.URL.Path, "/admin/settings"),
}
if a.deps.UpdateInfo != nil {
data.UpdateAvailable = a.deps.UpdateInfo()
}
if record != nil {
data.IsAuthenticated = true
data.CurrentRole = record.Role
data.DisplayName = record.Name
data.UserPhoto = record.Photo
if data.DisplayName == "" {
data.DisplayName = record.Username
}
data.UserInitial = firstUpper(data.DisplayName, "?")
}
return data
}
// templateEscape neutralises the characters that would end an HTML
// comment or open a tag inside one.
func templateEscape(s string) string {
s = strings.ReplaceAll(s, "--", "- -")
return html.EscapeString(s)
}
// renderPage executes an admin page with HTTP semantics.
func (a *Admin) renderPage(w http.ResponseWriter, r *http.Request, page string, data *PageData, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := a.renderer.Render(r.Context(), w, page, data); err != nil {
// The status line is already sent, and the page is the user's
// only signal, so it carries a note rather than nothing. The
// text is escaped: an error message quoting content must not
// close the comment and inject markup.
fmt.Fprintf(w, "<!-- template error: %s -->", templateEscape(err.Error()))
}
}
func (a *Admin) handleLoginForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
// A session that already answered the password sits halfway in: the
// second step is where it belongs, not the first one again.
if sess.Get("totp_user") != "" {
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
return
}
// A deployment with no accounts is one that has not been set up
// yet: the login screen would only be a door with nothing behind
// it, so the first visit goes to the wizard instead. It is a redirect,
// not a rewrite, so the wizard has its own honest URL.
if needed, broken := a.setupNeeded(); needed && broken == nil {
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
return
}
a.renderPage(w, r, "login.html", a.pageData(r), http.StatusOK)
}
func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
ip := a.clientIP(r)
a.deps.LoginLim.Record(ip)
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
data := a.pageData(r)
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
data.RetryAfter = retryAfter
a.renderPage(w, r, "login.html", data, http.StatusTooManyRequests)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
username := r.PostFormValue("username")
password := r.PostFormValue("password")
if user := a.deps.Users.Authenticate(username, password); user != nil {
// An account with the second factor answers one more question
// before it is in: the session holds the half-way name and no
// user, so nothing behind requireLogin opens yet.
if user.TotpSecret != "" {
sess.Set("totp_user", user.Username)
sess.Set("totp_at", strconv.FormatInt(time.Now().Unix(), 10))
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
return
}
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
data := a.pageData(r)
data.Error = data.Tr("Invalid username or password.")
a.renderPage(w, r, "login.html", data, http.StatusUnauthorized)
}
// twofactorWindow bounds how long a password already answered may wait
// for its code before the whole sign-in starts over.
const twofactorWindow = 10 * time.Minute
// handleTwofactorForm shows the code prompt while a session holds a
// password-verified name; anything else goes back to the first step.
func (a *Admin) handleTwofactorForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
if !a.twofactorPending(sess) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
a.renderPage(w, r, "twofactor.html", a.pageData(r), http.StatusOK)
}
func (a *Admin) twofactorPending(sess *session.Session) bool {
name := sess.Get("totp_user")
if name == "" {
return false
}
started, err := strconv.ParseInt(sess.Get("totp_at"), 10, 64)
if err != nil || time.Since(time.Unix(started, 0)) > twofactorWindow {
sess.Delete("totp_user")
sess.Delete("totp_at")
return false
}
return true
}
// handleTwofactor answers the second question: a six-digit code from
// the account's application, or one of its recovery codes. The same
// limiter guards it as the password, so guessing a code costs the same
// lockout as guessing a password.
func (a *Admin) handleTwofactor(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
ip := a.clientIP(r)
a.deps.LoginLim.Record(ip)
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
data := a.pageData(r)
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
data.RetryAfter = retryAfter
a.renderPage(w, r, "twofactor.html", data, http.StatusTooManyRequests)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
if !a.twofactorPending(sess) {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
name := sess.Get("totp_user")
code := strings.TrimSpace(r.PostFormValue("code"))
ok := false
if isTotpShape(code) {
ok = a.deps.Users.VerifyTotp(name, code, time.Now())
} else {
ok = a.deps.Users.ConsumeRecovery(name, code)
}
if !ok {
slog.Warn("admin: second factor refused", "user", name)
data := a.pageData(r)
data.Error = data.Tr("Wrong or expired code.")
a.renderPage(w, r, "twofactor.html", data, http.StatusUnauthorized)
return
}
user := a.deps.Users.Find(name)
if user == nil || user.TotpSecret == "" {
sess.Delete("totp_user")
sess.Delete("totp_at")
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
sess.Delete("totp_user")
sess.Delete("totp_at")
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
// isTotpShape reports whether the answer looks like an application
// code; everything else is tried as a recovery code.
func isTotpShape(code string) bool {
clean := strings.NewReplacer(" ", "", "-", "").Replace(code)
return len(clean) == 6 && strings.Trim(clean, "0123456789") == ""
}
func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
sess := session.FromContext(r.Context())
if !ValidateCSRF(r, sess) {
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
return
}
// Abandon rather than Clear: Clear would leave the session dirty, and
// the middleware's Save would then append a fresh cookie after
// Destroy's expiring one, which the browser applies last.
sess.Abandon()
a.deps.Sessions.Destroy(w)
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
}
+364
View File
@@ -0,0 +1,364 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/audit"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
var csrfRe = regexp.MustCompile(`name="_csrf" value="([a-f0-9]+)"`)
type fixture struct {
handler http.Handler
admin *Admin
users *users.Users
store *session.Store
storeObj *store.Store
contentDir string
events []string
payloads []map[string]any
}
func newFixture(t *testing.T) *fixture {
t.Helper()
return newFixtureSeeded(t, true)
}
// newFixtureSeeded builds the same handler over an empty users file
// when seeded is false: that is the first-run state, with no account
// and the wizard serving the admin screen.
func newFixtureSeeded(t *testing.T, seeded bool) *fixture {
t.Helper()
dir := t.TempDir()
content := filepath.Join(dir, "posts")
if err := os.MkdirAll(content, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
Port: config.PortUnset,
ContentDir: content,
UsersFile: filepath.Join(dir, "users.toml"),
})
// Preview links are signed with the session key, so the fixture sets
// one the way a real deployment does.
cfg.Admin.SessionKey = strings.Repeat("k", 64)
if err != nil {
t.Fatalf("config: %v", err)
}
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
usersObj := users.New(cfg.UsersFile)
if seeded {
if _, err := usersObj.Add("admin", "correct-horse-9", "admin"); err != nil {
t.Fatalf("seed admin: %v", err)
}
}
f := &fixture{storeObj: st, contentDir: content}
a, err := New(Deps{
Config: cfg,
Store: st,
Users: usersObj,
Templates: templates.New(filepath.Join(dir, "templates.toml")),
Tokens: tokens.New(filepath.Join(dir, "tokens.toml")),
Backup: backup.Options{
ContentDir: content,
UsersFile: cfg.UsersFile,
TemplatesFile: cfg.TemplatesFile(),
TokensFile: cfg.TokensFile(),
},
Audit: audit.New(""),
LoginLim: ratelimit.NewLoginLimiter(),
Sessions: session.New(strings.Repeat("k", 64), 0, false),
Version: "0.0.0-test",
PreviewKey: strings.Repeat("k", 64),
OnEvent: func(event string, payload map[string]any) {
f.events = append(f.events, event)
f.payloads = append(f.payloads, payload)
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
// Production mounts this handler inside the session middleware; the
// fixture mirrors that so cookies round-trip.
f.handler = a.deps.Sessions.Middleware(a.Handler())
f.admin = a
f.users = usersObj
f.store = a.deps.Sessions
return f
}
func (f *fixture) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
f.handler.ServeHTTP(rec, req)
return rec
}
func extractCSRF(t *testing.T, body string) string {
t.Helper()
m := csrfRe.FindStringSubmatch(body)
if m == nil {
t.Fatalf("no CSRF token in body:\n%s", body[:min(len(body), 500)])
}
return m[1]
}
func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == session.CookieName {
return cookie
}
}
t.Fatal("no session cookie")
return nil
}
func TestLoginPageRenders(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Sign in", "Volumen admin", "0.0.0-test"} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("content-type = %q", rec.Header().Get("Content-Type"))
}
}
func TestLoginRedirectsAuthenticatedUser(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// login performs the full CSRF + credential flow and returns the
// authenticated session cookie.
func login(t *testing.T, f *fixture, username, secret string) *http.Cookie {
t.Helper()
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
}
return sessionCookie(t, rec)
}
func TestLoginRejectsWrongPassword(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"nope"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid username or password.") {
t.Fatal("error message missing")
}
}
func TestLoginRejectsMissingCSRF(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
cookie := sessionCookie(t, get)
form := url.Values{"username": {"admin"}, "password": {"correct-horse-9"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", rec.Code)
}
}
func TestLoginRateLimitRendersCountdown(t *testing.T) {
f := newFixture(t)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
var last *httptest.ResponseRecorder
for range 12 {
form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"wrong"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
last = f.do(t, req)
}
if last.Code != http.StatusTooManyRequests {
t.Fatalf("code = %d, want 429", last.Code)
}
if !strings.Contains(last.Body.String(), "Auto-unlock in") {
t.Fatal("lockout message missing")
}
if !strings.Contains(last.Body.String(), `id="lockout-countdown"`) {
t.Fatal("countdown element missing")
}
}
func TestLogout(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
// Grab a fresh CSRF token via the session cookie's page.
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
// Authenticated users are redirected; get the CSRF from the session
// store directly instead.
sess := f.store.Load(req)
csrf := CSRFToken(sess)
form := url.Values{"_csrf": {csrf}}
logoutReq := httptest.NewRequest(http.MethodPost, "/admin/logout", strings.NewReader(form.Encode()))
logoutReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
logoutReq.AddCookie(cookie)
rec := f.do(t, logoutReq)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestBareAdminRedirects(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// An /admin path no route claims meets the shell's own 404 page, not the
// engine's bare text, and an anonymous visitor is still sent to the
// sign-in screen first.
func TestAdminNotFound(t *testing.T) {
f := newFixture(t)
anon := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil))
if anon.Code != http.StatusSeeOther || anon.Header().Get("Location") != "/admin/login" {
t.Fatalf("anonymous: code=%d location=%q", anon.Code, anon.Header().Get("Location"))
}
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Fatalf("content type = %q", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "Page not found") {
t.Fatalf("body lacks the 404 heading:\n%s", body[:min(len(body), 500)])
}
if !strings.Contains(body, `<html`) || !strings.Contains(body, `class="shell"`) {
t.Fatalf("body is not rendered in the shell")
}
post := httptest.NewRequest(http.MethodPost, "/admin/no-such-page", strings.NewReader(""))
post.AddCookie(cookie)
if rec := f.do(t, post); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
t.Fatalf("POST: code=%d", rec.Code)
}
}
func TestPasswordError(t *testing.T) {
if key, n := PasswordError("", 10, 1024); key != "New password cannot be empty." || n != 0 {
t.Fatalf("empty password = %q, %d", key, n)
}
if key, n := PasswordError("short", 10, 1024); key != "password.min" || n != 10 {
t.Fatalf("short password = %q, %d", key, n)
}
if key, n := PasswordError(strings.Repeat("x", 2000), 10, 1024); key != "password.max" || n != 1024 {
t.Fatalf("long password = %q, %d", key, n)
}
if key, n := PasswordError("password123", 10, 1024); key != "This password is too common." || n != 0 {
t.Fatalf("common password = %q, %d", key, n)
}
if key, n := PasswordError("a genuinely unique passphrase", 10, 1024); key != "" || n != 0 {
t.Fatalf("valid password = %q, %d", key, n)
}
}
func TestFirstUpper(t *testing.T) {
if got := firstUpper("petr", "?"); got != "P" {
t.Fatalf("got = %q", got)
}
if got := firstUpper("", "?"); got != "?" {
t.Fatalf("got = %q", got)
}
}
func TestHumanSize(t *testing.T) {
cases := map[int64]string{
0: "",
512: "1 kB",
10 * 1024: "10 kB",
1024 * 1024: "1.0 MB",
5 << 20: "5.0 MB",
1536 * 1024: "1.5 MB",
}
for in, want := range cases {
if got := humanSize(in); got != want {
t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
}
}
}
// A post-template body containing "</script>" must not be able to end
// the script element the JSON literal is embedded in.
func TestTemplatesJSONEscapesScriptClose(t *testing.T) {
list := []tplOption{{
Name: "s", Title: "T", Slug: "s", Tags: []string{},
Body: `Use <script>document.write("x")</script> carefully`,
}}
out := string(templatesJSON(list))
if strings.Contains(out, "</script>") {
t.Fatalf("literal script close survived: %s", out)
}
if !strings.Contains(out, `\u003c/script>`) {
t.Fatalf("expected unicode escapes: %s", out)
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
"sourcedock.dev/petrbalvin/volumen/internal/session"
)
// commonPasswords is the blocklist of trivially guessable passwords.
// This is the policy every admin password change goes through.
var commonPasswords = map[string]bool{
"password": true, "password1": true, "password123": true,
"123456": true, "12345678": true, "123456789": true,
"qwerty": true, "qwerty123": true, "letmein": true, "iloveyou": true,
"admin": true, "admin123": true, "welcome": true, "welcome1": true,
"monkey": true, "dragon": true, "football": true, "baseball": true,
"sunshine": true, "princess": true, "abc123": true, "111111": true,
"123123": true, "1q2w3e4r": true, "passw0rd": true, "trustno1": true,
"changeme": true, "secret": true, "secret123": true, "test": true,
"test123": true, "guest": true, "master": true, "000000": true,
"696969": true, "qwertyuiop": true, "superman": true, "batman": true,
"jordan": true, "harley": true, "hunter": true, "hunter2": true,
"shadow": true, "michael": true, "jennifer": true, "abcdef": true,
"abcdefg": true,
}
// PasswordError validates a newly chosen password and reports the
// first problem as a catalogue key. The key is either a plain sentence or
// the id of a plural message whose numeral n is the offending length;
// "" with n 0 means accepted.
func PasswordError(password string, minLength, maxLength int) (string, int) {
if strings.TrimSpace(password) == "" {
return "New password cannot be empty.", 0
}
length := len([]rune(password))
if length < minLength {
return "password.min", minLength
}
if length > maxLength {
return "password.max", maxLength
}
normalized := strings.ToLower(norm.NFKC.String(password))
if commonPasswords[normalized] {
return "This password is too common.", 0
}
return "", 0
}
// CSRFToken returns (and lazily creates) the CSRF token stored in the
// session.
func CSRFToken(sess *session.Session) string {
token := sess.Get("csrf")
if token == "" {
token = newTokenHex(32)
sess.Set("csrf", token)
}
return token
}
// sessionFingerprint derives the value the session carries to bind it to
// one password: it changes whenever the account's hash changes, so a
// password change or an admin reset retires every cookie issued before
// it. It is a digest of the stored hash, never of the password, and
// carries too few bits to help anyone invert the hash.
func sessionFingerprint(storedHash string) string {
sum := sha256.Sum256([]byte("volumen-session-v1:" + storedHash))
return hex.EncodeToString(sum[:8])
}
// ValidateCSRF compares the form's _csrf field against the session
// token in constant time.
func ValidateCSRF(r *http.Request, sess *session.Session) bool {
token := r.PostFormValue("_csrf")
sessionToken := sess.Get("csrf")
if token == "" || sessionToken == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(sessionToken), []byte(token)) == 1
}
func newTokenHex(nBytes int) string {
buf := make([]byte, nBytes)
if _, err := rand.Read(buf); err != nil {
return ""
}
return hex.EncodeToString(buf)
}
// firstUpper returns the uppercased first rune, or fallback.
func firstUpper(s, fallback string) string {
for _, r := range s {
if unicode.IsSpace(r) {
continue
}
return string(unicode.ToUpper(r))
}
return fallback
}
+63
View File
@@ -0,0 +1,63 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
func (a *Admin) registerMediaRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/media", a.requireLogin(a.handleMediaLibrary))
mux.HandleFunc("POST /admin/media/{name}/delete", a.requireLogin(a.handleMediaDelete))
}
func (a *Admin) handleMediaLibrary(w http.ResponseWriter, r *http.Request) {
data := a.pageData(r)
items := a.deps.Store.ListMedia()
data.MediaItems = mediaRows(items)
data.MediaTotal = humanSize(totalSize(items))
data.Crumbs = []Crumb{{Label: "Media", IsLast: true, UI: true}}
a.renderPage(w, r, "media.html", data, http.StatusOK)
}
// totalSize sums the byte sizes of the media library.
func totalSize(items []store.Media) int64 {
var total int64
for _, item := range items {
total += item.Size
}
return total
}
// humanSize formats a byte count for the library summary: kilobytes
// below a megabyte (rounded up, so nothing reads as zero), megabytes
// above it, empty for an empty library.
func humanSize(total int64) string {
switch {
case total <= 0:
return ""
case total < 1024*1024:
kb := (total + 1023) / 1024
return fmt.Sprintf("%d kB", kb)
default:
return fmt.Sprintf("%.1f MB", float64(total)/(1024*1024))
}
}
func (a *Admin) handleMediaDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := r.PathValue("name")
if !a.deps.Store.DeleteMedia("/media/" + strings.TrimPrefix(name, "/")) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
a.record(r, "media.deleted", fmt.Sprintf("/media/%s", name), nil)
http.Redirect(w, r, "/admin/media", http.StatusSeeOther)
}
+160
View File
@@ -0,0 +1,160 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/diff"
)
// unsafeSlugRe strips anything that is not safe in a header value.
var unsafeSlugRe = regexp.MustCompile(`[^a-z0-9._-]`)
// attachmentName reduces a path segment to a safe Content-Disposition
// filename.
func attachmentName(value string) string { return unsafeSlugRe.ReplaceAllString(value, "") }
func (a *Admin) handleDownload(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
p := a.deps.Store.Find(slug, "")
if p == nil {
http.NotFound(w, r)
return
}
content, err := p.ToFile()
if err != nil {
http.Error(w, "export failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s.md"`, attachmentName(slug)))
fmt.Fprint(w, content)
}
func (a *Admin) handleHistory(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
p := a.deps.Store.Find(slug, "")
if p == nil {
http.NotFound(w, r)
return
}
revisions := a.deps.Store.Revisions(slug)
rows := make([]revisionRow, 0, len(revisions))
for _, rev := range revisions {
rows = append(rows, newRevisionRow(rev))
}
heading := p.Title()
if heading == "" {
heading = slug
}
data := a.pageData(r)
data.Slug = slug
data.Heading = heading
data.Revisions = rows
data.Post = newEditorPost(p)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: heading, Href: "/admin/posts/" + slug + "/edit"},
{Label: "History", IsLast: true, UI: true},
}
a.renderPage(w, r, "history.html", data, http.StatusOK)
}
func (a *Admin) handleHistoryDownload(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
name := r.PathValue("name")
content := a.deps.Store.RevisionContent(slug, name)
if content == "" {
http.NotFound(w, r)
return
}
// The revision name is a server-generated stamp, but it arrives from
// the URL: the value is reduced to what cannot end the quoted string
// (a quote, a backslash, a control byte). Unlike attachmentName this
// keeps the stamp's uppercase T and Z.
safeName := strings.Map(func(r rune) rune {
if r == '"' || r == '\\' || r < 0x20 || r == 0x7f {
return -1
}
return r
}, filepath.Base(name))
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename="%s-%s"`, attachmentName(slug), safeName))
fmt.Fprint(w, content)
}
// handleHistoryDiff compares one archived revision with the current
// content, so the editor can judge what a restore would change before
// committing to it.
func (a *Admin) handleHistoryDiff(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
name := r.PathValue("name")
p := a.deps.Store.Find(slug, "")
if p == nil {
http.NotFound(w, r)
return
}
revision := a.deps.Store.RevisionContent(slug, name)
if revision == "" {
http.NotFound(w, r)
return
}
current, err := p.ToFile()
if err != nil {
http.Error(w, "export failed", http.StatusInternalServerError)
return
}
var when string
for _, rev := range a.deps.Store.Revisions(slug) {
if rev.Name == name {
when = rev.When
break
}
}
heading := p.Title()
if heading == "" {
heading = slug
}
data := a.pageData(r)
data.Slug = slug
data.Heading = heading
data.DiffName = name
data.DiffWhen = when
data.DiffChunks = diff.Chunks(revision, current, 3)
data.Post = newEditorPost(p)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: heading, Href: "/admin/posts/" + slug + "/edit"},
{Label: "History", Href: "/admin/posts/" + slug + "/history", UI: true},
{Label: "Changes", IsLast: true, UI: true},
}
a.renderPage(w, r, "diff.html", data, http.StatusOK)
}
func (a *Admin) handleHistoryRestore(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
name := r.PathValue("name")
p := a.deps.Store.Find(slug, "")
if p == nil {
http.NotFound(w, r)
return
}
if a.deps.Store.RestoreRevision(p, name) == nil {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/admin/posts/"+slug+"/edit?restored=1", http.StatusSeeOther)
}
// --- uploads and static SVGs ------------------------------------------------
+92
View File
@@ -0,0 +1,92 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
func (a *Admin) handleImportForm(w http.ResponseWriter, r *http.Request) {
data := a.pageData(r)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "Import", IsLast: true, UI: true},
}
a.renderPage(w, r, "import.html", data, http.StatusOK)
}
func (a *Admin) handleImport(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
data := a.pageData(r)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "Import", IsLast: true, UI: true},
}
fail := func(msg string) {
data.Error = i18n.Admin.T(data.Lang, msg)
a.renderPage(w, r, "import.html", data, http.StatusUnprocessableEntity)
}
file, header, err := r.FormFile("file")
if err != nil {
fail("No file selected.")
return
}
defer file.Close()
if !strings.HasSuffix(strings.ToLower(header.Filename), ".md") {
fail("Only .md files are accepted.")
return
}
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
if err != nil {
fail("File is too large.")
return
}
content := string(raw)
p, err := post.Parse(content)
if err != nil {
fail(i18n.Admin.Tf(data.Lang, "The file could not be read as a post: %s", err.Error()))
return
}
if p.Slug() == "" {
base := strings.ToLower(filepath.Base(header.Filename))
stem := strings.TrimSuffix(base, filepath.Ext(base))
p.Metadata.Set("slug", strings.ReplaceAll(stem, " ", "-"))
}
if p.Lang() == "" {
p.Metadata.Set("lang", a.deps.Config.Site.Language)
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
fail(err.Error())
return
}
if _, err := a.deps.Store.Save(p); err != nil {
fail("Import failed.")
return
}
http.Redirect(w, r, "/admin/posts/"+p.Slug()+"/edit", http.StatusSeeOther)
}
// readLimited reads at most limit+1 bytes so callers can detect
// oversize uploads.
func readLimited(r io.Reader, limit int64) ([]byte, error) {
raw, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, err
}
if int64(len(raw)) > limit {
return nil, fmt.Errorf("payload too large")
}
return raw, nil
}
+642
View File
@@ -0,0 +1,642 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/markdown"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// registerPostRoutes mounts the post, preview, import and upload
// endpoints. Literal paths are registered before {slug} patterns.
func (a *Admin) registerPostRoutes(mux *http.ServeMux) {
// The exact path, not a subtree: an unknown URL under /admin/ must be
// a 404 rather than a dashboard.
mux.HandleFunc("GET /admin/{$}", a.requireLogin(a.handleDashboard))
mux.HandleFunc("GET /admin/posts/exists", a.requireLogin(a.handleExists))
mux.HandleFunc("GET /admin/posts/new", a.requireLogin(a.handleNewForm))
mux.HandleFunc("GET /admin/posts/import", a.requireLogin(a.handleImportForm))
mux.HandleFunc("POST /admin/posts/import", a.requireLogin(a.handleImport))
mux.HandleFunc("POST /admin/posts/bulk", a.requireLogin(a.handleBulk))
mux.HandleFunc("POST /admin/posts", a.requireLogin(a.handleCreate))
mux.HandleFunc("POST /admin/preview", a.requireLogin(a.handlePreview))
mux.HandleFunc("POST /admin/uploads", a.requireLogin(a.handleUpload))
mux.HandleFunc("GET /admin/posts/{slug}/download", a.requireLogin(a.handleDownload))
mux.HandleFunc("GET /admin/posts/{slug}/history", a.requireLogin(a.handleHistory))
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}", a.requireLogin(a.handleHistoryDownload))
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}/diff", a.requireLogin(a.handleHistoryDiff))
mux.HandleFunc("POST /admin/posts/{slug}/history/{name}/restore", a.requireLogin(a.handleHistoryRestore))
mux.HandleFunc("GET /admin/posts/{slug}/edit", a.requireLogin(a.handleEditForm))
mux.HandleFunc("POST /admin/posts/{slug}/delete", a.requireLogin(a.handleDelete))
mux.HandleFunc("POST /admin/posts/{slug}/undelete", a.requireLogin(a.handleUndelete))
mux.HandleFunc("POST /admin/posts/{slug}/duplicate", a.requireLogin(a.handleDuplicate))
mux.HandleFunc("GET /admin/posts/{slug}/preview-link", a.requireLogin(a.handlePreviewLink))
mux.HandleFunc("POST /admin/posts/{slug}", a.requireLogin(a.handleUpdate))
mux.HandleFunc("GET /admin/icon.svg", a.handleIcon)
}
// requireLogin redirects unauthenticated requests to the login form.
func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
username := sess.Get("user")
record := a.deps.Users.Find(username)
if username == "" || record == nil {
if username != "" {
sess.Clear()
}
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// The session is bound to the password it was issued under: a
// change (the owner's or an admin reset) retires every cookie
// still in the wild, which is what "change the password" has to
// mean for a compromised account.
if sess.Get("pv") != sessionFingerprint(record.PasswordHash) {
sess.Clear()
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
// Everything logged from here on names the account it happened
// for.
ctx := web.WithLogger(r.Context(), web.Logger(r.Context()).With("user", username))
next(w, r.WithContext(ctx))
}
}
// maxBackupImportBytes bounds the settings import request. A backup
// archive legitimately exceeds max_upload_bytes (it carries every post
// and image), so it gets the same budget backup.Restore enforces on the
// decompressed side.
const maxBackupImportBytes = 512 << 20
// requestLimit is the byte bound on one admin POST body.
func (a *Admin) requestLimit(r *http.Request) int64 {
if r.URL.Path == "/admin/settings/import" {
return maxBackupImportBytes + 1<<20
}
return int64(a.deps.Config.Admin.MaxUploadBytes) + 1<<20
}
// requireCSRF validates the form token and writes the error response
// itself when invalid.
func (a *Admin) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
// The body is bounded before parsing: ParseMultipartForm's argument
// is only the in-memory threshold, and net/http drains the rest of a
// multipart body to temp files on disk whatever the threshold says.
// Wrapping the body also lifts ParseForm's internal 10 MiB urlencoded
// cap, so this limit is the one that applies.
r.Body = http.MaxBytesReader(w, r.Body, a.requestLimit(r))
var parseErr error
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
parseErr = r.ParseMultipartForm(32 << 20)
} else {
// ParseMultipartForm would call ParseForm internally, swallow its
// error and leave the body consumed, so the content type decides
// which parser runs.
parseErr = r.ParseForm()
}
if parseErr != nil {
if _, ok := errors.AsType[*http.MaxBytesError](parseErr); ok {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return false
}
if !errors.Is(parseErr, http.ErrNotMultipart) {
http.Error(w, "bad form", http.StatusBadRequest)
return false
}
// A body that claims a multipart type but is not parseable as one
// falls through; the token check rejects.
}
if !ValidateCSRF(r, session.FromContext(r.Context())) {
http.Error(w, a.tr(r, "Invalid CSRF token"), http.StatusForbidden)
return false
}
return true
}
func (a *Admin) currentUser(r *http.Request) string {
return session.FromContext(r.Context()).Get("user")
}
func (a *Admin) handleDashboard(w http.ResponseWriter, r *http.Request) {
notice := ""
if bulkAction := r.URL.Query().Get("bulk"); bulkAction == "delete" ||
bulkAction == "draft" || bulkAction == "publish" {
if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n > 0 {
id := map[string]string{
"delete": "posts.deleted", "draft": "posts.drafted", "publish": "posts.published",
}[bulkAction]
notice = i18n.Admin.N(a.langFor(r), id, n)
}
}
a.renderPage(w, r, "list.html", a.dashboardData(r, notice), http.StatusOK)
}
// dashboardData builds the dashboard page: one card per publication, the
// language versions merged into a group that the card can switch between,
// and the counters, the recent list and the tag cloud over the same set.
func (a *Admin) dashboardData(r *http.Request, notice string) *PageData {
posts := a.allPostsSorted()
lang := a.langFor(r)
groups := groupPosts(posts)
display := make([]*post.Post, 0, len(groups))
for _, group := range groups {
display = append(display, pickDisplay(group, lang))
}
cards := make([]postCard, 0, len(groups))
stats := dashboardStats{}
var recent []recentPost
type pending struct {
post *post.Post
due time.Time
}
var upcoming []pending
for i, group := range groups {
p := display[i]
card := newPostCard(p)
if len(group) > 1 {
variants := make([]postVariant, 0, len(group))
var slugs []string
seenSlug := map[string]bool{}
for _, member := range group {
variants = append(variants, newPostVariant(member))
if !seenSlug[member.Slug()] {
seenSlug[member.Slug()] = true
slugs = append(slugs, member.Slug())
}
}
slices.SortFunc(variants, func(x, y postVariant) int {
return strings.Compare(x.Lang, y.Lang)
})
card.Variants = variants
card.VariantsJS = variantsJSON(variants)
// One selection acts on the whole publication: the bulk
// form carries every variant slug, split by commas.
card.GroupSlugs = strings.Join(slugs, ",")
}
if card.GroupSlugs == "" {
card.GroupSlugs = p.Slug()
}
cards = append(cards, card)
switch p.Status() {
case post.StatusDraft:
stats.Drafts++
case post.StatusScheduled:
stats.Scheduled++
if due, ok := p.DueAt(); ok {
upcoming = append(upcoming, pending{p, due})
}
default:
stats.Published++
if len(recent) < 3 {
recent = append(recent, recentPost{
Slug: p.Slug(),
Title: p.Title(),
DateString: p.DateString(),
})
}
}
}
stats.Total = len(cards)
stats.Recent = recent
slices.SortStableFunc(upcoming, func(x, y pending) int {
return x.due.Compare(y.due)
})
for _, entry := range upcoming {
if len(stats.Upcoming) >= 5 {
break
}
when := entry.due.Format("2006-01-02")
if h, m := entry.due.Hour(), entry.due.Minute(); h != 0 || m != 0 {
when = entry.due.Format("2006-01-02 15:04")
}
stats.Upcoming = append(stats.Upcoming, scheduledPost{
Slug: entry.post.Slug(),
Title: entry.post.Title(),
When: when,
})
}
var tagCounts []tagCount
for _, entry := range payloads.BuildTagCounts(display) {
tagCounts = append(tagCounts, tagCount{Name: entry.Name, Count: entry.Count})
}
data := a.pageData(r)
data.Posts = cards
data.Stats = stats
data.TagCounts = tagCounts
data.Q = strings.TrimSpace(r.URL.Query().Get("q"))
data.Notice = notice
data.Crumbs = []Crumb{{Label: "Posts", IsLast: true, UI: true}}
return data
}
func (a *Admin) allPostsSorted() []*post.Post {
posts := a.deps.Store.All()
sorted := slices.Clone(posts)
slices.SortStableFunc(sorted, func(x, y *post.Post) int {
return strings.Compare(y.DateString(), x.DateString())
})
return sorted
}
// handleExists answers the slug-availability check of the editor's slug
// field.
func (a *Admin) handleExists(w http.ResponseWriter, r *http.Request) {
slug := r.URL.Query().Get("slug")
if slug == "" {
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": ""})
return
}
existing := a.deps.Store.Find(slug, "")
if existing == nil || (r.URL.Query().Get("exclude") != "" &&
existing.Slug() == r.URL.Query().Get("exclude")) {
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": slug})
return
}
writeAdminJSON(w, http.StatusOK, map[string]any{
"available": false, "slug": slug, "title": existing.Title(),
})
}
// editorData fills the shared editor context for new and edit forms.
func (a *Admin) editorData(r *http.Request, mode string, p *post.Post, errorMsg string) *PageData {
data := a.pageData(r)
// The shared validation messages are catalogue keys; an unknown
// message falls back to itself, so nothing breaks untranslated.
data.Error = i18n.Admin.T(data.Lang, errorMsg)
data.Restored = r.URL.Query().Get("restored") != ""
data.Duplicated = r.URL.Query().Get("duplicated") != ""
data.AuthorPlaceholder = i18n.Admin.T(data.Lang, "Author name")
if record := data.CurrentUserRecord; record != nil && record.Name != "" {
data.AuthorPlaceholder = record.Name
}
data.IsNew = mode == "new"
data.IsEdit = mode == "edit"
view := newEditorPost(p)
// The author falls back to the current user record, and the fediverse
// handle to the user record and then to the site.
if view.Author == "" {
if record := data.CurrentUserRecord; record != nil {
view.Author = record.Name
} else {
view.Author = data.CurrentUser
}
}
if view.FediverseCreator == "" {
view.FediverseCreator = a.deps.Config.Site.FediverseCreator
if record := data.CurrentUserRecord; record != nil && record.FediverseCreator != "" {
view.FediverseCreator = record.FediverseCreator
}
}
// The author's ORCID rides on the account: a new post carries it
// unless its own frontmatter names another identifier.
if view.ORCID == "" {
if record := data.CurrentUserRecord; record != nil {
view.ORCID = record.Orcid
}
}
data.Post = view
// The page head's API link carries a preview token, so it opens a
// draft as well as a published post. The token is signed for a week,
// far longer than an editor tab stays open.
data.PreviewToken = preview.Token(view.Slug, a.deps.PreviewKey, time.Now())
// The default excerpt placeholder is interface copy; a derived
// excerpt (the post's own first paragraph) is content and passes
// through untranslated.
if view.ExcerptPlaceholder == "Short summary for listings and previews" {
view.ExcerptPlaceholder = i18n.Admin.T(data.Lang, view.ExcerptPlaceholder)
}
if data.IsNew {
options := tplOptions(a.deps.Templates.All())
data.PostTemplates = options
data.TemplatesJSON = templatesJSON(options)
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: "New post", IsLast: true, UI: true},
}
} else {
label := view.Title
if strings.TrimSpace(label) == "" {
label = i18n.Admin.T(data.Lang, "Untitled")
}
data.Crumbs = []Crumb{
{Label: "Posts", Href: "/admin/", UI: true},
{Label: label, IsLast: true},
}
}
return data
}
func (a *Admin) handleNewForm(w http.ResponseWriter, r *http.Request) {
p := post.New(frontmatter.NewMeta(), "")
p.Metadata.Set("lang", a.deps.Config.Site.Language)
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, ""), http.StatusOK)
}
func (a *Admin) handleEditForm(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
http.NotFound(w, r)
return
}
a.renderPage(w, r, "form.html", a.editorData(r, "edit", existing, ""), http.StatusOK)
}
// formMap flattens the request form into a plain string map.
func formMap(r *http.Request) map[string]string {
out := map[string]string{}
for key, values := range r.PostForm {
if len(values) > 0 {
out[key] = values[0]
}
}
return out
}
func (a *Admin) handleCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
p, err := payloads.PostFromParams(formMap(r), nil)
if err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
return
}
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
return
}
saved, err := payloads.SavePost(a.deps.Store, p, nil)
if err != nil {
a.renderPage(w, r, "form.html",
a.editorData(r, "new", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
return
}
a.fire("post.created", saved)
a.record(r, "post.created", saved.Slug(), nil)
http.Redirect(w, r, "/admin/?saved=created", http.StatusSeeOther)
}
func (a *Admin) handleUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
existing := a.deps.Store.Find(slug, "")
if existing == nil {
http.NotFound(w, r)
return
}
p, err := payloads.PostFromParams(formMap(r), existing)
if err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
return
}
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
return
}
// SavePost moves the file when the slug changed, the same way the API
// does, so a rename behaves alike from either entry point.
saved, err := payloads.SavePost(a.deps.Store, p, existing)
if err != nil {
a.renderPage(w, r, "form.html",
a.editorData(r, "edit", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
return
}
a.fire("post.updated", saved)
a.record(r, "post.updated", saved.Slug(), nil)
http.Redirect(w, r, "/admin/?saved=updated", http.StatusSeeOther)
}
func (a *Admin) handleDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
deleted, undoable, err := a.deps.Store.Delete(slug, "")
if err != nil {
a.renderPage(w, r, "list.html",
a.dashboardData(r, i18n.Admin.Tf(a.langFor(r), "The post could not be deleted: %s", err.Error())), http.StatusInternalServerError)
return
}
if deleted == nil {
http.Redirect(w, r, "/admin/?saved=not_found", http.StatusSeeOther)
return
}
// The payload names the post under "post" like every other post
// event, so a subscriber sees one shape whichever entry point fired.
a.fireRaw("post.deleted", map[string]any{
"post": map[string]any{"slug": deleted.Slug(), "title": deleted.Title()},
})
a.record(r, "post.deleted", deleted.Slug(), nil)
target := "/admin/?saved=deleted"
if undoable {
// Only offer Undo when a tombstone exists to undo.
target += "&undo=" + url.QueryEscape(slug)
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (a *Admin) handleUndelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
if restored := a.deps.Store.Undelete(slug); restored != nil {
a.fire("post.created", restored)
a.record(r, "post.undeleted", slug, nil)
http.Redirect(w, r, "/admin/?saved=undone", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/admin/?saved=undelete_failed", http.StatusSeeOther)
}
func (a *Admin) handleDuplicate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
slug := r.PathValue("slug")
source := a.deps.Store.Find(slug, "")
if source == nil {
http.NotFound(w, r)
return
}
newSlug := a.nextAvailableSlug(slug + "-copy")
meta := frontmatter.NewMeta()
for _, key := range source.Metadata.Keys() {
if key == "slug" || key == "date" {
continue
}
value, _ := source.Metadata.Get(key)
meta.Set(key, value)
}
meta.Set("slug", newSlug)
meta.Set("draft", true)
clone := post.New(meta, source.Body)
if err := payloads.CreationError(clone, a.deps.Store, nil); err != nil {
slog.Warn("admin: duplicate rejected", "slug", slug, "error", err)
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
return
}
if _, err := a.deps.Store.Save(clone); err != nil {
slog.Warn("admin: duplicate failed", "slug", slug, "error", err)
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
return
}
a.fire("post.created", clone)
http.Redirect(w, r, "/admin/posts/"+newSlug+"/edit?saved=duplicated", http.StatusSeeOther)
}
func (a *Admin) nextAvailableSlug(base string) string {
candidate := base
for n := 2; a.deps.Store.Find(candidate, "") != nil; n++ {
candidate = fmt.Sprintf("%s-%d", base, n)
}
return candidate
}
func (a *Admin) handleBulk(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
action := r.PostFormValue("action")
var slugs []string
for slug := range strings.SplitSeq(r.PostFormValue("slugs"), ",") {
if slug != "" {
slugs = append(slugs, slug)
}
}
if len(slugs) == 0 || (action != "delete" && action != "draft" && action != "publish") {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
affected := 0
for _, slug := range slugs {
switch action {
case "delete":
deleted, _, err := a.deps.Store.Delete(slug, "")
if err == nil && deleted != nil {
a.fireRaw("post.deleted", map[string]any{
"post": map[string]any{"slug": slug, "title": deleted.Title()},
})
affected++
}
case "draft":
if cached := a.deps.Store.Find(slug, ""); cached != nil && !cached.Draft() {
// Cached posts are shared with other requests: clone first.
p := cached.Clone()
p.Metadata.Set("draft", true)
if _, err := a.deps.Store.Save(p); err == nil {
a.fire("post.updated", p)
affected++
}
}
case "publish":
if cached := a.deps.Store.Find(slug, ""); cached != nil && cached.Draft() {
p := cached.Clone()
p.Metadata.Delete("draft")
if _, err := a.deps.Store.Save(p); err == nil {
a.fireRaw("post.published", map[string]any{"post": payloads.BuildSummary(p)})
affected++
}
}
}
}
if affected > 0 {
a.record(r, "post.bulk_"+action, "", map[string]any{"slugs": slugs, "affected": affected})
}
http.Redirect(w, r, fmt.Sprintf("/admin/?bulk=%s&n=%d", action, affected), http.StatusSeeOther)
}
func (a *Admin) handlePreview(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
htmlOut, err := markdown.Render(r.PostFormValue("body"))
if err != nil {
http.Error(w, "render failed", http.StatusInternalServerError)
return
}
// The preview body carries no frontmatter, so the reference list it
// knows about comes from the saved post under the same slug: the
// editor then sees the bibliography the published page will show,
// not the raw [[refs]] marker. A new post has no saved refs, and its
// marker paragraph stays as written.
slug := r.PostFormValue("slug")
lang := r.PostFormValue("lang")
if slug != "" {
if p := a.deps.Store.Find(slug, lang); p != nil {
if refs := p.RefsLinked(); len(refs) > 0 {
htmlOut = biblio.LinkCitations(htmlOut, refs)
htmlOut = biblio.Place(htmlOut, refs)
}
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, htmlOut)
}
// --- import, download, history ---------------------------------------------
// fire and fireRaw notify the optional webhook sink.
func (a *Admin) fire(event string, p *post.Post) {
a.fireRaw(event, map[string]any{"post": payloads.BuildSummary(p)})
}
func (a *Admin) fireRaw(event string, payload map[string]any) {
if a.deps.OnEvent != nil {
a.deps.OnEvent(event, payload)
}
}
// handlePreviewLink returns a shareable preview URL for a draft or
// scheduled post. The link is signed with the session key, so without
// one no link can be honoured and none is offered.
func (a *Admin) handlePreviewLink(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if a.deps.Store.Find(slug, "") == nil {
http.NotFound(w, r)
return
}
token := preview.Token(slug, a.deps.PreviewKey, time.Now())
if token == "" {
writeAdminJSONError(w, http.StatusConflict, "no_session_key",
"Preview links need a session key: set [admin].session_key or make the state directory writable.")
return
}
base := strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
writeAdminJSON(w, http.StatusOK, map[string]any{
"url": fmt.Sprintf("%s/api/volumen/posts/%s?preview_token=%s", base, slug, token),
"token": token,
})
}
+929
View File
@@ -0,0 +1,929 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/preview"
)
func writeTestPost(t *testing.T, f *fixture, name, body string) {
t.Helper()
path := filepath.Join(f.contentDir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write post: %v", err)
}
}
const samplePost = `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "cs"
tags = ["go", "blog"]
+++
Hello **body**.
`
func TestDashboardRenders(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Posts", "Hello", `data-slug="hello"`, "Published", "Drafts",
`data-tag="go"`, "1 post", "Log out",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
func TestDashboardGroupsLanguageVariants(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
lang = "en"
translations = { cs = "ahoj" }
+++
English body.
`)
writeTestPost(t, f, "ahoj.md", `+++
title = "Ahoj"
slug = "ahoj"
date = 2026-08-18
lang = "cs"
translations = { en = "hello" }
+++
České tělo.
`)
writeTestPost(t, f, "lonely.md", `+++
title = "Lonely"
slug = "lonely"
date = 2026-08-17
lang = "en"
+++
Alone.
`)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
// The linked pair is one card, the unrelated post the second one.
if got := strings.Count(body, `<article class="post"`); got != 2 {
t.Fatalf("cards = %d, want 2", got)
}
if got := strings.Count(body, `data-variants=`); got != 1 {
t.Fatalf("cards with variants = %d, want 1", got)
}
if !strings.Contains(body, `data-slug="hello"`) || strings.Contains(body, `data-slug="ahoj"`) {
t.Fatal("the variant ahoj must not stand as its own card")
}
// Both language versions are reachable from the switch chips.
if !strings.Contains(body, `data-lang="en"`) || !strings.Contains(body, `data-lang="cs"`) {
t.Fatal("the card must offer both language variants")
}
// The selection acts on the whole publication: both slugs ride along.
if !strings.Contains(body, `value="hello,ahoj"`) && !strings.Contains(body, `value="ahoj,hello"`) {
t.Fatal("the bulk selection must carry every variant slug")
}
}
func TestDashboardRequiresLogin(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestNewFormRendersEditor(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"New post", `id="post-form"`, `action="/admin/posts"`, "Markdown"} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
func TestEditFormRendersPost(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Edit post", `value="Hello"`, `value="hello"`, `readonly`,
`action="/admin/posts/hello"`, "Hello **body**.",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
req = httptest.NewRequest(http.MethodGet, "/admin/posts/ghost/edit", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
func TestCreatePostViaForm(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf},
"title": {"Fresh"},
"slug": {"fresh"},
"lang": {"cs"},
"tags": {"a, b"},
"body": {"content"},
"draft": {"on"},
"author": {"Petr"},
}
rec := postForm(t, f, "/admin/posts", form, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=created" {
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
if p := f.findPost("fresh"); p == nil || p.Title() != "Fresh" || !p.Draft() {
t.Fatalf("post = %v", p)
}
if len(f.events) == 0 || f.events[len(f.events)-1] != "post.created" {
t.Fatalf("events = %v", f.events)
}
}
func TestCreatePostValidationRendersForm(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}, "title": {"X"}, "slug": {"Bad Slug"}, "body": {"b"}}
rec := postForm(t, f, "/admin/posts", form, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Invalid slug.") {
t.Fatal("validation message missing")
}
}
func TestUpdatePostKeepsPath(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf}, "title": {"Updated"}, "slug": {"hello"},
"lang": {"cs"}, "tags": {"go"}, "body": {"new body"},
}
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
p := f.findPost("hello")
if p == nil || p.Title() != "Updated" || p.Body != "new body\n" {
t.Fatalf("post = %v", p)
}
}
// The bibliography card writes the refs tables through the whole save
// path: the editor's JSON reaches the file as [[refs]] blocks, an
// author's ORCID survives as a name table, and a frontmatter key the
// form never names round-trips untouched beside them.
func TestEditorSavesTheBibliography(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Cite"
slug = "hello"
date = 2026-08-18
note = "keep me"
tags = ["go"]
[[refs]]
raw = "Old entry."
+++
Cites [1].
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// The edit form hands the stored list to the card's script, and the
// card sits below the editor with its own bounded list.
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
for _, want := range []string{`id="refs-card"`, "Old entry.", `id="ref-row-template"`, `id="refs-count"`, `class="refs-scroll"`} {
if !strings.Contains(body, want) {
t.Fatalf("edit form missing %q", want)
}
}
if strings.Index(body, `id="refs-card"`) < strings.Index(body, `id="post-form"`) {
t.Fatal("the bibliography card must not precede the form")
}
editorSection := strings.Index(body, `id="markdown-view"`)
refsCard := strings.Index(body, `id="refs-card"`)
if editorSection == -1 || refsCard == -1 || refsCard < editorSection {
t.Fatal("the bibliography card must sit below the editor")
}
form := url.Values{
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Cites [1].\n\n[[refs]]\n"},
"refs": {`[` +
`{"num":2,"raw":"Kept and edited."},` +
`{"raw":"Added verbatim.","doi":"10.1086/300499"},` +
`{"authors":[{"name":"Adam Riess","orcid":"0000-0002-1825-0097"}],` +
`"title":"Observational Evidence","year":"1998"}` +
`]`},
}
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
raw, err := os.ReadFile(filepath.Join(f.contentDir, "hello.md"))
if err != nil {
t.Fatalf("read post: %v", err)
}
file := string(raw)
for _, want := range []string{
`note = "keep me"`,
"[[refs]]",
"raw = \"Kept and edited.\"",
"num = 2",
"raw = \"Added verbatim.\"",
`doi = "10.1086/300499"`,
`title = "Observational Evidence"`,
`{name = "Adam Riess", orcid = "0000-0002-1825-0097"}`,
} {
if !strings.Contains(file, want) {
t.Fatalf("saved file missing %q:\n%s", want, file)
}
}
if strings.Contains(file, "Old entry.") {
t.Fatalf("the replaced entry survived:\n%s", file)
}
// The rewritten list renders with its citations linked.
p := f.findPost("hello")
refs := p.RefsLinked()
if len(refs) != 3 {
t.Fatalf("refs = %v", refs)
}
if refs[0].Num != 2 || refs[0].Raw != "Kept and edited." {
t.Fatalf("first entry = %+v", refs[0])
}
html, err := p.HTML()
if err != nil {
t.Fatalf("render: %v", err)
}
// The preserved numbers name the anchors: the first entry keeps its
// explicit num = 2, so the list carries ref-2 and ref-3 and no ref-1.
if !strings.Contains(html, `id="ref-2"`) || !strings.Contains(html, `id="ref-3"`) {
t.Fatalf("rendered list wrong:\n%s", html)
}
if strings.Contains(html, `id="ref-1"`) {
t.Fatalf("an unnumbered anchor appeared:\n%s", html)
}
}
// A save whose form carries no refs field keeps the stored list, so the
// bibliography never disappears under a page that does not edit it.
func TestEditorWithoutRefsKeepsTheStoredList(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Cite"
slug = "hello"
date = 2026-08-18
[[refs]]
raw = "Old entry."
+++
Body.
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Body.\n"},
}
if rec := postForm(t, f, "/admin/posts/hello", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
if refs := f.findPost("hello").Refs(); len(refs) != 1 || refs[0].Raw != "Old entry." {
t.Fatalf("refs = %v", refs)
}
}
func TestDeleteAndUndeleteFlow(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther ||
!strings.Contains(rec.Header().Get("Location"), "saved=deleted&undo=hello") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello") != nil {
t.Fatal("post still present")
}
// The webhook contract names the post under the "post" key, the same
// shape every other post event and the API's delete endpoint deliver.
last := len(f.events) - 1
if last < 0 || f.events[last] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
inner, ok := f.payloads[last]["post"].(map[string]any)
if !ok || inner["slug"] != "hello" || inner["title"] != "Hello" {
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
}
rec = postForm(t, f, "/admin/posts/hello/undelete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=undone" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello") == nil {
t.Fatal("post not restored")
}
}
// A bulk delete delivers the same post.deleted shape as the single
// delete, so a subscriber cannot tell which screen the change came from.
func TestBulkDeleteEventShape(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"hello"}}, cookie)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "n=1") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
last := len(f.events) - 1
if last < 0 || f.events[last] != "post.deleted" {
t.Fatalf("events = %v", f.events)
}
inner, ok := f.payloads[last]["post"].(map[string]any)
if !ok || inner["slug"] != "hello" {
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
}
}
func TestDuplicateCreatesDraftCopy(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther ||
!strings.Contains(rec.Header().Get("Location"), "/admin/posts/hello-copy/edit") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
copyPost := f.findPost("hello-copy")
if copyPost == nil || !copyPost.Draft() {
t.Fatalf("copy = %v", copyPost)
}
if copyPost.DateString() != "" {
t.Fatalf("copy kept the date: %q", copyPost.DateString())
}
// A second duplicate gets the -copy-2 suffix.
rec = postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Header().Get("Location"), "hello-copy-2") {
t.Fatalf("location = %q", rec.Header().Get("Location"))
}
}
// A duplicate that cannot be created must say so on the dashboard, not
// disappear behind a silent redirect.
func TestDuplicateFailureIsReported(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", `+++
title = "Hello"
slug = "hello"
date = 2026-08-18
doi = "not-a-doi"
+++
Body.
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=duplicate_failed" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("hello-copy") != nil {
t.Fatal("a rejected duplicate must not be saved")
}
}
// The editor's API link carries a valid preview token, so it opens a
// draft as well as a published post.
func TestEditFormCarriesPreviewToken(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
mark := `/api/volumen/posts/hello?preview_token=`
i := strings.Index(body, mark)
if i < 0 {
t.Fatal("API link without a preview token")
}
token, _, _ := strings.Cut(body[i+len(mark):], `"`)
if !preview.Valid(token, "hello", f.admin.deps.PreviewKey, time.Now()) {
t.Fatalf("preview token invalid: %q", token)
}
}
func TestBulkActions(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "a.md", "+++\nslug = \"a\"\ntitle = \"A\"\ndraft = true\n+++\nx\n")
writeTestPost(t, f, "b.md", "+++\nslug = \"b\"\ntitle = \"B\"\n+++\nx\n")
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"publish"}, "slugs": {"a"}}, cookie)
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "bulk=publish&n=1") {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if f.findPost("a").Draft() {
t.Fatal("post not published")
}
rec = postForm(t, f, "/admin/posts/bulk",
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"a,b"}}, cookie)
if !strings.Contains(rec.Header().Get("Location"), "n=2") {
t.Fatalf("location = %q", rec.Header().Get("Location"))
}
if f.findPost("a") != nil || f.findPost("b") != nil {
t.Fatal("posts not deleted")
}
}
func TestSlugExistsEndpoint(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
for path, want := range map[string]string{
"/admin/posts/exists?slug=hello": `"available":false`,
"/admin/posts/exists?slug=fresh": `"available":true`,
"/admin/posts/exists?slug=": `"available":true`,
"/admin/posts/exists?slug=hello&exclude=hello": `"available":true`,
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
t.Fatalf("%s: code=%d body=%s", path, rec.Code, rec.Body.String())
}
}
}
func TestPreviewEndpoint(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/preview", url.Values{"_csrf": {csrf}, "body": {"**bold**"}}, cookie)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<strong>bold</strong>") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPreviewLinkEndpoint(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "draft.md", "+++\nslug = \"d\"\ndraft = true\n+++\nx\n")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/d/preview-link", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "preview_token=") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestImportFlow(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "imported.md", "+++\ntitle = \"Imported\"\nslug = \"imported\"\n+++\nbody\n")
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/posts/imported/edit" {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if f.findPost("imported") == nil {
t.Fatal("import not saved")
}
// Non-.md rejected.
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "evil.txt", "content")
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d", rec.Code)
}
// Import without a slug derives one from the file name.
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
"file", "derived-slug.md", "Just a body, no frontmatter.\n")
if rec.Code != http.StatusSeeOther {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
if f.findPost("derived-slug") == nil {
t.Fatal("derived slug import failed")
}
}
func TestDownloadAndHistory(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// Saving twice archives one revision.
form := url.Values{
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
"lang": {"cs"}, "body": {"changed"},
}
postForm(t, f, "/admin/posts/hello", form, cookie)
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/download", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "title = \"Hello\"") {
t.Fatalf("download code=%d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Header().Get("Content-Disposition"), `filename="hello.md"`) {
t.Fatalf("disposition = %q", rec.Header().Get("Content-Disposition"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history", nil)
req.AddCookie(cookie)
rec = f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "History") {
t.Fatalf("history code=%d", rec.Code)
}
if !strings.Contains(rec.Body.String(), " kB") {
t.Fatal("revision size missing")
}
}
func TestUploadRejectsGarbage(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf,
"file", "x.webp", "not an image at all")
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
rec = multipartForm(t, f, "/admin/uploads", cookie, csrf,
"file", "pic.png", string(webpData))
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "/media/") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCSRFRequiredOnMutations(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
for _, path := range []string{
"/admin/posts", "/admin/posts/bulk", "/admin/preview",
"/admin/posts/import",
} {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("_csrf=wrong"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("%s: code = %d, want 403", path, rec.Code)
}
}
}
// --- helpers ---------------------------------------------------------------
func (f *fixture) findPost(slug string) *post.Post {
return f.storeObj.Find(slug, "")
}
// csrfFromSession performs the login GET flow and returns the CSRF token.
func csrfFromSession(t *testing.T, f *fixture, cookie *http.Cookie) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code == http.StatusOK {
return extractCSRF(t, rec.Body.String())
}
// Authenticated: pull the token from the session instead.
sess := f.store.Load(req)
return CSRFToken(sess)
}
func postForm(t *testing.T, f *fixture, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
return f.do(t, req)
}
func multipartForm(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field, filename, content string) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("_csrf", csrf)
part, err := mw.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write([]byte(content)); err != nil {
t.Fatalf("write part: %v", err)
}
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, path, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.AddCookie(cookie)
return f.do(t, req)
}
// The history page's two per-revision endpoints are the ones an operator
// reaches for after a bad edit, so both are exercised: the download and
// the restore, including the redirect that carries the flash message.
func TestHistoryDownloadAndRestore(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "hello.md", samplePost)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// One save archives the original.
postForm(t, f, "/admin/posts/hello", url.Values{
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
"lang": {"cs"}, "body": {"changed"},
}, cookie)
revisions := f.admin.deps.Store.Revisions("hello")
if len(revisions) != 1 {
t.Fatalf("revisions = %v", revisions)
}
name := revisions[0].Name
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/"+name, nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("history download code = %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Hello **body**.") {
t.Fatalf("revision body = %s", rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "hello-"+name) {
t.Fatalf("disposition = %q", got)
}
// An unknown revision name is a 404, not an empty file.
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/nope.md", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("unknown revision code = %d", rec.Code)
}
// Restoring puts the archived body back and redirects to the editor.
req = httptest.NewRequest(http.MethodPost, "/admin/posts/hello/history/"+name+"/restore",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec = f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("restore code = %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Location"); got != "/admin/posts/hello/edit?restored=1" {
t.Fatalf("location = %q", got)
}
restored := f.storeObj.Find("hello", "")
if restored == nil || !strings.Contains(restored.Body, "Hello **body**.") {
t.Fatalf("body after restore = %q", restored.Body)
}
}
// The brand SVG loads on every admin page, so a broken embed pattern
// would break the whole UI silently. The one asset is the icon: the
// favicon, the login brand and the topbar badge all read the same file.
func TestStaticSVGRoutes(t *testing.T) {
f := newFixture(t)
const path = "/admin/icon.svg"
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: code = %d", path, rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
t.Fatalf("%s: content-type = %q", path, got)
}
if !strings.Contains(rec.Body.String(), "<svg") {
t.Fatalf("%s: body is not an SVG", path)
}
}
func TestImportFormRenders(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/posts/import", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `name="file"`) {
t.Fatalf("import form code=%d body=%s", rec.Code, rec.Body.String())
}
}
// Deleting a media file removes it from the library and from the public
// route, and a second delete reports the absence.
func TestMediaDelete(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf, "file", "x.webp", string(webpFixture()))
if rec.Code != http.StatusOK {
t.Fatalf("upload code = %d body=%s", rec.Code, rec.Body.String())
}
items := f.storeObj.ListMedia()
if len(items) != 1 {
t.Fatalf("media = %v", items)
}
name := items[0].Name
req := httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
t.Fatalf("delete code = %d", rec.Code)
}
if len(f.storeObj.ListMedia()) != 0 {
t.Fatal("media survived the delete")
}
req = httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
t.Fatalf("second delete code = %d, want 404", rec.Code)
}
}
// webpFixture is a minimal RIFF/WEBP header, enough for the signature
// check the upload path performs.
func webpFixture() []byte {
data := append([]byte("RIFF"), 0, 0, 0, 0)
return append(data, []byte("WEBPVP8 ")...)
}
// A malformed date in the editor form is rejected with the post
// re-rendered, rather than silently dropping an inherited schedule.
func TestEditorRejectsMalformedPublishAt(t *testing.T) {
f := newFixture(t)
if err := os.WriteFile(filepath.Join(f.contentDir, "sched.md"),
[]byte("+++\ntitle = \"S\"\nslug = \"sched\"\npublish_at = 2999-01-01\n+++\nbody\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/sched", url.Values{
"_csrf": {csrf}, "title": {"S"}, "slug": {"sched"},
"publish_at": {"not a date"}, "body": {"body"},
}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "publish_at must be an ISO 8601 date") {
t.Fatal("validation message missing")
}
if p := f.admin.deps.Store.Find("sched", ""); p == nil || !p.Scheduled() {
t.Fatal("the stored schedule was dropped by the rejected save")
}
}
// An admin POST body over the configured allowance is cut off with 413
// before it can fill the temp directory.
func TestAdminBodyOverTheLimitIs413(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
huge := strings.Repeat("a", 12*1024*1024)
rec := postForm(t, f, "/admin/posts", url.Values{
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
}, cookie)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("code = %d, want 413", rec.Code)
}
}
// The editor preview must show the bibliography the published page
// will show: the refs live in the saved post's frontmatter, which the
// body-only render cannot see on its own.
func TestPreviewWeavesSavedRefs(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "cite.md", `+++
title = "Cite"
slug = "cite"
[[refs]]
title = "Observational evidence from supernovae"
doi = "10.1103/PhysRevD.59.103502"
+++
Tvrzení [1].
[[refs]]
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/preview", url.Values{
"_csrf": {csrf}, "body": {"Tvrzení [1].\n\n[[refs]]\n"},
"slug": {"cite"},
}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
`<section class="refs" id="references">`,
`<a class="ref-cite" href="#ref-1">[1]</a>`,
`href="https://doi.org/10.1103/PhysRevD.59.103502"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("preview missing %q:\n%s", want, body)
}
}
// A new post with no saved refs keeps the marker as inert text, and
// an unknown slug is just the plain body render.
for _, slug := range []string{"", "ghost"} {
rec := postForm(t, f, "/admin/preview", url.Values{
"_csrf": {csrf}, "body": {"[[refs]]\n"}, "slug": {slug},
}, cookie)
if !strings.Contains(rec.Body.String(), biblio.Marker) {
t.Fatalf("slug %q: preview rewrote an unsaved marker:\n%s", slug, rec.Body.String())
}
}
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"log/slog"
"net/http"
"strconv"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// writeAdminJSON writes one JSON object response; encoding/json escapes
// what a browser's JSON.parse requires, which a %q verb does not.
func writeAdminJSON(w http.ResponseWriter, status int, value map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.MarshalWrite(w, value, json.Deterministic(true)); err != nil {
slog.Warn("admin: cannot encode JSON response", "error", err)
}
}
func (a *Admin) handleUpload(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeAdminJSONError(w, http.StatusBadRequest, "no_file", i18n.Admin.T(a.langFor(r), "No file was uploaded."))
return
}
defer file.Close()
limit := int64(a.deps.Config.Admin.MaxUploadBytes)
raw, err := readLimited(file, limit)
if err != nil {
writeAdminJSONError(w, http.StatusRequestEntityTooLarge, "too_large",
i18n.Admin.Tf(a.langFor(r),
"The file could not be read (limit %s bytes).",
strconv.FormatInt(limit, 10)))
return
}
if errMsg := validateImageData(raw); errMsg != "" {
writeAdminJSONError(w, http.StatusUnsupportedMediaType, errMsg,
i18n.Admin.T(a.langFor(r), "Only WebP, AVIF and SVG images are supported."))
return
}
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
if err != nil {
writeAdminJSONError(w, http.StatusInternalServerError, "upload_failed",
i18n.Admin.T(a.langFor(r), "The upload could not be stored."))
return
}
writeAdminJSON(w, http.StatusOK, map[string]any{"url": url})
}
func writeAdminJSONError(w http.ResponseWriter, status int, code, message string) {
writeAdminJSON(w, status, map[string]any{"error": code, "message": message})
}
// validateImageData reports why data is not an acceptable upload. The
// stored extension is taken from the byte signature, so the declared
// filename's type is irrelevant: what matters is that the bytes are one
// of the accepted image formats.
func validateImageData(data []byte) string {
if imagefile.Detect(data) == "" {
return "invalid_signature"
}
return ""
}
func (a *Admin) handleIcon(w http.ResponseWriter, _ *http.Request) {
a.serveStaticSVG(w, "volumen-icon.svg")
}
func (a *Admin) serveStaticSVG(w http.ResponseWriter, name string) {
data, err := web.StaticFile(name)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
+244
View File
@@ -0,0 +1,244 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package admin serves the server-rendered admin UI: authentication,
// post management, settings, and the media library.
package admin
import (
"bytes"
"context"
"encoding/json/v2"
"fmt"
"html/template"
"io"
"strings"
"sync"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/diff"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// Crumb is one breadcrumb entry. UI marks a fixed interface label the
// renderer translates; content labels (post titles) pass through.
type Crumb struct {
Label string
Href string
IsLast bool
UI bool
}
// PageData is the template context shared by every admin page; page
// handlers fill the specific fields they need.
type PageData struct {
Config *config.Config
Path string
CSPNonce string
Version string
UpdateAvailable string
// Lang is the interface language this request renders in ("en" or
// "cs"), resolved from the account, the language cookie, or the
// site language. It feeds the html lang attribute, which the date
// picker and the relative-time formatter read.
Lang string
// Theme is the colour scheme this request renders in, resolved
// from the account, the theme cookie, or the default. It feeds the
// html data-theme attribute the stylesheet's scheme blocks read.
Theme string
CurrentUser string
CurrentRole string
CurrentUserRecord *users.User
UsersExist bool
CSRFToken string
IsLogin bool
IsSetup bool
IsAuthenticated bool
// SetupI18n carries the wizard's strings in every shipped
// language, so the language chips can swap the page text without
// a reload and without losing what the operator typed.
SetupI18n template.JS
DisplayName string
UserPhoto string
UserInitial string
Crumbs []Crumb
Error string
RetryAfter int
Notice string
// Dashboard.
Posts []postCard
Stats dashboardStats
TagCounts []tagCount
Q string
// Editor and history.
IsNew bool
IsEdit bool
Post *editorPost
PostTemplates []tplOption
TemplatesJSON template.JS
Restored bool
Duplicated bool
AuthorPlaceholder string
PreviewToken string
Slug string
Heading string
Revisions []revisionRow
DiffChunks []diff.Chunk
DiffName string
DiffWhen string
// Settings.
IsAdmin bool
Roles []string
DefaultRole string
UserRows []userRow
TemplatesList []tplOption
WebhookRows []hookRow
WebhookDeliveries []deliveryRow
TokenRows []tokenRow
NewToken string
MediaItems []mediaRow
// The second factor: its state on the account, an enrolment in
// flight, and the one-time recovery codes a change just produced.
TotpEnabled bool
TotpPending bool
TotpSVG template.HTML
TotpSecret string
TotpURI string
RecoveryCodes []string
RecoveryNotice string
MediaTotal string
Target string
// Sidebar navigation highlighting.
NavPosts bool
NavNew bool
NavImport bool
NavMedia bool
NavSettings bool
}
// Tr translates a simple message in this request's language. Handlers
// use it for the strings they compose in Go; templates use the tr and
// trn funcs, which read the same catalogue.
func (d *PageData) Tr(s string) string {
return i18n.Admin.T(d.Lang, s)
}
// Trf translates a simple message with one value.
func (d *PageData) Trf(s, arg string) string {
return i18n.Admin.Tf(d.Lang, s, arg)
}
// langRenderer is one language's parsed template set. The translation
// funcs close over the language, so a page renders whole in one tongue
// with no per-string lookups in the handlers.
type langRenderer struct {
pages map[string]*template.Template
}
// Renderer executes the embedded admin templates in every shipped
// language.
type Renderer struct {
mu sync.Mutex
langs map[string]*langRenderer
}
// NewRenderer parses the layout together with every page template, one
// set per shipped language.
func NewRenderer() (*Renderer, error) {
fs := web.TemplateFS()
r := &Renderer{langs: map[string]*langRenderer{}}
for _, lang := range i18n.Languages {
base, err := template.New("layout.html").Funcs(funcMap(lang)).ParseFS(fs, "templates/layout.html")
if err != nil {
return nil, fmt.Errorf("parse layout (%s): %w", lang, err)
}
lr := &langRenderer{pages: map[string]*template.Template{}}
for _, page := range pageNames() {
clone, err := base.Clone()
if err != nil {
return nil, fmt.Errorf("clone layout for %s (%s): %w", page, lang, err)
}
if _, err := clone.ParseFS(fs, "templates/"+page); err != nil {
return nil, fmt.Errorf("parse %s (%s): %w", page, lang, err)
}
lr.pages[page] = clone
}
r.langs[lang] = lr
}
return r, nil
}
// pageNames lists the page templates parsed alongside the layout.
func pageNames() []string {
return []string{
"login.html", "setup.html", "twofactor.html", "list.html", "form.html", "history.html", "diff.html",
"import.html",
"settings.html", "media.html", "update.html", "notfound.html",
}
}
// Render executes the named page inside the layout shell, in the
// language the page data carries. The context is the request's, so a
// template failure is logged against it.
func (r *Renderer) Render(ctx context.Context, w io.Writer, page string, data *PageData) error {
lang := data.Lang
if !i18n.Valid(lang) {
lang = "en"
}
// Fixed breadcrumb labels are interface strings; content labels
// (a post title) pass through untouched.
for i, c := range data.Crumbs {
if c.UI {
data.Crumbs[i].Label = i18n.Admin.T(lang, c.Label)
}
}
r.mu.Lock()
lr := r.langs[lang]
r.mu.Unlock()
tmpl, ok := lr.pages[page]
if !ok {
return fmt.Errorf("unknown admin page %q", page)
}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
web.Logger(ctx).Warn("admin: template error", "page", page, "error", err)
return err
}
_, err := w.Write(buf.Bytes())
return err
}
func funcMap(lang string) template.FuncMap {
cat := i18n.Admin
return template.FuncMap{
"lower": strings.ToLower,
"join": func(items []string, sep string) string { return strings.Join(items, sep) },
"tr": func(s string) string { return cat.T(lang, s) },
"trh": func(s string) template.HTML { return template.HTML(cat.TH(lang, s)) },
"trf": func(s, arg string) string { return cat.Tf(lang, s, arg) },
"trn": func(n int, id string) string { return cat.N(lang, id, n) },
"i18nJSON": func() template.JS {
b, err := json.Marshal(cat.JS(lang))
if err != nil {
return "{}"
}
// The catalogue holds authored strings only, but the same
// script-embedding rule as templatesJSON applies: no literal
// "<" may reach the page inside a script element.
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
},
}
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/session"
)
// passwordPolicy returns the configured length bounds. Validate
// guarantees a minimum of at least one and a maximum at or above it
// before the server starts.
func (a *Admin) passwordPolicy() (int, int) {
return a.deps.Config.Admin.MinPasswordLength, a.deps.Config.Admin.MaxPasswordLength
}
func (a *Admin) handleSettingsPassword(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := a.currentUser(r)
if a.deps.Users.Authenticate(username, r.PostFormValue("current_password")) == nil {
a.renderSettings(w, r, a.tr(r, "Current password is incorrect."), "", http.StatusUnprocessableEntity)
return
}
newPassword := r.PostFormValue("new_password")
if strings.TrimSpace(newPassword) == "" {
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdatePassword(username, newPassword); err != nil {
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
// Every other session dies with the changed fingerprint; this one is
// re-bound, so the device the change was made on stays signed in.
if updated := a.deps.Users.Find(username); updated != nil {
session.FromContext(r.Context()).Set("pv", sessionFingerprint(updated.PasswordHash))
}
a.record(r, "user.password_changed", username, nil)
a.renderSettings(w, r, "", a.tr(r, "Password updated."), http.StatusOK)
}
func (a *Admin) handleSettingsUsername(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
current := a.currentUser(r)
name := strings.TrimSpace(r.PostFormValue("username"))
sess := session.FromContext(r.Context())
switch {
case name == "":
a.renderSettings(w, r, a.tr(r, "Username cannot be empty."), "", http.StatusUnprocessableEntity)
case !usernameRe.MatchString(name):
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
case name == current:
a.renderSettings(w, r, "", a.tr(r, "Username unchanged."), http.StatusOK)
default:
if _, err := a.deps.Users.Rename(current, name); err != nil {
a.renderSettings(w, r, a.trf(r, "The username could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
sess.Set("user", name)
a.record(r, "user.renamed", name, nil)
a.renderSettings(w, r, "", a.tr(r, "Username updated."), http.StatusOK)
}
}
func (a *Admin) handleSettingsName(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := strings.TrimSpace(r.PostFormValue("name"))
if _, err := a.deps.Users.UpdateName(a.currentUser(r), name); err != nil {
a.renderSettings(w, r, a.trf(r, "The display name could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
notice := a.tr(r, "Display name updated.")
if name == "" {
notice = a.tr(r, "Display name cleared.")
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
func (a *Admin) handleSettingsFediverse(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
value := strings.TrimSpace(r.PostFormValue("fediverse_creator"))
if value == "" {
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle cleared."), http.StatusOK)
return
}
if !fediverse.Valid(value) {
a.renderSettings(w, r, a.tr(r, "Fediverse handle must look like @user@host."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), value); err != nil {
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle updated."), http.StatusOK)
}
func (a *Admin) handleSettingsOrcid(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
value := identifiers.NormalizeORCID(r.PostFormValue("orcid"))
if value == "" {
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "ORCID cleared."), http.StatusOK)
return
}
if !identifiers.ValidORCID(value) {
a.renderSettings(w, r, a.tr(r, "ORCID must look like 0000-0002-1825-0097."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), value); err != nil {
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "ORCID updated."), http.StatusOK)
}
func (a *Admin) handleSettingsPhoto(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, header, err := r.FormFile("photo")
if err != nil {
a.renderSettings(w, r, a.tr(r, "No file selected."), "", http.StatusUnprocessableEntity)
return
}
defer file.Close()
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
if err != nil {
a.renderSettings(w, r, a.tr(r, "File is too large."), "", http.StatusUnprocessableEntity)
return
}
if validateImageData(raw) != "" {
a.renderSettings(w, r,
a.tr(r, "Only WebP, AVIF and SVG images are supported."), "", http.StatusUnprocessableEntity)
return
}
username := a.currentUser(r)
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
if err != nil {
a.renderSettings(w, r, a.tr(r, "The photo could not be stored."), "", http.StatusInternalServerError)
return
}
previous := ""
if record := a.deps.Users.Find(username); record != nil {
previous = record.Photo
}
if _, err := a.deps.Users.UpdatePhoto(username, url); err != nil {
a.renderSettings(w, r, a.trf(r, "The profile photo could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
if previous != "" && previous != url {
a.deleteUnreferencedMedia(previous)
}
a.renderSettings(w, r, "", a.tr(r, "Profile photo updated."), http.StatusOK)
}
func (a *Admin) handleSettingsPhotoRemove(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := a.currentUser(r)
previous := ""
if record := a.deps.Users.Find(username); record != nil {
previous = record.Photo
}
if _, err := a.deps.Users.UpdatePhoto(username, ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The profile photo could not be removed: %s", err.Error()), "", http.StatusInternalServerError)
return
}
if previous != "" {
a.deleteUnreferencedMedia(previous)
}
a.renderSettings(w, r, "", a.tr(r, "Profile photo removed."), http.StatusOK)
}
// deleteUnreferencedMedia removes a photo file no user references any
// more.
func (a *Admin) deleteUnreferencedMedia(url string) {
if !strings.HasPrefix(url, "/media/") {
return
}
for _, user := range a.deps.Users.All() {
if user.Photo == url {
return
}
}
a.deps.Store.DeleteMedia(url)
}
// --- users panel ------------------------------------------------------------
+170
View File
@@ -0,0 +1,170 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"maps"
"net/http"
"slices"
"strconv"
"strings"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
)
// templateFields are the editor inputs a template may pre-fill beyond
// its title, slug, tags and body; anything else in the fields box is a
// typo waiting to seed every new post with a key nobody reads.
var templateFields = map[string]bool{
"lang": true, "author": true, "fediverse_creator": true,
"doi": true, "orcid": true,
"series": true, "series_order": true,
"excerpt": true, "cover": true, "cover_alt": true, "cover_caption": true,
}
// parseTemplateFields reads the fields box: key = value lines in TOML,
// each key an editor field. unknown names the first key outside the
// allowed set; err reports text that is not a small TOML document.
func parseTemplateFields(text string) (fields map[string]string, unknown string, err error) {
if strings.TrimSpace(text) == "" {
return nil, "", nil
}
data, err := interpres.ParseMap([]byte(text))
if err != nil {
return nil, "", fmt.Errorf("template fields must be key = value lines")
}
out := map[string]string{}
for _, key := range slices.Sorted(maps.Keys(data)) {
if !templateFields[key] {
return nil, key, nil
}
value := data[key]
if value == nil {
continue
}
if text, isString := value.(string); isString {
if text == "" {
continue
}
out[key] = text
continue
}
if number, isInt := value.(int64); isInt {
out[key] = strconv.FormatInt(number, 10)
continue
}
out[key] = fmt.Sprintf("%v", value)
}
if len(out) == 0 {
return nil, "", nil
}
return out, "", nil
}
func (a *Admin) handleSettingsTemplateCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := strings.TrimSpace(r.PostFormValue("name"))
if name == "" {
a.renderSettings(w, r, a.tr(r, "Template name is required."), "", http.StatusUnprocessableEntity)
return
}
fields, unknown, err := parseTemplateFields(r.PostFormValue("fields"))
switch {
case err != nil:
a.renderSettings(w, r, a.tr(r, "Template fields must be key = value TOML lines."), "", http.StatusUnprocessableEntity)
return
case unknown != "":
a.renderSettings(w, r, a.trf(r, "Unknown template field %s.", unknown), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Templates.Add(templates.PostTemplate{
Name: name,
Tags: payloads.ParseTags(r.PostFormValue("tags")),
Body: r.PostFormValue("body"),
Title: strings.TrimSpace(r.PostFormValue("title")),
Slug: strings.TrimSpace(r.PostFormValue("slug")),
Fields: fields,
}); err != nil {
a.renderSettings(w, r, a.trf(r, "That template could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.renderSettings(w, r, "", a.tr(r, "Template added."), http.StatusOK)
}
func (a *Admin) handleSettingsTemplateDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if err := a.deps.Templates.Delete(r.PathValue("name")); err != nil {
a.renderSettings(w, r, a.trf(r, "The template could not be deleted: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.renderSettings(w, r, "", a.tr(r, "Template deleted."), http.StatusOK)
}
// --- backup export / import -------------------------------------------------
// handleSettingsWebhookTest delivers a ping inline and reports the
// outcome from the delivery it produced, not from the shared history a
// concurrent delivery could reshuffle.
func (a *Admin) handleSettingsWebhookTest(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.Webhooks == nil {
a.renderSettings(w, r, a.tr(r, "No webhooks configured."), "", http.StatusUnprocessableEntity)
return
}
// The list is taken once: a settings change that reshuffles it between
// the bounds check and the fetch would test a different hook than the
// one the form named.
hooks := a.deps.Webhooks.Hooks()
index, err := strconv.Atoi(r.PathValue("index"))
if err != nil || index < 0 || index >= len(hooks) {
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
return
}
hook := hooks[index]
delivery := a.deps.Webhooks.TestHook(hook)
a.record(r, "webhook.tested", hook.URL, nil)
notice := a.tr(r, "Test delivery failed.")
if delivery.Status == "ok" {
notice = a.tr(r, "Test delivery sent.")
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
func (a *Admin) handleSettingsTokenCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
created, raw, err := a.deps.Tokens.Create(r.PostFormValue("name"), r.PostForm["scope"])
if err != nil {
a.renderSettings(w, r, a.trf(r, "The token could not be created: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "token.created", created.Name, nil)
data := a.settingsData(r)
data.NewToken = raw
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
func (a *Admin) handleSettingsTokenDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := r.PathValue("name")
notice := a.tr(r, "Token revoked.")
if !a.deps.Tokens.Revoke(name) {
notice = a.tr(r, "That token was not found.")
} else {
a.record(r, "token.revoked", name, nil)
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"log/slog"
"net/http"
"sourcedock.dev/petrbalvin/volumen/internal/backup"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
)
func (a *Admin) handleSettingsExport(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="volumen-backup.tar.gz"`)
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
// The archive streams straight to the client: the app routes it past
// the buffering wrappers, so no copy of it waits in memory. An error
// before the first byte still answers as a plain 500; after it, the
// download ends truncated and the gzip footer makes that visible.
sent := false
if err := backup.Write(writeTracker{w, &sent}, a.deps.Backup); err != nil {
slog.Error("admin: backup export failed", "error", err)
if !sent {
w.Header().Del("Content-Type")
w.Header().Del("Content-Disposition")
http.Error(w, "The backup could not be written: "+err.Error(), http.StatusInternalServerError)
}
}
}
// writeTracker records whether anything reached the client, so a failure
// can still choose between a clean error page and a logged truncation.
type writeTracker struct {
w http.ResponseWriter
sent *bool
}
func (t writeTracker) Write(p []byte) (int, error) {
*t.sent = true
return t.w.Write(p)
}
func (a *Admin) handleSettingsImport(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, _, err := r.FormFile("backup")
if err != nil {
a.renderSettings(w, r, a.tr(r, "No backup file selected."), "", http.StatusUnprocessableEntity)
return
}
defer file.Close()
written, err := backup.Restore(file, a.deps.Backup)
if written > 0 {
// A partial restore changed files on disk; the caches must drop
// even when a later entry failed, or the admin keeps serving the
// pre-import state until an unrelated write invalidates them.
a.deps.Store.InvalidateCache()
a.deps.Users.Invalidate()
a.deps.Templates.Invalidate()
a.deps.Tokens.Invalidate()
}
if err != nil {
slog.Warn("admin: backup import failed", "error", err)
a.renderSettings(w, r, a.trf(r, "Could not restore backup: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
if written == 0 {
a.renderSettings(w, r, a.tr(r, "The archive holds no files this deployment recognises."), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "backup.imported", fmt.Sprintf("%d files", written), nil)
a.renderSettings(w, r, "", i18n.Admin.N(a.langFor(r), "backup.files", written), http.StatusOK)
}
// --- updates ----------------------------------------------------------------
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"regexp"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
func (a *Admin) registerSettingsRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/settings", a.requireLogin(a.handleSettings))
mux.HandleFunc("POST /admin/settings/password", a.requireLogin(a.handleSettingsPassword))
mux.HandleFunc("POST /admin/settings/username", a.requireLogin(a.handleSettingsUsername))
mux.HandleFunc("POST /admin/settings/name", a.requireLogin(a.handleSettingsName))
mux.HandleFunc("POST /admin/settings/language", a.requireLogin(a.handleSettingsLanguage))
mux.HandleFunc("POST /admin/settings/theme", a.requireLogin(a.handleSettingsTheme))
mux.HandleFunc("POST /admin/settings/fediverse", a.requireLogin(a.handleSettingsFediverse))
mux.HandleFunc("POST /admin/settings/orcid", a.requireLogin(a.handleSettingsOrcid))
mux.HandleFunc("POST /admin/settings/photo", a.requireLogin(a.handleSettingsPhoto))
mux.HandleFunc("POST /admin/settings/photo/remove", a.requireLogin(a.handleSettingsPhotoRemove))
mux.HandleFunc("POST /admin/settings/users", a.requireAdmin(a.handleSettingsUserCreate))
mux.HandleFunc("POST /admin/settings/users/{name}/role", a.requireAdmin(a.handleSettingsUserRole))
mux.HandleFunc("POST /admin/settings/users/{name}/password", a.requireAdmin(a.handleSettingsUserPassword))
mux.HandleFunc("POST /admin/settings/users/{name}/delete", a.requireAdmin(a.handleSettingsUserDelete))
mux.HandleFunc("POST /admin/settings/templates", a.requireAdmin(a.handleSettingsTemplateCreate))
mux.HandleFunc("POST /admin/settings/templates/{name}/delete", a.requireAdmin(a.handleSettingsTemplateDelete))
mux.HandleFunc("GET /admin/settings/export", a.requireAdmin(a.handleSettingsExport))
mux.HandleFunc("POST /admin/settings/import", a.requireAdmin(a.handleSettingsImport))
mux.HandleFunc("POST /admin/settings/check-update", a.requireAdmin(a.handleSettingsCheckUpdate))
mux.HandleFunc("POST /admin/settings/update", a.requireAdmin(a.handleSettingsUpdate))
mux.HandleFunc("POST /admin/settings/webhooks", a.requireAdmin(a.handleSettingsWebhookAdd))
mux.HandleFunc("POST /admin/settings/webhooks/toggle", a.requireAdmin(a.handleSettingsWebhookToggle))
mux.HandleFunc("POST /admin/settings/webhooks/delete", a.requireAdmin(a.handleSettingsWebhookDelete))
mux.HandleFunc("POST /admin/settings/webhooks/{index}/test", a.requireAdmin(a.handleSettingsWebhookTest))
mux.HandleFunc("POST /admin/settings/tokens", a.requireAdmin(a.handleSettingsTokenCreate))
mux.HandleFunc("POST /admin/settings/tokens/{name}/delete", a.requireAdmin(a.handleSettingsTokenDelete))
mux.HandleFunc("POST /admin/settings/twofactor/start", a.requireLogin(a.handleTotpStart))
mux.HandleFunc("POST /admin/settings/twofactor/cancel", a.requireLogin(a.handleTotpCancel))
mux.HandleFunc("POST /admin/settings/twofactor/verify", a.requireLogin(a.handleTotpVerify))
mux.HandleFunc("POST /admin/settings/twofactor/disable", a.requireLogin(a.handleTotpDisable))
mux.HandleFunc("POST /admin/settings/twofactor/codes", a.requireLogin(a.handleTotpCodes))
}
// requireAdmin additionally enforces the admin role.
func (a *Admin) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return a.requireLogin(func(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
record := a.deps.Users.Find(sess.Get("user"))
if record == nil || record.Role != "admin" {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
next(w, r)
})
}
// settingsData builds the settings page context: the account record,
// the user list, the post templates, the webhook rows and the API
// tokens.
func (a *Admin) settingsData(r *http.Request) *PageData {
data := a.pageData(r)
data.IsAdmin = data.CurrentRole == "admin"
data.Roles = users.Roles
data.DefaultRole = users.DefaultRole
data.UserRows = userRows(data.CurrentUser, a.deps.Users.All())
data.TemplatesList = tplOptions(a.deps.Templates.All())
if a.deps.Webhooks != nil {
// The manager delivers the config-declared hooks first, the
// admin-managed ones after it, so the row's position tells where
// it came from and which forms apply to it.
data.WebhookRows = hookRows(a.deps.Webhooks.Hooks(), len(a.deps.StaticWebhooks))
deliveries := a.deps.Webhooks.Deliveries("")
data.WebhookDeliveries = deliveryRows(deliveries)
for i, d := range deliveries {
if d.Status != "ok" {
data.WebhookDeliveries[i].Result = i18n.Admin.N(data.Lang, "deliveries.attempts", d.Attempts)
}
}
}
data.TokenRows = tokenRows(a.deps.Tokens.All())
a.fillTotpState(data, r)
data.Crumbs = []Crumb{{Label: "Settings", IsLast: true, UI: true}}
return data
}
// handleSettingsLanguage switches the signed-in account's interface
// language. The choice persists on the user record for every request
// and in a cookie, so the login screen follows it too; the confirmation
// renders in the language just picked.
func (a *Admin) handleSettingsLanguage(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
lang := r.PostFormValue("language")
if !i18n.Valid(lang) {
a.renderSettings(w, r, i18n.Admin.T("en", "Unsupported language."), "", http.StatusUnprocessableEntity)
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if _, err := a.deps.Users.UpdateLanguage(username, lang); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf("en", "The language could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: i18n.Cookie,
Value: lang,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: a.cookieSecure(),
SameSite: http.SameSiteLaxMode,
})
data := a.settingsData(r)
data.Lang = lang
data.Notice = i18n.Admin.T(lang, "The interface language is set.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// handleSettingsTheme switches the signed-in account's colour scheme.
// The choice persists on the user record for every request and in a
// cookie, so the login screen follows it too.
func (a *Admin) handleSettingsTheme(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
theme := r.PostFormValue("theme")
if !web.ValidTheme(theme) {
a.renderSettings(w, r, i18n.Admin.T("en", "Unsupported colour scheme."), "", http.StatusUnprocessableEntity)
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if _, err := a.deps.Users.UpdateTheme(username, theme); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf("en", "The colour scheme could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: web.ThemeCookie,
Value: theme,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: a.cookieSecure(),
SameSite: http.SameSiteLaxMode,
})
data := a.settingsData(r)
data.Theme = theme
data.Notice = i18n.Admin.T(data.Lang, "The colour scheme is set.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// cookieSecure reports whether the deployment serves over HTTPS or
// behind a trusted proxy, the condition the session cookie and the
// preference cookies take their Secure flag from.
func (a *Admin) cookieSecure() bool {
return a.deps.Config.Server.CookieSecure || a.deps.Config.Server.TrustProxy
}
// renderSettings renders the settings page with a flash message.
func (a *Admin) renderSettings(w http.ResponseWriter, r *http.Request, errorMsg, notice string, status int) {
data := a.settingsData(r)
data.Error = errorMsg
data.Notice = notice
a.renderPage(w, r, "settings.html", data, status)
}
// handleSettings renders the settings page.
func (a *Admin) handleSettings(w http.ResponseWriter, r *http.Request) {
a.renderSettings(w, r, "", "", http.StatusOK)
}
+820
View File
@@ -0,0 +1,820 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"bytes"
"maps"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/web"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
func settingsForm(t *testing.T, f *fixture, path string, extra url.Values) *httptest.ResponseRecorder {
t.Helper()
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}}
maps.Copy(form, extra)
return postForm(t, f, path, form, cookie)
}
func TestSettingsPageRenders(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
"Settings", "Account", "Users", "Templates", "Backup",
"API tokens", "Webhooks", "admin",
} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
// Every field that sets a password carries the strength meter: the
// own-account change and the add-user form are on the page itself,
// the per-user reset form rides each non-self user row. The floor
// attribute rides the same inputs and nowhere else on the page.
meters := strings.Count(body, `data-min-length=`)
wantMeters := 2
for _, row := range f.admin.deps.Users.All() {
if row.Username != "admin" {
wantMeters++
}
}
if meters != wantMeters {
t.Fatalf("password meters = %d, want %d", meters, wantMeters)
}
if !strings.Contains(body, `class="pw-level__bar"`) {
t.Fatal("meter bar markup missing")
}
}
func TestPasswordChange(t *testing.T) {
f := newFixture(t)
// Wrong current password.
rec := settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"nope"},
"new_password": {"another-good-pass"},
})
if !strings.Contains(rec.Body.String(), "Current password is incorrect.") {
t.Fatal("wrong-password message missing")
}
// Weak new password.
rec = settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"correct-horse-9"},
"new_password": {"short"},
})
if !strings.Contains(rec.Body.String(), "at least") {
t.Fatal("policy message missing")
}
// Success.
rec = settingsForm(t, f, "/admin/settings/password", url.Values{
"current_password": {"correct-horse-9"},
"new_password": {"a-brand-new-passphrase"},
})
if !strings.Contains(rec.Body.String(), "Password updated.") {
t.Fatal("success message missing")
}
if f.users.Authenticate("admin", "a-brand-new-passphrase") == nil {
t.Fatal("new password does not authenticate")
}
}
func TestUsernameChangeUpdatesSession(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/settings/username",
url.Values{"_csrf": {csrf}, "username": {"bad name!"}}, cookie)
if !strings.Contains(rec.Body.String(), "letters, numbers") {
t.Fatal("format message missing")
}
rec = postForm(t, f, "/admin/settings/username",
url.Values{"_csrf": {csrf}, "username": {"petr"}}, cookie)
if !strings.Contains(rec.Body.String(), "Username updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("petr") == nil {
t.Fatal("rename not applied")
}
// The session cookie was re-signed with the new username.
newCookie := sessionCookie(t, rec)
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(newCookie)
rec = f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `value="petr"`) {
t.Fatalf("session lost after rename: code=%d", rec.Code)
}
}
func TestThemeChange(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"plasma"}})
if !strings.Contains(rec.Body.String(), "The colour scheme is set.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Theme != "plasma" {
t.Fatal("theme not stored on the account")
}
found := false
for _, c := range rec.Result().Cookies() {
if c.Name == web.ThemeCookie && c.Value == "plasma" {
found = true
}
}
if !found {
t.Fatal("theme cookie missing")
}
// The picker re-renders with the choice marked pressed.
if !strings.Contains(rec.Body.String(), `value="plasma" class="chip" aria-pressed="true"`) {
t.Fatal("picked scheme not marked active")
}
// An unknown scheme is refused and does not overwrite the choice.
rec = settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"sepia"}})
if !strings.Contains(rec.Body.String(), "Unsupported colour scheme.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Theme != "plasma" {
t.Fatal("invalid scheme overwrote the stored choice")
}
}
func TestNameAndFediverseChange(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/name", url.Values{"name": {"Petr Balvín"}})
if !strings.Contains(rec.Body.String(), "Display name updated.") {
t.Fatal("name message missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"nope"}})
if !strings.Contains(rec.Body.String(), "@user@host") {
t.Fatal("fediverse validation missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"@petr@social"}})
if !strings.Contains(rec.Body.String(), "Fediverse handle updated.") {
t.Fatal("fediverse message missing")
}
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {""}})
if !strings.Contains(rec.Body.String(), "Fediverse handle cleared.") {
t.Fatal("fediverse clear missing")
}
}
func TestOrcidChange(t *testing.T) {
f := newFixture(t)
// A malformed iD is refused and nothing is stored.
rec := settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0098"}})
if !strings.Contains(rec.Body.String(), "ORCID must look like") {
t.Fatalf("orcid validation missing: %s", rec.Body.String())
}
if f.users.Find("admin").Orcid != "" {
t.Fatal("invalid orcid was stored")
}
// A valid iD is kept, normalised to upper case.
rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0097"}})
if !strings.Contains(rec.Body.String(), "ORCID updated.") {
t.Fatal("orcid message missing")
}
if got := f.users.Find("admin").Orcid; got != "0000-0002-1825-0097" {
t.Fatalf("stored orcid = %q", got)
}
// An empty value clears it.
rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {""}})
if !strings.Contains(rec.Body.String(), "ORCID cleared.") {
t.Fatal("orcid clear missing")
}
if got := f.users.Find("admin").Orcid; got != "" {
t.Fatalf("orcid not cleared: %q", got)
}
}
// A password an admin sets keeps its edge spaces: only the emptiness
// check may trim, the stored value must not, or the trimmed form would
// work where the typed one does not.
func TestUserCreateKeepsPasswordSpaces(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {" padded-passphrase "}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "User added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Authenticate("joe", " padded-passphrase ") == nil {
t.Fatal("the exact password, spaces included, must authenticate")
}
if f.users.Authenticate("joe", "padded-passphrase") != nil {
t.Fatal("the trimmed password must not authenticate")
}
}
func TestUserManagement(t *testing.T) {
f := newFixture(t)
// Create a second user.
rec := settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "User added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe") == nil {
t.Fatal("user not created")
}
// Duplicate rejected.
rec = settingsForm(t, f, "/admin/settings/users", url.Values{
"username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"},
})
if !strings.Contains(rec.Body.String(), "could not be added") {
t.Fatal("duplicate message missing")
}
// Role change.
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/users/joe/role",
url.Values{"_csrf": {csrf}, "role": {"admin"}}, cookie)
if !strings.Contains(rec.Body.String(), "Role updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe").Role != "admin" {
t.Fatal("role not applied")
}
// Own role cannot change.
rec = postForm(t, f, "/admin/settings/users/admin/role",
url.Values{"_csrf": {csrf}, "role": {"author"}}, cookie)
if !strings.Contains(rec.Body.String(), "own role") {
t.Fatal("self role-change not blocked")
}
// Delete.
rec = postForm(t, f, "/admin/settings/users/joe/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "User removed.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("joe") != nil {
t.Fatal("user not deleted")
}
// Own account cannot be deleted.
rec = postForm(t, f, "/admin/settings/users/admin/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "own account") {
t.Fatal("self delete not blocked")
}
}
func TestUserManagementRequiresAdmin(t *testing.T) {
f := newFixture(t)
f.users.Add("joe", "joes-good-passphrase", "author")
cookie := login(t, f, "joe", "joes-good-passphrase")
csrf := csrfFromSession(t, f, cookie)
req := httptest.NewRequest(http.MethodPost, "/admin/settings/users",
strings.NewReader(url.Values{
"_csrf": {csrf}, "username": {"x"}, "password": {"good-enough-pass"},
}.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403", rec.Code)
}
}
func TestTemplateCRUD(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Review"}, "tags": {"review, opinion"}, "body": {"## Summary"},
})
if !strings.Contains(rec.Body.String(), "Template added.") {
t.Fatalf("body = %s", rec.Body.String())
}
if len(f.admin.deps.Templates.All()) != 1 {
t.Fatal("template not stored")
}
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{"name": {"Review"}})
if !strings.Contains(rec.Body.String(), "could not be added") {
t.Fatal("duplicate message missing")
}
// A fields box pre-fills the scientific editor inputs; the values are
// TOML, so strings are quoted and a bare number arrives as text.
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Paper"}, "fields": {"series = \"tds\"\ndoi = \"10.5281/zenodo.1\"\nseries_order = 3\n"},
})
if !strings.Contains(rec.Body.String(), "Template added.") {
t.Fatalf("fields template rejected: %s", rec.Body.String())
}
var paperFields map[string]string
for _, tpl := range f.admin.deps.Templates.All() {
if tpl.Name == "Paper" {
paperFields = tpl.Fields
}
}
if paperFields["series"] != "tds" || paperFields["doi"] != "10.5281/zenodo.1" ||
paperFields["series_order"] != "3" {
t.Fatalf("stored fields = %v", paperFields)
}
// A key outside the editor's inputs is refused, so a typo cannot
// silently seed every new post with a dead key.
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Nope"}, "fields": {"journal = \"Nature\"\n"},
})
if !strings.Contains(rec.Body.String(), "Unknown template field") {
t.Fatal("unknown field accepted")
}
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
"name": {"Broken"}, "fields": {"series == tds\n\n("},
})
if !strings.Contains(rec.Body.String(), "must be key = value") {
t.Fatal("unparsable fields accepted")
}
// The new-post form embeds the templates with the lowercase keys its
// picker reads, fields included.
cookie := login(t, f, "admin", "correct-horse-9")
newReq := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
newReq.AddCookie(cookie)
rec = f.do(t, newReq)
if !strings.Contains(rec.Body.String(), `"fields":{`) ||
!strings.Contains(rec.Body.String(), `"series":"tds"`) {
t.Fatal("template fields missing from the editor payload")
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/templates/Review/delete",
url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Template deleted.") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestTokenCRUD(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/settings/tokens",
url.Values{"_csrf": {csrf}, "name": {"ci"}}, cookie)
body := rec.Body.String()
if !strings.Contains(body, "Copy this token now") || !strings.Contains(body, "vol_") {
t.Fatalf("new token not shown: %s", body)
}
rec = postForm(t, f, "/admin/settings/tokens/ci/delete", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Token revoked.") {
t.Fatalf("body = %s", rec.Body.String())
}
if len(f.admin.deps.Tokens.All()) != 0 {
t.Fatal("token not revoked")
}
}
func TestBackupExportImport(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "keep.md", "+++\nslug = \"keep\"\ntitle = \"Keep\"\n+++\nbody\n")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/gzip" {
t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type"))
}
archive := rec.Body.Bytes()
if len(archive) == 0 {
t.Fatal("empty archive")
}
// Wipe the content dir, then restore.
if err := os.Remove(filepath.Join(f.contentDir, "keep.md")); err != nil {
t.Fatalf("remove: %v", err)
}
csrf := csrfFromSession(t, f, cookie)
rec = multipartBytes(t, f, "/admin/settings/import", cookie, csrf, "backup", archive)
if !strings.Contains(rec.Body.String(), "Backup restored") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.storeObj.Find("keep", "") == nil {
t.Fatal("post not restored from backup")
}
}
func TestCheckUpdateWithoutWiring(t *testing.T) {
f := newFixture(t)
rec := settingsForm(t, f, "/admin/settings/check-update", nil)
if !strings.Contains(rec.Body.String(), "not available in this build") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestMediaLibraryAndDelete(t *testing.T) {
f := newFixture(t)
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
uploaded, err := f.storeObj.StoreUpload("pic.webp", webpData)
if err != nil {
t.Fatalf("upload: %v", err)
}
name := strings.TrimPrefix(uploaded, "/media/")
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/media", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), name) {
t.Fatalf("code=%d", rec.Code)
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/media/"+name+"/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/media" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
if _, err := f.storeObj.MediaPath(name); err == nil {
t.Fatal("media not deleted")
}
rec = postForm(t, f, "/admin/media/ghost.webp/delete", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusNotFound {
t.Fatalf("code = %d", rec.Code)
}
}
// multipartBytes posts a binary file field.
func multipartBytes(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field string, data []byte) *httptest.ResponseRecorder {
t.Helper()
body, contentType := buildMultipart(t, csrf, field, "backup.tar.gz", data)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
req.AddCookie(cookie)
return f.do(t, req)
}
// buildMultipart renders a single-file multipart body.
func buildMultipart(t *testing.T, csrf, field, filename string, data []byte) (string, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("_csrf", csrf)
part, err := mw.CreateFormFile(field, filename)
if err != nil {
t.Fatalf("create form file: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("write part: %v", err)
}
_ = mw.Close()
return buf.String(), mw.FormDataContentType()
}
func TestPhotoUploadAndRemove(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
webpData = append(webpData, []byte("WEBPVP8 ")...)
body, contentType := buildMultipart(t, csrf, "photo", "me.webp", webpData)
req := httptest.NewRequest(http.MethodPost, "/admin/settings/photo", strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "Profile photo updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Photo == "" {
t.Fatal("photo not stored on the user")
}
rec = postForm(t, f, "/admin/settings/photo/remove", url.Values{"_csrf": {csrf}}, cookie)
if !strings.Contains(rec.Body.String(), "Profile photo removed.") {
t.Fatalf("body = %s", rec.Body.String())
}
if f.users.Find("admin").Photo != "" {
t.Fatal("photo not cleared")
}
}
func TestWebhookTestDelivery(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&hits, 1)
}))
defer srv.Close()
f := newFixture(t)
f.admin.deps.Config.Webhooks = []config.Webhook{{URL: srv.URL}}
f.admin.deps.Webhooks = webhooks.NewManager(
[]webhooks.Webhook{{URL: srv.URL, Enabled: true}}, "0.0.0-test")
rec := settingsForm(t, f, "/admin/settings/webhooks/0/test", nil)
if !strings.Contains(rec.Body.String(), "Test delivery sent.") {
t.Fatalf("body = %s", rec.Body.String())
}
if atomic.LoadInt32(&hits) != 1 {
t.Fatalf("hits = %d", hits)
}
rec = settingsForm(t, f, "/admin/settings/webhooks/9/test", nil)
if !strings.Contains(rec.Body.String(), "Webhook not found.") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestUpdateHooksFlow(t *testing.T) {
f := newFixture(t)
f.admin.SetUpdateHooks(func() (string, error) { return "9.9.9", nil },
func() (string, error) { return "9.9.9", nil })
// Banner appears on the dashboard.
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "is available") {
t.Fatal("update banner missing")
}
csrf := csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/settings/update", url.Values{"_csrf": {csrf}}, cookie)
// The version is printed as the toolchain recorded it, prefix included.
if !strings.Contains(rec.Body.String(), "9.9.9") {
t.Fatalf("update page body = %s", rec.Body.String())
}
f.admin.SetUpdateHooks(func() (string, error) { return "", nil }, nil)
rec = settingsForm(t, f, "/admin/settings/check-update", nil)
if !strings.Contains(rec.Body.String(), "already the latest release") {
t.Fatalf("body = %s", rec.Body.String())
}
}
func TestTokenCreateWithScopes(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
form := url.Values{"_csrf": {csrf}, "name": {"scoped"}, "scope": {"write", "delete"}}
rec := postForm(t, f, "/admin/settings/tokens", form, cookie)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "vol_") {
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
}
list := f.admin.deps.Tokens.All()
if len(list) != 1 {
t.Fatalf("tokens = %v", list)
}
if len(list[0].Scopes) != 2 || !list[0].HasScope("write") || !list[0].HasScope("delete") {
t.Fatalf("scopes = %v", list[0].Scopes)
}
if list[0].HasScope("read") {
t.Fatal("the removed read scope was granted")
}
}
func TestEditorSaveKeepsUnknownMetadata(t *testing.T) {
f := newFixture(t)
writeTestPost(t, f, "aliased.md", `+++
title = "Aliased"
slug = "aliased"
aliases = ["old-slug"]
custom_field = "keep me"
[translations]
en = "aliased-en"
+++
body
`)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
rec := postForm(t, f, "/admin/posts/aliased", url.Values{
"_csrf": {csrf}, "title": {"Aliased v2"}, "slug": {"aliased"},
"lang": {"cs"}, "body": {"new body"},
}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("code = %d", rec.Code)
}
p := f.storeObj.Find("aliased", "")
if p == nil {
t.Fatal("post lost")
}
if got := p.Aliases(); len(got) != 1 || got[0] != "old-slug" {
t.Fatalf("aliases lost: %v", got)
}
if got := p.Translations(); got["en"] != "aliased-en" {
t.Fatalf("translations lost: %v", got)
}
if _, ok := p.Metadata.Get("custom_field"); !ok {
t.Fatal("custom field lost")
}
if p.Title() != "Aliased v2" {
t.Fatalf("title = %q", p.Title())
}
}
// A webhook added in Settings lands in webhooks.toml and reaches the
// manager without a restart; a config-declared hook stays read-only.
func TestSettingsWebhookLifecycle(t *testing.T) {
f := newFixture(t)
f.admin.deps.WebhooksFile = filepath.Join(t.TempDir(), "webhooks.toml")
f.admin.deps.Webhooks = webhooks.NewManager(nil, "t")
f.admin.deps.StaticWebhooks = []webhooks.Webhook{{URL: "https://cfg.example/hook", Enabled: true}}
f.admin.deps.Webhooks.SetHooks(f.admin.deps.StaticWebhooks)
// A config hook renders as read-only: no toggle form for it.
cookie := login(t, f, "admin", "correct-horse-9")
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
rec := f.do(t, req)
if !strings.Contains(rec.Body.String(), "https://cfg.example/hook") ||
strings.Contains(rec.Body.String(), "Remove this webhook?") {
t.Fatalf("config hook row wrong: %d", rec.Code)
}
// Add one.
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"https://example.com/hook"},
"secret": {"s3cret"},
"events": {"post.created, post.updated"},
"enabled": {"on"},
})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook added.") {
t.Fatalf("add: %d %s", rec.Code, rec.Body.String())
}
stored, err := webhooks.LoadFile(f.admin.deps.WebhooksFile)
if err != nil || len(stored) != 1 || stored[0].URL != "https://example.com/hook" ||
stored[0].Secret != "s3cret" || !stored[0].Enabled || len(stored[0].Events) != 2 {
t.Fatalf("stored = %+v err = %v", stored, err)
}
hooks := f.admin.deps.Webhooks.Hooks()
if len(hooks) != 2 || hooks[0].URL != "https://cfg.example/hook" || hooks[1].URL != "https://example.com/hook" {
t.Fatalf("manager = %+v", hooks)
}
// A duplicate URL and a broken URL are refused.
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"https://example.com/hook"}, "enabled": {"on"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "already configured") {
t.Fatalf("duplicate: %d %s", rec.Code, rec.Body.String())
}
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
"url": {"ftp://example.com/hook"}, "enabled": {"on"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not a valid") {
t.Fatalf("invalid url: %d %s", rec.Code, rec.Body.String())
}
// Toggle flips the stored flag and the manager's.
rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{
"url": {"https://example.com/hook"},
})
if rec.Code != http.StatusOK {
t.Fatalf("toggle: %d %s", rec.Code, rec.Body.String())
}
stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile)
if stored[0].Enabled {
t.Fatal("toggle did not disable the hook")
}
if f.admin.deps.Webhooks.Hooks()[1].Enabled {
t.Fatal("manager kept the hook enabled")
}
// A config-declared URL is not toggleable.
rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{
"url": {"https://cfg.example/hook"},
})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Webhook not found.") {
t.Fatalf("config hook toggle: %d %s", rec.Code, rec.Body.String())
}
// Delete removes the hook from the file and the manager.
rec = settingsForm(t, f, "/admin/settings/webhooks/delete", url.Values{
"url": {"https://example.com/hook"},
})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook removed.") {
t.Fatalf("delete: %d %s", rec.Code, rec.Body.String())
}
stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile)
if len(stored) != 0 {
t.Fatalf("store = %+v", stored)
}
if len(f.admin.deps.Webhooks.Hooks()) != 1 {
t.Fatalf("manager = %+v", f.admin.deps.Webhooks.Hooks())
}
}
func TestOversizedBodyRejected(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
huge := strings.Repeat("a", 1_048_577)
rec := postForm(t, f, "/admin/posts", url.Values{
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("code = %d, want 422", rec.Code)
}
if !strings.Contains(rec.Body.String(), "at most 1048576 bytes") {
t.Fatal("size message missing")
}
}
// A changed password retires every session issued before it: the cookie
// carries a fingerprint of the hash, and only the device the change was
// made on gets re-bound.
func TestPasswordChangeSignsOutOtherSessions(t *testing.T) {
f := newFixture(t)
first := login(t, f, "admin", "correct-horse-9")
second := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, first)
rec := postForm(t, f, "/admin/settings/password", url.Values{
"_csrf": {csrf}, "current_password": {"correct-horse-9"},
"new_password": {"new-good-passphrase"},
}, first)
if !strings.Contains(rec.Body.String(), "Password updated.") {
t.Fatalf("body = %s", rec.Body.String())
}
// The change re-signs this device.s session; the cookie to test
// with is the one the response just set.
first = sessionCookie(t, rec)
// The other device.s session is dead.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(second)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("other session survived: %d %s", rec.Code, rec.Header().Get("Location"))
}
// The device the change was made on stays signed in.
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(first)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("current session died: %d", rec.Code)
}
// The old password no longer signs in; the new one does.
if f.users.Authenticate("admin", "correct-horse-9") != nil {
t.Fatal("the old password still authenticates")
}
if f.users.Authenticate("admin", "new-good-passphrase") == nil {
t.Fatal("the new password does not authenticate")
}
}
// An admin can reset another account's password; the account's sessions
// die with it, and the admin cannot shortcut their own current-password
// check through the route.
func TestAdminPasswordReset(t *testing.T) {
f := newFixture(t)
if _, err := f.users.Add("author", "authors-good-passphrase", "author"); err != nil {
t.Fatalf("author not created: %v", err)
}
authorCookie := login(t, f, "author", "authors-good-passphrase")
// Self-reset is refused.
rec := settingsForm(t, f, "/admin/settings/users/admin/password",
url.Values{"password": {"shortcut-passphrase"}})
if rec.Code != http.StatusUnprocessableEntity ||
!strings.Contains(rec.Body.String(), "own password") {
t.Fatalf("self reset not blocked: %d %s", rec.Code, rec.Body.String())
}
// Reset the author's password.
rec = settingsForm(t, f, "/admin/settings/users/author/password",
url.Values{"password": {"reset-passphrase-9"}})
if !strings.Contains(rec.Body.String(), "sessions were signed out") {
t.Fatalf("body = %s", rec.Body.String())
}
// The author's session is dead, and the new password works.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(authorCookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
t.Fatalf("author session survived the reset: %d", rec.Code)
}
if f.users.Authenticate("author", "reset-passphrase-9") == nil {
t.Fatal("the reset password does not authenticate")
}
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"encoding/base32"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/qrcode"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/totp"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
// The enrolment state rides the session: the candidate secret lives
// there between the QR page and the verifying code, so the users file
// only ever holds secrets that were proven by a working application.
const (
totpEnrollKey = "totp_enroll"
totpEnrollAt = "totp_enroll_at"
)
// enrolWindow bounds how long a candidate secret stays answerable.
const enrolWindow = 10 * time.Minute
// totpURI builds the otpauth URI every application understands.
func totpURI(secret, username string) string {
u := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: "/Volumen:" + username,
RawQuery: url.Values{"secret": {secret}, "issuer": {"Volumen"}, "algorithm": {"SHA1"}, "digits": {"6"}, "period": {"30"}}.Encode(),
}
return u.String()
}
// fillTotpState carries the second-factor state of the signed-in
// account and of an enrolment in flight onto the settings page.
func (a *Admin) fillTotpState(data *PageData, r *http.Request) {
record := a.deps.Users.Find(data.CurrentUser)
if record != nil && record.TotpSecret != "" {
data.TotpEnabled = true
return
}
sess := session.FromContext(r.Context())
secret := sess.Get(totpEnrollKey)
if secret == "" {
return
}
started, err := strconv.ParseInt(sess.Get(totpEnrollAt), 10, 64)
if err != nil || time.Since(time.Unix(started, 0)) > enrolWindow {
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
return
}
data.TotpPending = true
data.TotpSecret = secret
data.TotpURI = totpURI(secret, data.CurrentUser)
if svg, err := qrcode.SVG(data.TotpURI); err == nil {
data.TotpSVG = template.HTML(svg)
}
}
// decodeBase32Secret turns the stored candidate back into key bytes.
func decodeBase32Secret(encoded string) ([]byte, error) {
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded))
}
// totpOK checks a candidate secret against the code the application
// shows; no replay floor applies, this is the first use.
func totpOK(secret []byte, code string) bool {
ok, _ := totp.Validate(secret, code, time.Now(), 0)
return ok
}
// handleTotpStart begins enrolment: a fresh candidate secret travels to
// the settings page inside the session, and nothing is stored yet.
func (a *Admin) handleTotpStart(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
if record := a.deps.Users.Find(sess.Get("user")); record != nil && record.TotpSecret != "" {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Two-factor authentication is already on."), "", http.StatusUnprocessableEntity)
return
}
secret := users.GenerateTotpSecret()
sess.Set(totpEnrollKey, secret)
sess.Set(totpEnrollAt, strconv.FormatInt(time.Now().Unix(), 10))
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
}
// handleTotpCancel drops an enrolment in flight.
func (a *Admin) handleTotpCancel(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
}
// handleTotpVerify finishes enrolment: the code the application shows
// proves the candidate secret, which is stored together with a fresh
// set of recovery codes. The codes are shown exactly once, here.
func (a *Admin) handleTotpVerify(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
secret := sess.Get(totpEnrollKey)
if secret == "" {
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
return
}
code := r.PostFormValue("code")
decoded, err := decodeBase32Secret(secret)
if err != nil || !totpOK(decoded, code) {
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "That code did not match; start again."), "", http.StatusUnprocessableEntity)
return
}
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := a.deps.Users.EnableTotp(username, secret, hashes); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be enabled: %s", err.Error()), "", http.StatusInternalServerError)
return
}
sess.Delete(totpEnrollKey)
sess.Delete(totpEnrollAt)
a.record(r, "user.totp_enabled", username, nil)
data := a.settingsData(r)
data.RecoveryCodes = codes
data.RecoveryNotice = i18n.Admin.T(data.Lang, "Two-factor is on. Store these recovery codes now; they will not be shown again.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
// handleTotpDisable turns the second factor off; possession of a
// current code is the proof, so a stolen cookie alone cannot.
func (a *Admin) handleTotpDisable(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.ClearTotp(username); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be disabled: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.totp_disabled", username, nil)
a.renderSettings(w, r, "", i18n.Admin.T(a.lang(r, nil), "Two-factor is off."), http.StatusOK)
}
// handleTotpCodes replaces the recovery codes; the old ones stop
// working, and the new ones are shown exactly once.
func (a *Admin) handleTotpCodes(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
sess := session.FromContext(r.Context())
username := sess.Get("user")
if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) {
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity)
return
}
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := a.deps.Users.ReplaceRecovery(username, hashes); err != nil {
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "The codes could not be replaced: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.totp_codes", username, nil)
data := a.settingsData(r)
data.RecoveryCodes = codes
data.RecoveryNotice = i18n.Admin.T(data.Lang, "New recovery codes. Store them now; they will not be shown again.")
a.renderPage(w, r, "settings.html", data, http.StatusOK)
}
+56
View File
@@ -0,0 +1,56 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
)
func (a *Admin) handleSettingsCheckUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.CheckUpdate == nil {
a.renderSettings(w, r, "", a.tr(r, "Update checks are not available in this build."), http.StatusOK)
return
}
latest, err := a.deps.CheckUpdate()
if err != nil {
a.renderSettings(w, r, a.trf(r, "Update check failed: %s", err.Error()), "", http.StatusOK)
return
}
// The hook returns "" when the running version is current, so the
// comparison has already been made by the one implementation that
// knows how to make it.
if latest == "" {
a.renderSettings(w, r, "",
a.trf(r, "volumen %s is already the latest release.", a.deps.Version), http.StatusOK)
return
}
a.renderSettings(w, r, "", a.trf(r, "volumen %s is available.", latest), http.StatusOK)
}
func (a *Admin) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
if a.deps.SelfUpdate == nil {
a.renderSettings(w, r, a.tr(r, "Self-update is not available in this build."), "", http.StatusUnprocessableEntity)
return
}
target, err := a.deps.SelfUpdate()
if err != nil {
message := a.trf(r, "The upgrade failed: %s", err.Error())
if target != "" {
message = a.trf2(r, "Upgrade to %s failed: %s", target, err.Error())
}
a.renderSettings(w, r, message, "", http.StatusInternalServerError)
return
}
data := a.pageData(r)
data.Target = target
a.renderPage(w, r, "update.html", data, http.StatusOK)
}
// --- webhooks and tokens ----------------------------------------------------
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
)
func (a *Admin) handleSettingsUserCreate(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
// A password keeps its edge spaces: the reset path stores them the
// same way, and trimming here would create a password only the
// trimmed form of which works.
password := r.PostFormValue("password")
role := r.PostFormValue("role")
if username == "" || strings.TrimSpace(password) == "" {
a.renderSettings(w, r, a.tr(r, "Username and password are required."), "", http.StatusUnprocessableEntity)
return
}
if !usernameRe.MatchString(username) {
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(password, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.Add(username, password, role); err != nil {
a.renderSettings(w, r, a.trf(r, "That user could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "user.created", username, nil)
a.renderSettings(w, r, "", a.tr(r, "User added."), http.StatusOK)
}
func (a *Admin) handleSettingsUserRole(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot change your own role."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.SetRole(target, r.PostFormValue("role")); err != nil {
a.renderSettings(w, r, a.trf(r, "The role could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "user.role_changed", target, nil)
a.renderSettings(w, r, "", a.tr(r, "Role updated."), http.StatusOK)
}
func (a *Admin) handleSettingsUserDelete(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot delete your own account."), "", http.StatusUnprocessableEntity)
return
}
photo := ""
if record := a.deps.Users.Find(target); record != nil {
photo = record.Photo
}
if _, err := a.deps.Users.Delete(target); err != nil {
a.renderSettings(w, r, a.trf(r, "The user could not be removed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
if photo != "" {
a.deleteUnreferencedMedia(photo)
}
a.record(r, "user.deleted", target, nil)
a.renderSettings(w, r, "", a.tr(r, "User removed."), http.StatusOK)
}
// --- post templates ---------------------------------------------------------
// handleSettingsUserPassword resets another account's password. The
// account's sessions die with the change (the session fingerprint
// changes), which is the point: an admin resetting a password is
// remedying an account, and every cookie issued before must stop
// working.
func (a *Admin) handleSettingsUserPassword(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
target := r.PathValue("name")
if target == a.currentUser(r) {
a.renderSettings(w, r, a.tr(r, "You cannot reset your own password here."), "", http.StatusUnprocessableEntity)
return
}
if a.deps.Users.Find(target) == nil {
a.renderSettings(w, r, a.tr(r, "That user was not found."), "", http.StatusUnprocessableEntity)
return
}
newPassword := r.PostFormValue("password")
if strings.TrimSpace(newPassword) == "" {
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdatePassword(target, newPassword); err != nil {
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.record(r, "user.password_reset", target, nil)
a.renderSettings(w, r, "", a.tr(r, "Password reset; that user's sessions were signed out."), http.StatusOK)
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"fmt"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// roleOption is one <option> of a role select.
type roleOption struct {
Value string
Selected bool
}
// userRow is one entry of the users panel.
type userRow struct {
Username string
Display string
Initial string
Role string
Photo string
IsSelf bool
RoleOptions []roleOption
}
// hookRow is one configured webhook endpoint.
type hookRow struct {
Index string
URL string
// Managed is false for a hook declared in config.toml, which the
// settings forms may test but not change.
Managed bool
Enabled bool
Signed bool
EventsText string
}
// deliveryRow is one webhook delivery log entry.
type deliveryRow struct {
Timestamp string
Event string
HookURL string
OK bool
StatusCode int
Error string
Attempts int
// Result is the pre-formatted failure text ("failed (N attempts)"),
// translated by the caller that knows the request's language.
Result string
}
// tokenRow is one API token table row.
type tokenRow struct {
Name string
CreatedDay string
LastUsedDay string
}
// mediaRow is one media library tile.
type mediaRow struct {
Name string
URL string
SizeKB string
// Dimensions is the header-carried pixel size ("1920 × 1080"), or
// "" when the container did not yield one.
Dimensions string
}
func roleOptions(current string) []roleOption {
out := make([]roleOption, 0, len(users.Roles))
for _, role := range users.Roles {
out = append(out, roleOption{Value: role, Selected: role == current})
}
return out
}
func userRows(current string, list []*users.User) []userRow {
out := make([]userRow, 0, len(list))
for _, user := range list {
display := user.Name
if display == "" {
display = user.Username
}
out = append(out, userRow{
Username: user.Username,
Display: display,
Initial: firstUpper(display, "?"),
Role: user.Role,
Photo: user.Photo,
IsSelf: user.Username == current,
RoleOptions: roleOptions(user.Role),
})
}
return out
}
// hookRows renders the manager's merged hook list; the first static
// count came from config.toml and the rest are the admin's to manage.
func hookRows(hooks []webhooks.Webhook, staticCount int) []hookRow {
out := make([]hookRow, 0, len(hooks))
for i, hook := range hooks {
eventsText := "all"
if len(hook.Events) > 0 {
eventsText = strings.Join(hook.Events, ", ")
}
out = append(out, hookRow{
Index: fmt.Sprintf("%d", i),
URL: hook.URL,
Managed: i >= staticCount,
Enabled: hook.Enabled,
Signed: hook.Secret != "",
EventsText: eventsText,
})
}
return out
}
func deliveryRows(list []webhooks.Delivery) []deliveryRow {
out := make([]deliveryRow, 0, len(list))
for _, d := range list {
out = append(out, deliveryRow{
Timestamp: d.Timestamp,
Event: d.Event,
HookURL: d.HookURL,
OK: d.Status == "ok",
StatusCode: d.StatusCode,
Error: d.Error,
Attempts: d.Attempts,
})
}
return out
}
func tokenRows(list []tokens.Token) []tokenRow {
out := make([]tokenRow, 0, len(list))
for _, token := range list {
lastUsed := "never"
if len(token.LastUsed) >= 10 {
lastUsed = token.LastUsed[:10]
}
created := token.Created
if len(created) >= 10 {
created = created[:10]
}
out = append(out, tokenRow{
Name: token.Name,
CreatedDay: created,
LastUsedDay: lastUsed,
})
}
return out
}
func mediaRows(list []store.Media) []mediaRow {
out := make([]mediaRow, 0, len(list))
for _, item := range list {
out = append(out, mediaRow{
Name: item.Name,
URL: item.URL,
SizeKB: fmt.Sprintf("%.1f", float64(item.Size)/1024),
Dimensions: pixelSize(item.Width, item.Height),
})
}
return out
}
// pixelSize renders the header-carried pixel size, empty when the
// container did not yield one.
func pixelSize(width, height int) string {
if width <= 0 || height <= 0 {
return ""
}
return fmt.Sprintf("%d × %d", width, height)
}
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/url"
"slices"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
)
// fileHooks reads the admin-managed hook store.
func (a *Admin) fileHooks() ([]webhooks.Webhook, error) {
if a.deps.WebhooksFile == "" {
return nil, nil
}
return webhooks.LoadFile(a.deps.WebhooksFile)
}
// refreshWebhooks re-saves the store and applies the merged hook set to
// the manager, so a settings change delivers without a restart.
func (a *Admin) refreshWebhooks(hooks []webhooks.Webhook) error {
if err := webhooks.SaveFile(a.deps.WebhooksFile, hooks); err != nil {
return err
}
merged := make([]webhooks.Webhook, 0, len(a.deps.StaticWebhooks)+len(hooks))
merged = append(merged, a.deps.StaticWebhooks...)
merged = append(merged, hooks...)
a.deps.Webhooks.SetHooks(merged)
return nil
}
// handleSettingsWebhookAdd adds one endpoint to the store. Config-declared
// hooks are the operator's business and stay read-only; this list is the
// admin's to manage.
func (a *Admin) handleSettingsWebhookAdd(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, err := a.fileHooks()
if err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
hook, problem := validateHookInput(
r.PostFormValue("url"), strings.TrimSpace(r.PostFormValue("secret")),
r.PostFormValue("events"), r.PostFormValue("enabled") == "on",
append(slices.Clone(a.deps.StaticWebhooks), file...),
)
if problem != "" {
a.renderSettings(w, r, a.tr(r, problem), "", http.StatusUnprocessableEntity)
return
}
if err := a.refreshWebhooks(append(file, hook)); err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, "webhook.added", hook.URL, nil)
a.renderSettings(w, r, "", a.tr(r, "Webhook added."), http.StatusOK)
}
// handleSettingsWebhookToggle flips one stored hook's enabled flag. The
// URL names the hook, because the row the form was rendered from may no
// longer be at its old index by the time the POST lands.
func (a *Admin) handleSettingsWebhookToggle(w http.ResponseWriter, r *http.Request) {
a.mutateStoredHook(w, r, "webhook.updated", "Webhook updated.",
func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) {
for i, hook := range hooks {
if hook.URL == url {
hooks[i].Enabled = !hook.Enabled
return hooks, true
}
}
return hooks, false
})
}
func (a *Admin) handleSettingsWebhookDelete(w http.ResponseWriter, r *http.Request) {
a.mutateStoredHook(w, r, "webhook.deleted", "Webhook removed.",
func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) {
for i, hook := range hooks {
if hook.URL == url {
return slices.Delete(hooks, i, i+1), true
}
}
return hooks, false
})
}
// mutateStoredHook applies a change to the stored hook the form's url
// field names, re-saves, and refreshes the manager.
func (a *Admin) mutateStoredHook(w http.ResponseWriter, r *http.Request, auditAction, notice string, apply func([]webhooks.Webhook, string) ([]webhooks.Webhook, bool)) {
if !a.requireCSRF(w, r) {
return
}
file, err := a.fileHooks()
if err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
target := r.PostFormValue("url")
changed, ok := apply(file, target)
if !ok {
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
return
}
if err := a.refreshWebhooks(changed); err != nil {
a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
a.record(r, auditAction, target, nil)
a.renderSettings(w, r, "", a.tr(r, notice), http.StatusOK)
}
// validateHookInput checks the add form: an absolute http(s) URL no
// configured hook already uses, an optional secret, and the event
// filter as a comma-separated list (empty delivers everything).
func validateHookInput(raw, secret, events string, enabled bool, existing []webhooks.Webhook) (webhooks.Webhook, string) {
raw = strings.TrimSpace(raw)
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return webhooks.Webhook{}, "That URL is not a valid http(s) endpoint."
}
for _, hook := range existing {
if hook.URL == raw {
return webhooks.Webhook{}, "That URL is already configured."
}
}
return webhooks.Webhook{
URL: raw,
Secret: secret,
Events: payloads.ParseTags(events),
Enabled: enabled,
}, ""
}
+240
View File
@@ -0,0 +1,240 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"errors"
"html/template"
"net/http"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/session"
"sourcedock.dev/petrbalvin/volumen/internal/users"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// setupNeeded reports whether the first-run wizard should serve: a
// readable users file that holds no accounts. A file that cannot be
// read answers through the second value: the wizard would refuse to
// write over it anyway, so the caller says so instead of offering a
// form that cannot work.
func (a *Admin) setupNeeded() (needed bool, broken error) {
if err := a.deps.Users.Health(); err != nil {
return false, err
}
return !a.deps.Users.Any(), nil
}
// registerSetupRoutes mounts the wizard. The routes are public in the
// same sense the login is public: they exist for the owner of the
// installation before any account does, and the wizard retires itself
// as soon as one account exists.
func (a *Admin) registerSetupRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin/setup", a.handleSetupForm)
mux.HandleFunc("POST /admin/setup", a.handleSetup)
}
// handleSetupForm serves the wizard while no account exists. Once one
// does, the route sends the browser back to the login, which is the
// same answer as deleting the route: the first run happens exactly
// once. The ?lang query re-renders the page in another shipped language:
// the language chips are real links, so the choice works without
// JavaScript too.
func (a *Admin) handleSetupForm(w http.ResponseWriter, r *http.Request) {
sess := session.FromContext(r.Context())
if sess.Get("user") != "" && a.deps.Users.Find(sess.Get("user")) != nil {
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
return
}
needed, broken := a.setupNeeded()
if broken != nil {
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
return
}
if !needed {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
lang := i18n.Normalize(r.URL.Query().Get("lang"))
a.renderSetup(w, r, "", http.StatusOK, lang)
}
// renderSetup draws the wizard page in the given language ("" keeps the
// site default) and with the error the last attempt reported when there
// is one. The wizard always opens on the clean defaults: the site
// language and the shipped scheme, never on the anonymous preview
// cookies, which belong to the login screen after an account exists and
// here would only be a leftover from a half-finished or reset setup. The
// cookies are expired on the way so the next screen starts from the same
// truth the form shows. The page carries both languages of its own
// strings so the chips can swap the text without a reload.
func (a *Admin) renderSetup(w http.ResponseWriter, r *http.Request, errorMsg string, status int, lang string) {
if lang == "" {
lang = a.siteLanguage()
}
data := a.pageData(r)
data.IsSetup = true
data.Lang = lang
data.Theme = web.DefaultTheme
data.Error = errorMsg
data.SetupI18n = setupI18n(data.Config.Admin.MinPasswordLength)
expires := &http.Cookie{MaxAge: -1, Path: "/admin", HttpOnly: true}
c1 := *expires
c1.Name = i18n.Cookie
http.SetCookie(w, &c1)
c2 := *expires
c2.Name = web.ThemeCookie
http.SetCookie(w, &c2)
a.renderPage(w, r, "setup.html", data, status)
}
// setupI18nKeys are the wizard's own interface strings, keyed by their
// English source; the page swaps them client-side when a language chip
// is clicked, so the typed values survive. "password.hint" is added
// separately because it carries the configured length.
var setupI18nKeys = []string{
"Welcome to Volumen",
"Set up the administrator account to open this installation.",
"Account",
"Username",
"Display name",
"Your real name",
"Password",
"Show password",
"Hide password",
"Language",
"Colour scheme",
"The page takes the colours as you choose.",
"Create account",
}
// setupI18n builds the page's bilingual payload: every wizard string in
// both shipped languages, and the script catalogue per language, escaped
// for embedding in a script element the way the shared catalogue is.
func setupI18n(minLength int) template.JS {
ui := make(map[string]map[string]string, len(setupI18nKeys)+1)
for _, key := range setupI18nKeys {
ui[key] = map[string]string{
"en": i18n.Admin.T("en", key),
"cs": i18n.Admin.T("cs", key),
}
}
ui["password.hint"] = map[string]string{
"en": i18n.Admin.N("en", "password.min", minLength),
"cs": i18n.Admin.N("cs", "password.min", minLength),
}
payload := map[string]any{
"ui": ui,
"js": map[string]any{
"en": i18n.Admin.JS("en"),
"cs": i18n.Admin.JS("cs"),
},
}
b, err := json.Marshal(payload, json.Deterministic(true))
if err != nil {
return "{}"
}
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
}
// siteLanguage is the interface language a request falls back to when no
// account and no cookie decide it: the configured site language when the
// UI ships it, English otherwise.
func (a *Admin) siteLanguage() string {
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
return lang
}
return "en"
}
// handleSetup creates the first administrator account, stores the
// interface choices with it and signs the operator straight in. The
// password goes through the same server policy as every other password
// change; the page meter is advice, this is the gate.
func (a *Admin) handleSetup(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
needed, broken := a.setupNeeded()
if broken != nil {
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
return
}
if !needed {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if username == "" {
username = "admin"
}
name := strings.TrimSpace(r.PostFormValue("name"))
language := i18n.Normalize(r.PostFormValue("language"))
if language == "" {
language = a.siteLanguage()
}
theme := r.PostFormValue("theme")
if !web.ValidTheme(theme) {
theme = web.DefaultTheme
}
secret := r.PostFormValue("password")
if !usernameRe.MatchString(username) {
a.renderSetup(w, r, i18n.Admin.T(language, "Username may use letters, numbers, dot, dash, underscore."), http.StatusUnprocessableEntity, language)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(secret, minLen, maxLen); key != "" {
msg := i18n.Admin.T(language, key)
if n > 0 {
msg = i18n.Admin.N(language, key, n)
}
a.renderSetup(w, r, msg, http.StatusUnprocessableEntity, language)
return
}
user, err := a.deps.Users.AddFirst(username, secret, language, theme, name)
switch {
case err == nil:
case errors.Is(err, users.ErrUsersExist):
// Another claim won the race a moment ago; the wizard is gone
// and the account is already there.
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
default:
a.renderSetup(w, r, i18n.Admin.Tf(language, "The account could not be created: %s", err.Error()), http.StatusInternalServerError, language)
return
}
// Sign the operator in with the same session shape the login uses,
// so the wizard ends where a first sign-in would: inside the
// dashboard, on one request.
sess := session.FromContext(r.Context())
sess.Set("user", user.Username)
sess.Set("pv", sessionFingerprint(user.PasswordHash))
a.record(r, "setup.first_user", user.Username, nil)
secure := a.cookieSecure()
http.SetCookie(w, &http.Cookie{
Name: i18n.Cookie,
Value: language,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: web.ThemeCookie,
Value: theme,
Path: "/admin",
MaxAge: 365 * 24 * 3600,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
}
+236
View File
@@ -0,0 +1,236 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/web"
)
// A stale anonymous preview cookie (a leftover of an abandoned or reset
// setup) must not greet the operator on the wizard: the first run opens
// on the clean defaults.
func TestSetupFormIgnoresPreviewCookies(t *testing.T) {
f := newFixtureSeeded(t, false)
req := httptest.NewRequest(http.MethodGet, "/admin/setup", nil)
req.AddCookie(&http.Cookie{Name: i18n.Cookie, Value: "cs"})
req.AddCookie(&http.Cookie{Name: web.ThemeCookie, Value: "magma"})
rec := f.do(t, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `<html lang="en" data-palette="viridis">`) {
t.Fatalf("wizard did not open on the clean defaults:\n%s", body[:min(len(body), 400)])
}
// The response expires both preview cookies so later screens are clean too.
var sawLang, sawTheme bool
for _, c := range rec.Result().Cookies() {
if c.Name == i18n.Cookie && c.MaxAge < 0 {
sawLang = true
}
if c.Name == web.ThemeCookie && c.MaxAge < 0 {
sawTheme = true
}
}
if !sawLang || !sawTheme {
t.Fatalf("preview cookies not expired on the wizard response: %v", rec.Result().Cookies())
}
}
// A deployment with no accounts shows the wizard in place of the login
// screen; the login URL itself redirects, so an old bookmark lands in
// the right place too.
func TestLoginFormRedirectsToWizard(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/setup" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
func TestSetupFormServesWhileNoAccounts(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Welcome to Volumen", "name=\"password\"", `name="theme"`, `name="language"`} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
}
// The chips are real links, so the language is a server-side choice
// too: ?lang renders the whole page in it and the hidden field carries
// it into the account.
func TestSetupFormHonoursLangQuery(t *testing.T) {
f := newFixtureSeeded(t, false)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup?lang=cs", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{`<html lang="cs"`, "Účet", `name="language" id="setup-language" value="cs"`} {
if !strings.Contains(body, want) {
t.Fatalf("missing %q", want)
}
}
// The bilingual bundle rides along for the client-side swap.
if !strings.Contains(body, "Vítejte ve Volumenu") {
t.Fatal("the language bundle is missing")
}
}
func TestSetupFormRetiresWhenAccountsExist(t *testing.T) {
f := newFixture(t)
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
}
// The wizard creates the first account, keeps the language and the
// colour scheme with it, and signs the operator in on the same trip.
func TestSetupCreatesAndSignsIn(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"balvin"},
"name": {"Petr Balvín"},
"password": {"a-genuinely-unique-passphrase"},
"language": {"cs"},
"theme": {"plasma"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
user := f.users.Find("balvin")
if user == nil || user.Role != "admin" {
t.Fatalf("first account missing: %v", user)
}
if user.Language != "cs" || user.Theme != "plasma" {
t.Fatalf("wizard choices not stored: lang=%q theme=%q", user.Language, user.Theme)
}
if user.Name != "Petr Balvín" {
t.Fatalf("display name = %q", user.Name)
}
// The session cookie from the wizard opens the dashboard: the
// operator is signed in, not sent back through the login.
authed := sessionCookie(t, rec)
dash := httptest.NewRequest(http.MethodGet, "/admin/", nil)
dash.AddCookie(authed)
if rec := f.do(t, dash); rec.Code != http.StatusOK {
t.Fatalf("dashboard after setup: code = %d", rec.Code)
}
// A second claim of the same wizard is refused and pointed at the
// login: the installation has exactly one first account. The CSRF
// token rides the same session, so the refusal comes from the
// accounts already existing, not from the form.
req2 := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req2.AddCookie(authed)
rec2 := f.do(t, req2)
if rec2.Code != http.StatusSeeOther || rec2.Header().Get("Location") != "/admin/login" {
t.Fatalf("second claim: code=%d location=%q", rec2.Code, rec2.Header().Get("Location"))
}
}
// The wizard POST validates with the same server-side rules every
// password change uses: the page meter is advice, this is the gate.
func TestSetupRejectsWeakPasswordAndBadCSRF(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"admin"},
"password": {"short"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("weak password: code = %d", rec.Code)
}
if f.users.Any() {
t.Fatal("a refused wizard still created an account")
}
noCSRF := url.Values{"username": {"admin"}, "password": {"a-genuinely-unique-passphrase"}}
req = httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(noCSRF.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
t.Fatalf("missing CSRF: code = %d", rec.Code)
}
}
func TestSetupRejectsBadUsername(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {"not a name!"},
"password": {"a-genuinely-unique-passphrase"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("bad username: code = %d", rec.Code)
}
if f.users.Any() {
t.Fatal("a refused username still created an account")
}
}
// The default username is admin, and an empty field gets it: the form
// starts filled, a submit that cleared it still lands on a valid name.
func TestSetupDefaultsUsername(t *testing.T) {
f := newFixtureSeeded(t, false)
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{
"_csrf": {csrf},
"username": {""},
"password": {"a-genuinely-unique-passphrase"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("empty username: code = %d body = %s", rec.Code, rec.Body.String())
}
if f.users.Find("admin") == nil {
t.Fatal("the empty username field did not fall back to admin")
}
}
+237
View File
@@ -0,0 +1,237 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
"encoding/base32"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/totp"
"sourcedock.dev/petrbalvin/volumen/internal/users"
)
func currentCode(t *testing.T, secret string) string {
t.Helper()
return currentCodeIn(t, secret, 0)
}
// currentCodeIn computes the code of a neighbouring time step, so a
// test can answer twice without tripping the replay floor.
func currentCodeIn(t *testing.T, secret string, steps int) string {
t.Helper()
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
t.Fatalf("decode secret: %v", err)
}
return totp.Code(key, time.Now().Add(time.Duration(steps)*totp.Step))
}
// loginTo opens the first door and returns the session wherever it
// stands: the dashboard, or the second-factor step when the account
// has one.
func loginTo(t *testing.T, f *fixture, username, secret string) *http.Cookie {
t.Helper()
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
csrf := extractCSRF(t, get.Body.String())
cookie := sessionCookie(t, get)
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := f.do(t, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
}
return sessionCookie(t, rec)
}
// TestLoginWithSecondFactor walks the whole door: password, code, in,
// and the recovery path when the application is lost.
func TestLoginWithSecondFactor(t *testing.T) {
f := newFixture(t)
secret := users.GenerateTotpSecret()
codes, hashes := users.GenerateRecoveryCodes(10)
if _, err := f.users.EnableTotp("admin", secret, hashes); err != nil {
t.Fatal(err)
}
cookie := loginTo(t, f, "admin", "correct-horse-9")
// The password alone no longer opens anything: the admin bounces
// to the login, which forwards a pending session to the step.
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("password step: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/twofactor" {
t.Fatalf("login form forwards pending session: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Verification code") {
t.Fatalf("twofactor form: code=%d", rec.Code)
}
// The direct route redirects anonymous traffic to the first step.
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
t.Fatalf("anonymous twofactor: code=%d", rec.Code)
}
csrf := csrfFromSession(t, f, cookie)
// A wrong code is refused and changes nothing.
rec := postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong code: code=%d", rec.Code)
}
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("right code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Code != http.StatusOK {
t.Fatalf("dashboard after second factor: %d", rec.Code)
}
// The recovery path: sign out, in again, spend one code; the same
// code never works twice.
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("recovery code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
}
cookie = sessionCookie(t, rec)
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("reused recovery code: code=%d", rec.Code)
}
// The typed shapes humans use still work.
rec = postForm(t, f, "/admin/twofactor",
url.Values{"_csrf": {csrf}, "code": {strings.ReplaceAll(codes[1], "-", " ")}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("spaced recovery code: code=%d", rec.Code)
}
}
// TestTotpEnrolment drives the settings flow: start, the QR page, the
// verifying code, the one-time recovery codes, and turning it off.
func TestTotpEnrolment(t *testing.T) {
f := newFixture(t)
cookie := login(t, f, "admin", "correct-horse-9")
csrf := csrfFromSession(t, f, cookie)
// Before anything, the settings page offers the setup.
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body := f.do(t, req).Body.String()
if !strings.Contains(body, "Set up two-factor") {
t.Fatal("setup offer missing")
}
// Start shows the QR and the secret, and stores nothing yet. The
// candidate rides the cookie, so the jar moves on with it.
rec := postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("start: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body = f.do(t, req).Body.String()
if !strings.Contains(body, "totp__qr") || !strings.Contains(body, `<path fill="#000"`) {
t.Fatal("QR panel missing after start")
}
if f.users.Find("admin").TotpSecret != "" {
t.Fatal("start stored a secret before verification")
}
// A wrong verifying code clears the candidate.
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("wrong verify: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
if strings.Contains(f.do(t, req).Body.String(), "totp__qr") {
t.Fatal("candidate survived a wrong code")
}
// The honest path: start again, verify with the code the
// application shows, receive the recovery codes once.
rec = postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
cookie = sessionCookie(t, rec)
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
req.AddCookie(cookie)
body = f.do(t, req).Body.String()
i := strings.Index(body, `class="totp__secret"`)
if i < 0 {
t.Fatal("secret text missing")
}
rest := body[i:]
j := strings.Index(rest, ">")
k := strings.Index(rest[j:], "<")
secret := rest[j+1 : j+k]
if len(secret) < 26 {
t.Fatalf("secret looks wrong: %q", secret)
}
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("verify: %d body=%s", rec.Code, rec.Body.String()[:200])
}
page := rec.Body.String()
if !strings.Contains(page, "recovery__code") {
t.Fatal("recovery codes not shown once")
}
if f.users.Find("admin").TotpSecret == "" {
t.Fatal("enabled secret not stored")
}
// The next sign-in needs the second factor.
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
cookie = loginTo(t, f, "admin", "correct-horse-9")
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
req.AddCookie(cookie)
if rec := f.do(t, req); rec.Header().Get("Location") != "/admin/twofactor" {
t.Fatalf("second factor not asked: %q", rec.Header().Get("Location"))
}
// Turning it off asks for a current code.
csrf = csrfFromSession(t, f, cookie)
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
if rec.Code != http.StatusSeeOther {
t.Fatalf("sign-in code: %d", rec.Code)
}
cookie = sessionCookie(t, rec)
rec = postForm(t, f, "/admin/settings/twofactor/disable", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("disable with wrong code: %d", rec.Code)
}
// The sign-in already spent this window's code: the next window's
// code answers, the spent one must not.
rec = postForm(t, f, "/admin/settings/twofactor/disable",
url.Values{"_csrf": {csrf}, "code": {currentCodeIn(t, secret, 1)}}, cookie)
if rec.Code != http.StatusOK {
t.Fatalf("disable: %d", rec.Code)
}
if f.users.Find("admin").TotpSecret != "" {
t.Fatal("secret survived disable")
}
}
+426
View File
@@ -0,0 +1,426 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package admin
import (
json "encoding/json/v2"
"fmt"
"html/template"
"log/slog"
"maps"
"slices"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/store"
"sourcedock.dev/petrbalvin/volumen/internal/templates"
)
// postVariant is one language version of a publication. The dashboard
// shows one card per publication and lists its variants here, so the card
// can switch what it names, links and shows between them.
type postVariant struct {
Lang string `json:"lang"`
Slug string `json:"slug"`
Title string `json:"title"`
Excerpt string `json:"excerpt"`
DateString string `json:"date"`
Timestamp int64 `json:"timestamp,omitempty"`
Status string `json:"status"`
Draft bool `json:"draft,omitempty"`
Scheduled bool `json:"scheduled,omitempty"`
Series string `json:"series,omitempty"`
HasOrder bool `json:"hasOrder,omitempty"`
SeriesOrder int `json:"seriesOrder,omitempty"`
Cover string `json:"cover,omitempty"`
}
// postCard is one dashboard card.
type postCard struct {
Slug string
Title string
Excerpt string
Lang string
Series string
HasOrder bool
SeriesOrder int
DateString string
Cover string
Draft bool
Scheduled bool
Tags []string
Timestamp int64
Status string
// Variants carries every language version of the publication;
// VariantsJSON is the same list prepared for the card's data
// attribute, so the page script can switch between them.
Variants []postVariant
VariantsJS template.JS
GroupSlugs string
}
// recentPost is one entry of the dashboard's recent strip.
type recentPost struct {
Slug string
Title string
DateString string
}
// scheduledPost is one entry of the dashboard's upcoming strip.
type scheduledPost struct {
Slug string
Title string
When string
}
// dashboardStats holds the counters shown above the post grid.
type dashboardStats struct {
Total int
Published int
Drafts int
Scheduled int
Recent []recentPost
Upcoming []scheduledPost
}
// tagCount is one tag-cloud chip.
type tagCount struct {
Name string
Count int
}
// editorPost carries the form-ready post fields for the editor.
type editorPost struct {
Slug string
Title string
Lang string
Author string
FediverseCreator string
DOI string
ORCID string
Date string
PublishAt string
TagsCSV string
Series string
SeriesOrder string
Excerpt string
ExcerptIsSet bool
ExcerptPlaceholder string
Cover string
CoverAlt string
CoverCaption string
Body string
Draft bool
AllLangs bool
Scheduled bool
// RefsJSON is the post's reference list as a JS literal for the
// bibliography card: the frontmatter tables as they are stored, so
// the editor edits what the file holds and no field is lost in a
// decode/encode cycle. RefsCount is the same list's length, shown in
// the card's head.
RefsJSON template.JS
RefsCount int
}
// revisionRow is one history table row.
type revisionRow struct {
Name string
When string
SizeKB string
}
// tplOption is one post-template dropdown entry.
type tplOption struct {
Name string `json:"name"`
Title string `json:"title"`
Slug string `json:"slug"`
Tags []string `json:"tags"`
Body string `json:"body"`
// Fields are the extra editor inputs the template pre-fills. The
// keys are the editor's own input names.
Fields map[string]string `json:"fields,omitzero"`
// FieldsText lists the same fields for the settings screen, sorted
// and joined, so the template row shows what it will pre-fill.
FieldsText string `json:"-"`
}
// newPostVariant maps one stored post onto one card variant.
func newPostVariant(p *post.Post) postVariant {
v := postVariant{
Lang: p.Lang(),
Slug: p.Slug(),
Title: p.Title(),
Excerpt: p.Excerpt(),
DateString: p.DateString(),
Status: statusKey(p),
Draft: p.Draft(),
Scheduled: p.Scheduled(),
Series: p.Series(),
Cover: p.Cover(),
}
if order, ok := p.SeriesOrder(); ok {
v.HasOrder = true
v.SeriesOrder = order
}
if ts, ok := p.PublishedTimestamp(); ok {
v.Timestamp = ts
}
return v
}
// statusKey names the post status with the filter vocabulary.
func statusKey(p *post.Post) string {
switch {
case p.Draft():
return "draft"
case p.Scheduled():
return "scheduled"
default:
return "published"
}
}
// groupPosts merges the posts whose frontmatter names each other in the
// translations map into one group: the dashboard then shows one card per
// publication instead of one per language file. The groups keep the order
// of their first appearance, and the posts arrive newest first, so the
// grid stays date-ordered.
func groupPosts(posts []*post.Post) [][]*post.Post {
parent := map[string]string{}
var find func(slug string) string
find = func(slug string) string {
root, ok := parent[slug]
if !ok {
parent[slug] = slug
return slug
}
if root == slug {
return slug
}
parent[slug] = find(root)
return parent[slug]
}
union := func(a, b string) {
ra, rb := find(a), find(b)
if ra != rb {
parent[rb] = ra
}
}
for _, p := range posts {
find(p.Slug())
}
for _, p := range posts {
for _, target := range p.Translations() {
if target != "" {
union(p.Slug(), target)
}
}
}
var order []string
members := map[string][]*post.Post{}
for _, p := range posts {
root := find(p.Slug())
if _, seen := members[root]; !seen {
order = append(order, root)
}
members[root] = append(members[root], p)
}
groups := make([][]*post.Post, 0, len(order))
for _, root := range order {
groups = append(groups, members[root])
}
return groups
}
// pickDisplay chooses the variant the card shows: the one in the
// interface language when the publication carries it, the newest one
// otherwise.
func pickDisplay(group []*post.Post, lang string) *post.Post {
if lang != "" {
for _, p := range group {
if p.Lang() == lang {
return p
}
}
}
return group[0]
}
// variantsJSON renders the language variants as a JS literal for the
// card's data attribute. The same script-embedding rule as templatesJSON
// applies: no literal "<" may reach the page.
func variantsJSON(variants []postVariant) template.JS {
raw, err := json.Marshal(variants)
if err != nil {
slog.Warn("admin: cannot encode post variants", "error", err)
return template.JS("[]")
}
return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`))
}
// newEditorPost maps a stored post onto the editor form fields.
func newEditorPost(p *post.Post) *editorPost {
view := &editorPost{
Slug: p.Slug(),
Title: p.Title(),
Lang: p.Lang(),
Author: p.Author(),
Date: p.DateString(),
TagsCSV: strings.Join(p.Tags(), ", "),
Series: p.Series(),
Excerpt: p.StoredExcerpt(),
Cover: p.Cover(),
CoverAlt: p.CoverAlt(),
CoverCaption: p.CoverCaption(),
Body: p.Body,
Draft: p.Draft(),
AllLangs: p.AllLangs(),
Scheduled: p.Scheduled(),
}
view.RefsJSON, view.RefsCount = refsJS(p)
if fc := p.FediverseCreator(); fc != "" {
view.FediverseCreator = fc
}
view.DOI = p.DOI()
view.ORCID = p.ORCID()
if d, ok := p.DueAt(); ok {
view.PublishAt = d.Format("2006-01-02")
}
if order, ok := p.SeriesOrder(); ok {
view.SeriesOrder = fmt.Sprintf("%d", order)
}
view.ExcerptIsSet = strings.TrimSpace(view.Excerpt) != ""
// An unset excerpt shows the derived text as a placeholder, so saving
// the form never writes text the author did not type.
view.ExcerptPlaceholder = "Short summary for listings and previews"
if !view.ExcerptIsSet {
if derived := p.Excerpt(); derived != "" {
view.ExcerptPlaceholder = derived
}
}
return view
}
// refsJS renders the post's stored reference tables as a JS literal for
// the bibliography card, with the list's length beside it. The same
// script-embedding rule as templatesJSON applies: no literal "<" may
// reach the page, and a post without refs still gets a literal the
// script can iterate.
func refsJS(p *post.Post) (template.JS, int) {
raw, _ := p.Metadata.Get("refs")
// A parsed file hands the tables over as []map[string]any while a
// freshly written list is []any; both shapes carry the same entries.
var list []any
switch tables := raw.(type) {
case []any:
list = tables
case []map[string]any:
list = make([]any, len(tables))
for i, table := range tables {
list[i] = table
}
}
if len(list) == 0 {
return template.JS("[]"), 0
}
encoded, err := json.Marshal(list)
if err != nil {
slog.Warn("admin: cannot encode the post references", "slug", p.Slug(), "error", err)
return template.JS("[]"), 0
}
return template.JS(strings.ReplaceAll(string(encoded), "<", `\u003c`)), len(list)
}
// newPostCard maps a stored post onto one dashboard card.
func newPostCard(p *post.Post) postCard {
card := postCard{
Slug: p.Slug(),
Title: p.Title(),
Excerpt: p.Excerpt(),
Lang: p.Lang(),
Series: p.Series(),
DateString: p.DateString(),
Cover: p.Cover(),
Draft: p.Draft(),
Scheduled: p.Scheduled(),
Tags: p.Tags(),
}
if order, ok := p.SeriesOrder(); ok {
card.HasOrder = true
card.SeriesOrder = order
}
if ts, ok := p.PublishedTimestamp(); ok {
card.Timestamp = ts
}
switch {
case card.Draft:
card.Status = "draft"
case card.Scheduled:
card.Status = "scheduled"
default:
card.Status = "published"
}
return card
}
// newRevisionRow formats one revision for the history table.
func newRevisionRow(rev store.Revision) revisionRow {
return revisionRow{
Name: rev.Name,
When: rev.When,
SizeKB: fmt.Sprintf("%.1f", float64(rev.Size)/1024),
}
}
// tplOptions converts stored post templates for the dropdown and the
// embedded JSON blob.
func tplOptions(list []templates.PostTemplate) []tplOption {
out := make([]tplOption, 0, len(list))
for _, tpl := range list {
tags := tpl.Tags
if tags == nil {
tags = []string{}
}
out = append(out, tplOption{
Name: tpl.Name,
Title: tpl.Title,
Slug: tpl.Slug,
Tags: tags,
Body: tpl.Body,
Fields: tpl.Fields,
FieldsText: fieldsText(tpl.Fields),
})
}
return out
}
// fieldsText renders a sorted "key = value" summary of template fields.
func fieldsText(fields map[string]string) string {
if len(fields) == 0 {
return ""
}
parts := make([]string, 0, len(fields))
for _, key := range slices.Sorted(maps.Keys(fields)) {
parts = append(parts, key+" = "+fields[key])
}
return strings.Join(parts, ", ")
}
// templatesJSON renders the template list as a JS literal.
func templatesJSON(list []tplOption) template.JS {
raw, err := json.Marshal(list)
if err != nil {
slog.Warn("admin: cannot encode post templates", "error", err)
return template.JS("[]")
}
// A template body is free-text admin content, and encoding/json/v2
// escapes only what JSON requires: a literal "</script>" inside the
// JSON would end the script element the literal is embedded in. "<"
// cannot occur outside a string in JSON, so escaping it as a unicode
// escape inside the literal closes the hole while staying valid JSON.
return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`))
}