Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s
Assisted-by: GLM 5.3
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/audit"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/backup"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/templates"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
|
||||
)
|
||||
|
||||
// Content is the content surface the admin uses: every post, one post by
|
||||
// slug, the write and delete paths, the revision archive and the media
|
||||
// library. It is an interface rather than *store.Store so the handlers can
|
||||
// be tested against a fake and a second backend stays conceivable.
|
||||
type Content interface {
|
||||
All() []*post.Post
|
||||
Find(slug, lang string) *post.Post
|
||||
Save(p *post.Post) (*post.Post, error)
|
||||
Delete(slug, lang string) (*post.Post, bool, error)
|
||||
Undelete(slug string) *post.Post
|
||||
Revisions(slug string) []store.Revision
|
||||
RevisionContent(slug, name string) string
|
||||
RestoreRevision(p *post.Post, name string) *post.Post
|
||||
InvalidateCache()
|
||||
StoreUpload(originalName string, data []byte) (string, error)
|
||||
MediaPath(name string) (string, error)
|
||||
DeleteMedia(url string) bool
|
||||
ListMedia() []store.Media
|
||||
}
|
||||
|
||||
// Deps are the shared services the admin UI needs.
|
||||
type Deps struct {
|
||||
Config *config.Config
|
||||
Store Content
|
||||
Users *users.Users
|
||||
Templates *templates.Store
|
||||
Tokens *tokens.Store
|
||||
Audit *audit.Log
|
||||
LoginLim *ratelimit.LoginLimiter
|
||||
Sessions *session.Store
|
||||
Webhooks *webhooks.Manager
|
||||
|
||||
// PreviewKey signs the shareable preview links: the session secret
|
||||
// the app layer resolved, from [admin].session_key or from the
|
||||
// secret.key file it generated, so a default deployment offers
|
||||
// preview links the way the configuration documents it. Empty means
|
||||
// no link can be signed and none is offered.
|
||||
PreviewKey string
|
||||
|
||||
// WebhooksFile is the admin-managed hook store the settings forms
|
||||
// rewrite, and StaticWebhooks the hooks that came from config.toml
|
||||
// and are read-only here. Together they are what the manager
|
||||
// delivers; every mutation re-saves the file and refreshes the
|
||||
// manager with the merge of the two.
|
||||
WebhooksFile string
|
||||
StaticWebhooks []webhooks.Webhook
|
||||
|
||||
Version string
|
||||
OnEvent func(event string, payload map[string]any)
|
||||
Backup backup.Options
|
||||
|
||||
// CheckUpdate returns the latest available version ("" when none),
|
||||
// and SelfUpdate replaces the running binary; both are wired by the
|
||||
// CLI layer and may be nil.
|
||||
CheckUpdate func() (string, error)
|
||||
SelfUpdate func() (target string, err error)
|
||||
|
||||
// UpdateInfo returns the newer version for the update banner, or ""
|
||||
// when the running version is current.
|
||||
UpdateInfo func() string
|
||||
}
|
||||
|
||||
// Admin serves /admin routes.
|
||||
type Admin struct {
|
||||
deps Deps
|
||||
renderer *Renderer
|
||||
|
||||
// trustedProxies are the peers whose X-Forwarded-For is believed.
|
||||
// The configuration is validated before the admin is built, so a
|
||||
// parse failure here cannot happen.
|
||||
trustedProxies []netip.Prefix
|
||||
}
|
||||
|
||||
// New builds the admin handler.
|
||||
func New(deps Deps) (*Admin, error) {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trusted, err := deps.Config.TrustedProxyPrefixes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !deps.Config.Server.TrustProxy {
|
||||
trusted = nil
|
||||
}
|
||||
return &Admin{deps: deps, renderer: renderer, trustedProxies: trusted}, nil
|
||||
}
|
||||
|
||||
// SetUpdateHooks wires the version-check callbacks after construction.
|
||||
func (a *Admin) SetUpdateHooks(check func() (string, error), selfUpdate func() (string, error)) {
|
||||
a.deps.CheckUpdate = check
|
||||
a.deps.SelfUpdate = selfUpdate
|
||||
a.deps.UpdateInfo = func() string {
|
||||
latest, err := check()
|
||||
if err != nil || latest == "" || latest == a.deps.Version {
|
||||
return ""
|
||||
}
|
||||
return latest
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the admin route tree.
|
||||
func (a *Admin) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
})
|
||||
// The interface sheets and fonts: public by design (the login page
|
||||
// needs them before any session), confined to the embedded set.
|
||||
mux.HandleFunc("GET /admin/assets/", web.AssetHandler)
|
||||
mux.HandleFunc("GET /admin/login", a.handleLoginForm)
|
||||
mux.HandleFunc("POST /admin/login", a.handleLogin)
|
||||
mux.HandleFunc("GET /admin/twofactor", a.handleTwofactorForm)
|
||||
mux.HandleFunc("POST /admin/twofactor", a.handleTwofactor)
|
||||
mux.HandleFunc("POST /admin/logout", a.handleLogout)
|
||||
a.registerSetupRoutes(mux)
|
||||
a.registerPostRoutes(mux)
|
||||
a.registerSettingsRoutes(mux)
|
||||
a.registerMediaRoutes(mux)
|
||||
// The subtree catch-all answers any /admin path no route above claims,
|
||||
// so a mistyped URL meets the shell's own 404 page rather than the
|
||||
// engine's bare text. It is the least specific pattern, so every
|
||||
// registered route still wins, and it sits behind requireLogin so an
|
||||
// anonymous visitor is sent to the sign-in screen first.
|
||||
mux.HandleFunc("/admin/", a.requireLogin(a.handleNotFound))
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleNotFound renders the admin 404 page inside the shell. Nothing
|
||||
// about the request reaches the page beyond the interface strings, so the
|
||||
// answer leaks nothing and carries the honest status.
|
||||
func (a *Admin) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
a.renderPage(w, r, "notfound.html", a.pageData(r), http.StatusNotFound)
|
||||
}
|
||||
|
||||
// record appends an audit entry for one request, filling in the acting
|
||||
// user and the client address so the log can answer "who, from where".
|
||||
func (a *Admin) record(r *http.Request, action, resource string, detail map[string]any) {
|
||||
a.deps.Audit.Record(audit.Entry{
|
||||
User: a.currentUser(r),
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
Detail: detail,
|
||||
IP: a.clientIP(r),
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP resolves the request origin with proxy awareness.
|
||||
func (a *Admin) clientIP(r *http.Request) string {
|
||||
return web.ClientIP(r, a.trustedProxies)
|
||||
}
|
||||
|
||||
// lang resolves the interface language for one request: the account's
|
||||
// choice first, then the language cookie the choice set for the login
|
||||
// screen, then the site language when it is one the UI ships, and
|
||||
// English otherwise.
|
||||
func (a *Admin) lang(r *http.Request, record *users.User) string {
|
||||
if record != nil && record.Language != "" {
|
||||
return record.Language
|
||||
}
|
||||
if c, err := r.Cookie(i18n.Cookie); err == nil {
|
||||
if lang := i18n.Normalize(c.Value); lang != "" {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
|
||||
return lang
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// theme resolves the colour scheme for one request: the account's
|
||||
// choice first, then the theme cookie the choice set for the login
|
||||
// screen, then the default scheme.
|
||||
func (a *Admin) theme(r *http.Request, record *users.User) string {
|
||||
if record != nil && record.Theme != "" {
|
||||
if web.ValidTheme(record.Theme) {
|
||||
return record.Theme
|
||||
}
|
||||
}
|
||||
if c, err := r.Cookie(web.ThemeCookie); err == nil && web.ValidTheme(c.Value) {
|
||||
return c.Value
|
||||
}
|
||||
return web.DefaultTheme
|
||||
}
|
||||
|
||||
// langFor resolves the interface language from the request alone,
|
||||
// without a page context: the signed-in account's choice, the language
|
||||
// cookie, the site language, then English.
|
||||
func (a *Admin) langFor(r *http.Request) string {
|
||||
return a.lang(r, a.deps.Users.Find(session.FromContext(r.Context()).Get("user")))
|
||||
}
|
||||
|
||||
// tr translates an admin interface message in the request's language.
|
||||
func (a *Admin) tr(r *http.Request, s string) string {
|
||||
return i18n.Admin.T(a.langFor(r), s)
|
||||
}
|
||||
|
||||
// trf translates an admin interface message with one value.
|
||||
func (a *Admin) trf(r *http.Request, s, arg string) string {
|
||||
return i18n.Admin.Tf(a.langFor(r), s, arg)
|
||||
}
|
||||
|
||||
// trf2 translates an admin interface message with two values.
|
||||
func (a *Admin) trf2(r *http.Request, s, first, second string) string {
|
||||
return i18n.Admin.Tf2(a.langFor(r), s, first, second)
|
||||
}
|
||||
|
||||
// pageData builds the shared template context for one request.
|
||||
func (a *Admin) pageData(r *http.Request) *PageData {
|
||||
sess := session.FromContext(r.Context())
|
||||
username := sess.Get("user")
|
||||
record := a.deps.Users.Find(username)
|
||||
data := &PageData{
|
||||
Config: a.deps.Config,
|
||||
Path: r.URL.Path,
|
||||
CSPNonce: web.Nonce(r.Context()),
|
||||
Version: a.deps.Version,
|
||||
Lang: a.lang(r, record),
|
||||
Theme: a.theme(r, record),
|
||||
CurrentUser: username,
|
||||
CurrentUserRecord: record,
|
||||
UsersExist: a.deps.Users.Any(),
|
||||
CSRFToken: CSRFToken(sess),
|
||||
IsLogin: strings.HasPrefix(r.URL.Path, "/admin/login") || r.URL.Path == "/admin/twofactor",
|
||||
IsSetup: r.URL.Path == "/admin/setup",
|
||||
NavPosts: r.URL.Path == "/admin/" || r.URL.Path == "/admin",
|
||||
NavNew: r.URL.Path == "/admin/posts/new",
|
||||
NavImport: r.URL.Path == "/admin/posts/import",
|
||||
NavMedia: strings.HasPrefix(r.URL.Path, "/admin/media"),
|
||||
NavSettings: strings.HasPrefix(r.URL.Path, "/admin/settings"),
|
||||
}
|
||||
if a.deps.UpdateInfo != nil {
|
||||
data.UpdateAvailable = a.deps.UpdateInfo()
|
||||
}
|
||||
if record != nil {
|
||||
data.IsAuthenticated = true
|
||||
data.CurrentRole = record.Role
|
||||
data.DisplayName = record.Name
|
||||
data.UserPhoto = record.Photo
|
||||
if data.DisplayName == "" {
|
||||
data.DisplayName = record.Username
|
||||
}
|
||||
data.UserInitial = firstUpper(data.DisplayName, "?")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// templateEscape neutralises the characters that would end an HTML
|
||||
// comment or open a tag inside one.
|
||||
func templateEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "--", "- -")
|
||||
return html.EscapeString(s)
|
||||
}
|
||||
|
||||
// renderPage executes an admin page with HTTP semantics.
|
||||
func (a *Admin) renderPage(w http.ResponseWriter, r *http.Request, page string, data *PageData, status int) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := a.renderer.Render(r.Context(), w, page, data); err != nil {
|
||||
// The status line is already sent, and the page is the user's
|
||||
// only signal, so it carries a note rather than nothing. The
|
||||
// text is escaped: an error message quoting content must not
|
||||
// close the comment and inject markup.
|
||||
fmt.Fprintf(w, "<!-- template error: %s -->", templateEscape(err.Error()))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Admin) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.FromContext(r.Context())
|
||||
if sess.Get("user") != "" {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// A session that already answered the password sits halfway in: the
|
||||
// second step is where it belongs, not the first one again.
|
||||
if sess.Get("totp_user") != "" {
|
||||
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// A deployment with no accounts is one that has not been set up
|
||||
// yet: the login screen would only be a door with nothing behind
|
||||
// it, so the first visit goes to the wizard instead. It is a redirect,
|
||||
// not a rewrite, so the wizard has its own honest URL.
|
||||
if needed, broken := a.setupNeeded(); needed && broken == nil {
|
||||
http.Redirect(w, r, "/admin/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.renderPage(w, r, "login.html", a.pageData(r), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ip := a.clientIP(r)
|
||||
a.deps.LoginLim.Record(ip)
|
||||
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
|
||||
data := a.pageData(r)
|
||||
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
|
||||
data.RetryAfter = retryAfter
|
||||
a.renderPage(w, r, "login.html", data, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
if !ValidateCSRF(r, sess) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
username := r.PostFormValue("username")
|
||||
password := r.PostFormValue("password")
|
||||
if user := a.deps.Users.Authenticate(username, password); user != nil {
|
||||
// An account with the second factor answers one more question
|
||||
// before it is in: the session holds the half-way name and no
|
||||
// user, so nothing behind requireLogin opens yet.
|
||||
if user.TotpSecret != "" {
|
||||
sess.Set("totp_user", user.Username)
|
||||
sess.Set("totp_at", strconv.FormatInt(time.Now().Unix(), 10))
|
||||
http.Redirect(w, r, "/admin/twofactor", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
sess.Set("user", user.Username)
|
||||
sess.Set("pv", sessionFingerprint(user.PasswordHash))
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
data := a.pageData(r)
|
||||
data.Error = data.Tr("Invalid username or password.")
|
||||
a.renderPage(w, r, "login.html", data, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
// twofactorWindow bounds how long a password already answered may wait
|
||||
// for its code before the whole sign-in starts over.
|
||||
const twofactorWindow = 10 * time.Minute
|
||||
|
||||
// handleTwofactorForm shows the code prompt while a session holds a
|
||||
// password-verified name; anything else goes back to the first step.
|
||||
func (a *Admin) handleTwofactorForm(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.FromContext(r.Context())
|
||||
if sess.Get("user") != "" {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !a.twofactorPending(sess) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.renderPage(w, r, "twofactor.html", a.pageData(r), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) twofactorPending(sess *session.Session) bool {
|
||||
name := sess.Get("totp_user")
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
started, err := strconv.ParseInt(sess.Get("totp_at"), 10, 64)
|
||||
if err != nil || time.Since(time.Unix(started, 0)) > twofactorWindow {
|
||||
sess.Delete("totp_user")
|
||||
sess.Delete("totp_at")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleTwofactor answers the second question: a six-digit code from
|
||||
// the account's application, or one of its recovery codes. The same
|
||||
// limiter guards it as the password, so guessing a code costs the same
|
||||
// lockout as guessing a password.
|
||||
func (a *Admin) handleTwofactor(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ip := a.clientIP(r)
|
||||
a.deps.LoginLim.Record(ip)
|
||||
if blocked, retryAfter := a.deps.LoginLim.Blocked(ip); blocked {
|
||||
data := a.pageData(r)
|
||||
data.Error = i18n.Admin.N(a.lang(r, nil), "login.seconds", retryAfter)
|
||||
data.RetryAfter = retryAfter
|
||||
a.renderPage(w, r, "twofactor.html", data, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
if !ValidateCSRF(r, sess) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if !a.twofactorPending(sess) {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
name := sess.Get("totp_user")
|
||||
code := strings.TrimSpace(r.PostFormValue("code"))
|
||||
ok := false
|
||||
if isTotpShape(code) {
|
||||
ok = a.deps.Users.VerifyTotp(name, code, time.Now())
|
||||
} else {
|
||||
ok = a.deps.Users.ConsumeRecovery(name, code)
|
||||
}
|
||||
if !ok {
|
||||
slog.Warn("admin: second factor refused", "user", name)
|
||||
data := a.pageData(r)
|
||||
data.Error = data.Tr("Wrong or expired code.")
|
||||
a.renderPage(w, r, "twofactor.html", data, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user := a.deps.Users.Find(name)
|
||||
if user == nil || user.TotpSecret == "" {
|
||||
sess.Delete("totp_user")
|
||||
sess.Delete("totp_at")
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
sess.Delete("totp_user")
|
||||
sess.Delete("totp_at")
|
||||
sess.Set("user", user.Username)
|
||||
sess.Set("pv", sessionFingerprint(user.PasswordHash))
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// isTotpShape reports whether the answer looks like an application
|
||||
// code; everything else is tried as a recovery code.
|
||||
func isTotpShape(code string) bool {
|
||||
clean := strings.NewReplacer(" ", "", "-", "").Replace(code)
|
||||
return len(clean) == 6 && strings.Trim(clean, "0123456789") == ""
|
||||
}
|
||||
|
||||
func (a *Admin) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
if !ValidateCSRF(r, sess) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
// Abandon rather than Clear: Clear would leave the session dirty, and
|
||||
// the middleware's Save would then append a fresh cookie after
|
||||
// Destroy's expiring one, which the browser applies last.
|
||||
sess.Abandon()
|
||||
a.deps.Sessions.Destroy(w)
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/audit"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/backup"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/ratelimit"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/templates"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
)
|
||||
|
||||
var csrfRe = regexp.MustCompile(`name="_csrf" value="([a-f0-9]+)"`)
|
||||
|
||||
type fixture struct {
|
||||
handler http.Handler
|
||||
admin *Admin
|
||||
users *users.Users
|
||||
store *session.Store
|
||||
storeObj *store.Store
|
||||
contentDir string
|
||||
events []string
|
||||
payloads []map[string]any
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
return newFixtureSeeded(t, true)
|
||||
}
|
||||
|
||||
// newFixtureSeeded builds the same handler over an empty users file
|
||||
// when seeded is false: that is the first-run state, with no account
|
||||
// and the wizard serving the admin screen.
|
||||
func newFixtureSeeded(t *testing.T, seeded bool) *fixture {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
content := filepath.Join(dir, "posts")
|
||||
if err := os.MkdirAll(content, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
cfg, err := config.Load(filepath.Join(dir, "config.toml"), config.Overrides{
|
||||
Port: config.PortUnset,
|
||||
ContentDir: content,
|
||||
UsersFile: filepath.Join(dir, "users.toml"),
|
||||
})
|
||||
// Preview links are signed with the session key, so the fixture sets
|
||||
// one the way a real deployment does.
|
||||
cfg.Admin.SessionKey = strings.Repeat("k", 64)
|
||||
if err != nil {
|
||||
t.Fatalf("config: %v", err)
|
||||
}
|
||||
st := store.New(store.Options{ContentDir: content, DefaultLang: "en", RevisionLimit: 10})
|
||||
usersObj := users.New(cfg.UsersFile)
|
||||
if seeded {
|
||||
if _, err := usersObj.Add("admin", "correct-horse-9", "admin"); err != nil {
|
||||
t.Fatalf("seed admin: %v", err)
|
||||
}
|
||||
}
|
||||
f := &fixture{storeObj: st, contentDir: content}
|
||||
a, err := New(Deps{
|
||||
Config: cfg,
|
||||
Store: st,
|
||||
Users: usersObj,
|
||||
Templates: templates.New(filepath.Join(dir, "templates.toml")),
|
||||
Tokens: tokens.New(filepath.Join(dir, "tokens.toml")),
|
||||
Backup: backup.Options{
|
||||
ContentDir: content,
|
||||
UsersFile: cfg.UsersFile,
|
||||
TemplatesFile: cfg.TemplatesFile(),
|
||||
TokensFile: cfg.TokensFile(),
|
||||
},
|
||||
Audit: audit.New(""),
|
||||
LoginLim: ratelimit.NewLoginLimiter(),
|
||||
Sessions: session.New(strings.Repeat("k", 64), 0, false),
|
||||
Version: "0.0.0-test",
|
||||
PreviewKey: strings.Repeat("k", 64),
|
||||
OnEvent: func(event string, payload map[string]any) {
|
||||
f.events = append(f.events, event)
|
||||
f.payloads = append(f.payloads, payload)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
// Production mounts this handler inside the session middleware; the
|
||||
// fixture mirrors that so cookies round-trip.
|
||||
f.handler = a.deps.Sessions.Middleware(a.Handler())
|
||||
f.admin = a
|
||||
f.users = usersObj
|
||||
f.store = a.deps.Sessions
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
f.handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func extractCSRF(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
m := csrfRe.FindStringSubmatch(body)
|
||||
if m == nil {
|
||||
t.Fatalf("no CSRF token in body:\n%s", body[:min(len(body), 500)])
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, rec *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
if cookie.Name == session.CookieName {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatal("no session cookie")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoginPageRenders(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Sign in", "Volumen admin", "0.0.0-test"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("content-type = %q", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRedirectsAuthenticatedUser(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// login performs the full CSRF + credential flow and returns the
|
||||
// authenticated session cookie.
|
||||
func login(t *testing.T, f *fixture, username, secret string) *http.Cookie {
|
||||
t.Helper()
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return sessionCookie(t, rec)
|
||||
}
|
||||
|
||||
func TestLoginRejectsWrongPassword(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"nope"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Invalid username or password.") {
|
||||
t.Fatal("error message missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsMissingCSRF(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{"username": {"admin"}, "password": {"correct-horse-9"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimitRendersCountdown(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
var last *httptest.ResponseRecorder
|
||||
for range 12 {
|
||||
form := url.Values{"_csrf": {csrf}, "username": {"admin"}, "password": {"wrong"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
last = f.do(t, req)
|
||||
}
|
||||
if last.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("code = %d, want 429", last.Code)
|
||||
}
|
||||
if !strings.Contains(last.Body.String(), "Auto-unlock in") {
|
||||
t.Fatal("lockout message missing")
|
||||
}
|
||||
if !strings.Contains(last.Body.String(), `id="lockout-countdown"`) {
|
||||
t.Fatal("countdown element missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
|
||||
// Grab a fresh CSRF token via the session cookie's page.
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
// Authenticated users are redirected; get the CSRF from the session
|
||||
// store directly instead.
|
||||
sess := f.store.Load(req)
|
||||
csrf := CSRFToken(sess)
|
||||
|
||||
form := url.Values{"_csrf": {csrf}}
|
||||
logoutReq := httptest.NewRequest(http.MethodPost, "/admin/logout", strings.NewReader(form.Encode()))
|
||||
logoutReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
logoutReq.AddCookie(cookie)
|
||||
rec := f.do(t, logoutReq)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBareAdminRedirects(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin", nil))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// An /admin path no route claims meets the shell's own 404 page, not the
|
||||
// engine's bare text, and an anonymous visitor is still sent to the
|
||||
// sign-in screen first.
|
||||
func TestAdminNotFound(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
|
||||
anon := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil))
|
||||
if anon.Code != http.StatusSeeOther || anon.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("anonymous: code=%d location=%q", anon.Code, anon.Header().Get("Location"))
|
||||
}
|
||||
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/no-such-page", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Fatalf("content type = %q", ct)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Page not found") {
|
||||
t.Fatalf("body lacks the 404 heading:\n%s", body[:min(len(body), 500)])
|
||||
}
|
||||
if !strings.Contains(body, `<html`) || !strings.Contains(body, `class="shell"`) {
|
||||
t.Fatalf("body is not rendered in the shell")
|
||||
}
|
||||
|
||||
post := httptest.NewRequest(http.MethodPost, "/admin/no-such-page", strings.NewReader(""))
|
||||
post.AddCookie(cookie)
|
||||
if rec := f.do(t, post); rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
|
||||
t.Fatalf("POST: code=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordError(t *testing.T) {
|
||||
if key, n := PasswordError("", 10, 1024); key != "New password cannot be empty." || n != 0 {
|
||||
t.Fatalf("empty password = %q, %d", key, n)
|
||||
}
|
||||
if key, n := PasswordError("short", 10, 1024); key != "password.min" || n != 10 {
|
||||
t.Fatalf("short password = %q, %d", key, n)
|
||||
}
|
||||
if key, n := PasswordError(strings.Repeat("x", 2000), 10, 1024); key != "password.max" || n != 1024 {
|
||||
t.Fatalf("long password = %q, %d", key, n)
|
||||
}
|
||||
if key, n := PasswordError("password123", 10, 1024); key != "This password is too common." || n != 0 {
|
||||
t.Fatalf("common password = %q, %d", key, n)
|
||||
}
|
||||
if key, n := PasswordError("a genuinely unique passphrase", 10, 1024); key != "" || n != 0 {
|
||||
t.Fatalf("valid password = %q, %d", key, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstUpper(t *testing.T) {
|
||||
if got := firstUpper("petr", "?"); got != "P" {
|
||||
t.Fatalf("got = %q", got)
|
||||
}
|
||||
if got := firstUpper("", "?"); got != "?" {
|
||||
t.Fatalf("got = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanSize(t *testing.T) {
|
||||
cases := map[int64]string{
|
||||
0: "",
|
||||
512: "1 kB",
|
||||
10 * 1024: "10 kB",
|
||||
1024 * 1024: "1.0 MB",
|
||||
5 << 20: "5.0 MB",
|
||||
1536 * 1024: "1.5 MB",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := humanSize(in); got != want {
|
||||
t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A post-template body containing "</script>" must not be able to end
|
||||
// the script element the JSON literal is embedded in.
|
||||
func TestTemplatesJSONEscapesScriptClose(t *testing.T) {
|
||||
list := []tplOption{{
|
||||
Name: "s", Title: "T", Slug: "s", Tags: []string{},
|
||||
Body: `Use <script>document.write("x")</script> carefully`,
|
||||
}}
|
||||
out := string(templatesJSON(list))
|
||||
if strings.Contains(out, "</script>") {
|
||||
t.Fatalf("literal script close survived: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `\u003c/script>`) {
|
||||
t.Fatalf("expected unicode escapes: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||||
)
|
||||
|
||||
// commonPasswords is the blocklist of trivially guessable passwords.
|
||||
// This is the policy every admin password change goes through.
|
||||
var commonPasswords = map[string]bool{
|
||||
"password": true, "password1": true, "password123": true,
|
||||
"123456": true, "12345678": true, "123456789": true,
|
||||
"qwerty": true, "qwerty123": true, "letmein": true, "iloveyou": true,
|
||||
"admin": true, "admin123": true, "welcome": true, "welcome1": true,
|
||||
"monkey": true, "dragon": true, "football": true, "baseball": true,
|
||||
"sunshine": true, "princess": true, "abc123": true, "111111": true,
|
||||
"123123": true, "1q2w3e4r": true, "passw0rd": true, "trustno1": true,
|
||||
"changeme": true, "secret": true, "secret123": true, "test": true,
|
||||
"test123": true, "guest": true, "master": true, "000000": true,
|
||||
"696969": true, "qwertyuiop": true, "superman": true, "batman": true,
|
||||
"jordan": true, "harley": true, "hunter": true, "hunter2": true,
|
||||
"shadow": true, "michael": true, "jennifer": true, "abcdef": true,
|
||||
"abcdefg": true,
|
||||
}
|
||||
|
||||
// PasswordError validates a newly chosen password and reports the
|
||||
// first problem as a catalogue key. The key is either a plain sentence or
|
||||
// the id of a plural message whose numeral n is the offending length;
|
||||
// "" with n 0 means accepted.
|
||||
func PasswordError(password string, minLength, maxLength int) (string, int) {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return "New password cannot be empty.", 0
|
||||
}
|
||||
length := len([]rune(password))
|
||||
if length < minLength {
|
||||
return "password.min", minLength
|
||||
}
|
||||
if length > maxLength {
|
||||
return "password.max", maxLength
|
||||
}
|
||||
normalized := strings.ToLower(norm.NFKC.String(password))
|
||||
if commonPasswords[normalized] {
|
||||
return "This password is too common.", 0
|
||||
}
|
||||
return "", 0
|
||||
}
|
||||
|
||||
// CSRFToken returns (and lazily creates) the CSRF token stored in the
|
||||
// session.
|
||||
func CSRFToken(sess *session.Session) string {
|
||||
token := sess.Get("csrf")
|
||||
if token == "" {
|
||||
token = newTokenHex(32)
|
||||
sess.Set("csrf", token)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// sessionFingerprint derives the value the session carries to bind it to
|
||||
// one password: it changes whenever the account's hash changes, so a
|
||||
// password change or an admin reset retires every cookie issued before
|
||||
// it. It is a digest of the stored hash, never of the password, and
|
||||
// carries too few bits to help anyone invert the hash.
|
||||
func sessionFingerprint(storedHash string) string {
|
||||
sum := sha256.Sum256([]byte("volumen-session-v1:" + storedHash))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// ValidateCSRF compares the form's _csrf field against the session
|
||||
// token in constant time.
|
||||
func ValidateCSRF(r *http.Request, sess *session.Session) bool {
|
||||
token := r.PostFormValue("_csrf")
|
||||
sessionToken := sess.Get("csrf")
|
||||
if token == "" || sessionToken == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(sessionToken), []byte(token)) == 1
|
||||
}
|
||||
|
||||
func newTokenHex(nBytes int) string {
|
||||
buf := make([]byte, nBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// firstUpper returns the uppercased first rune, or fallback.
|
||||
func firstUpper(s, fallback string) string {
|
||||
for _, r := range s {
|
||||
if unicode.IsSpace(r) {
|
||||
continue
|
||||
}
|
||||
return string(unicode.ToUpper(r))
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
)
|
||||
|
||||
func (a *Admin) registerMediaRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /admin/media", a.requireLogin(a.handleMediaLibrary))
|
||||
mux.HandleFunc("POST /admin/media/{name}/delete", a.requireLogin(a.handleMediaDelete))
|
||||
}
|
||||
|
||||
func (a *Admin) handleMediaLibrary(w http.ResponseWriter, r *http.Request) {
|
||||
data := a.pageData(r)
|
||||
items := a.deps.Store.ListMedia()
|
||||
data.MediaItems = mediaRows(items)
|
||||
data.MediaTotal = humanSize(totalSize(items))
|
||||
data.Crumbs = []Crumb{{Label: "Media", IsLast: true, UI: true}}
|
||||
a.renderPage(w, r, "media.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
// totalSize sums the byte sizes of the media library.
|
||||
func totalSize(items []store.Media) int64 {
|
||||
var total int64
|
||||
for _, item := range items {
|
||||
total += item.Size
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// humanSize formats a byte count for the library summary: kilobytes
|
||||
// below a megabyte (rounded up, so nothing reads as zero), megabytes
|
||||
// above it, empty for an empty library.
|
||||
func humanSize(total int64) string {
|
||||
switch {
|
||||
case total <= 0:
|
||||
return ""
|
||||
case total < 1024*1024:
|
||||
kb := (total + 1023) / 1024
|
||||
return fmt.Sprintf("%d kB", kb)
|
||||
default:
|
||||
return fmt.Sprintf("%.1f MB", float64(total)/(1024*1024))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Admin) handleMediaDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
if !a.deps.Store.DeleteMedia("/media/" + strings.TrimPrefix(name, "/")) {
|
||||
http.Error(w, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
a.record(r, "media.deleted", fmt.Sprintf("/media/%s", name), nil)
|
||||
http.Redirect(w, r, "/admin/media", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/diff"
|
||||
)
|
||||
|
||||
// unsafeSlugRe strips anything that is not safe in a header value.
|
||||
var unsafeSlugRe = regexp.MustCompile(`[^a-z0-9._-]`)
|
||||
|
||||
// attachmentName reduces a path segment to a safe Content-Disposition
|
||||
// filename.
|
||||
func attachmentName(value string) string { return unsafeSlugRe.ReplaceAllString(value, "") }
|
||||
|
||||
func (a *Admin) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
p := a.deps.Store.Find(slug, "")
|
||||
if p == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
content, err := p.ToFile()
|
||||
if err != nil {
|
||||
http.Error(w, "export failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`attachment; filename="%s.md"`, attachmentName(slug)))
|
||||
fmt.Fprint(w, content)
|
||||
}
|
||||
|
||||
func (a *Admin) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
p := a.deps.Store.Find(slug, "")
|
||||
if p == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
revisions := a.deps.Store.Revisions(slug)
|
||||
rows := make([]revisionRow, 0, len(revisions))
|
||||
for _, rev := range revisions {
|
||||
rows = append(rows, newRevisionRow(rev))
|
||||
}
|
||||
heading := p.Title()
|
||||
if heading == "" {
|
||||
heading = slug
|
||||
}
|
||||
data := a.pageData(r)
|
||||
data.Slug = slug
|
||||
data.Heading = heading
|
||||
data.Revisions = rows
|
||||
data.Post = newEditorPost(p)
|
||||
data.Crumbs = []Crumb{
|
||||
{Label: "Posts", Href: "/admin/", UI: true},
|
||||
{Label: heading, Href: "/admin/posts/" + slug + "/edit"},
|
||||
{Label: "History", IsLast: true, UI: true},
|
||||
}
|
||||
a.renderPage(w, r, "history.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleHistoryDownload(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
name := r.PathValue("name")
|
||||
content := a.deps.Store.RevisionContent(slug, name)
|
||||
if content == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// The revision name is a server-generated stamp, but it arrives from
|
||||
// the URL: the value is reduced to what cannot end the quoted string
|
||||
// (a quote, a backslash, a control byte). Unlike attachmentName this
|
||||
// keeps the stamp's uppercase T and Z.
|
||||
safeName := strings.Map(func(r rune) rune {
|
||||
if r == '"' || r == '\\' || r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, filepath.Base(name))
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf(`attachment; filename="%s-%s"`, attachmentName(slug), safeName))
|
||||
fmt.Fprint(w, content)
|
||||
}
|
||||
|
||||
// handleHistoryDiff compares one archived revision with the current
|
||||
// content, so the editor can judge what a restore would change before
|
||||
// committing to it.
|
||||
func (a *Admin) handleHistoryDiff(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
name := r.PathValue("name")
|
||||
p := a.deps.Store.Find(slug, "")
|
||||
if p == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
revision := a.deps.Store.RevisionContent(slug, name)
|
||||
if revision == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
current, err := p.ToFile()
|
||||
if err != nil {
|
||||
http.Error(w, "export failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var when string
|
||||
for _, rev := range a.deps.Store.Revisions(slug) {
|
||||
if rev.Name == name {
|
||||
when = rev.When
|
||||
break
|
||||
}
|
||||
}
|
||||
heading := p.Title()
|
||||
if heading == "" {
|
||||
heading = slug
|
||||
}
|
||||
data := a.pageData(r)
|
||||
data.Slug = slug
|
||||
data.Heading = heading
|
||||
data.DiffName = name
|
||||
data.DiffWhen = when
|
||||
data.DiffChunks = diff.Chunks(revision, current, 3)
|
||||
data.Post = newEditorPost(p)
|
||||
data.Crumbs = []Crumb{
|
||||
{Label: "Posts", Href: "/admin/", UI: true},
|
||||
{Label: heading, Href: "/admin/posts/" + slug + "/edit"},
|
||||
{Label: "History", Href: "/admin/posts/" + slug + "/history", UI: true},
|
||||
{Label: "Changes", IsLast: true, UI: true},
|
||||
}
|
||||
a.renderPage(w, r, "diff.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleHistoryRestore(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
name := r.PathValue("name")
|
||||
p := a.deps.Store.Find(slug, "")
|
||||
if p == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if a.deps.Store.RestoreRevision(p, name) == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/posts/"+slug+"/edit?restored=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- uploads and static SVGs ------------------------------------------------
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
)
|
||||
|
||||
func (a *Admin) handleImportForm(w http.ResponseWriter, r *http.Request) {
|
||||
data := a.pageData(r)
|
||||
data.Crumbs = []Crumb{
|
||||
{Label: "Posts", Href: "/admin/", UI: true},
|
||||
{Label: "Import", IsLast: true, UI: true},
|
||||
}
|
||||
a.renderPage(w, r, "import.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
data := a.pageData(r)
|
||||
data.Crumbs = []Crumb{
|
||||
{Label: "Posts", Href: "/admin/", UI: true},
|
||||
{Label: "Import", IsLast: true, UI: true},
|
||||
}
|
||||
fail := func(msg string) {
|
||||
data.Error = i18n.Admin.T(data.Lang, msg)
|
||||
a.renderPage(w, r, "import.html", data, http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
fail("No file selected.")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
if !strings.HasSuffix(strings.ToLower(header.Filename), ".md") {
|
||||
fail("Only .md files are accepted.")
|
||||
return
|
||||
}
|
||||
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
|
||||
if err != nil {
|
||||
fail("File is too large.")
|
||||
return
|
||||
}
|
||||
content := string(raw)
|
||||
p, err := post.Parse(content)
|
||||
if err != nil {
|
||||
fail(i18n.Admin.Tf(data.Lang, "The file could not be read as a post: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
if p.Slug() == "" {
|
||||
base := strings.ToLower(filepath.Base(header.Filename))
|
||||
stem := strings.TrimSuffix(base, filepath.Ext(base))
|
||||
p.Metadata.Set("slug", strings.ReplaceAll(stem, " ", "-"))
|
||||
}
|
||||
if p.Lang() == "" {
|
||||
p.Metadata.Set("lang", a.deps.Config.Site.Language)
|
||||
}
|
||||
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
|
||||
fail(err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Store.Save(p); err != nil {
|
||||
fail("Import failed.")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/posts/"+p.Slug()+"/edit", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// readLimited reads at most limit+1 bytes so callers can detect
|
||||
// oversize uploads.
|
||||
func readLimited(r io.Reader, limit int64) ([]byte, error) {
|
||||
raw, err := io.ReadAll(io.LimitReader(r, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
return nil, fmt.Errorf("payload too large")
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/markdown"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/preview"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
)
|
||||
|
||||
// registerPostRoutes mounts the post, preview, import and upload
|
||||
// endpoints. Literal paths are registered before {slug} patterns.
|
||||
func (a *Admin) registerPostRoutes(mux *http.ServeMux) {
|
||||
// The exact path, not a subtree: an unknown URL under /admin/ must be
|
||||
// a 404 rather than a dashboard.
|
||||
mux.HandleFunc("GET /admin/{$}", a.requireLogin(a.handleDashboard))
|
||||
mux.HandleFunc("GET /admin/posts/exists", a.requireLogin(a.handleExists))
|
||||
mux.HandleFunc("GET /admin/posts/new", a.requireLogin(a.handleNewForm))
|
||||
mux.HandleFunc("GET /admin/posts/import", a.requireLogin(a.handleImportForm))
|
||||
mux.HandleFunc("POST /admin/posts/import", a.requireLogin(a.handleImport))
|
||||
mux.HandleFunc("POST /admin/posts/bulk", a.requireLogin(a.handleBulk))
|
||||
mux.HandleFunc("POST /admin/posts", a.requireLogin(a.handleCreate))
|
||||
mux.HandleFunc("POST /admin/preview", a.requireLogin(a.handlePreview))
|
||||
mux.HandleFunc("POST /admin/uploads", a.requireLogin(a.handleUpload))
|
||||
mux.HandleFunc("GET /admin/posts/{slug}/download", a.requireLogin(a.handleDownload))
|
||||
mux.HandleFunc("GET /admin/posts/{slug}/history", a.requireLogin(a.handleHistory))
|
||||
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}", a.requireLogin(a.handleHistoryDownload))
|
||||
mux.HandleFunc("GET /admin/posts/{slug}/history/{name}/diff", a.requireLogin(a.handleHistoryDiff))
|
||||
mux.HandleFunc("POST /admin/posts/{slug}/history/{name}/restore", a.requireLogin(a.handleHistoryRestore))
|
||||
mux.HandleFunc("GET /admin/posts/{slug}/edit", a.requireLogin(a.handleEditForm))
|
||||
mux.HandleFunc("POST /admin/posts/{slug}/delete", a.requireLogin(a.handleDelete))
|
||||
mux.HandleFunc("POST /admin/posts/{slug}/undelete", a.requireLogin(a.handleUndelete))
|
||||
mux.HandleFunc("POST /admin/posts/{slug}/duplicate", a.requireLogin(a.handleDuplicate))
|
||||
mux.HandleFunc("GET /admin/posts/{slug}/preview-link", a.requireLogin(a.handlePreviewLink))
|
||||
mux.HandleFunc("POST /admin/posts/{slug}", a.requireLogin(a.handleUpdate))
|
||||
mux.HandleFunc("GET /admin/icon.svg", a.handleIcon)
|
||||
}
|
||||
|
||||
// requireLogin redirects unauthenticated requests to the login form.
|
||||
func (a *Admin) requireLogin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.FromContext(r.Context())
|
||||
username := sess.Get("user")
|
||||
record := a.deps.Users.Find(username)
|
||||
if username == "" || record == nil {
|
||||
if username != "" {
|
||||
sess.Clear()
|
||||
}
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// The session is bound to the password it was issued under: a
|
||||
// change (the owner's or an admin reset) retires every cookie
|
||||
// still in the wild, which is what "change the password" has to
|
||||
// mean for a compromised account.
|
||||
if sess.Get("pv") != sessionFingerprint(record.PasswordHash) {
|
||||
sess.Clear()
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// Everything logged from here on names the account it happened
|
||||
// for.
|
||||
ctx := web.WithLogger(r.Context(), web.Logger(r.Context()).With("user", username))
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// maxBackupImportBytes bounds the settings import request. A backup
|
||||
// archive legitimately exceeds max_upload_bytes (it carries every post
|
||||
// and image), so it gets the same budget backup.Restore enforces on the
|
||||
// decompressed side.
|
||||
const maxBackupImportBytes = 512 << 20
|
||||
|
||||
// requestLimit is the byte bound on one admin POST body.
|
||||
func (a *Admin) requestLimit(r *http.Request) int64 {
|
||||
if r.URL.Path == "/admin/settings/import" {
|
||||
return maxBackupImportBytes + 1<<20
|
||||
}
|
||||
return int64(a.deps.Config.Admin.MaxUploadBytes) + 1<<20
|
||||
}
|
||||
|
||||
// requireCSRF validates the form token and writes the error response
|
||||
// itself when invalid.
|
||||
func (a *Admin) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
|
||||
// The body is bounded before parsing: ParseMultipartForm's argument
|
||||
// is only the in-memory threshold, and net/http drains the rest of a
|
||||
// multipart body to temp files on disk whatever the threshold says.
|
||||
// Wrapping the body also lifts ParseForm's internal 10 MiB urlencoded
|
||||
// cap, so this limit is the one that applies.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, a.requestLimit(r))
|
||||
var parseErr error
|
||||
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
|
||||
parseErr = r.ParseMultipartForm(32 << 20)
|
||||
} else {
|
||||
// ParseMultipartForm would call ParseForm internally, swallow its
|
||||
// error and leave the body consumed, so the content type decides
|
||||
// which parser runs.
|
||||
parseErr = r.ParseForm()
|
||||
}
|
||||
if parseErr != nil {
|
||||
if _, ok := errors.AsType[*http.MaxBytesError](parseErr); ok {
|
||||
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
|
||||
return false
|
||||
}
|
||||
if !errors.Is(parseErr, http.ErrNotMultipart) {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
// A body that claims a multipart type but is not parseable as one
|
||||
// falls through; the token check rejects.
|
||||
}
|
||||
if !ValidateCSRF(r, session.FromContext(r.Context())) {
|
||||
http.Error(w, a.tr(r, "Invalid CSRF token"), http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *Admin) currentUser(r *http.Request) string {
|
||||
return session.FromContext(r.Context()).Get("user")
|
||||
}
|
||||
|
||||
func (a *Admin) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
notice := ""
|
||||
if bulkAction := r.URL.Query().Get("bulk"); bulkAction == "delete" ||
|
||||
bulkAction == "draft" || bulkAction == "publish" {
|
||||
if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n > 0 {
|
||||
id := map[string]string{
|
||||
"delete": "posts.deleted", "draft": "posts.drafted", "publish": "posts.published",
|
||||
}[bulkAction]
|
||||
notice = i18n.Admin.N(a.langFor(r), id, n)
|
||||
}
|
||||
}
|
||||
a.renderPage(w, r, "list.html", a.dashboardData(r, notice), http.StatusOK)
|
||||
}
|
||||
|
||||
// dashboardData builds the dashboard page: one card per publication, the
|
||||
// language versions merged into a group that the card can switch between,
|
||||
// and the counters, the recent list and the tag cloud over the same set.
|
||||
func (a *Admin) dashboardData(r *http.Request, notice string) *PageData {
|
||||
posts := a.allPostsSorted()
|
||||
lang := a.langFor(r)
|
||||
groups := groupPosts(posts)
|
||||
display := make([]*post.Post, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
display = append(display, pickDisplay(group, lang))
|
||||
}
|
||||
|
||||
cards := make([]postCard, 0, len(groups))
|
||||
stats := dashboardStats{}
|
||||
var recent []recentPost
|
||||
type pending struct {
|
||||
post *post.Post
|
||||
due time.Time
|
||||
}
|
||||
var upcoming []pending
|
||||
for i, group := range groups {
|
||||
p := display[i]
|
||||
card := newPostCard(p)
|
||||
if len(group) > 1 {
|
||||
variants := make([]postVariant, 0, len(group))
|
||||
var slugs []string
|
||||
seenSlug := map[string]bool{}
|
||||
for _, member := range group {
|
||||
variants = append(variants, newPostVariant(member))
|
||||
if !seenSlug[member.Slug()] {
|
||||
seenSlug[member.Slug()] = true
|
||||
slugs = append(slugs, member.Slug())
|
||||
}
|
||||
}
|
||||
slices.SortFunc(variants, func(x, y postVariant) int {
|
||||
return strings.Compare(x.Lang, y.Lang)
|
||||
})
|
||||
card.Variants = variants
|
||||
card.VariantsJS = variantsJSON(variants)
|
||||
// One selection acts on the whole publication: the bulk
|
||||
// form carries every variant slug, split by commas.
|
||||
card.GroupSlugs = strings.Join(slugs, ",")
|
||||
}
|
||||
if card.GroupSlugs == "" {
|
||||
card.GroupSlugs = p.Slug()
|
||||
}
|
||||
cards = append(cards, card)
|
||||
switch p.Status() {
|
||||
case post.StatusDraft:
|
||||
stats.Drafts++
|
||||
case post.StatusScheduled:
|
||||
stats.Scheduled++
|
||||
if due, ok := p.DueAt(); ok {
|
||||
upcoming = append(upcoming, pending{p, due})
|
||||
}
|
||||
default:
|
||||
stats.Published++
|
||||
if len(recent) < 3 {
|
||||
recent = append(recent, recentPost{
|
||||
Slug: p.Slug(),
|
||||
Title: p.Title(),
|
||||
DateString: p.DateString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
stats.Total = len(cards)
|
||||
stats.Recent = recent
|
||||
|
||||
slices.SortStableFunc(upcoming, func(x, y pending) int {
|
||||
return x.due.Compare(y.due)
|
||||
})
|
||||
for _, entry := range upcoming {
|
||||
if len(stats.Upcoming) >= 5 {
|
||||
break
|
||||
}
|
||||
when := entry.due.Format("2006-01-02")
|
||||
if h, m := entry.due.Hour(), entry.due.Minute(); h != 0 || m != 0 {
|
||||
when = entry.due.Format("2006-01-02 15:04")
|
||||
}
|
||||
stats.Upcoming = append(stats.Upcoming, scheduledPost{
|
||||
Slug: entry.post.Slug(),
|
||||
Title: entry.post.Title(),
|
||||
When: when,
|
||||
})
|
||||
}
|
||||
|
||||
var tagCounts []tagCount
|
||||
for _, entry := range payloads.BuildTagCounts(display) {
|
||||
tagCounts = append(tagCounts, tagCount{Name: entry.Name, Count: entry.Count})
|
||||
}
|
||||
|
||||
data := a.pageData(r)
|
||||
data.Posts = cards
|
||||
data.Stats = stats
|
||||
data.TagCounts = tagCounts
|
||||
data.Q = strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
data.Notice = notice
|
||||
data.Crumbs = []Crumb{{Label: "Posts", IsLast: true, UI: true}}
|
||||
return data
|
||||
}
|
||||
|
||||
func (a *Admin) allPostsSorted() []*post.Post {
|
||||
posts := a.deps.Store.All()
|
||||
sorted := slices.Clone(posts)
|
||||
slices.SortStableFunc(sorted, func(x, y *post.Post) int {
|
||||
return strings.Compare(y.DateString(), x.DateString())
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
// handleExists answers the slug-availability check of the editor's slug
|
||||
// field.
|
||||
func (a *Admin) handleExists(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.URL.Query().Get("slug")
|
||||
if slug == "" {
|
||||
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": ""})
|
||||
return
|
||||
}
|
||||
existing := a.deps.Store.Find(slug, "")
|
||||
if existing == nil || (r.URL.Query().Get("exclude") != "" &&
|
||||
existing.Slug() == r.URL.Query().Get("exclude")) {
|
||||
writeAdminJSON(w, http.StatusOK, map[string]any{"available": true, "slug": slug})
|
||||
return
|
||||
}
|
||||
writeAdminJSON(w, http.StatusOK, map[string]any{
|
||||
"available": false, "slug": slug, "title": existing.Title(),
|
||||
})
|
||||
}
|
||||
|
||||
// editorData fills the shared editor context for new and edit forms.
|
||||
func (a *Admin) editorData(r *http.Request, mode string, p *post.Post, errorMsg string) *PageData {
|
||||
data := a.pageData(r)
|
||||
// The shared validation messages are catalogue keys; an unknown
|
||||
// message falls back to itself, so nothing breaks untranslated.
|
||||
data.Error = i18n.Admin.T(data.Lang, errorMsg)
|
||||
data.Restored = r.URL.Query().Get("restored") != ""
|
||||
data.Duplicated = r.URL.Query().Get("duplicated") != ""
|
||||
data.AuthorPlaceholder = i18n.Admin.T(data.Lang, "Author name")
|
||||
if record := data.CurrentUserRecord; record != nil && record.Name != "" {
|
||||
data.AuthorPlaceholder = record.Name
|
||||
}
|
||||
data.IsNew = mode == "new"
|
||||
data.IsEdit = mode == "edit"
|
||||
|
||||
view := newEditorPost(p)
|
||||
// The author falls back to the current user record, and the fediverse
|
||||
// handle to the user record and then to the site.
|
||||
if view.Author == "" {
|
||||
if record := data.CurrentUserRecord; record != nil {
|
||||
view.Author = record.Name
|
||||
} else {
|
||||
view.Author = data.CurrentUser
|
||||
}
|
||||
}
|
||||
if view.FediverseCreator == "" {
|
||||
view.FediverseCreator = a.deps.Config.Site.FediverseCreator
|
||||
if record := data.CurrentUserRecord; record != nil && record.FediverseCreator != "" {
|
||||
view.FediverseCreator = record.FediverseCreator
|
||||
}
|
||||
}
|
||||
// The author's ORCID rides on the account: a new post carries it
|
||||
// unless its own frontmatter names another identifier.
|
||||
if view.ORCID == "" {
|
||||
if record := data.CurrentUserRecord; record != nil {
|
||||
view.ORCID = record.Orcid
|
||||
}
|
||||
}
|
||||
data.Post = view
|
||||
|
||||
// The page head's API link carries a preview token, so it opens a
|
||||
// draft as well as a published post. The token is signed for a week,
|
||||
// far longer than an editor tab stays open.
|
||||
data.PreviewToken = preview.Token(view.Slug, a.deps.PreviewKey, time.Now())
|
||||
|
||||
// The default excerpt placeholder is interface copy; a derived
|
||||
// excerpt (the post's own first paragraph) is content and passes
|
||||
// through untranslated.
|
||||
if view.ExcerptPlaceholder == "Short summary for listings and previews" {
|
||||
view.ExcerptPlaceholder = i18n.Admin.T(data.Lang, view.ExcerptPlaceholder)
|
||||
}
|
||||
|
||||
if data.IsNew {
|
||||
options := tplOptions(a.deps.Templates.All())
|
||||
data.PostTemplates = options
|
||||
data.TemplatesJSON = templatesJSON(options)
|
||||
data.Crumbs = []Crumb{
|
||||
{Label: "Posts", Href: "/admin/", UI: true},
|
||||
{Label: "New post", IsLast: true, UI: true},
|
||||
}
|
||||
} else {
|
||||
label := view.Title
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = i18n.Admin.T(data.Lang, "Untitled")
|
||||
}
|
||||
data.Crumbs = []Crumb{
|
||||
{Label: "Posts", Href: "/admin/", UI: true},
|
||||
{Label: label, IsLast: true},
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (a *Admin) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
p := post.New(frontmatter.NewMeta(), "")
|
||||
p.Metadata.Set("lang", a.deps.Config.Site.Language)
|
||||
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, ""), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleEditForm(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
existing := a.deps.Store.Find(slug, "")
|
||||
if existing == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
a.renderPage(w, r, "form.html", a.editorData(r, "edit", existing, ""), http.StatusOK)
|
||||
}
|
||||
|
||||
// formMap flattens the request form into a plain string map.
|
||||
func formMap(r *http.Request) map[string]string {
|
||||
out := map[string]string{}
|
||||
for key, values := range r.PostForm {
|
||||
if len(values) > 0 {
|
||||
out[key] = values[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Admin) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
p, err := payloads.PostFromParams(formMap(r), nil)
|
||||
if err != nil {
|
||||
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := payloads.CreationError(p, a.deps.Store, nil); err != nil {
|
||||
a.renderPage(w, r, "form.html", a.editorData(r, "new", p, err.Error()), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
saved, err := payloads.SavePost(a.deps.Store, p, nil)
|
||||
if err != nil {
|
||||
a.renderPage(w, r, "form.html",
|
||||
a.editorData(r, "new", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.fire("post.created", saved)
|
||||
a.record(r, "post.created", saved.Slug(), nil)
|
||||
http.Redirect(w, r, "/admin/?saved=created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *Admin) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
existing := a.deps.Store.Find(slug, "")
|
||||
if existing == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
p, err := payloads.PostFromParams(formMap(r), existing)
|
||||
if err != nil {
|
||||
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := payloads.CreationError(p, a.deps.Store, existing); err != nil {
|
||||
a.renderPage(w, r, "form.html", a.editorData(r, "edit", p, err.Error()), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
// SavePost moves the file when the slug changed, the same way the API
|
||||
// does, so a rename behaves alike from either entry point.
|
||||
saved, err := payloads.SavePost(a.deps.Store, p, existing)
|
||||
if err != nil {
|
||||
a.renderPage(w, r, "form.html",
|
||||
a.editorData(r, "edit", p, i18n.Admin.Tf(a.langFor(r), "The post could not be saved: %s", err.Error())), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.fire("post.updated", saved)
|
||||
a.record(r, "post.updated", saved.Slug(), nil)
|
||||
http.Redirect(w, r, "/admin/?saved=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *Admin) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
deleted, undoable, err := a.deps.Store.Delete(slug, "")
|
||||
if err != nil {
|
||||
a.renderPage(w, r, "list.html",
|
||||
a.dashboardData(r, i18n.Admin.Tf(a.langFor(r), "The post could not be deleted: %s", err.Error())), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if deleted == nil {
|
||||
http.Redirect(w, r, "/admin/?saved=not_found", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// The payload names the post under "post" like every other post
|
||||
// event, so a subscriber sees one shape whichever entry point fired.
|
||||
a.fireRaw("post.deleted", map[string]any{
|
||||
"post": map[string]any{"slug": deleted.Slug(), "title": deleted.Title()},
|
||||
})
|
||||
a.record(r, "post.deleted", deleted.Slug(), nil)
|
||||
target := "/admin/?saved=deleted"
|
||||
if undoable {
|
||||
// Only offer Undo when a tombstone exists to undo.
|
||||
target += "&undo=" + url.QueryEscape(slug)
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *Admin) handleUndelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
if restored := a.deps.Store.Undelete(slug); restored != nil {
|
||||
a.fire("post.created", restored)
|
||||
a.record(r, "post.undeleted", slug, nil)
|
||||
http.Redirect(w, r, "/admin/?saved=undone", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/?saved=undelete_failed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *Admin) handleDuplicate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
source := a.deps.Store.Find(slug, "")
|
||||
if source == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
newSlug := a.nextAvailableSlug(slug + "-copy")
|
||||
meta := frontmatter.NewMeta()
|
||||
for _, key := range source.Metadata.Keys() {
|
||||
if key == "slug" || key == "date" {
|
||||
continue
|
||||
}
|
||||
value, _ := source.Metadata.Get(key)
|
||||
meta.Set(key, value)
|
||||
}
|
||||
meta.Set("slug", newSlug)
|
||||
meta.Set("draft", true)
|
||||
clone := post.New(meta, source.Body)
|
||||
|
||||
if err := payloads.CreationError(clone, a.deps.Store, nil); err != nil {
|
||||
slog.Warn("admin: duplicate rejected", "slug", slug, "error", err)
|
||||
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Store.Save(clone); err != nil {
|
||||
slog.Warn("admin: duplicate failed", "slug", slug, "error", err)
|
||||
http.Redirect(w, r, "/admin/?saved=duplicate_failed", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.fire("post.created", clone)
|
||||
http.Redirect(w, r, "/admin/posts/"+newSlug+"/edit?saved=duplicated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *Admin) nextAvailableSlug(base string) string {
|
||||
candidate := base
|
||||
for n := 2; a.deps.Store.Find(candidate, "") != nil; n++ {
|
||||
candidate = fmt.Sprintf("%s-%d", base, n)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func (a *Admin) handleBulk(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
action := r.PostFormValue("action")
|
||||
var slugs []string
|
||||
for slug := range strings.SplitSeq(r.PostFormValue("slugs"), ",") {
|
||||
if slug != "" {
|
||||
slugs = append(slugs, slug)
|
||||
}
|
||||
}
|
||||
if len(slugs) == 0 || (action != "delete" && action != "draft" && action != "publish") {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
affected := 0
|
||||
for _, slug := range slugs {
|
||||
switch action {
|
||||
case "delete":
|
||||
deleted, _, err := a.deps.Store.Delete(slug, "")
|
||||
if err == nil && deleted != nil {
|
||||
a.fireRaw("post.deleted", map[string]any{
|
||||
"post": map[string]any{"slug": slug, "title": deleted.Title()},
|
||||
})
|
||||
affected++
|
||||
}
|
||||
case "draft":
|
||||
if cached := a.deps.Store.Find(slug, ""); cached != nil && !cached.Draft() {
|
||||
// Cached posts are shared with other requests: clone first.
|
||||
p := cached.Clone()
|
||||
p.Metadata.Set("draft", true)
|
||||
if _, err := a.deps.Store.Save(p); err == nil {
|
||||
a.fire("post.updated", p)
|
||||
affected++
|
||||
}
|
||||
}
|
||||
case "publish":
|
||||
if cached := a.deps.Store.Find(slug, ""); cached != nil && cached.Draft() {
|
||||
p := cached.Clone()
|
||||
p.Metadata.Delete("draft")
|
||||
if _, err := a.deps.Store.Save(p); err == nil {
|
||||
a.fireRaw("post.published", map[string]any{"post": payloads.BuildSummary(p)})
|
||||
affected++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if affected > 0 {
|
||||
a.record(r, "post.bulk_"+action, "", map[string]any{"slugs": slugs, "affected": affected})
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/admin/?bulk=%s&n=%d", action, affected), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *Admin) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
htmlOut, err := markdown.Render(r.PostFormValue("body"))
|
||||
if err != nil {
|
||||
http.Error(w, "render failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// The preview body carries no frontmatter, so the reference list it
|
||||
// knows about comes from the saved post under the same slug: the
|
||||
// editor then sees the bibliography the published page will show,
|
||||
// not the raw [[refs]] marker. A new post has no saved refs, and its
|
||||
// marker paragraph stays as written.
|
||||
slug := r.PostFormValue("slug")
|
||||
lang := r.PostFormValue("lang")
|
||||
if slug != "" {
|
||||
if p := a.deps.Store.Find(slug, lang); p != nil {
|
||||
if refs := p.RefsLinked(); len(refs) > 0 {
|
||||
htmlOut = biblio.LinkCitations(htmlOut, refs)
|
||||
htmlOut = biblio.Place(htmlOut, refs)
|
||||
}
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, htmlOut)
|
||||
}
|
||||
|
||||
// --- import, download, history ---------------------------------------------
|
||||
|
||||
// fire and fireRaw notify the optional webhook sink.
|
||||
func (a *Admin) fire(event string, p *post.Post) {
|
||||
a.fireRaw(event, map[string]any{"post": payloads.BuildSummary(p)})
|
||||
}
|
||||
|
||||
func (a *Admin) fireRaw(event string, payload map[string]any) {
|
||||
if a.deps.OnEvent != nil {
|
||||
a.deps.OnEvent(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePreviewLink returns a shareable preview URL for a draft or
|
||||
// scheduled post. The link is signed with the session key, so without
|
||||
// one no link can be honoured and none is offered.
|
||||
func (a *Admin) handlePreviewLink(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if a.deps.Store.Find(slug, "") == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
token := preview.Token(slug, a.deps.PreviewKey, time.Now())
|
||||
if token == "" {
|
||||
writeAdminJSONError(w, http.StatusConflict, "no_session_key",
|
||||
"Preview links need a session key: set [admin].session_key or make the state directory writable.")
|
||||
return
|
||||
}
|
||||
base := strings.TrimRight(a.deps.Config.Site.BaseURL, "/")
|
||||
writeAdminJSON(w, http.StatusOK, map[string]any{
|
||||
"url": fmt.Sprintf("%s/api/volumen/posts/%s?preview_token=%s", base, slug, token),
|
||||
"token": token,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/preview"
|
||||
)
|
||||
|
||||
func writeTestPost(t *testing.T, f *fixture, name, body string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(f.contentDir, name)
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write post: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const samplePost = `+++
|
||||
title = "Hello"
|
||||
slug = "hello"
|
||||
date = 2026-08-18
|
||||
lang = "cs"
|
||||
tags = ["go", "blog"]
|
||||
+++
|
||||
|
||||
Hello **body**.
|
||||
`
|
||||
|
||||
func TestDashboardRenders(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"Posts", "Hello", `data-slug="hello"`, "Published", "Drafts",
|
||||
`data-tag="go"`, "1 post", "Log out",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardGroupsLanguageVariants(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", `+++
|
||||
title = "Hello"
|
||||
slug = "hello"
|
||||
date = 2026-08-18
|
||||
lang = "en"
|
||||
translations = { cs = "ahoj" }
|
||||
+++
|
||||
|
||||
English body.
|
||||
`)
|
||||
writeTestPost(t, f, "ahoj.md", `+++
|
||||
title = "Ahoj"
|
||||
slug = "ahoj"
|
||||
date = 2026-08-18
|
||||
lang = "cs"
|
||||
translations = { en = "hello" }
|
||||
+++
|
||||
|
||||
České tělo.
|
||||
`)
|
||||
writeTestPost(t, f, "lonely.md", `+++
|
||||
title = "Lonely"
|
||||
slug = "lonely"
|
||||
date = 2026-08-17
|
||||
lang = "en"
|
||||
+++
|
||||
|
||||
Alone.
|
||||
`)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(cookie)
|
||||
body := f.do(t, req).Body.String()
|
||||
|
||||
// The linked pair is one card, the unrelated post the second one.
|
||||
if got := strings.Count(body, `<article class="post"`); got != 2 {
|
||||
t.Fatalf("cards = %d, want 2", got)
|
||||
}
|
||||
if got := strings.Count(body, `data-variants=`); got != 1 {
|
||||
t.Fatalf("cards with variants = %d, want 1", got)
|
||||
}
|
||||
if !strings.Contains(body, `data-slug="hello"`) || strings.Contains(body, `data-slug="ahoj"`) {
|
||||
t.Fatal("the variant ahoj must not stand as its own card")
|
||||
}
|
||||
// Both language versions are reachable from the switch chips.
|
||||
if !strings.Contains(body, `data-lang="en"`) || !strings.Contains(body, `data-lang="cs"`) {
|
||||
t.Fatal("the card must offer both language variants")
|
||||
}
|
||||
// The selection acts on the whole publication: both slugs ride along.
|
||||
if !strings.Contains(body, `value="hello,ahoj"`) && !strings.Contains(body, `value="ahoj,hello"`) {
|
||||
t.Fatal("the bulk selection must carry every variant slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardRequiresLogin(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/", nil))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFormRendersEditor(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"New post", `id="post-form"`, `action="/admin/posts"`, "Markdown"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFormRendersPost(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"Edit post", `value="Hello"`, `value="hello"`, `readonly`,
|
||||
`action="/admin/posts/hello"`, "Hello **body**.",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/posts/ghost/edit", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePostViaForm(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"title": {"Fresh"},
|
||||
"slug": {"fresh"},
|
||||
"lang": {"cs"},
|
||||
"tags": {"a, b"},
|
||||
"body": {"content"},
|
||||
"draft": {"on"},
|
||||
"author": {"Petr"},
|
||||
}
|
||||
rec := postForm(t, f, "/admin/posts", form, cookie)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=created" {
|
||||
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||
}
|
||||
if p := f.findPost("fresh"); p == nil || p.Title() != "Fresh" || !p.Draft() {
|
||||
t.Fatalf("post = %v", p)
|
||||
}
|
||||
if len(f.events) == 0 || f.events[len(f.events)-1] != "post.created" {
|
||||
t.Fatalf("events = %v", f.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePostValidationRendersForm(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
form := url.Values{"_csrf": {csrf}, "title": {"X"}, "slug": {"Bad Slug"}, "body": {"b"}}
|
||||
rec := postForm(t, f, "/admin/posts", form, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Invalid slug.") {
|
||||
t.Fatal("validation message missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePostKeepsPath(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf}, "title": {"Updated"}, "slug": {"hello"},
|
||||
"lang": {"cs"}, "tags": {"go"}, "body": {"new body"},
|
||||
}
|
||||
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
p := f.findPost("hello")
|
||||
if p == nil || p.Title() != "Updated" || p.Body != "new body\n" {
|
||||
t.Fatalf("post = %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// The bibliography card writes the refs tables through the whole save
|
||||
// path: the editor's JSON reaches the file as [[refs]] blocks, an
|
||||
// author's ORCID survives as a name table, and a frontmatter key the
|
||||
// form never names round-trips untouched beside them.
|
||||
func TestEditorSavesTheBibliography(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", `+++
|
||||
title = "Cite"
|
||||
slug = "hello"
|
||||
date = 2026-08-18
|
||||
note = "keep me"
|
||||
tags = ["go"]
|
||||
|
||||
[[refs]]
|
||||
raw = "Old entry."
|
||||
+++
|
||||
Cites [1].
|
||||
`)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
// The edit form hands the stored list to the card's script, and the
|
||||
// card sits below the editor with its own bounded list.
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
|
||||
req.AddCookie(cookie)
|
||||
body := f.do(t, req).Body.String()
|
||||
for _, want := range []string{`id="refs-card"`, "Old entry.", `id="ref-row-template"`, `id="refs-count"`, `class="refs-scroll"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("edit form missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Index(body, `id="refs-card"`) < strings.Index(body, `id="post-form"`) {
|
||||
t.Fatal("the bibliography card must not precede the form")
|
||||
}
|
||||
editorSection := strings.Index(body, `id="markdown-view"`)
|
||||
refsCard := strings.Index(body, `id="refs-card"`)
|
||||
if editorSection == -1 || refsCard == -1 || refsCard < editorSection {
|
||||
t.Fatal("the bibliography card must sit below the editor")
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Cites [1].\n\n[[refs]]\n"},
|
||||
"refs": {`[` +
|
||||
`{"num":2,"raw":"Kept and edited."},` +
|
||||
`{"raw":"Added verbatim.","doi":"10.1086/300499"},` +
|
||||
`{"authors":[{"name":"Adam Riess","orcid":"0000-0002-1825-0097"}],` +
|
||||
`"title":"Observational Evidence","year":"1998"}` +
|
||||
`]`},
|
||||
}
|
||||
rec := postForm(t, f, "/admin/posts/hello", form, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join(f.contentDir, "hello.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read post: %v", err)
|
||||
}
|
||||
file := string(raw)
|
||||
for _, want := range []string{
|
||||
`note = "keep me"`,
|
||||
"[[refs]]",
|
||||
"raw = \"Kept and edited.\"",
|
||||
"num = 2",
|
||||
"raw = \"Added verbatim.\"",
|
||||
`doi = "10.1086/300499"`,
|
||||
`title = "Observational Evidence"`,
|
||||
`{name = "Adam Riess", orcid = "0000-0002-1825-0097"}`,
|
||||
} {
|
||||
if !strings.Contains(file, want) {
|
||||
t.Fatalf("saved file missing %q:\n%s", want, file)
|
||||
}
|
||||
}
|
||||
if strings.Contains(file, "Old entry.") {
|
||||
t.Fatalf("the replaced entry survived:\n%s", file)
|
||||
}
|
||||
// The rewritten list renders with its citations linked.
|
||||
p := f.findPost("hello")
|
||||
refs := p.RefsLinked()
|
||||
if len(refs) != 3 {
|
||||
t.Fatalf("refs = %v", refs)
|
||||
}
|
||||
if refs[0].Num != 2 || refs[0].Raw != "Kept and edited." {
|
||||
t.Fatalf("first entry = %+v", refs[0])
|
||||
}
|
||||
html, err := p.HTML()
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
// The preserved numbers name the anchors: the first entry keeps its
|
||||
// explicit num = 2, so the list carries ref-2 and ref-3 and no ref-1.
|
||||
if !strings.Contains(html, `id="ref-2"`) || !strings.Contains(html, `id="ref-3"`) {
|
||||
t.Fatalf("rendered list wrong:\n%s", html)
|
||||
}
|
||||
if strings.Contains(html, `id="ref-1"`) {
|
||||
t.Fatalf("an unnumbered anchor appeared:\n%s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// A save whose form carries no refs field keeps the stored list, so the
|
||||
// bibliography never disappears under a page that does not edit it.
|
||||
func TestEditorWithoutRefsKeepsTheStoredList(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", `+++
|
||||
title = "Cite"
|
||||
slug = "hello"
|
||||
date = 2026-08-18
|
||||
|
||||
[[refs]]
|
||||
raw = "Old entry."
|
||||
+++
|
||||
Body.
|
||||
`)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf}, "title": {"Cite"}, "slug": {"hello"}, "body": {"Body.\n"},
|
||||
}
|
||||
if rec := postForm(t, f, "/admin/posts/hello", form, cookie); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
if refs := f.findPost("hello").Refs(); len(refs) != 1 || refs[0].Raw != "Old entry." {
|
||||
t.Fatalf("refs = %v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAndUndeleteFlow(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/posts/hello/delete", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther ||
|
||||
!strings.Contains(rec.Header().Get("Location"), "saved=deleted&undo=hello") {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if f.findPost("hello") != nil {
|
||||
t.Fatal("post still present")
|
||||
}
|
||||
// The webhook contract names the post under the "post" key, the same
|
||||
// shape every other post event and the API's delete endpoint deliver.
|
||||
last := len(f.events) - 1
|
||||
if last < 0 || f.events[last] != "post.deleted" {
|
||||
t.Fatalf("events = %v", f.events)
|
||||
}
|
||||
inner, ok := f.payloads[last]["post"].(map[string]any)
|
||||
if !ok || inner["slug"] != "hello" || inner["title"] != "Hello" {
|
||||
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
|
||||
}
|
||||
|
||||
rec = postForm(t, f, "/admin/posts/hello/undelete", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=undone" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if f.findPost("hello") == nil {
|
||||
t.Fatal("post not restored")
|
||||
}
|
||||
}
|
||||
|
||||
// A bulk delete delivers the same post.deleted shape as the single
|
||||
// delete, so a subscriber cannot tell which screen the change came from.
|
||||
func TestBulkDeleteEventShape(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/posts/bulk",
|
||||
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"hello"}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "n=1") {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
last := len(f.events) - 1
|
||||
if last < 0 || f.events[last] != "post.deleted" {
|
||||
t.Fatalf("events = %v", f.events)
|
||||
}
|
||||
inner, ok := f.payloads[last]["post"].(map[string]any)
|
||||
if !ok || inner["slug"] != "hello" {
|
||||
t.Fatalf("post.deleted payload = %#v", f.payloads[last])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateCreatesDraftCopy(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther ||
|
||||
!strings.Contains(rec.Header().Get("Location"), "/admin/posts/hello-copy/edit") {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
copyPost := f.findPost("hello-copy")
|
||||
if copyPost == nil || !copyPost.Draft() {
|
||||
t.Fatalf("copy = %v", copyPost)
|
||||
}
|
||||
if copyPost.DateString() != "" {
|
||||
t.Fatalf("copy kept the date: %q", copyPost.DateString())
|
||||
}
|
||||
|
||||
// A second duplicate gets the -copy-2 suffix.
|
||||
rec = postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if !strings.Contains(rec.Header().Get("Location"), "hello-copy-2") {
|
||||
t.Fatalf("location = %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// A duplicate that cannot be created must say so on the dashboard, not
|
||||
// disappear behind a silent redirect.
|
||||
func TestDuplicateFailureIsReported(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", `+++
|
||||
title = "Hello"
|
||||
slug = "hello"
|
||||
date = 2026-08-18
|
||||
doi = "not-a-doi"
|
||||
+++
|
||||
|
||||
Body.
|
||||
`)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/posts/hello/duplicate", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/?saved=duplicate_failed" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if f.findPost("hello-copy") != nil {
|
||||
t.Fatal("a rejected duplicate must not be saved")
|
||||
}
|
||||
}
|
||||
|
||||
// The editor's API link carries a valid preview token, so it opens a
|
||||
// draft as well as a published post.
|
||||
func TestEditFormCarriesPreviewToken(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/edit", nil)
|
||||
req.AddCookie(cookie)
|
||||
body := f.do(t, req).Body.String()
|
||||
|
||||
mark := `/api/volumen/posts/hello?preview_token=`
|
||||
i := strings.Index(body, mark)
|
||||
if i < 0 {
|
||||
t.Fatal("API link without a preview token")
|
||||
}
|
||||
token, _, _ := strings.Cut(body[i+len(mark):], `"`)
|
||||
if !preview.Valid(token, "hello", f.admin.deps.PreviewKey, time.Now()) {
|
||||
t.Fatalf("preview token invalid: %q", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkActions(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "a.md", "+++\nslug = \"a\"\ntitle = \"A\"\ndraft = true\n+++\nx\n")
|
||||
writeTestPost(t, f, "b.md", "+++\nslug = \"b\"\ntitle = \"B\"\n+++\nx\n")
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/posts/bulk",
|
||||
url.Values{"_csrf": {csrf}, "action": {"publish"}, "slugs": {"a"}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "bulk=publish&n=1") {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if f.findPost("a").Draft() {
|
||||
t.Fatal("post not published")
|
||||
}
|
||||
|
||||
rec = postForm(t, f, "/admin/posts/bulk",
|
||||
url.Values{"_csrf": {csrf}, "action": {"delete"}, "slugs": {"a,b"}}, cookie)
|
||||
if !strings.Contains(rec.Header().Get("Location"), "n=2") {
|
||||
t.Fatalf("location = %q", rec.Header().Get("Location"))
|
||||
}
|
||||
if f.findPost("a") != nil || f.findPost("b") != nil {
|
||||
t.Fatal("posts not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugExistsEndpoint(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
|
||||
for path, want := range map[string]string{
|
||||
"/admin/posts/exists?slug=hello": `"available":false`,
|
||||
"/admin/posts/exists?slug=fresh": `"available":true`,
|
||||
"/admin/posts/exists?slug=": `"available":true`,
|
||||
"/admin/posts/exists?slug=hello&exclude=hello": `"available":true`,
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("%s: code=%d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewEndpoint(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/preview", url.Values{"_csrf": {csrf}, "body": {"**bold**"}}, cookie)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<strong>bold</strong>") {
|
||||
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewLinkEndpoint(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "draft.md", "+++\nslug = \"d\"\ndraft = true\n+++\nx\n")
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/posts/d/preview-link", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "preview_token=") {
|
||||
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportFlow(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := multipartForm(t, f, "/admin/posts/import", cookie, csrf,
|
||||
"file", "imported.md", "+++\ntitle = \"Imported\"\nslug = \"imported\"\n+++\nbody\n")
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/posts/imported/edit" {
|
||||
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if f.findPost("imported") == nil {
|
||||
t.Fatal("import not saved")
|
||||
}
|
||||
|
||||
// Non-.md rejected.
|
||||
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
|
||||
"file", "evil.txt", "content")
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
|
||||
// Import without a slug derives one from the file name.
|
||||
rec = multipartForm(t, f, "/admin/posts/import", cookie, csrf,
|
||||
"file", "derived-slug.md", "Just a body, no frontmatter.\n")
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if f.findPost("derived-slug") == nil {
|
||||
t.Fatal("derived slug import failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndHistory(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
// Saving twice archives one revision.
|
||||
form := url.Values{
|
||||
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
|
||||
"lang": {"cs"}, "body": {"changed"},
|
||||
}
|
||||
postForm(t, f, "/admin/posts/hello", form, cookie)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/download", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "title = \"Hello\"") {
|
||||
t.Fatalf("download code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Disposition"), `filename="hello.md"`) {
|
||||
t.Fatalf("disposition = %q", rec.Header().Get("Content-Disposition"))
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = f.do(t, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "History") {
|
||||
t.Fatalf("history code=%d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), " kB") {
|
||||
t.Fatal("revision size missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsGarbage(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf,
|
||||
"file", "x.webp", "not an image at all")
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
|
||||
webpData = append(webpData, []byte("WEBPVP8 ")...)
|
||||
rec = multipartForm(t, f, "/admin/uploads", cookie, csrf,
|
||||
"file", "pic.png", string(webpData))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "/media/") {
|
||||
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFRequiredOnMutations(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
for _, path := range []string{
|
||||
"/admin/posts", "/admin/posts/bulk", "/admin/preview",
|
||||
"/admin/posts/import",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("_csrf=wrong"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s: code = %d, want 403", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
func (f *fixture) findPost(slug string) *post.Post {
|
||||
return f.storeObj.Find(slug, "")
|
||||
}
|
||||
|
||||
// csrfFromSession performs the login GET flow and returns the CSRF token.
|
||||
func csrfFromSession(t *testing.T, f *fixture, cookie *http.Cookie) string {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
return extractCSRF(t, rec.Body.String())
|
||||
}
|
||||
// Authenticated: pull the token from the session instead.
|
||||
sess := f.store.Load(req)
|
||||
return CSRFToken(sess)
|
||||
}
|
||||
|
||||
func postForm(t *testing.T, f *fixture, path string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
return f.do(t, req)
|
||||
}
|
||||
|
||||
func multipartForm(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field, filename, content string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
_ = mw.WriteField("_csrf", csrf)
|
||||
part, err := mw.CreateFormFile(field, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write([]byte(content)); err != nil {
|
||||
t.Fatalf("write part: %v", err)
|
||||
}
|
||||
_ = mw.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.AddCookie(cookie)
|
||||
return f.do(t, req)
|
||||
}
|
||||
|
||||
// The history page's two per-revision endpoints are the ones an operator
|
||||
// reaches for after a bad edit, so both are exercised: the download and
|
||||
// the restore, including the redirect that carries the flash message.
|
||||
func TestHistoryDownloadAndRestore(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "hello.md", samplePost)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
// One save archives the original.
|
||||
postForm(t, f, "/admin/posts/hello", url.Values{
|
||||
"_csrf": {csrf}, "title": {"Hello"}, "slug": {"hello"},
|
||||
"lang": {"cs"}, "body": {"changed"},
|
||||
}, cookie)
|
||||
revisions := f.admin.deps.Store.Revisions("hello")
|
||||
if len(revisions) != 1 {
|
||||
t.Fatalf("revisions = %v", revisions)
|
||||
}
|
||||
name := revisions[0].Name
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/"+name, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("history download code = %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Hello **body**.") {
|
||||
t.Fatalf("revision body = %s", rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Disposition"); !strings.Contains(got, "hello-"+name) {
|
||||
t.Fatalf("disposition = %q", got)
|
||||
}
|
||||
|
||||
// An unknown revision name is a 404, not an empty file.
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/posts/hello/history/nope.md", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown revision code = %d", rec.Code)
|
||||
}
|
||||
|
||||
// Restoring puts the archived body back and redirects to the editor.
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/posts/hello/history/"+name+"/restore",
|
||||
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec = f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("restore code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Location"); got != "/admin/posts/hello/edit?restored=1" {
|
||||
t.Fatalf("location = %q", got)
|
||||
}
|
||||
restored := f.storeObj.Find("hello", "")
|
||||
if restored == nil || !strings.Contains(restored.Body, "Hello **body**.") {
|
||||
t.Fatalf("body after restore = %q", restored.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The brand SVG loads on every admin page, so a broken embed pattern
|
||||
// would break the whole UI silently. The one asset is the icon: the
|
||||
// favicon, the login brand and the topbar badge all read the same file.
|
||||
func TestStaticSVGRoutes(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
const path = "/admin/icon.svg"
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: code = %d", path, rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "image/svg+xml" {
|
||||
t.Fatalf("%s: content-type = %q", path, got)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<svg") {
|
||||
t.Fatalf("%s: body is not an SVG", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportFormRenders(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/posts/import", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `name="file"`) {
|
||||
t.Fatalf("import form code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a media file removes it from the library and from the public
|
||||
// route, and a second delete reports the absence.
|
||||
func TestMediaDelete(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := multipartForm(t, f, "/admin/uploads", cookie, csrf, "file", "x.webp", string(webpFixture()))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("upload code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
items := f.storeObj.ListMedia()
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("media = %v", items)
|
||||
}
|
||||
name := items[0].Name
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
|
||||
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete code = %d", rec.Code)
|
||||
}
|
||||
if len(f.storeObj.ListMedia()) != 0 {
|
||||
t.Fatal("media survived the delete")
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/media/"+name+"/delete",
|
||||
strings.NewReader("_csrf="+url.QueryEscape(csrf)))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("second delete code = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// webpFixture is a minimal RIFF/WEBP header, enough for the signature
|
||||
// check the upload path performs.
|
||||
func webpFixture() []byte {
|
||||
data := append([]byte("RIFF"), 0, 0, 0, 0)
|
||||
return append(data, []byte("WEBPVP8 ")...)
|
||||
}
|
||||
|
||||
// A malformed date in the editor form is rejected with the post
|
||||
// re-rendered, rather than silently dropping an inherited schedule.
|
||||
func TestEditorRejectsMalformedPublishAt(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if err := os.WriteFile(filepath.Join(f.contentDir, "sched.md"),
|
||||
[]byte("+++\ntitle = \"S\"\nslug = \"sched\"\npublish_at = 2999-01-01\n+++\nbody\n"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
rec := postForm(t, f, "/admin/posts/sched", url.Values{
|
||||
"_csrf": {csrf}, "title": {"S"}, "slug": {"sched"},
|
||||
"publish_at": {"not a date"}, "body": {"body"},
|
||||
}, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("code = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "publish_at must be an ISO 8601 date") {
|
||||
t.Fatal("validation message missing")
|
||||
}
|
||||
if p := f.admin.deps.Store.Find("sched", ""); p == nil || !p.Scheduled() {
|
||||
t.Fatal("the stored schedule was dropped by the rejected save")
|
||||
}
|
||||
}
|
||||
|
||||
// An admin POST body over the configured allowance is cut off with 413
|
||||
// before it can fill the temp directory.
|
||||
func TestAdminBodyOverTheLimitIs413(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
huge := strings.Repeat("a", 12*1024*1024)
|
||||
rec := postForm(t, f, "/admin/posts", url.Values{
|
||||
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
|
||||
}, cookie)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("code = %d, want 413", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The editor preview must show the bibliography the published page
|
||||
// will show: the refs live in the saved post's frontmatter, which the
|
||||
// body-only render cannot see on its own.
|
||||
func TestPreviewWeavesSavedRefs(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "cite.md", `+++
|
||||
title = "Cite"
|
||||
slug = "cite"
|
||||
|
||||
[[refs]]
|
||||
title = "Observational evidence from supernovae"
|
||||
doi = "10.1103/PhysRevD.59.103502"
|
||||
+++
|
||||
|
||||
Tvrzení [1].
|
||||
|
||||
[[refs]]
|
||||
`)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/preview", url.Values{
|
||||
"_csrf": {csrf}, "body": {"Tvrzení [1].\n\n[[refs]]\n"},
|
||||
"slug": {"cite"},
|
||||
}, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
`<section class="refs" id="references">`,
|
||||
`<a class="ref-cite" href="#ref-1">[1]</a>`,
|
||||
`href="https://doi.org/10.1103/PhysRevD.59.103502"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("preview missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// A new post with no saved refs keeps the marker as inert text, and
|
||||
// an unknown slug is just the plain body render.
|
||||
for _, slug := range []string{"", "ghost"} {
|
||||
rec := postForm(t, f, "/admin/preview", url.Values{
|
||||
"_csrf": {csrf}, "body": {"[[refs]]\n"}, "slug": {slug},
|
||||
}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), biblio.Marker) {
|
||||
t.Fatalf("slug %q: preview rewrote an unsaved marker:\n%s", slug, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
json "encoding/json/v2"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/imagefile"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
)
|
||||
|
||||
// writeAdminJSON writes one JSON object response; encoding/json escapes
|
||||
// what a browser's JSON.parse requires, which a %q verb does not.
|
||||
func writeAdminJSON(w http.ResponseWriter, status int, value map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.MarshalWrite(w, value, json.Deterministic(true)); err != nil {
|
||||
slog.Warn("admin: cannot encode JSON response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Admin) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeAdminJSONError(w, http.StatusBadRequest, "no_file", i18n.Admin.T(a.langFor(r), "No file was uploaded."))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
limit := int64(a.deps.Config.Admin.MaxUploadBytes)
|
||||
raw, err := readLimited(file, limit)
|
||||
if err != nil {
|
||||
writeAdminJSONError(w, http.StatusRequestEntityTooLarge, "too_large",
|
||||
i18n.Admin.Tf(a.langFor(r),
|
||||
"The file could not be read (limit %s bytes).",
|
||||
strconv.FormatInt(limit, 10)))
|
||||
return
|
||||
}
|
||||
if errMsg := validateImageData(raw); errMsg != "" {
|
||||
writeAdminJSONError(w, http.StatusUnsupportedMediaType, errMsg,
|
||||
i18n.Admin.T(a.langFor(r), "Only WebP, AVIF and SVG images are supported."))
|
||||
return
|
||||
}
|
||||
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
|
||||
if err != nil {
|
||||
writeAdminJSONError(w, http.StatusInternalServerError, "upload_failed",
|
||||
i18n.Admin.T(a.langFor(r), "The upload could not be stored."))
|
||||
return
|
||||
}
|
||||
writeAdminJSON(w, http.StatusOK, map[string]any{"url": url})
|
||||
}
|
||||
|
||||
func writeAdminJSONError(w http.ResponseWriter, status int, code, message string) {
|
||||
writeAdminJSON(w, status, map[string]any{"error": code, "message": message})
|
||||
}
|
||||
|
||||
// validateImageData reports why data is not an acceptable upload. The
|
||||
// stored extension is taken from the byte signature, so the declared
|
||||
// filename's type is irrelevant: what matters is that the bytes are one
|
||||
// of the accepted image formats.
|
||||
func validateImageData(data []byte) string {
|
||||
if imagefile.Detect(data) == "" {
|
||||
return "invalid_signature"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *Admin) handleIcon(w http.ResponseWriter, _ *http.Request) {
|
||||
a.serveStaticSVG(w, "volumen-icon.svg")
|
||||
}
|
||||
|
||||
func (a *Admin) serveStaticSVG(w http.ResponseWriter, name string) {
|
||||
data, err := web.StaticFile(name)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
// Package admin serves the server-rendered admin UI: authentication,
|
||||
// post management, settings, and the media library.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json/v2"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/diff"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
)
|
||||
|
||||
// Crumb is one breadcrumb entry. UI marks a fixed interface label the
|
||||
// renderer translates; content labels (post titles) pass through.
|
||||
type Crumb struct {
|
||||
Label string
|
||||
Href string
|
||||
IsLast bool
|
||||
UI bool
|
||||
}
|
||||
|
||||
// PageData is the template context shared by every admin page; page
|
||||
// handlers fill the specific fields they need.
|
||||
type PageData struct {
|
||||
Config *config.Config
|
||||
Path string
|
||||
CSPNonce string
|
||||
Version string
|
||||
UpdateAvailable string
|
||||
|
||||
// Lang is the interface language this request renders in ("en" or
|
||||
// "cs"), resolved from the account, the language cookie, or the
|
||||
// site language. It feeds the html lang attribute, which the date
|
||||
// picker and the relative-time formatter read.
|
||||
Lang string
|
||||
|
||||
// Theme is the colour scheme this request renders in, resolved
|
||||
// from the account, the theme cookie, or the default. It feeds the
|
||||
// html data-theme attribute the stylesheet's scheme blocks read.
|
||||
Theme string
|
||||
|
||||
CurrentUser string
|
||||
CurrentRole string
|
||||
CurrentUserRecord *users.User
|
||||
UsersExist bool
|
||||
CSRFToken string
|
||||
|
||||
IsLogin bool
|
||||
IsSetup bool
|
||||
IsAuthenticated bool
|
||||
|
||||
// SetupI18n carries the wizard's strings in every shipped
|
||||
// language, so the language chips can swap the page text without
|
||||
// a reload and without losing what the operator typed.
|
||||
SetupI18n template.JS
|
||||
DisplayName string
|
||||
UserPhoto string
|
||||
UserInitial string
|
||||
|
||||
Crumbs []Crumb
|
||||
Error string
|
||||
RetryAfter int
|
||||
Notice string
|
||||
|
||||
// Dashboard.
|
||||
Posts []postCard
|
||||
Stats dashboardStats
|
||||
TagCounts []tagCount
|
||||
Q string
|
||||
|
||||
// Editor and history.
|
||||
IsNew bool
|
||||
IsEdit bool
|
||||
Post *editorPost
|
||||
PostTemplates []tplOption
|
||||
TemplatesJSON template.JS
|
||||
Restored bool
|
||||
Duplicated bool
|
||||
AuthorPlaceholder string
|
||||
PreviewToken string
|
||||
Slug string
|
||||
Heading string
|
||||
Revisions []revisionRow
|
||||
DiffChunks []diff.Chunk
|
||||
DiffName string
|
||||
DiffWhen string
|
||||
|
||||
// Settings.
|
||||
IsAdmin bool
|
||||
Roles []string
|
||||
DefaultRole string
|
||||
UserRows []userRow
|
||||
TemplatesList []tplOption
|
||||
WebhookRows []hookRow
|
||||
WebhookDeliveries []deliveryRow
|
||||
TokenRows []tokenRow
|
||||
NewToken string
|
||||
MediaItems []mediaRow
|
||||
|
||||
// The second factor: its state on the account, an enrolment in
|
||||
// flight, and the one-time recovery codes a change just produced.
|
||||
TotpEnabled bool
|
||||
TotpPending bool
|
||||
TotpSVG template.HTML
|
||||
TotpSecret string
|
||||
TotpURI string
|
||||
RecoveryCodes []string
|
||||
RecoveryNotice string
|
||||
MediaTotal string
|
||||
Target string
|
||||
|
||||
// Sidebar navigation highlighting.
|
||||
NavPosts bool
|
||||
NavNew bool
|
||||
NavImport bool
|
||||
NavMedia bool
|
||||
NavSettings bool
|
||||
}
|
||||
|
||||
// Tr translates a simple message in this request's language. Handlers
|
||||
// use it for the strings they compose in Go; templates use the tr and
|
||||
// trn funcs, which read the same catalogue.
|
||||
func (d *PageData) Tr(s string) string {
|
||||
return i18n.Admin.T(d.Lang, s)
|
||||
}
|
||||
|
||||
// Trf translates a simple message with one value.
|
||||
func (d *PageData) Trf(s, arg string) string {
|
||||
return i18n.Admin.Tf(d.Lang, s, arg)
|
||||
}
|
||||
|
||||
// langRenderer is one language's parsed template set. The translation
|
||||
// funcs close over the language, so a page renders whole in one tongue
|
||||
// with no per-string lookups in the handlers.
|
||||
type langRenderer struct {
|
||||
pages map[string]*template.Template
|
||||
}
|
||||
|
||||
// Renderer executes the embedded admin templates in every shipped
|
||||
// language.
|
||||
type Renderer struct {
|
||||
mu sync.Mutex
|
||||
langs map[string]*langRenderer
|
||||
}
|
||||
|
||||
// NewRenderer parses the layout together with every page template, one
|
||||
// set per shipped language.
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
fs := web.TemplateFS()
|
||||
r := &Renderer{langs: map[string]*langRenderer{}}
|
||||
for _, lang := range i18n.Languages {
|
||||
base, err := template.New("layout.html").Funcs(funcMap(lang)).ParseFS(fs, "templates/layout.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse layout (%s): %w", lang, err)
|
||||
}
|
||||
lr := &langRenderer{pages: map[string]*template.Template{}}
|
||||
for _, page := range pageNames() {
|
||||
clone, err := base.Clone()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone layout for %s (%s): %w", page, lang, err)
|
||||
}
|
||||
if _, err := clone.ParseFS(fs, "templates/"+page); err != nil {
|
||||
return nil, fmt.Errorf("parse %s (%s): %w", page, lang, err)
|
||||
}
|
||||
lr.pages[page] = clone
|
||||
}
|
||||
r.langs[lang] = lr
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// pageNames lists the page templates parsed alongside the layout.
|
||||
func pageNames() []string {
|
||||
return []string{
|
||||
"login.html", "setup.html", "twofactor.html", "list.html", "form.html", "history.html", "diff.html",
|
||||
"import.html",
|
||||
"settings.html", "media.html", "update.html", "notfound.html",
|
||||
}
|
||||
}
|
||||
|
||||
// Render executes the named page inside the layout shell, in the
|
||||
// language the page data carries. The context is the request's, so a
|
||||
// template failure is logged against it.
|
||||
func (r *Renderer) Render(ctx context.Context, w io.Writer, page string, data *PageData) error {
|
||||
lang := data.Lang
|
||||
if !i18n.Valid(lang) {
|
||||
lang = "en"
|
||||
}
|
||||
// Fixed breadcrumb labels are interface strings; content labels
|
||||
// (a post title) pass through untouched.
|
||||
for i, c := range data.Crumbs {
|
||||
if c.UI {
|
||||
data.Crumbs[i].Label = i18n.Admin.T(lang, c.Label)
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
lr := r.langs[lang]
|
||||
r.mu.Unlock()
|
||||
tmpl, ok := lr.pages[page]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown admin page %q", page)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
||||
web.Logger(ctx).Warn("admin: template error", "page", page, "error", err)
|
||||
return err
|
||||
}
|
||||
_, err := w.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func funcMap(lang string) template.FuncMap {
|
||||
cat := i18n.Admin
|
||||
return template.FuncMap{
|
||||
"lower": strings.ToLower,
|
||||
"join": func(items []string, sep string) string { return strings.Join(items, sep) },
|
||||
"tr": func(s string) string { return cat.T(lang, s) },
|
||||
"trh": func(s string) template.HTML { return template.HTML(cat.TH(lang, s)) },
|
||||
"trf": func(s, arg string) string { return cat.Tf(lang, s, arg) },
|
||||
"trn": func(n int, id string) string { return cat.N(lang, id, n) },
|
||||
"i18nJSON": func() template.JS {
|
||||
b, err := json.Marshal(cat.JS(lang))
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
// The catalogue holds authored strings only, but the same
|
||||
// script-embedding rule as templatesJSON applies: no literal
|
||||
// "<" may reach the page inside a script element.
|
||||
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||||
)
|
||||
|
||||
// passwordPolicy returns the configured length bounds. Validate
|
||||
// guarantees a minimum of at least one and a maximum at or above it
|
||||
// before the server starts.
|
||||
func (a *Admin) passwordPolicy() (int, int) {
|
||||
return a.deps.Config.Admin.MinPasswordLength, a.deps.Config.Admin.MaxPasswordLength
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
username := a.currentUser(r)
|
||||
if a.deps.Users.Authenticate(username, r.PostFormValue("current_password")) == nil {
|
||||
a.renderSettings(w, r, a.tr(r, "Current password is incorrect."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
newPassword := r.PostFormValue("new_password")
|
||||
if strings.TrimSpace(newPassword) == "" {
|
||||
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
minLen, maxLen := a.passwordPolicy()
|
||||
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
|
||||
msg := a.tr(r, key)
|
||||
if n > 0 {
|
||||
msg = i18n.Admin.N(a.langFor(r), key, n)
|
||||
}
|
||||
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Users.UpdatePassword(username, newPassword); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Every other session dies with the changed fingerprint; this one is
|
||||
// re-bound, so the device the change was made on stays signed in.
|
||||
if updated := a.deps.Users.Find(username); updated != nil {
|
||||
session.FromContext(r.Context()).Set("pv", sessionFingerprint(updated.PasswordHash))
|
||||
}
|
||||
a.record(r, "user.password_changed", username, nil)
|
||||
a.renderSettings(w, r, "", a.tr(r, "Password updated."), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsUsername(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
current := a.currentUser(r)
|
||||
name := strings.TrimSpace(r.PostFormValue("username"))
|
||||
sess := session.FromContext(r.Context())
|
||||
switch {
|
||||
case name == "":
|
||||
a.renderSettings(w, r, a.tr(r, "Username cannot be empty."), "", http.StatusUnprocessableEntity)
|
||||
case !usernameRe.MatchString(name):
|
||||
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
|
||||
case name == current:
|
||||
a.renderSettings(w, r, "", a.tr(r, "Username unchanged."), http.StatusOK)
|
||||
default:
|
||||
if _, err := a.deps.Users.Rename(current, name); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The username could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
sess.Set("user", name)
|
||||
a.record(r, "user.renamed", name, nil)
|
||||
a.renderSettings(w, r, "", a.tr(r, "Username updated."), http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsName(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.PostFormValue("name"))
|
||||
if _, err := a.deps.Users.UpdateName(a.currentUser(r), name); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The display name could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
notice := a.tr(r, "Display name updated.")
|
||||
if name == "" {
|
||||
notice = a.tr(r, "Display name cleared.")
|
||||
}
|
||||
a.renderSettings(w, r, "", notice, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsFediverse(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
value := strings.TrimSpace(r.PostFormValue("fediverse_creator"))
|
||||
if value == "" {
|
||||
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), ""); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle cleared."), http.StatusOK)
|
||||
return
|
||||
}
|
||||
if !fediverse.Valid(value) {
|
||||
a.renderSettings(w, r, a.tr(r, "Fediverse handle must look like @user@host."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), value); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle updated."), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsOrcid(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
value := identifiers.NormalizeORCID(r.PostFormValue("orcid"))
|
||||
if value == "" {
|
||||
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), ""); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.renderSettings(w, r, "", a.tr(r, "ORCID cleared."), http.StatusOK)
|
||||
return
|
||||
}
|
||||
if !identifiers.ValidORCID(value) {
|
||||
a.renderSettings(w, r, a.tr(r, "ORCID must look like 0000-0002-1825-0097."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), value); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.renderSettings(w, r, "", a.tr(r, "ORCID updated."), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsPhoto(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
file, header, err := r.FormFile("photo")
|
||||
if err != nil {
|
||||
a.renderSettings(w, r, a.tr(r, "No file selected."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
|
||||
if err != nil {
|
||||
a.renderSettings(w, r, a.tr(r, "File is too large."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if validateImageData(raw) != "" {
|
||||
a.renderSettings(w, r,
|
||||
a.tr(r, "Only WebP, AVIF and SVG images are supported."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
username := a.currentUser(r)
|
||||
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
|
||||
if err != nil {
|
||||
a.renderSettings(w, r, a.tr(r, "The photo could not be stored."), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
previous := ""
|
||||
if record := a.deps.Users.Find(username); record != nil {
|
||||
previous = record.Photo
|
||||
}
|
||||
if _, err := a.deps.Users.UpdatePhoto(username, url); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The profile photo could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if previous != "" && previous != url {
|
||||
a.deleteUnreferencedMedia(previous)
|
||||
}
|
||||
a.renderSettings(w, r, "", a.tr(r, "Profile photo updated."), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsPhotoRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
username := a.currentUser(r)
|
||||
previous := ""
|
||||
if record := a.deps.Users.Find(username); record != nil {
|
||||
previous = record.Photo
|
||||
}
|
||||
if _, err := a.deps.Users.UpdatePhoto(username, ""); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The profile photo could not be removed: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if previous != "" {
|
||||
a.deleteUnreferencedMedia(previous)
|
||||
}
|
||||
a.renderSettings(w, r, "", a.tr(r, "Profile photo removed."), http.StatusOK)
|
||||
}
|
||||
|
||||
// deleteUnreferencedMedia removes a photo file no user references any
|
||||
// more.
|
||||
func (a *Admin) deleteUnreferencedMedia(url string) {
|
||||
if !strings.HasPrefix(url, "/media/") {
|
||||
return
|
||||
}
|
||||
for _, user := range a.deps.Users.All() {
|
||||
if user.Photo == url {
|
||||
return
|
||||
}
|
||||
}
|
||||
a.deps.Store.DeleteMedia(url)
|
||||
}
|
||||
|
||||
// --- users panel ------------------------------------------------------------
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/interpres/v2"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/templates"
|
||||
)
|
||||
|
||||
// templateFields are the editor inputs a template may pre-fill beyond
|
||||
// its title, slug, tags and body; anything else in the fields box is a
|
||||
// typo waiting to seed every new post with a key nobody reads.
|
||||
var templateFields = map[string]bool{
|
||||
"lang": true, "author": true, "fediverse_creator": true,
|
||||
"doi": true, "orcid": true,
|
||||
"series": true, "series_order": true,
|
||||
"excerpt": true, "cover": true, "cover_alt": true, "cover_caption": true,
|
||||
}
|
||||
|
||||
// parseTemplateFields reads the fields box: key = value lines in TOML,
|
||||
// each key an editor field. unknown names the first key outside the
|
||||
// allowed set; err reports text that is not a small TOML document.
|
||||
func parseTemplateFields(text string) (fields map[string]string, unknown string, err error) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
data, err := interpres.ParseMap([]byte(text))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("template fields must be key = value lines")
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, key := range slices.Sorted(maps.Keys(data)) {
|
||||
if !templateFields[key] {
|
||||
return nil, key, nil
|
||||
}
|
||||
value := data[key]
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if text, isString := value.(string); isString {
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = text
|
||||
continue
|
||||
}
|
||||
if number, isInt := value.(int64); isInt {
|
||||
out[key] = strconv.FormatInt(number, 10)
|
||||
continue
|
||||
}
|
||||
out[key] = fmt.Sprintf("%v", value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, "", nil
|
||||
}
|
||||
return out, "", nil
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsTemplateCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.PostFormValue("name"))
|
||||
if name == "" {
|
||||
a.renderSettings(w, r, a.tr(r, "Template name is required."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
fields, unknown, err := parseTemplateFields(r.PostFormValue("fields"))
|
||||
switch {
|
||||
case err != nil:
|
||||
a.renderSettings(w, r, a.tr(r, "Template fields must be key = value TOML lines."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
case unknown != "":
|
||||
a.renderSettings(w, r, a.trf(r, "Unknown template field %s.", unknown), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Templates.Add(templates.PostTemplate{
|
||||
Name: name,
|
||||
Tags: payloads.ParseTags(r.PostFormValue("tags")),
|
||||
Body: r.PostFormValue("body"),
|
||||
Title: strings.TrimSpace(r.PostFormValue("title")),
|
||||
Slug: strings.TrimSpace(r.PostFormValue("slug")),
|
||||
Fields: fields,
|
||||
}); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "That template could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
a.renderSettings(w, r, "", a.tr(r, "Template added."), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsTemplateDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := a.deps.Templates.Delete(r.PathValue("name")); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The template could not be deleted: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
a.renderSettings(w, r, "", a.tr(r, "Template deleted."), http.StatusOK)
|
||||
}
|
||||
|
||||
// --- backup export / import -------------------------------------------------
|
||||
|
||||
// handleSettingsWebhookTest delivers a ping inline and reports the
|
||||
// outcome from the delivery it produced, not from the shared history a
|
||||
// concurrent delivery could reshuffle.
|
||||
func (a *Admin) handleSettingsWebhookTest(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if a.deps.Webhooks == nil {
|
||||
a.renderSettings(w, r, a.tr(r, "No webhooks configured."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
// The list is taken once: a settings change that reshuffles it between
|
||||
// the bounds check and the fetch would test a different hook than the
|
||||
// one the form named.
|
||||
hooks := a.deps.Webhooks.Hooks()
|
||||
index, err := strconv.Atoi(r.PathValue("index"))
|
||||
if err != nil || index < 0 || index >= len(hooks) {
|
||||
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
hook := hooks[index]
|
||||
delivery := a.deps.Webhooks.TestHook(hook)
|
||||
a.record(r, "webhook.tested", hook.URL, nil)
|
||||
notice := a.tr(r, "Test delivery failed.")
|
||||
if delivery.Status == "ok" {
|
||||
notice = a.tr(r, "Test delivery sent.")
|
||||
}
|
||||
a.renderSettings(w, r, "", notice, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsTokenCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
created, raw, err := a.deps.Tokens.Create(r.PostFormValue("name"), r.PostForm["scope"])
|
||||
if err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The token could not be created: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
a.record(r, "token.created", created.Name, nil)
|
||||
data := a.settingsData(r)
|
||||
data.NewToken = raw
|
||||
a.renderPage(w, r, "settings.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsTokenDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
notice := a.tr(r, "Token revoked.")
|
||||
if !a.deps.Tokens.Revoke(name) {
|
||||
notice = a.tr(r, "That token was not found.")
|
||||
} else {
|
||||
a.record(r, "token.revoked", name, nil)
|
||||
}
|
||||
a.renderSettings(w, r, "", notice, http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/backup"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
)
|
||||
|
||||
func (a *Admin) handleSettingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="volumen-backup.tar.gz"`)
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
// The archive streams straight to the client: the app routes it past
|
||||
// the buffering wrappers, so no copy of it waits in memory. An error
|
||||
// before the first byte still answers as a plain 500; after it, the
|
||||
// download ends truncated and the gzip footer makes that visible.
|
||||
sent := false
|
||||
if err := backup.Write(writeTracker{w, &sent}, a.deps.Backup); err != nil {
|
||||
slog.Error("admin: backup export failed", "error", err)
|
||||
if !sent {
|
||||
w.Header().Del("Content-Type")
|
||||
w.Header().Del("Content-Disposition")
|
||||
http.Error(w, "The backup could not be written: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeTracker records whether anything reached the client, so a failure
|
||||
// can still choose between a clean error page and a logged truncation.
|
||||
type writeTracker struct {
|
||||
w http.ResponseWriter
|
||||
sent *bool
|
||||
}
|
||||
|
||||
func (t writeTracker) Write(p []byte) (int, error) {
|
||||
*t.sent = true
|
||||
return t.w.Write(p)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsImport(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("backup")
|
||||
if err != nil {
|
||||
a.renderSettings(w, r, a.tr(r, "No backup file selected."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
written, err := backup.Restore(file, a.deps.Backup)
|
||||
if written > 0 {
|
||||
// A partial restore changed files on disk; the caches must drop
|
||||
// even when a later entry failed, or the admin keeps serving the
|
||||
// pre-import state until an unrelated write invalidates them.
|
||||
a.deps.Store.InvalidateCache()
|
||||
a.deps.Users.Invalidate()
|
||||
a.deps.Templates.Invalidate()
|
||||
a.deps.Tokens.Invalidate()
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("admin: backup import failed", "error", err)
|
||||
a.renderSettings(w, r, a.trf(r, "Could not restore backup: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if written == 0 {
|
||||
a.renderSettings(w, r, a.tr(r, "The archive holds no files this deployment recognises."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
a.record(r, "backup.imported", fmt.Sprintf("%d files", written), nil)
|
||||
a.renderSettings(w, r, "", i18n.Admin.N(a.langFor(r), "backup.files", written), http.StatusOK)
|
||||
}
|
||||
|
||||
// --- updates ----------------------------------------------------------------
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
)
|
||||
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
|
||||
|
||||
func (a *Admin) registerSettingsRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /admin/settings", a.requireLogin(a.handleSettings))
|
||||
mux.HandleFunc("POST /admin/settings/password", a.requireLogin(a.handleSettingsPassword))
|
||||
mux.HandleFunc("POST /admin/settings/username", a.requireLogin(a.handleSettingsUsername))
|
||||
mux.HandleFunc("POST /admin/settings/name", a.requireLogin(a.handleSettingsName))
|
||||
mux.HandleFunc("POST /admin/settings/language", a.requireLogin(a.handleSettingsLanguage))
|
||||
mux.HandleFunc("POST /admin/settings/theme", a.requireLogin(a.handleSettingsTheme))
|
||||
mux.HandleFunc("POST /admin/settings/fediverse", a.requireLogin(a.handleSettingsFediverse))
|
||||
mux.HandleFunc("POST /admin/settings/orcid", a.requireLogin(a.handleSettingsOrcid))
|
||||
mux.HandleFunc("POST /admin/settings/photo", a.requireLogin(a.handleSettingsPhoto))
|
||||
mux.HandleFunc("POST /admin/settings/photo/remove", a.requireLogin(a.handleSettingsPhotoRemove))
|
||||
mux.HandleFunc("POST /admin/settings/users", a.requireAdmin(a.handleSettingsUserCreate))
|
||||
mux.HandleFunc("POST /admin/settings/users/{name}/role", a.requireAdmin(a.handleSettingsUserRole))
|
||||
mux.HandleFunc("POST /admin/settings/users/{name}/password", a.requireAdmin(a.handleSettingsUserPassword))
|
||||
mux.HandleFunc("POST /admin/settings/users/{name}/delete", a.requireAdmin(a.handleSettingsUserDelete))
|
||||
mux.HandleFunc("POST /admin/settings/templates", a.requireAdmin(a.handleSettingsTemplateCreate))
|
||||
mux.HandleFunc("POST /admin/settings/templates/{name}/delete", a.requireAdmin(a.handleSettingsTemplateDelete))
|
||||
mux.HandleFunc("GET /admin/settings/export", a.requireAdmin(a.handleSettingsExport))
|
||||
mux.HandleFunc("POST /admin/settings/import", a.requireAdmin(a.handleSettingsImport))
|
||||
mux.HandleFunc("POST /admin/settings/check-update", a.requireAdmin(a.handleSettingsCheckUpdate))
|
||||
mux.HandleFunc("POST /admin/settings/update", a.requireAdmin(a.handleSettingsUpdate))
|
||||
mux.HandleFunc("POST /admin/settings/webhooks", a.requireAdmin(a.handleSettingsWebhookAdd))
|
||||
mux.HandleFunc("POST /admin/settings/webhooks/toggle", a.requireAdmin(a.handleSettingsWebhookToggle))
|
||||
mux.HandleFunc("POST /admin/settings/webhooks/delete", a.requireAdmin(a.handleSettingsWebhookDelete))
|
||||
mux.HandleFunc("POST /admin/settings/webhooks/{index}/test", a.requireAdmin(a.handleSettingsWebhookTest))
|
||||
mux.HandleFunc("POST /admin/settings/tokens", a.requireAdmin(a.handleSettingsTokenCreate))
|
||||
mux.HandleFunc("POST /admin/settings/tokens/{name}/delete", a.requireAdmin(a.handleSettingsTokenDelete))
|
||||
mux.HandleFunc("POST /admin/settings/twofactor/start", a.requireLogin(a.handleTotpStart))
|
||||
mux.HandleFunc("POST /admin/settings/twofactor/cancel", a.requireLogin(a.handleTotpCancel))
|
||||
mux.HandleFunc("POST /admin/settings/twofactor/verify", a.requireLogin(a.handleTotpVerify))
|
||||
mux.HandleFunc("POST /admin/settings/twofactor/disable", a.requireLogin(a.handleTotpDisable))
|
||||
mux.HandleFunc("POST /admin/settings/twofactor/codes", a.requireLogin(a.handleTotpCodes))
|
||||
}
|
||||
|
||||
// requireAdmin additionally enforces the admin role.
|
||||
func (a *Admin) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return a.requireLogin(func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.FromContext(r.Context())
|
||||
record := a.deps.Users.Find(sess.Get("user"))
|
||||
if record == nil || record.Role != "admin" {
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// settingsData builds the settings page context: the account record,
|
||||
// the user list, the post templates, the webhook rows and the API
|
||||
// tokens.
|
||||
func (a *Admin) settingsData(r *http.Request) *PageData {
|
||||
data := a.pageData(r)
|
||||
data.IsAdmin = data.CurrentRole == "admin"
|
||||
data.Roles = users.Roles
|
||||
data.DefaultRole = users.DefaultRole
|
||||
data.UserRows = userRows(data.CurrentUser, a.deps.Users.All())
|
||||
data.TemplatesList = tplOptions(a.deps.Templates.All())
|
||||
if a.deps.Webhooks != nil {
|
||||
// The manager delivers the config-declared hooks first, the
|
||||
// admin-managed ones after it, so the row's position tells where
|
||||
// it came from and which forms apply to it.
|
||||
data.WebhookRows = hookRows(a.deps.Webhooks.Hooks(), len(a.deps.StaticWebhooks))
|
||||
deliveries := a.deps.Webhooks.Deliveries("")
|
||||
data.WebhookDeliveries = deliveryRows(deliveries)
|
||||
for i, d := range deliveries {
|
||||
if d.Status != "ok" {
|
||||
data.WebhookDeliveries[i].Result = i18n.Admin.N(data.Lang, "deliveries.attempts", d.Attempts)
|
||||
}
|
||||
}
|
||||
}
|
||||
data.TokenRows = tokenRows(a.deps.Tokens.All())
|
||||
a.fillTotpState(data, r)
|
||||
data.Crumbs = []Crumb{{Label: "Settings", IsLast: true, UI: true}}
|
||||
return data
|
||||
}
|
||||
|
||||
// handleSettingsLanguage switches the signed-in account's interface
|
||||
// language. The choice persists on the user record for every request
|
||||
// and in a cookie, so the login screen follows it too; the confirmation
|
||||
// renders in the language just picked.
|
||||
func (a *Admin) handleSettingsLanguage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
lang := r.PostFormValue("language")
|
||||
if !i18n.Valid(lang) {
|
||||
a.renderSettings(w, r, i18n.Admin.T("en", "Unsupported language."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
username := sess.Get("user")
|
||||
if _, err := a.deps.Users.UpdateLanguage(username, lang); err != nil {
|
||||
a.renderSettings(w, r, i18n.Admin.Tf("en", "The language could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: i18n.Cookie,
|
||||
Value: lang,
|
||||
Path: "/admin",
|
||||
MaxAge: 365 * 24 * 3600,
|
||||
HttpOnly: true,
|
||||
Secure: a.cookieSecure(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
data := a.settingsData(r)
|
||||
data.Lang = lang
|
||||
data.Notice = i18n.Admin.T(lang, "The interface language is set.")
|
||||
a.renderPage(w, r, "settings.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleSettingsTheme switches the signed-in account's colour scheme.
|
||||
// The choice persists on the user record for every request and in a
|
||||
// cookie, so the login screen follows it too.
|
||||
func (a *Admin) handleSettingsTheme(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
theme := r.PostFormValue("theme")
|
||||
if !web.ValidTheme(theme) {
|
||||
a.renderSettings(w, r, i18n.Admin.T("en", "Unsupported colour scheme."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
username := sess.Get("user")
|
||||
if _, err := a.deps.Users.UpdateTheme(username, theme); err != nil {
|
||||
a.renderSettings(w, r, i18n.Admin.Tf("en", "The colour scheme could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: web.ThemeCookie,
|
||||
Value: theme,
|
||||
Path: "/admin",
|
||||
MaxAge: 365 * 24 * 3600,
|
||||
HttpOnly: true,
|
||||
Secure: a.cookieSecure(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
data := a.settingsData(r)
|
||||
data.Theme = theme
|
||||
data.Notice = i18n.Admin.T(data.Lang, "The colour scheme is set.")
|
||||
a.renderPage(w, r, "settings.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
// cookieSecure reports whether the deployment serves over HTTPS or
|
||||
// behind a trusted proxy, the condition the session cookie and the
|
||||
// preference cookies take their Secure flag from.
|
||||
func (a *Admin) cookieSecure() bool {
|
||||
return a.deps.Config.Server.CookieSecure || a.deps.Config.Server.TrustProxy
|
||||
}
|
||||
|
||||
// renderSettings renders the settings page with a flash message.
|
||||
func (a *Admin) renderSettings(w http.ResponseWriter, r *http.Request, errorMsg, notice string, status int) {
|
||||
data := a.settingsData(r)
|
||||
data.Error = errorMsg
|
||||
data.Notice = notice
|
||||
a.renderPage(w, r, "settings.html", data, status)
|
||||
}
|
||||
|
||||
// handleSettings renders the settings page.
|
||||
func (a *Admin) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a.renderSettings(w, r, "", "", http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"maps"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/config"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
|
||||
)
|
||||
|
||||
func settingsForm(t *testing.T, f *fixture, path string, extra url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
form := url.Values{"_csrf": {csrf}}
|
||||
maps.Copy(form, extra)
|
||||
return postForm(t, f, path, form, cookie)
|
||||
}
|
||||
|
||||
func TestSettingsPageRenders(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{
|
||||
"Settings", "Account", "Users", "Templates", "Backup",
|
||||
"API tokens", "Webhooks", "admin",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
// Every field that sets a password carries the strength meter: the
|
||||
// own-account change and the add-user form are on the page itself,
|
||||
// the per-user reset form rides each non-self user row. The floor
|
||||
// attribute rides the same inputs and nowhere else on the page.
|
||||
meters := strings.Count(body, `data-min-length=`)
|
||||
wantMeters := 2
|
||||
for _, row := range f.admin.deps.Users.All() {
|
||||
if row.Username != "admin" {
|
||||
wantMeters++
|
||||
}
|
||||
}
|
||||
if meters != wantMeters {
|
||||
t.Fatalf("password meters = %d, want %d", meters, wantMeters)
|
||||
}
|
||||
if !strings.Contains(body, `class="pw-level__bar"`) {
|
||||
t.Fatal("meter bar markup missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordChange(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// Wrong current password.
|
||||
rec := settingsForm(t, f, "/admin/settings/password", url.Values{
|
||||
"current_password": {"nope"},
|
||||
"new_password": {"another-good-pass"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "Current password is incorrect.") {
|
||||
t.Fatal("wrong-password message missing")
|
||||
}
|
||||
|
||||
// Weak new password.
|
||||
rec = settingsForm(t, f, "/admin/settings/password", url.Values{
|
||||
"current_password": {"correct-horse-9"},
|
||||
"new_password": {"short"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "at least") {
|
||||
t.Fatal("policy message missing")
|
||||
}
|
||||
|
||||
// Success.
|
||||
rec = settingsForm(t, f, "/admin/settings/password", url.Values{
|
||||
"current_password": {"correct-horse-9"},
|
||||
"new_password": {"a-brand-new-passphrase"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "Password updated.") {
|
||||
t.Fatal("success message missing")
|
||||
}
|
||||
if f.users.Authenticate("admin", "a-brand-new-passphrase") == nil {
|
||||
t.Fatal("new password does not authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameChangeUpdatesSession(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/settings/username",
|
||||
url.Values{"_csrf": {csrf}, "username": {"bad name!"}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "letters, numbers") {
|
||||
t.Fatal("format message missing")
|
||||
}
|
||||
|
||||
rec = postForm(t, f, "/admin/settings/username",
|
||||
url.Values{"_csrf": {csrf}, "username": {"petr"}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "Username updated.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("petr") == nil {
|
||||
t.Fatal("rename not applied")
|
||||
}
|
||||
// The session cookie was re-signed with the new username.
|
||||
newCookie := sessionCookie(t, rec)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(newCookie)
|
||||
rec = f.do(t, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `value="petr"`) {
|
||||
t.Fatalf("session lost after rename: code=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeChange(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"plasma"}})
|
||||
if !strings.Contains(rec.Body.String(), "The colour scheme is set.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("admin").Theme != "plasma" {
|
||||
t.Fatal("theme not stored on the account")
|
||||
}
|
||||
found := false
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == web.ThemeCookie && c.Value == "plasma" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("theme cookie missing")
|
||||
}
|
||||
// The picker re-renders with the choice marked pressed.
|
||||
if !strings.Contains(rec.Body.String(), `value="plasma" class="chip" aria-pressed="true"`) {
|
||||
t.Fatal("picked scheme not marked active")
|
||||
}
|
||||
|
||||
// An unknown scheme is refused and does not overwrite the choice.
|
||||
rec = settingsForm(t, f, "/admin/settings/theme", url.Values{"theme": {"sepia"}})
|
||||
if !strings.Contains(rec.Body.String(), "Unsupported colour scheme.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("admin").Theme != "plasma" {
|
||||
t.Fatal("invalid scheme overwrote the stored choice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameAndFediverseChange(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := settingsForm(t, f, "/admin/settings/name", url.Values{"name": {"Petr Balvín"}})
|
||||
if !strings.Contains(rec.Body.String(), "Display name updated.") {
|
||||
t.Fatal("name message missing")
|
||||
}
|
||||
|
||||
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"nope"}})
|
||||
if !strings.Contains(rec.Body.String(), "@user@host") {
|
||||
t.Fatal("fediverse validation missing")
|
||||
}
|
||||
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {"@petr@social"}})
|
||||
if !strings.Contains(rec.Body.String(), "Fediverse handle updated.") {
|
||||
t.Fatal("fediverse message missing")
|
||||
}
|
||||
rec = settingsForm(t, f, "/admin/settings/fediverse", url.Values{"fediverse_creator": {""}})
|
||||
if !strings.Contains(rec.Body.String(), "Fediverse handle cleared.") {
|
||||
t.Fatal("fediverse clear missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrcidChange(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// A malformed iD is refused and nothing is stored.
|
||||
rec := settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0098"}})
|
||||
if !strings.Contains(rec.Body.String(), "ORCID must look like") {
|
||||
t.Fatalf("orcid validation missing: %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("admin").Orcid != "" {
|
||||
t.Fatal("invalid orcid was stored")
|
||||
}
|
||||
// A valid iD is kept, normalised to upper case.
|
||||
rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {"0000-0002-1825-0097"}})
|
||||
if !strings.Contains(rec.Body.String(), "ORCID updated.") {
|
||||
t.Fatal("orcid message missing")
|
||||
}
|
||||
if got := f.users.Find("admin").Orcid; got != "0000-0002-1825-0097" {
|
||||
t.Fatalf("stored orcid = %q", got)
|
||||
}
|
||||
// An empty value clears it.
|
||||
rec = settingsForm(t, f, "/admin/settings/orcid", url.Values{"orcid": {""}})
|
||||
if !strings.Contains(rec.Body.String(), "ORCID cleared.") {
|
||||
t.Fatal("orcid clear missing")
|
||||
}
|
||||
if got := f.users.Find("admin").Orcid; got != "" {
|
||||
t.Fatalf("orcid not cleared: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A password an admin sets keeps its edge spaces: only the emptiness
|
||||
// check may trim, the stored value must not, or the trimmed form would
|
||||
// work where the typed one does not.
|
||||
func TestUserCreateKeepsPasswordSpaces(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
|
||||
rec := settingsForm(t, f, "/admin/settings/users", url.Values{
|
||||
"username": {"joe"}, "password": {" padded-passphrase "}, "role": {"author"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "User added.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Authenticate("joe", " padded-passphrase ") == nil {
|
||||
t.Fatal("the exact password, spaces included, must authenticate")
|
||||
}
|
||||
if f.users.Authenticate("joe", "padded-passphrase") != nil {
|
||||
t.Fatal("the trimmed password must not authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserManagement(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
|
||||
// Create a second user.
|
||||
rec := settingsForm(t, f, "/admin/settings/users", url.Values{
|
||||
"username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "User added.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("joe") == nil {
|
||||
t.Fatal("user not created")
|
||||
}
|
||||
// Duplicate rejected.
|
||||
rec = settingsForm(t, f, "/admin/settings/users", url.Values{
|
||||
"username": {"joe"}, "password": {"joes-good-passphrase"}, "role": {"author"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "could not be added") {
|
||||
t.Fatal("duplicate message missing")
|
||||
}
|
||||
|
||||
// Role change.
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/settings/users/joe/role",
|
||||
url.Values{"_csrf": {csrf}, "role": {"admin"}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "Role updated.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("joe").Role != "admin" {
|
||||
t.Fatal("role not applied")
|
||||
}
|
||||
|
||||
// Own role cannot change.
|
||||
rec = postForm(t, f, "/admin/settings/users/admin/role",
|
||||
url.Values{"_csrf": {csrf}, "role": {"author"}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "own role") {
|
||||
t.Fatal("self role-change not blocked")
|
||||
}
|
||||
|
||||
// Delete.
|
||||
rec = postForm(t, f, "/admin/settings/users/joe/delete", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "User removed.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("joe") != nil {
|
||||
t.Fatal("user not deleted")
|
||||
}
|
||||
|
||||
// Own account cannot be deleted.
|
||||
rec = postForm(t, f, "/admin/settings/users/admin/delete", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "own account") {
|
||||
t.Fatal("self delete not blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserManagementRequiresAdmin(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.users.Add("joe", "joes-good-passphrase", "author")
|
||||
cookie := login(t, f, "joe", "joes-good-passphrase")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/settings/users",
|
||||
strings.NewReader(url.Values{
|
||||
"_csrf": {csrf}, "username": {"x"}, "password": {"good-enough-pass"},
|
||||
}.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("code = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateCRUD(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := settingsForm(t, f, "/admin/settings/templates", url.Values{
|
||||
"name": {"Review"}, "tags": {"review, opinion"}, "body": {"## Summary"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "Template added.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if len(f.admin.deps.Templates.All()) != 1 {
|
||||
t.Fatal("template not stored")
|
||||
}
|
||||
|
||||
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{"name": {"Review"}})
|
||||
if !strings.Contains(rec.Body.String(), "could not be added") {
|
||||
t.Fatal("duplicate message missing")
|
||||
}
|
||||
|
||||
// A fields box pre-fills the scientific editor inputs; the values are
|
||||
// TOML, so strings are quoted and a bare number arrives as text.
|
||||
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
|
||||
"name": {"Paper"}, "fields": {"series = \"tds\"\ndoi = \"10.5281/zenodo.1\"\nseries_order = 3\n"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "Template added.") {
|
||||
t.Fatalf("fields template rejected: %s", rec.Body.String())
|
||||
}
|
||||
var paperFields map[string]string
|
||||
for _, tpl := range f.admin.deps.Templates.All() {
|
||||
if tpl.Name == "Paper" {
|
||||
paperFields = tpl.Fields
|
||||
}
|
||||
}
|
||||
if paperFields["series"] != "tds" || paperFields["doi"] != "10.5281/zenodo.1" ||
|
||||
paperFields["series_order"] != "3" {
|
||||
t.Fatalf("stored fields = %v", paperFields)
|
||||
}
|
||||
// A key outside the editor's inputs is refused, so a typo cannot
|
||||
// silently seed every new post with a dead key.
|
||||
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
|
||||
"name": {"Nope"}, "fields": {"journal = \"Nature\"\n"},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "Unknown template field") {
|
||||
t.Fatal("unknown field accepted")
|
||||
}
|
||||
rec = settingsForm(t, f, "/admin/settings/templates", url.Values{
|
||||
"name": {"Broken"}, "fields": {"series == tds\n\n("},
|
||||
})
|
||||
if !strings.Contains(rec.Body.String(), "must be key = value") {
|
||||
t.Fatal("unparsable fields accepted")
|
||||
}
|
||||
|
||||
// The new-post form embeds the templates with the lowercase keys its
|
||||
// picker reads, fields included.
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
newReq := httptest.NewRequest(http.MethodGet, "/admin/posts/new", nil)
|
||||
newReq.AddCookie(cookie)
|
||||
rec = f.do(t, newReq)
|
||||
if !strings.Contains(rec.Body.String(), `"fields":{`) ||
|
||||
!strings.Contains(rec.Body.String(), `"series":"tds"`) {
|
||||
t.Fatal("template fields missing from the editor payload")
|
||||
}
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/settings/templates/Review/delete",
|
||||
url.Values{"_csrf": {csrf}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "Template deleted.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCRUD(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
rec := postForm(t, f, "/admin/settings/tokens",
|
||||
url.Values{"_csrf": {csrf}, "name": {"ci"}}, cookie)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Copy this token now") || !strings.Contains(body, "vol_") {
|
||||
t.Fatalf("new token not shown: %s", body)
|
||||
}
|
||||
|
||||
rec = postForm(t, f, "/admin/settings/tokens/ci/delete", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "Token revoked.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if len(f.admin.deps.Tokens.All()) != 0 {
|
||||
t.Fatal("token not revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupExportImport(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "keep.md", "+++\nslug = \"keep\"\ntitle = \"Keep\"\n+++\nbody\n")
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings/export", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/gzip" {
|
||||
t.Fatalf("code=%d type=%q", rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
archive := rec.Body.Bytes()
|
||||
if len(archive) == 0 {
|
||||
t.Fatal("empty archive")
|
||||
}
|
||||
|
||||
// Wipe the content dir, then restore.
|
||||
if err := os.Remove(filepath.Join(f.contentDir, "keep.md")); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
rec = multipartBytes(t, f, "/admin/settings/import", cookie, csrf, "backup", archive)
|
||||
if !strings.Contains(rec.Body.String(), "Backup restored") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.storeObj.Find("keep", "") == nil {
|
||||
t.Fatal("post not restored from backup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckUpdateWithoutWiring(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := settingsForm(t, f, "/admin/settings/check-update", nil)
|
||||
if !strings.Contains(rec.Body.String(), "not available in this build") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaLibraryAndDelete(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
|
||||
webpData = append(webpData, []byte("WEBPVP8 ")...)
|
||||
uploaded, err := f.storeObj.StoreUpload("pic.webp", webpData)
|
||||
if err != nil {
|
||||
t.Fatalf("upload: %v", err)
|
||||
}
|
||||
name := strings.TrimPrefix(uploaded, "/media/")
|
||||
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/media", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), name) {
|
||||
t.Fatalf("code=%d", rec.Code)
|
||||
}
|
||||
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/media/"+name+"/delete", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/media" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if _, err := f.storeObj.MediaPath(name); err == nil {
|
||||
t.Fatal("media not deleted")
|
||||
}
|
||||
|
||||
rec = postForm(t, f, "/admin/media/ghost.webp/delete", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// multipartBytes posts a binary file field.
|
||||
func multipartBytes(t *testing.T, f *fixture, path string, cookie *http.Cookie, csrf, field string, data []byte) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
body, contentType := buildMultipart(t, csrf, field, "backup.tar.gz", data)
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.AddCookie(cookie)
|
||||
return f.do(t, req)
|
||||
}
|
||||
|
||||
// buildMultipart renders a single-file multipart body.
|
||||
func buildMultipart(t *testing.T, csrf, field, filename string, data []byte) (string, string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
_ = mw.WriteField("_csrf", csrf)
|
||||
part, err := mw.CreateFormFile(field, filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create form file: %v", err)
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
t.Fatalf("write part: %v", err)
|
||||
}
|
||||
_ = mw.Close()
|
||||
return buf.String(), mw.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestPhotoUploadAndRemove(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
webpData := append([]byte("RIFF"), 0, 0, 0, 0)
|
||||
webpData = append(webpData, []byte("WEBPVP8 ")...)
|
||||
body, contentType := buildMultipart(t, csrf, "photo", "me.webp", webpData)
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/settings/photo", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if !strings.Contains(rec.Body.String(), "Profile photo updated.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("admin").Photo == "" {
|
||||
t.Fatal("photo not stored on the user")
|
||||
}
|
||||
|
||||
rec = postForm(t, f, "/admin/settings/photo/remove", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if !strings.Contains(rec.Body.String(), "Profile photo removed.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if f.users.Find("admin").Photo != "" {
|
||||
t.Fatal("photo not cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTestDelivery(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
f := newFixture(t)
|
||||
f.admin.deps.Config.Webhooks = []config.Webhook{{URL: srv.URL}}
|
||||
f.admin.deps.Webhooks = webhooks.NewManager(
|
||||
[]webhooks.Webhook{{URL: srv.URL, Enabled: true}}, "0.0.0-test")
|
||||
|
||||
rec := settingsForm(t, f, "/admin/settings/webhooks/0/test", nil)
|
||||
if !strings.Contains(rec.Body.String(), "Test delivery sent.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
if atomic.LoadInt32(&hits) != 1 {
|
||||
t.Fatalf("hits = %d", hits)
|
||||
}
|
||||
|
||||
rec = settingsForm(t, f, "/admin/settings/webhooks/9/test", nil)
|
||||
if !strings.Contains(rec.Body.String(), "Webhook not found.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHooksFlow(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.admin.SetUpdateHooks(func() (string, error) { return "9.9.9", nil },
|
||||
func() (string, error) { return "9.9.9", nil })
|
||||
|
||||
// Banner appears on the dashboard.
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if !strings.Contains(rec.Body.String(), "is available") {
|
||||
t.Fatal("update banner missing")
|
||||
}
|
||||
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/settings/update", url.Values{"_csrf": {csrf}}, cookie)
|
||||
// The version is printed as the toolchain recorded it, prefix included.
|
||||
if !strings.Contains(rec.Body.String(), "9.9.9") {
|
||||
t.Fatalf("update page body = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
f.admin.SetUpdateHooks(func() (string, error) { return "", nil }, nil)
|
||||
rec = settingsForm(t, f, "/admin/settings/check-update", nil)
|
||||
if !strings.Contains(rec.Body.String(), "already the latest release") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCreateWithScopes(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
form := url.Values{"_csrf": {csrf}, "name": {"scoped"}, "scope": {"write", "delete"}}
|
||||
rec := postForm(t, f, "/admin/settings/tokens", form, cookie)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "vol_") {
|
||||
t.Fatalf("code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
list := f.admin.deps.Tokens.All()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("tokens = %v", list)
|
||||
}
|
||||
if len(list[0].Scopes) != 2 || !list[0].HasScope("write") || !list[0].HasScope("delete") {
|
||||
t.Fatalf("scopes = %v", list[0].Scopes)
|
||||
}
|
||||
if list[0].HasScope("read") {
|
||||
t.Fatal("the removed read scope was granted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorSaveKeepsUnknownMetadata(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
writeTestPost(t, f, "aliased.md", `+++
|
||||
title = "Aliased"
|
||||
slug = "aliased"
|
||||
aliases = ["old-slug"]
|
||||
custom_field = "keep me"
|
||||
|
||||
[translations]
|
||||
en = "aliased-en"
|
||||
+++
|
||||
|
||||
body
|
||||
`)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
rec := postForm(t, f, "/admin/posts/aliased", url.Values{
|
||||
"_csrf": {csrf}, "title": {"Aliased v2"}, "slug": {"aliased"},
|
||||
"lang": {"cs"}, "body": {"new body"},
|
||||
}, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
p := f.storeObj.Find("aliased", "")
|
||||
if p == nil {
|
||||
t.Fatal("post lost")
|
||||
}
|
||||
if got := p.Aliases(); len(got) != 1 || got[0] != "old-slug" {
|
||||
t.Fatalf("aliases lost: %v", got)
|
||||
}
|
||||
if got := p.Translations(); got["en"] != "aliased-en" {
|
||||
t.Fatalf("translations lost: %v", got)
|
||||
}
|
||||
if _, ok := p.Metadata.Get("custom_field"); !ok {
|
||||
t.Fatal("custom field lost")
|
||||
}
|
||||
if p.Title() != "Aliased v2" {
|
||||
t.Fatalf("title = %q", p.Title())
|
||||
}
|
||||
}
|
||||
|
||||
// A webhook added in Settings lands in webhooks.toml and reaches the
|
||||
// manager without a restart; a config-declared hook stays read-only.
|
||||
func TestSettingsWebhookLifecycle(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.admin.deps.WebhooksFile = filepath.Join(t.TempDir(), "webhooks.toml")
|
||||
f.admin.deps.Webhooks = webhooks.NewManager(nil, "t")
|
||||
f.admin.deps.StaticWebhooks = []webhooks.Webhook{{URL: "https://cfg.example/hook", Enabled: true}}
|
||||
f.admin.deps.Webhooks.SetHooks(f.admin.deps.StaticWebhooks)
|
||||
|
||||
// A config hook renders as read-only: no toggle form for it.
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if !strings.Contains(rec.Body.String(), "https://cfg.example/hook") ||
|
||||
strings.Contains(rec.Body.String(), "Remove this webhook?") {
|
||||
t.Fatalf("config hook row wrong: %d", rec.Code)
|
||||
}
|
||||
|
||||
// Add one.
|
||||
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
|
||||
"url": {"https://example.com/hook"},
|
||||
"secret": {"s3cret"},
|
||||
"events": {"post.created, post.updated"},
|
||||
"enabled": {"on"},
|
||||
})
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook added.") {
|
||||
t.Fatalf("add: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
stored, err := webhooks.LoadFile(f.admin.deps.WebhooksFile)
|
||||
if err != nil || len(stored) != 1 || stored[0].URL != "https://example.com/hook" ||
|
||||
stored[0].Secret != "s3cret" || !stored[0].Enabled || len(stored[0].Events) != 2 {
|
||||
t.Fatalf("stored = %+v err = %v", stored, err)
|
||||
}
|
||||
hooks := f.admin.deps.Webhooks.Hooks()
|
||||
if len(hooks) != 2 || hooks[0].URL != "https://cfg.example/hook" || hooks[1].URL != "https://example.com/hook" {
|
||||
t.Fatalf("manager = %+v", hooks)
|
||||
}
|
||||
|
||||
// A duplicate URL and a broken URL are refused.
|
||||
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
|
||||
"url": {"https://example.com/hook"}, "enabled": {"on"},
|
||||
})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "already configured") {
|
||||
t.Fatalf("duplicate: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = settingsForm(t, f, "/admin/settings/webhooks", url.Values{
|
||||
"url": {"ftp://example.com/hook"}, "enabled": {"on"},
|
||||
})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not a valid") {
|
||||
t.Fatalf("invalid url: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Toggle flips the stored flag and the manager's.
|
||||
rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{
|
||||
"url": {"https://example.com/hook"},
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("toggle: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile)
|
||||
if stored[0].Enabled {
|
||||
t.Fatal("toggle did not disable the hook")
|
||||
}
|
||||
if f.admin.deps.Webhooks.Hooks()[1].Enabled {
|
||||
t.Fatal("manager kept the hook enabled")
|
||||
}
|
||||
|
||||
// A config-declared URL is not toggleable.
|
||||
rec = settingsForm(t, f, "/admin/settings/webhooks/toggle", url.Values{
|
||||
"url": {"https://cfg.example/hook"},
|
||||
})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Webhook not found.") {
|
||||
t.Fatalf("config hook toggle: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Delete removes the hook from the file and the manager.
|
||||
rec = settingsForm(t, f, "/admin/settings/webhooks/delete", url.Values{
|
||||
"url": {"https://example.com/hook"},
|
||||
})
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Webhook removed.") {
|
||||
t.Fatalf("delete: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
stored, _ = webhooks.LoadFile(f.admin.deps.WebhooksFile)
|
||||
if len(stored) != 0 {
|
||||
t.Fatalf("store = %+v", stored)
|
||||
}
|
||||
if len(f.admin.deps.Webhooks.Hooks()) != 1 {
|
||||
t.Fatalf("manager = %+v", f.admin.deps.Webhooks.Hooks())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedBodyRejected(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
huge := strings.Repeat("a", 1_048_577)
|
||||
rec := postForm(t, f, "/admin/posts", url.Values{
|
||||
"_csrf": {csrf}, "title": {"Big"}, "slug": {"big"}, "body": {huge},
|
||||
}, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("code = %d, want 422", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "at most 1048576 bytes") {
|
||||
t.Fatal("size message missing")
|
||||
}
|
||||
}
|
||||
|
||||
// A changed password retires every session issued before it: the cookie
|
||||
// carries a fingerprint of the hash, and only the device the change was
|
||||
// made on gets re-bound.
|
||||
func TestPasswordChangeSignsOutOtherSessions(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
first := login(t, f, "admin", "correct-horse-9")
|
||||
second := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, first)
|
||||
rec := postForm(t, f, "/admin/settings/password", url.Values{
|
||||
"_csrf": {csrf}, "current_password": {"correct-horse-9"},
|
||||
"new_password": {"new-good-passphrase"},
|
||||
}, first)
|
||||
if !strings.Contains(rec.Body.String(), "Password updated.") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
// The change re-signs this device.s session; the cookie to test
|
||||
// with is the one the response just set.
|
||||
first = sessionCookie(t, rec)
|
||||
|
||||
// The other device.s session is dead.
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(second)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("other session survived: %d %s", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
// The device the change was made on stays signed in.
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(first)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusOK {
|
||||
t.Fatalf("current session died: %d", rec.Code)
|
||||
}
|
||||
// The old password no longer signs in; the new one does.
|
||||
if f.users.Authenticate("admin", "correct-horse-9") != nil {
|
||||
t.Fatal("the old password still authenticates")
|
||||
}
|
||||
if f.users.Authenticate("admin", "new-good-passphrase") == nil {
|
||||
t.Fatal("the new password does not authenticate")
|
||||
}
|
||||
}
|
||||
|
||||
// An admin can reset another account's password; the account's sessions
|
||||
// die with it, and the admin cannot shortcut their own current-password
|
||||
// check through the route.
|
||||
func TestAdminPasswordReset(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if _, err := f.users.Add("author", "authors-good-passphrase", "author"); err != nil {
|
||||
t.Fatalf("author not created: %v", err)
|
||||
}
|
||||
authorCookie := login(t, f, "author", "authors-good-passphrase")
|
||||
|
||||
// Self-reset is refused.
|
||||
rec := settingsForm(t, f, "/admin/settings/users/admin/password",
|
||||
url.Values{"password": {"shortcut-passphrase"}})
|
||||
if rec.Code != http.StatusUnprocessableEntity ||
|
||||
!strings.Contains(rec.Body.String(), "own password") {
|
||||
t.Fatalf("self reset not blocked: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Reset the author's password.
|
||||
rec = settingsForm(t, f, "/admin/settings/users/author/password",
|
||||
url.Values{"password": {"reset-passphrase-9"}})
|
||||
if !strings.Contains(rec.Body.String(), "sessions were signed out") {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// The author's session is dead, and the new password works.
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(authorCookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("author session survived the reset: %d", rec.Code)
|
||||
}
|
||||
if f.users.Authenticate("author", "reset-passphrase-9") == nil {
|
||||
t.Fatal("the reset password does not authenticate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/qrcode"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/totp"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
)
|
||||
|
||||
// The enrolment state rides the session: the candidate secret lives
|
||||
// there between the QR page and the verifying code, so the users file
|
||||
// only ever holds secrets that were proven by a working application.
|
||||
const (
|
||||
totpEnrollKey = "totp_enroll"
|
||||
totpEnrollAt = "totp_enroll_at"
|
||||
)
|
||||
|
||||
// enrolWindow bounds how long a candidate secret stays answerable.
|
||||
const enrolWindow = 10 * time.Minute
|
||||
|
||||
// totpURI builds the otpauth URI every application understands.
|
||||
func totpURI(secret, username string) string {
|
||||
u := url.URL{
|
||||
Scheme: "otpauth",
|
||||
Host: "totp",
|
||||
Path: "/Volumen:" + username,
|
||||
RawQuery: url.Values{"secret": {secret}, "issuer": {"Volumen"}, "algorithm": {"SHA1"}, "digits": {"6"}, "period": {"30"}}.Encode(),
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// fillTotpState carries the second-factor state of the signed-in
|
||||
// account and of an enrolment in flight onto the settings page.
|
||||
func (a *Admin) fillTotpState(data *PageData, r *http.Request) {
|
||||
record := a.deps.Users.Find(data.CurrentUser)
|
||||
if record != nil && record.TotpSecret != "" {
|
||||
data.TotpEnabled = true
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
secret := sess.Get(totpEnrollKey)
|
||||
if secret == "" {
|
||||
return
|
||||
}
|
||||
started, err := strconv.ParseInt(sess.Get(totpEnrollAt), 10, 64)
|
||||
if err != nil || time.Since(time.Unix(started, 0)) > enrolWindow {
|
||||
sess.Delete(totpEnrollKey)
|
||||
sess.Delete(totpEnrollAt)
|
||||
return
|
||||
}
|
||||
data.TotpPending = true
|
||||
data.TotpSecret = secret
|
||||
data.TotpURI = totpURI(secret, data.CurrentUser)
|
||||
if svg, err := qrcode.SVG(data.TotpURI); err == nil {
|
||||
data.TotpSVG = template.HTML(svg)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeBase32Secret turns the stored candidate back into key bytes.
|
||||
func decodeBase32Secret(encoded string) ([]byte, error) {
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded))
|
||||
}
|
||||
|
||||
// totpOK checks a candidate secret against the code the application
|
||||
// shows; no replay floor applies, this is the first use.
|
||||
func totpOK(secret []byte, code string) bool {
|
||||
ok, _ := totp.Validate(secret, code, time.Now(), 0)
|
||||
return ok
|
||||
}
|
||||
|
||||
// handleTotpStart begins enrolment: a fresh candidate secret travels to
|
||||
// the settings page inside the session, and nothing is stored yet.
|
||||
func (a *Admin) handleTotpStart(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
if record := a.deps.Users.Find(sess.Get("user")); record != nil && record.TotpSecret != "" {
|
||||
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Two-factor authentication is already on."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
secret := users.GenerateTotpSecret()
|
||||
sess.Set(totpEnrollKey, secret)
|
||||
sess.Set(totpEnrollAt, strconv.FormatInt(time.Now().Unix(), 10))
|
||||
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleTotpCancel drops an enrolment in flight.
|
||||
func (a *Admin) handleTotpCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
sess.Delete(totpEnrollKey)
|
||||
sess.Delete(totpEnrollAt)
|
||||
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleTotpVerify finishes enrolment: the code the application shows
|
||||
// proves the candidate secret, which is stored together with a fresh
|
||||
// set of recovery codes. The codes are shown exactly once, here.
|
||||
func (a *Admin) handleTotpVerify(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
username := sess.Get("user")
|
||||
secret := sess.Get(totpEnrollKey)
|
||||
if secret == "" {
|
||||
http.Redirect(w, r, "/admin/settings#security", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
code := r.PostFormValue("code")
|
||||
decoded, err := decodeBase32Secret(secret)
|
||||
if err != nil || !totpOK(decoded, code) {
|
||||
sess.Delete(totpEnrollKey)
|
||||
sess.Delete(totpEnrollAt)
|
||||
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "That code did not match; start again."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
codes, hashes := users.GenerateRecoveryCodes(10)
|
||||
if _, err := a.deps.Users.EnableTotp(username, secret, hashes); err != nil {
|
||||
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be enabled: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
sess.Delete(totpEnrollKey)
|
||||
sess.Delete(totpEnrollAt)
|
||||
a.record(r, "user.totp_enabled", username, nil)
|
||||
data := a.settingsData(r)
|
||||
data.RecoveryCodes = codes
|
||||
data.RecoveryNotice = i18n.Admin.T(data.Lang, "Two-factor is on. Store these recovery codes now; they will not be shown again.")
|
||||
a.renderPage(w, r, "settings.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
// handleTotpDisable turns the second factor off; possession of a
|
||||
// current code is the proof, so a stolen cookie alone cannot.
|
||||
func (a *Admin) handleTotpDisable(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
username := sess.Get("user")
|
||||
if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) {
|
||||
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Users.ClearTotp(username); err != nil {
|
||||
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "Two-factor could not be disabled: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.record(r, "user.totp_disabled", username, nil)
|
||||
a.renderSettings(w, r, "", i18n.Admin.T(a.lang(r, nil), "Two-factor is off."), http.StatusOK)
|
||||
}
|
||||
|
||||
// handleTotpCodes replaces the recovery codes; the old ones stop
|
||||
// working, and the new ones are shown exactly once.
|
||||
func (a *Admin) handleTotpCodes(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
sess := session.FromContext(r.Context())
|
||||
username := sess.Get("user")
|
||||
if !a.deps.Users.VerifyTotp(username, r.PostFormValue("code"), time.Now()) {
|
||||
a.renderSettings(w, r, i18n.Admin.T(a.lang(r, nil), "Wrong or expired code."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
codes, hashes := users.GenerateRecoveryCodes(10)
|
||||
if _, err := a.deps.Users.ReplaceRecovery(username, hashes); err != nil {
|
||||
a.renderSettings(w, r, i18n.Admin.Tf(a.lang(r, nil), "The codes could not be replaced: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.record(r, "user.totp_codes", username, nil)
|
||||
data := a.settingsData(r)
|
||||
data.RecoveryCodes = codes
|
||||
data.RecoveryNotice = i18n.Admin.T(data.Lang, "New recovery codes. Store them now; they will not be shown again.")
|
||||
a.renderPage(w, r, "settings.html", data, http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (a *Admin) handleSettingsCheckUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if a.deps.CheckUpdate == nil {
|
||||
a.renderSettings(w, r, "", a.tr(r, "Update checks are not available in this build."), http.StatusOK)
|
||||
return
|
||||
}
|
||||
latest, err := a.deps.CheckUpdate()
|
||||
if err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "Update check failed: %s", err.Error()), "", http.StatusOK)
|
||||
return
|
||||
}
|
||||
// The hook returns "" when the running version is current, so the
|
||||
// comparison has already been made by the one implementation that
|
||||
// knows how to make it.
|
||||
if latest == "" {
|
||||
a.renderSettings(w, r, "",
|
||||
a.trf(r, "volumen %s is already the latest release.", a.deps.Version), http.StatusOK)
|
||||
return
|
||||
}
|
||||
a.renderSettings(w, r, "", a.trf(r, "volumen %s is available.", latest), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if a.deps.SelfUpdate == nil {
|
||||
a.renderSettings(w, r, a.tr(r, "Self-update is not available in this build."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
target, err := a.deps.SelfUpdate()
|
||||
if err != nil {
|
||||
message := a.trf(r, "The upgrade failed: %s", err.Error())
|
||||
if target != "" {
|
||||
message = a.trf2(r, "Upgrade to %s failed: %s", target, err.Error())
|
||||
}
|
||||
a.renderSettings(w, r, message, "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := a.pageData(r)
|
||||
data.Target = target
|
||||
a.renderPage(w, r, "update.html", data, http.StatusOK)
|
||||
}
|
||||
|
||||
// --- webhooks and tokens ----------------------------------------------------
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
)
|
||||
|
||||
func (a *Admin) handleSettingsUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
// A password keeps its edge spaces: the reset path stores them the
|
||||
// same way, and trimming here would create a password only the
|
||||
// trimmed form of which works.
|
||||
password := r.PostFormValue("password")
|
||||
role := r.PostFormValue("role")
|
||||
if username == "" || strings.TrimSpace(password) == "" {
|
||||
a.renderSettings(w, r, a.tr(r, "Username and password are required."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if !usernameRe.MatchString(username) {
|
||||
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
minLen, maxLen := a.passwordPolicy()
|
||||
if key, n := PasswordError(password, minLen, maxLen); key != "" {
|
||||
msg := a.tr(r, key)
|
||||
if n > 0 {
|
||||
msg = i18n.Admin.N(a.langFor(r), key, n)
|
||||
}
|
||||
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Users.Add(username, password, role); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "That user could not be added: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
a.record(r, "user.created", username, nil)
|
||||
a.renderSettings(w, r, "", a.tr(r, "User added."), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsUserRole(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
target := r.PathValue("name")
|
||||
if target == a.currentUser(r) {
|
||||
a.renderSettings(w, r, a.tr(r, "You cannot change your own role."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Users.SetRole(target, r.PostFormValue("role")); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The role could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
a.record(r, "user.role_changed", target, nil)
|
||||
a.renderSettings(w, r, "", a.tr(r, "Role updated."), http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
target := r.PathValue("name")
|
||||
if target == a.currentUser(r) {
|
||||
a.renderSettings(w, r, a.tr(r, "You cannot delete your own account."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
photo := ""
|
||||
if record := a.deps.Users.Find(target); record != nil {
|
||||
photo = record.Photo
|
||||
}
|
||||
if _, err := a.deps.Users.Delete(target); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The user could not be removed: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if photo != "" {
|
||||
a.deleteUnreferencedMedia(photo)
|
||||
}
|
||||
a.record(r, "user.deleted", target, nil)
|
||||
a.renderSettings(w, r, "", a.tr(r, "User removed."), http.StatusOK)
|
||||
}
|
||||
|
||||
// --- post templates ---------------------------------------------------------
|
||||
|
||||
// handleSettingsUserPassword resets another account's password. The
|
||||
// account's sessions die with the change (the session fingerprint
|
||||
// changes), which is the point: an admin resetting a password is
|
||||
// remedying an account, and every cookie issued before must stop
|
||||
// working.
|
||||
func (a *Admin) handleSettingsUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
target := r.PathValue("name")
|
||||
if target == a.currentUser(r) {
|
||||
a.renderSettings(w, r, a.tr(r, "You cannot reset your own password here."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if a.deps.Users.Find(target) == nil {
|
||||
a.renderSettings(w, r, a.tr(r, "That user was not found."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
newPassword := r.PostFormValue("password")
|
||||
if strings.TrimSpace(newPassword) == "" {
|
||||
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
minLen, maxLen := a.passwordPolicy()
|
||||
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
|
||||
msg := a.tr(r, key)
|
||||
if n > 0 {
|
||||
msg = i18n.Admin.N(a.langFor(r), key, n)
|
||||
}
|
||||
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if _, err := a.deps.Users.UpdatePassword(target, newPassword); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
a.record(r, "user.password_reset", target, nil)
|
||||
a.renderSettings(w, r, "", a.tr(r, "Password reset; that user's sessions were signed out."), http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/tokens"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
|
||||
)
|
||||
|
||||
// roleOption is one <option> of a role select.
|
||||
type roleOption struct {
|
||||
Value string
|
||||
Selected bool
|
||||
}
|
||||
|
||||
// userRow is one entry of the users panel.
|
||||
type userRow struct {
|
||||
Username string
|
||||
Display string
|
||||
Initial string
|
||||
Role string
|
||||
Photo string
|
||||
IsSelf bool
|
||||
RoleOptions []roleOption
|
||||
}
|
||||
|
||||
// hookRow is one configured webhook endpoint.
|
||||
type hookRow struct {
|
||||
Index string
|
||||
URL string
|
||||
// Managed is false for a hook declared in config.toml, which the
|
||||
// settings forms may test but not change.
|
||||
Managed bool
|
||||
Enabled bool
|
||||
Signed bool
|
||||
EventsText string
|
||||
}
|
||||
|
||||
// deliveryRow is one webhook delivery log entry.
|
||||
type deliveryRow struct {
|
||||
Timestamp string
|
||||
Event string
|
||||
HookURL string
|
||||
OK bool
|
||||
StatusCode int
|
||||
Error string
|
||||
Attempts int
|
||||
|
||||
// Result is the pre-formatted failure text ("failed (N attempts)"),
|
||||
// translated by the caller that knows the request's language.
|
||||
Result string
|
||||
}
|
||||
|
||||
// tokenRow is one API token table row.
|
||||
type tokenRow struct {
|
||||
Name string
|
||||
CreatedDay string
|
||||
LastUsedDay string
|
||||
}
|
||||
|
||||
// mediaRow is one media library tile.
|
||||
type mediaRow struct {
|
||||
Name string
|
||||
URL string
|
||||
SizeKB string
|
||||
// Dimensions is the header-carried pixel size ("1920 × 1080"), or
|
||||
// "" when the container did not yield one.
|
||||
Dimensions string
|
||||
}
|
||||
|
||||
func roleOptions(current string) []roleOption {
|
||||
out := make([]roleOption, 0, len(users.Roles))
|
||||
for _, role := range users.Roles {
|
||||
out = append(out, roleOption{Value: role, Selected: role == current})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func userRows(current string, list []*users.User) []userRow {
|
||||
out := make([]userRow, 0, len(list))
|
||||
for _, user := range list {
|
||||
display := user.Name
|
||||
if display == "" {
|
||||
display = user.Username
|
||||
}
|
||||
out = append(out, userRow{
|
||||
Username: user.Username,
|
||||
Display: display,
|
||||
Initial: firstUpper(display, "?"),
|
||||
Role: user.Role,
|
||||
Photo: user.Photo,
|
||||
IsSelf: user.Username == current,
|
||||
RoleOptions: roleOptions(user.Role),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hookRows renders the manager's merged hook list; the first static
|
||||
// count came from config.toml and the rest are the admin's to manage.
|
||||
func hookRows(hooks []webhooks.Webhook, staticCount int) []hookRow {
|
||||
out := make([]hookRow, 0, len(hooks))
|
||||
for i, hook := range hooks {
|
||||
eventsText := "all"
|
||||
if len(hook.Events) > 0 {
|
||||
eventsText = strings.Join(hook.Events, ", ")
|
||||
}
|
||||
out = append(out, hookRow{
|
||||
Index: fmt.Sprintf("%d", i),
|
||||
URL: hook.URL,
|
||||
Managed: i >= staticCount,
|
||||
Enabled: hook.Enabled,
|
||||
Signed: hook.Secret != "",
|
||||
EventsText: eventsText,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deliveryRows(list []webhooks.Delivery) []deliveryRow {
|
||||
out := make([]deliveryRow, 0, len(list))
|
||||
for _, d := range list {
|
||||
out = append(out, deliveryRow{
|
||||
Timestamp: d.Timestamp,
|
||||
Event: d.Event,
|
||||
HookURL: d.HookURL,
|
||||
OK: d.Status == "ok",
|
||||
StatusCode: d.StatusCode,
|
||||
Error: d.Error,
|
||||
Attempts: d.Attempts,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tokenRows(list []tokens.Token) []tokenRow {
|
||||
out := make([]tokenRow, 0, len(list))
|
||||
for _, token := range list {
|
||||
lastUsed := "never"
|
||||
if len(token.LastUsed) >= 10 {
|
||||
lastUsed = token.LastUsed[:10]
|
||||
}
|
||||
created := token.Created
|
||||
if len(created) >= 10 {
|
||||
created = created[:10]
|
||||
}
|
||||
out = append(out, tokenRow{
|
||||
Name: token.Name,
|
||||
CreatedDay: created,
|
||||
LastUsedDay: lastUsed,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mediaRows(list []store.Media) []mediaRow {
|
||||
out := make([]mediaRow, 0, len(list))
|
||||
for _, item := range list {
|
||||
out = append(out, mediaRow{
|
||||
Name: item.Name,
|
||||
URL: item.URL,
|
||||
SizeKB: fmt.Sprintf("%.1f", float64(item.Size)/1024),
|
||||
Dimensions: pixelSize(item.Width, item.Height),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pixelSize renders the header-carried pixel size, empty when the
|
||||
// container did not yield one.
|
||||
func pixelSize(width, height int) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%d × %d", width, height)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/payloads"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/webhooks"
|
||||
)
|
||||
|
||||
// fileHooks reads the admin-managed hook store.
|
||||
func (a *Admin) fileHooks() ([]webhooks.Webhook, error) {
|
||||
if a.deps.WebhooksFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return webhooks.LoadFile(a.deps.WebhooksFile)
|
||||
}
|
||||
|
||||
// refreshWebhooks re-saves the store and applies the merged hook set to
|
||||
// the manager, so a settings change delivers without a restart.
|
||||
func (a *Admin) refreshWebhooks(hooks []webhooks.Webhook) error {
|
||||
if err := webhooks.SaveFile(a.deps.WebhooksFile, hooks); err != nil {
|
||||
return err
|
||||
}
|
||||
merged := make([]webhooks.Webhook, 0, len(a.deps.StaticWebhooks)+len(hooks))
|
||||
merged = append(merged, a.deps.StaticWebhooks...)
|
||||
merged = append(merged, hooks...)
|
||||
a.deps.Webhooks.SetHooks(merged)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleSettingsWebhookAdd adds one endpoint to the store. Config-declared
|
||||
// hooks are the operator's business and stay read-only; this list is the
|
||||
// admin's to manage.
|
||||
func (a *Admin) handleSettingsWebhookAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
file, err := a.fileHooks()
|
||||
if err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
hook, problem := validateHookInput(
|
||||
r.PostFormValue("url"), strings.TrimSpace(r.PostFormValue("secret")),
|
||||
r.PostFormValue("events"), r.PostFormValue("enabled") == "on",
|
||||
append(slices.Clone(a.deps.StaticWebhooks), file...),
|
||||
)
|
||||
if problem != "" {
|
||||
a.renderSettings(w, r, a.tr(r, problem), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := a.refreshWebhooks(append(file, hook)); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
a.record(r, "webhook.added", hook.URL, nil)
|
||||
a.renderSettings(w, r, "", a.tr(r, "Webhook added."), http.StatusOK)
|
||||
}
|
||||
|
||||
// handleSettingsWebhookToggle flips one stored hook's enabled flag. The
|
||||
// URL names the hook, because the row the form was rendered from may no
|
||||
// longer be at its old index by the time the POST lands.
|
||||
func (a *Admin) handleSettingsWebhookToggle(w http.ResponseWriter, r *http.Request) {
|
||||
a.mutateStoredHook(w, r, "webhook.updated", "Webhook updated.",
|
||||
func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) {
|
||||
for i, hook := range hooks {
|
||||
if hook.URL == url {
|
||||
hooks[i].Enabled = !hook.Enabled
|
||||
return hooks, true
|
||||
}
|
||||
}
|
||||
return hooks, false
|
||||
})
|
||||
}
|
||||
|
||||
func (a *Admin) handleSettingsWebhookDelete(w http.ResponseWriter, r *http.Request) {
|
||||
a.mutateStoredHook(w, r, "webhook.deleted", "Webhook removed.",
|
||||
func(hooks []webhooks.Webhook, url string) ([]webhooks.Webhook, bool) {
|
||||
for i, hook := range hooks {
|
||||
if hook.URL == url {
|
||||
return slices.Delete(hooks, i, i+1), true
|
||||
}
|
||||
}
|
||||
return hooks, false
|
||||
})
|
||||
}
|
||||
|
||||
// mutateStoredHook applies a change to the stored hook the form's url
|
||||
// field names, re-saves, and refreshes the manager.
|
||||
func (a *Admin) mutateStoredHook(w http.ResponseWriter, r *http.Request, auditAction, notice string, apply func([]webhooks.Webhook, string) ([]webhooks.Webhook, bool)) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
file, err := a.fileHooks()
|
||||
if err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The webhook store could not be read: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
target := r.PostFormValue("url")
|
||||
changed, ok := apply(file, target)
|
||||
if !ok {
|
||||
a.renderSettings(w, r, a.tr(r, "Webhook not found."), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := a.refreshWebhooks(changed); err != nil {
|
||||
a.renderSettings(w, r, a.trf(r, "The webhook could not be saved: %s", err.Error()), "", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
a.record(r, auditAction, target, nil)
|
||||
a.renderSettings(w, r, "", a.tr(r, notice), http.StatusOK)
|
||||
}
|
||||
|
||||
// validateHookInput checks the add form: an absolute http(s) URL no
|
||||
// configured hook already uses, an optional secret, and the event
|
||||
// filter as a comma-separated list (empty delivers everything).
|
||||
func validateHookInput(raw, secret, events string, enabled bool, existing []webhooks.Webhook) (webhooks.Webhook, string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return webhooks.Webhook{}, "That URL is not a valid http(s) endpoint."
|
||||
}
|
||||
for _, hook := range existing {
|
||||
if hook.URL == raw {
|
||||
return webhooks.Webhook{}, "That URL is already configured."
|
||||
}
|
||||
}
|
||||
return webhooks.Webhook{
|
||||
URL: raw,
|
||||
Secret: secret,
|
||||
Events: payloads.ParseTags(events),
|
||||
Enabled: enabled,
|
||||
}, ""
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
json "encoding/json/v2"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/session"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
)
|
||||
|
||||
// setupNeeded reports whether the first-run wizard should serve: a
|
||||
// readable users file that holds no accounts. A file that cannot be
|
||||
// read answers through the second value: the wizard would refuse to
|
||||
// write over it anyway, so the caller says so instead of offering a
|
||||
// form that cannot work.
|
||||
func (a *Admin) setupNeeded() (needed bool, broken error) {
|
||||
if err := a.deps.Users.Health(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !a.deps.Users.Any(), nil
|
||||
}
|
||||
|
||||
// registerSetupRoutes mounts the wizard. The routes are public in the
|
||||
// same sense the login is public: they exist for the owner of the
|
||||
// installation before any account does, and the wizard retires itself
|
||||
// as soon as one account exists.
|
||||
func (a *Admin) registerSetupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /admin/setup", a.handleSetupForm)
|
||||
mux.HandleFunc("POST /admin/setup", a.handleSetup)
|
||||
}
|
||||
|
||||
// handleSetupForm serves the wizard while no account exists. Once one
|
||||
// does, the route sends the browser back to the login, which is the
|
||||
// same answer as deleting the route: the first run happens exactly
|
||||
// once. The ?lang query re-renders the page in another shipped language:
|
||||
// the language chips are real links, so the choice works without
|
||||
// JavaScript too.
|
||||
func (a *Admin) handleSetupForm(w http.ResponseWriter, r *http.Request) {
|
||||
sess := session.FromContext(r.Context())
|
||||
if sess.Get("user") != "" && a.deps.Users.Find(sess.Get("user")) != nil {
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
needed, broken := a.setupNeeded()
|
||||
if broken != nil {
|
||||
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !needed {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
lang := i18n.Normalize(r.URL.Query().Get("lang"))
|
||||
a.renderSetup(w, r, "", http.StatusOK, lang)
|
||||
}
|
||||
|
||||
// renderSetup draws the wizard page in the given language ("" keeps the
|
||||
// site default) and with the error the last attempt reported when there
|
||||
// is one. The wizard always opens on the clean defaults: the site
|
||||
// language and the shipped scheme, never on the anonymous preview
|
||||
// cookies, which belong to the login screen after an account exists and
|
||||
// here would only be a leftover from a half-finished or reset setup. The
|
||||
// cookies are expired on the way so the next screen starts from the same
|
||||
// truth the form shows. The page carries both languages of its own
|
||||
// strings so the chips can swap the text without a reload.
|
||||
func (a *Admin) renderSetup(w http.ResponseWriter, r *http.Request, errorMsg string, status int, lang string) {
|
||||
if lang == "" {
|
||||
lang = a.siteLanguage()
|
||||
}
|
||||
data := a.pageData(r)
|
||||
data.IsSetup = true
|
||||
data.Lang = lang
|
||||
data.Theme = web.DefaultTheme
|
||||
data.Error = errorMsg
|
||||
data.SetupI18n = setupI18n(data.Config.Admin.MinPasswordLength)
|
||||
expires := &http.Cookie{MaxAge: -1, Path: "/admin", HttpOnly: true}
|
||||
c1 := *expires
|
||||
c1.Name = i18n.Cookie
|
||||
http.SetCookie(w, &c1)
|
||||
c2 := *expires
|
||||
c2.Name = web.ThemeCookie
|
||||
http.SetCookie(w, &c2)
|
||||
a.renderPage(w, r, "setup.html", data, status)
|
||||
}
|
||||
|
||||
// setupI18nKeys are the wizard's own interface strings, keyed by their
|
||||
// English source; the page swaps them client-side when a language chip
|
||||
// is clicked, so the typed values survive. "password.hint" is added
|
||||
// separately because it carries the configured length.
|
||||
var setupI18nKeys = []string{
|
||||
"Welcome to Volumen",
|
||||
"Set up the administrator account to open this installation.",
|
||||
"Account",
|
||||
"Username",
|
||||
"Display name",
|
||||
"Your real name",
|
||||
"Password",
|
||||
"Show password",
|
||||
"Hide password",
|
||||
"Language",
|
||||
"Colour scheme",
|
||||
"The page takes the colours as you choose.",
|
||||
"Create account",
|
||||
}
|
||||
|
||||
// setupI18n builds the page's bilingual payload: every wizard string in
|
||||
// both shipped languages, and the script catalogue per language, escaped
|
||||
// for embedding in a script element the way the shared catalogue is.
|
||||
func setupI18n(minLength int) template.JS {
|
||||
ui := make(map[string]map[string]string, len(setupI18nKeys)+1)
|
||||
for _, key := range setupI18nKeys {
|
||||
ui[key] = map[string]string{
|
||||
"en": i18n.Admin.T("en", key),
|
||||
"cs": i18n.Admin.T("cs", key),
|
||||
}
|
||||
}
|
||||
ui["password.hint"] = map[string]string{
|
||||
"en": i18n.Admin.N("en", "password.min", minLength),
|
||||
"cs": i18n.Admin.N("cs", "password.min", minLength),
|
||||
}
|
||||
payload := map[string]any{
|
||||
"ui": ui,
|
||||
"js": map[string]any{
|
||||
"en": i18n.Admin.JS("en"),
|
||||
"cs": i18n.Admin.JS("cs"),
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(payload, json.Deterministic(true))
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return template.JS(strings.ReplaceAll(string(b), "<", `\u003c`))
|
||||
}
|
||||
|
||||
// siteLanguage is the interface language a request falls back to when no
|
||||
// account and no cookie decide it: the configured site language when the
|
||||
// UI ships it, English otherwise.
|
||||
func (a *Admin) siteLanguage() string {
|
||||
if lang := i18n.Normalize(a.deps.Config.Site.Language); lang != "" {
|
||||
return lang
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
|
||||
// handleSetup creates the first administrator account, stores the
|
||||
// interface choices with it and signs the operator straight in. The
|
||||
// password goes through the same server policy as every other password
|
||||
// change; the page meter is advice, this is the gate.
|
||||
func (a *Admin) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
needed, broken := a.setupNeeded()
|
||||
if broken != nil {
|
||||
http.Error(w, a.tr(r, "The users file cannot be read; repair it before setting up."), http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !needed {
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
name := strings.TrimSpace(r.PostFormValue("name"))
|
||||
language := i18n.Normalize(r.PostFormValue("language"))
|
||||
if language == "" {
|
||||
language = a.siteLanguage()
|
||||
}
|
||||
theme := r.PostFormValue("theme")
|
||||
if !web.ValidTheme(theme) {
|
||||
theme = web.DefaultTheme
|
||||
}
|
||||
secret := r.PostFormValue("password")
|
||||
|
||||
if !usernameRe.MatchString(username) {
|
||||
a.renderSetup(w, r, i18n.Admin.T(language, "Username may use letters, numbers, dot, dash, underscore."), http.StatusUnprocessableEntity, language)
|
||||
return
|
||||
}
|
||||
minLen, maxLen := a.passwordPolicy()
|
||||
if key, n := PasswordError(secret, minLen, maxLen); key != "" {
|
||||
msg := i18n.Admin.T(language, key)
|
||||
if n > 0 {
|
||||
msg = i18n.Admin.N(language, key, n)
|
||||
}
|
||||
a.renderSetup(w, r, msg, http.StatusUnprocessableEntity, language)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := a.deps.Users.AddFirst(username, secret, language, theme, name)
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, users.ErrUsersExist):
|
||||
// Another claim won the race a moment ago; the wizard is gone
|
||||
// and the account is already there.
|
||||
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
|
||||
return
|
||||
default:
|
||||
a.renderSetup(w, r, i18n.Admin.Tf(language, "The account could not be created: %s", err.Error()), http.StatusInternalServerError, language)
|
||||
return
|
||||
}
|
||||
|
||||
// Sign the operator in with the same session shape the login uses,
|
||||
// so the wizard ends where a first sign-in would: inside the
|
||||
// dashboard, on one request.
|
||||
sess := session.FromContext(r.Context())
|
||||
sess.Set("user", user.Username)
|
||||
sess.Set("pv", sessionFingerprint(user.PasswordHash))
|
||||
a.record(r, "setup.first_user", user.Username, nil)
|
||||
secure := a.cookieSecure()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: i18n.Cookie,
|
||||
Value: language,
|
||||
Path: "/admin",
|
||||
MaxAge: 365 * 24 * 3600,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: web.ThemeCookie,
|
||||
Value: theme,
|
||||
Path: "/admin",
|
||||
MaxAge: 365 * 24 * 3600,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, "/admin/", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/web"
|
||||
)
|
||||
|
||||
// A stale anonymous preview cookie (a leftover of an abandoned or reset
|
||||
// setup) must not greet the operator on the wizard: the first run opens
|
||||
// on the clean defaults.
|
||||
func TestSetupFormIgnoresPreviewCookies(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/setup", nil)
|
||||
req.AddCookie(&http.Cookie{Name: i18n.Cookie, Value: "cs"})
|
||||
req.AddCookie(&http.Cookie{Name: web.ThemeCookie, Value: "magma"})
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, `<html lang="en" data-palette="viridis">`) {
|
||||
t.Fatalf("wizard did not open on the clean defaults:\n%s", body[:min(len(body), 400)])
|
||||
}
|
||||
// The response expires both preview cookies so later screens are clean too.
|
||||
var sawLang, sawTheme bool
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == i18n.Cookie && c.MaxAge < 0 {
|
||||
sawLang = true
|
||||
}
|
||||
if c.Name == web.ThemeCookie && c.MaxAge < 0 {
|
||||
sawTheme = true
|
||||
}
|
||||
}
|
||||
if !sawLang || !sawTheme {
|
||||
t.Fatalf("preview cookies not expired on the wizard response: %v", rec.Result().Cookies())
|
||||
}
|
||||
}
|
||||
|
||||
// A deployment with no accounts shows the wizard in place of the login
|
||||
// screen; the login URL itself redirects, so an old bookmark lands in
|
||||
// the right place too.
|
||||
func TestLoginFormRedirectsToWizard(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/setup" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupFormServesWhileNoAccounts(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Welcome to Volumen", "name=\"password\"", `name="theme"`, `name="language"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The chips are real links, so the language is a server-side choice
|
||||
// too: ?lang renders the whole page in it and the hidden field carries
|
||||
// it into the account.
|
||||
func TestSetupFormHonoursLangQuery(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup?lang=cs", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{`<html lang="cs"`, "Účet", `name="language" id="setup-language" value="cs"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("missing %q", want)
|
||||
}
|
||||
}
|
||||
// The bilingual bundle rides along for the client-side swap.
|
||||
if !strings.Contains(body, "Vítejte ve Volumenu") {
|
||||
t.Fatal("the language bundle is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupFormRetiresWhenAccountsExist(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
rec := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// The wizard creates the first account, keeps the language and the
|
||||
// colour scheme with it, and signs the operator in on the same trip.
|
||||
func TestSetupCreatesAndSignsIn(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"username": {"balvin"},
|
||||
"name": {"Petr Balvín"},
|
||||
"password": {"a-genuinely-unique-passphrase"},
|
||||
"language": {"cs"},
|
||||
"theme": {"plasma"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("code=%d location=%q body=%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||
}
|
||||
user := f.users.Find("balvin")
|
||||
if user == nil || user.Role != "admin" {
|
||||
t.Fatalf("first account missing: %v", user)
|
||||
}
|
||||
if user.Language != "cs" || user.Theme != "plasma" {
|
||||
t.Fatalf("wizard choices not stored: lang=%q theme=%q", user.Language, user.Theme)
|
||||
}
|
||||
if user.Name != "Petr Balvín" {
|
||||
t.Fatalf("display name = %q", user.Name)
|
||||
}
|
||||
|
||||
// The session cookie from the wizard opens the dashboard: the
|
||||
// operator is signed in, not sent back through the login.
|
||||
authed := sessionCookie(t, rec)
|
||||
dash := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
dash.AddCookie(authed)
|
||||
if rec := f.do(t, dash); rec.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard after setup: code = %d", rec.Code)
|
||||
}
|
||||
|
||||
// A second claim of the same wizard is refused and pointed at the
|
||||
// login: the installation has exactly one first account. The CSRF
|
||||
// token rides the same session, so the refusal comes from the
|
||||
// accounts already existing, not from the form.
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req2.AddCookie(authed)
|
||||
rec2 := f.do(t, req2)
|
||||
if rec2.Code != http.StatusSeeOther || rec2.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("second claim: code=%d location=%q", rec2.Code, rec2.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// The wizard POST validates with the same server-side rules every
|
||||
// password change uses: the page meter is advice, this is the gate.
|
||||
func TestSetupRejectsWeakPasswordAndBadCSRF(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"username": {"admin"},
|
||||
"password": {"short"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("weak password: code = %d", rec.Code)
|
||||
}
|
||||
if f.users.Any() {
|
||||
t.Fatal("a refused wizard still created an account")
|
||||
}
|
||||
|
||||
noCSRF := url.Values{"username": {"admin"}, "password": {"a-genuinely-unique-passphrase"}}
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(noCSRF.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing CSRF: code = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRejectsBadUsername(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"username": {"not a name!"},
|
||||
"password": {"a-genuinely-unique-passphrase"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("bad username: code = %d", rec.Code)
|
||||
}
|
||||
if f.users.Any() {
|
||||
t.Fatal("a refused username still created an account")
|
||||
}
|
||||
}
|
||||
|
||||
// The default username is admin, and an empty field gets it: the form
|
||||
// starts filled, a submit that cleared it still lands on a valid name.
|
||||
func TestSetupDefaultsUsername(t *testing.T) {
|
||||
f := newFixtureSeeded(t, false)
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/setup", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
|
||||
form := url.Values{
|
||||
"_csrf": {csrf},
|
||||
"username": {""},
|
||||
"password": {"a-genuinely-unique-passphrase"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/setup", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("empty username: code = %d body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if f.users.Find("admin") == nil {
|
||||
t.Fatal("the empty username field did not fall back to admin")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/totp"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/users"
|
||||
)
|
||||
|
||||
func currentCode(t *testing.T, secret string) string {
|
||||
t.Helper()
|
||||
return currentCodeIn(t, secret, 0)
|
||||
}
|
||||
|
||||
// currentCodeIn computes the code of a neighbouring time step, so a
|
||||
// test can answer twice without tripping the replay floor.
|
||||
func currentCodeIn(t *testing.T, secret string, steps int) string {
|
||||
t.Helper()
|
||||
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("decode secret: %v", err)
|
||||
}
|
||||
return totp.Code(key, time.Now().Add(time.Duration(steps)*totp.Step))
|
||||
}
|
||||
|
||||
// loginTo opens the first door and returns the session wherever it
|
||||
// stands: the dashboard, or the second-factor step when the account
|
||||
// has one.
|
||||
func loginTo(t *testing.T, f *fixture, username, secret string) *http.Cookie {
|
||||
t.Helper()
|
||||
get := f.do(t, httptest.NewRequest(http.MethodGet, "/admin/login", nil))
|
||||
csrf := extractCSRF(t, get.Body.String())
|
||||
cookie := sessionCookie(t, get)
|
||||
form := url.Values{"_csrf": {csrf}, "username": {username}, "password": {secret}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(cookie)
|
||||
rec := f.do(t, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login failed: code=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return sessionCookie(t, rec)
|
||||
}
|
||||
|
||||
// TestLoginWithSecondFactor walks the whole door: password, code, in,
|
||||
// and the recovery path when the application is lost.
|
||||
func TestLoginWithSecondFactor(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
secret := users.GenerateTotpSecret()
|
||||
codes, hashes := users.GenerateRecoveryCodes(10)
|
||||
if _, err := f.users.EnableTotp("admin", secret, hashes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cookie := loginTo(t, f, "admin", "correct-horse-9")
|
||||
// The password alone no longer opens anything: the admin bounces
|
||||
// to the login, which forwards a pending session to the step.
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("password step: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/twofactor" {
|
||||
t.Fatalf("login form forwards pending session: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Verification code") {
|
||||
t.Fatalf("twofactor form: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
// The direct route redirects anonymous traffic to the first step.
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/twofactor", nil)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/login" {
|
||||
t.Fatalf("anonymous twofactor: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
// A wrong code is refused and changes nothing.
|
||||
rec := postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong code: code=%d", rec.Code)
|
||||
}
|
||||
|
||||
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("right code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Code != http.StatusOK {
|
||||
t.Fatalf("dashboard after second factor: %d", rec.Code)
|
||||
}
|
||||
|
||||
// The recovery path: sign out, in again, spend one code; the same
|
||||
// code never works twice.
|
||||
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
|
||||
cookie = loginTo(t, f, "admin", "correct-horse-9")
|
||||
csrf = csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
|
||||
t.Fatalf("recovery code: code=%d location=%q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
|
||||
cookie = loginTo(t, f, "admin", "correct-horse-9")
|
||||
csrf = csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {codes[0]}}, cookie)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("reused recovery code: code=%d", rec.Code)
|
||||
}
|
||||
// The typed shapes humans use still work.
|
||||
rec = postForm(t, f, "/admin/twofactor",
|
||||
url.Values{"_csrf": {csrf}, "code": {strings.ReplaceAll(codes[1], "-", " ")}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("spaced recovery code: code=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTotpEnrolment drives the settings flow: start, the QR page, the
|
||||
// verifying code, the one-time recovery codes, and turning it off.
|
||||
func TestTotpEnrolment(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
cookie := login(t, f, "admin", "correct-horse-9")
|
||||
csrf := csrfFromSession(t, f, cookie)
|
||||
|
||||
// Before anything, the settings page offers the setup.
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
body := f.do(t, req).Body.String()
|
||||
if !strings.Contains(body, "Set up two-factor") {
|
||||
t.Fatal("setup offer missing")
|
||||
}
|
||||
|
||||
// Start shows the QR and the secret, and stores nothing yet. The
|
||||
// candidate rides the cookie, so the jar moves on with it.
|
||||
rec := postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("start: %d", rec.Code)
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
body = f.do(t, req).Body.String()
|
||||
if !strings.Contains(body, "totp__qr") || !strings.Contains(body, `<path fill="#000"`) {
|
||||
t.Fatal("QR panel missing after start")
|
||||
}
|
||||
if f.users.Find("admin").TotpSecret != "" {
|
||||
t.Fatal("start stored a secret before verification")
|
||||
}
|
||||
|
||||
// A wrong verifying code clears the candidate.
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("wrong verify: %d", rec.Code)
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
if strings.Contains(f.do(t, req).Body.String(), "totp__qr") {
|
||||
t.Fatal("candidate survived a wrong code")
|
||||
}
|
||||
|
||||
// The honest path: start again, verify with the code the
|
||||
// application shows, receive the recovery codes once.
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/start", url.Values{"_csrf": {csrf}}, cookie)
|
||||
cookie = sessionCookie(t, rec)
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/settings", nil)
|
||||
req.AddCookie(cookie)
|
||||
body = f.do(t, req).Body.String()
|
||||
i := strings.Index(body, `class="totp__secret"`)
|
||||
if i < 0 {
|
||||
t.Fatal("secret text missing")
|
||||
}
|
||||
rest := body[i:]
|
||||
j := strings.Index(rest, ">")
|
||||
k := strings.Index(rest[j:], "<")
|
||||
secret := rest[j+1 : j+k]
|
||||
if len(secret) < 26 {
|
||||
t.Fatalf("secret looks wrong: %q", secret)
|
||||
}
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/verify", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("verify: %d body=%s", rec.Code, rec.Body.String()[:200])
|
||||
}
|
||||
page := rec.Body.String()
|
||||
if !strings.Contains(page, "recovery__code") {
|
||||
t.Fatal("recovery codes not shown once")
|
||||
}
|
||||
if f.users.Find("admin").TotpSecret == "" {
|
||||
t.Fatal("enabled secret not stored")
|
||||
}
|
||||
|
||||
// The next sign-in needs the second factor.
|
||||
postForm(t, f, "/admin/logout", url.Values{"_csrf": {csrf}}, cookie)
|
||||
cookie = loginTo(t, f, "admin", "correct-horse-9")
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/login", nil)
|
||||
req.AddCookie(cookie)
|
||||
if rec := f.do(t, req); rec.Header().Get("Location") != "/admin/twofactor" {
|
||||
t.Fatalf("second factor not asked: %q", rec.Header().Get("Location"))
|
||||
}
|
||||
|
||||
// Turning it off asks for a current code.
|
||||
csrf = csrfFromSession(t, f, cookie)
|
||||
rec = postForm(t, f, "/admin/twofactor", url.Values{"_csrf": {csrf}, "code": {currentCode(t, secret)}}, cookie)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("sign-in code: %d", rec.Code)
|
||||
}
|
||||
cookie = sessionCookie(t, rec)
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/disable", url.Values{"_csrf": {csrf}, "code": {"000000"}}, cookie)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("disable with wrong code: %d", rec.Code)
|
||||
}
|
||||
// The sign-in already spent this window's code: the next window's
|
||||
// code answers, the spent one must not.
|
||||
rec = postForm(t, f, "/admin/settings/twofactor/disable",
|
||||
url.Values{"_csrf": {csrf}, "code": {currentCodeIn(t, secret, 1)}}, cookie)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("disable: %d", rec.Code)
|
||||
}
|
||||
if f.users.Find("admin").TotpSecret != "" {
|
||||
t.Fatal("secret survived disable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
json "encoding/json/v2"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/post"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/store"
|
||||
"sourcedock.dev/petrbalvin/volumen/internal/templates"
|
||||
)
|
||||
|
||||
// postVariant is one language version of a publication. The dashboard
|
||||
// shows one card per publication and lists its variants here, so the card
|
||||
// can switch what it names, links and shows between them.
|
||||
type postVariant struct {
|
||||
Lang string `json:"lang"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
DateString string `json:"date"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Draft bool `json:"draft,omitempty"`
|
||||
Scheduled bool `json:"scheduled,omitempty"`
|
||||
Series string `json:"series,omitempty"`
|
||||
HasOrder bool `json:"hasOrder,omitempty"`
|
||||
SeriesOrder int `json:"seriesOrder,omitempty"`
|
||||
Cover string `json:"cover,omitempty"`
|
||||
}
|
||||
|
||||
// postCard is one dashboard card.
|
||||
type postCard struct {
|
||||
Slug string
|
||||
Title string
|
||||
Excerpt string
|
||||
Lang string
|
||||
Series string
|
||||
HasOrder bool
|
||||
SeriesOrder int
|
||||
DateString string
|
||||
Cover string
|
||||
Draft bool
|
||||
Scheduled bool
|
||||
Tags []string
|
||||
Timestamp int64
|
||||
Status string
|
||||
// Variants carries every language version of the publication;
|
||||
// VariantsJSON is the same list prepared for the card's data
|
||||
// attribute, so the page script can switch between them.
|
||||
Variants []postVariant
|
||||
VariantsJS template.JS
|
||||
GroupSlugs string
|
||||
}
|
||||
|
||||
// recentPost is one entry of the dashboard's recent strip.
|
||||
type recentPost struct {
|
||||
Slug string
|
||||
Title string
|
||||
DateString string
|
||||
}
|
||||
|
||||
// scheduledPost is one entry of the dashboard's upcoming strip.
|
||||
type scheduledPost struct {
|
||||
Slug string
|
||||
Title string
|
||||
When string
|
||||
}
|
||||
|
||||
// dashboardStats holds the counters shown above the post grid.
|
||||
type dashboardStats struct {
|
||||
Total int
|
||||
Published int
|
||||
Drafts int
|
||||
Scheduled int
|
||||
Recent []recentPost
|
||||
Upcoming []scheduledPost
|
||||
}
|
||||
|
||||
// tagCount is one tag-cloud chip.
|
||||
type tagCount struct {
|
||||
Name string
|
||||
Count int
|
||||
}
|
||||
|
||||
// editorPost carries the form-ready post fields for the editor.
|
||||
type editorPost struct {
|
||||
Slug string
|
||||
Title string
|
||||
Lang string
|
||||
Author string
|
||||
FediverseCreator string
|
||||
DOI string
|
||||
ORCID string
|
||||
Date string
|
||||
PublishAt string
|
||||
TagsCSV string
|
||||
Series string
|
||||
SeriesOrder string
|
||||
Excerpt string
|
||||
ExcerptIsSet bool
|
||||
ExcerptPlaceholder string
|
||||
Cover string
|
||||
CoverAlt string
|
||||
CoverCaption string
|
||||
Body string
|
||||
Draft bool
|
||||
AllLangs bool
|
||||
Scheduled bool
|
||||
|
||||
// RefsJSON is the post's reference list as a JS literal for the
|
||||
// bibliography card: the frontmatter tables as they are stored, so
|
||||
// the editor edits what the file holds and no field is lost in a
|
||||
// decode/encode cycle. RefsCount is the same list's length, shown in
|
||||
// the card's head.
|
||||
RefsJSON template.JS
|
||||
RefsCount int
|
||||
}
|
||||
|
||||
// revisionRow is one history table row.
|
||||
type revisionRow struct {
|
||||
Name string
|
||||
When string
|
||||
SizeKB string
|
||||
}
|
||||
|
||||
// tplOption is one post-template dropdown entry.
|
||||
type tplOption struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Tags []string `json:"tags"`
|
||||
Body string `json:"body"`
|
||||
|
||||
// Fields are the extra editor inputs the template pre-fills. The
|
||||
// keys are the editor's own input names.
|
||||
Fields map[string]string `json:"fields,omitzero"`
|
||||
|
||||
// FieldsText lists the same fields for the settings screen, sorted
|
||||
// and joined, so the template row shows what it will pre-fill.
|
||||
FieldsText string `json:"-"`
|
||||
}
|
||||
|
||||
// newPostVariant maps one stored post onto one card variant.
|
||||
func newPostVariant(p *post.Post) postVariant {
|
||||
v := postVariant{
|
||||
Lang: p.Lang(),
|
||||
Slug: p.Slug(),
|
||||
Title: p.Title(),
|
||||
Excerpt: p.Excerpt(),
|
||||
DateString: p.DateString(),
|
||||
Status: statusKey(p),
|
||||
Draft: p.Draft(),
|
||||
Scheduled: p.Scheduled(),
|
||||
Series: p.Series(),
|
||||
Cover: p.Cover(),
|
||||
}
|
||||
if order, ok := p.SeriesOrder(); ok {
|
||||
v.HasOrder = true
|
||||
v.SeriesOrder = order
|
||||
}
|
||||
if ts, ok := p.PublishedTimestamp(); ok {
|
||||
v.Timestamp = ts
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// statusKey names the post status with the filter vocabulary.
|
||||
func statusKey(p *post.Post) string {
|
||||
switch {
|
||||
case p.Draft():
|
||||
return "draft"
|
||||
case p.Scheduled():
|
||||
return "scheduled"
|
||||
default:
|
||||
return "published"
|
||||
}
|
||||
}
|
||||
|
||||
// groupPosts merges the posts whose frontmatter names each other in the
|
||||
// translations map into one group: the dashboard then shows one card per
|
||||
// publication instead of one per language file. The groups keep the order
|
||||
// of their first appearance, and the posts arrive newest first, so the
|
||||
// grid stays date-ordered.
|
||||
func groupPosts(posts []*post.Post) [][]*post.Post {
|
||||
parent := map[string]string{}
|
||||
var find func(slug string) string
|
||||
find = func(slug string) string {
|
||||
root, ok := parent[slug]
|
||||
if !ok {
|
||||
parent[slug] = slug
|
||||
return slug
|
||||
}
|
||||
if root == slug {
|
||||
return slug
|
||||
}
|
||||
parent[slug] = find(root)
|
||||
return parent[slug]
|
||||
}
|
||||
union := func(a, b string) {
|
||||
ra, rb := find(a), find(b)
|
||||
if ra != rb {
|
||||
parent[rb] = ra
|
||||
}
|
||||
}
|
||||
for _, p := range posts {
|
||||
find(p.Slug())
|
||||
}
|
||||
for _, p := range posts {
|
||||
for _, target := range p.Translations() {
|
||||
if target != "" {
|
||||
union(p.Slug(), target)
|
||||
}
|
||||
}
|
||||
}
|
||||
var order []string
|
||||
members := map[string][]*post.Post{}
|
||||
for _, p := range posts {
|
||||
root := find(p.Slug())
|
||||
if _, seen := members[root]; !seen {
|
||||
order = append(order, root)
|
||||
}
|
||||
members[root] = append(members[root], p)
|
||||
}
|
||||
groups := make([][]*post.Post, 0, len(order))
|
||||
for _, root := range order {
|
||||
groups = append(groups, members[root])
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// pickDisplay chooses the variant the card shows: the one in the
|
||||
// interface language when the publication carries it, the newest one
|
||||
// otherwise.
|
||||
func pickDisplay(group []*post.Post, lang string) *post.Post {
|
||||
if lang != "" {
|
||||
for _, p := range group {
|
||||
if p.Lang() == lang {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return group[0]
|
||||
}
|
||||
|
||||
// variantsJSON renders the language variants as a JS literal for the
|
||||
// card's data attribute. The same script-embedding rule as templatesJSON
|
||||
// applies: no literal "<" may reach the page.
|
||||
func variantsJSON(variants []postVariant) template.JS {
|
||||
raw, err := json.Marshal(variants)
|
||||
if err != nil {
|
||||
slog.Warn("admin: cannot encode post variants", "error", err)
|
||||
return template.JS("[]")
|
||||
}
|
||||
return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`))
|
||||
}
|
||||
|
||||
// newEditorPost maps a stored post onto the editor form fields.
|
||||
func newEditorPost(p *post.Post) *editorPost {
|
||||
view := &editorPost{
|
||||
Slug: p.Slug(),
|
||||
Title: p.Title(),
|
||||
Lang: p.Lang(),
|
||||
Author: p.Author(),
|
||||
Date: p.DateString(),
|
||||
TagsCSV: strings.Join(p.Tags(), ", "),
|
||||
Series: p.Series(),
|
||||
Excerpt: p.StoredExcerpt(),
|
||||
Cover: p.Cover(),
|
||||
CoverAlt: p.CoverAlt(),
|
||||
CoverCaption: p.CoverCaption(),
|
||||
Body: p.Body,
|
||||
Draft: p.Draft(),
|
||||
AllLangs: p.AllLangs(),
|
||||
Scheduled: p.Scheduled(),
|
||||
}
|
||||
view.RefsJSON, view.RefsCount = refsJS(p)
|
||||
if fc := p.FediverseCreator(); fc != "" {
|
||||
view.FediverseCreator = fc
|
||||
}
|
||||
view.DOI = p.DOI()
|
||||
view.ORCID = p.ORCID()
|
||||
if d, ok := p.DueAt(); ok {
|
||||
view.PublishAt = d.Format("2006-01-02")
|
||||
}
|
||||
if order, ok := p.SeriesOrder(); ok {
|
||||
view.SeriesOrder = fmt.Sprintf("%d", order)
|
||||
}
|
||||
view.ExcerptIsSet = strings.TrimSpace(view.Excerpt) != ""
|
||||
// An unset excerpt shows the derived text as a placeholder, so saving
|
||||
// the form never writes text the author did not type.
|
||||
view.ExcerptPlaceholder = "Short summary for listings and previews"
|
||||
if !view.ExcerptIsSet {
|
||||
if derived := p.Excerpt(); derived != "" {
|
||||
view.ExcerptPlaceholder = derived
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// refsJS renders the post's stored reference tables as a JS literal for
|
||||
// the bibliography card, with the list's length beside it. The same
|
||||
// script-embedding rule as templatesJSON applies: no literal "<" may
|
||||
// reach the page, and a post without refs still gets a literal the
|
||||
// script can iterate.
|
||||
func refsJS(p *post.Post) (template.JS, int) {
|
||||
raw, _ := p.Metadata.Get("refs")
|
||||
// A parsed file hands the tables over as []map[string]any while a
|
||||
// freshly written list is []any; both shapes carry the same entries.
|
||||
var list []any
|
||||
switch tables := raw.(type) {
|
||||
case []any:
|
||||
list = tables
|
||||
case []map[string]any:
|
||||
list = make([]any, len(tables))
|
||||
for i, table := range tables {
|
||||
list[i] = table
|
||||
}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return template.JS("[]"), 0
|
||||
}
|
||||
encoded, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
slog.Warn("admin: cannot encode the post references", "slug", p.Slug(), "error", err)
|
||||
return template.JS("[]"), 0
|
||||
}
|
||||
return template.JS(strings.ReplaceAll(string(encoded), "<", `\u003c`)), len(list)
|
||||
}
|
||||
|
||||
// newPostCard maps a stored post onto one dashboard card.
|
||||
func newPostCard(p *post.Post) postCard {
|
||||
card := postCard{
|
||||
Slug: p.Slug(),
|
||||
Title: p.Title(),
|
||||
Excerpt: p.Excerpt(),
|
||||
Lang: p.Lang(),
|
||||
Series: p.Series(),
|
||||
DateString: p.DateString(),
|
||||
Cover: p.Cover(),
|
||||
Draft: p.Draft(),
|
||||
Scheduled: p.Scheduled(),
|
||||
Tags: p.Tags(),
|
||||
}
|
||||
if order, ok := p.SeriesOrder(); ok {
|
||||
card.HasOrder = true
|
||||
card.SeriesOrder = order
|
||||
}
|
||||
if ts, ok := p.PublishedTimestamp(); ok {
|
||||
card.Timestamp = ts
|
||||
}
|
||||
switch {
|
||||
case card.Draft:
|
||||
card.Status = "draft"
|
||||
case card.Scheduled:
|
||||
card.Status = "scheduled"
|
||||
default:
|
||||
card.Status = "published"
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
// newRevisionRow formats one revision for the history table.
|
||||
func newRevisionRow(rev store.Revision) revisionRow {
|
||||
return revisionRow{
|
||||
Name: rev.Name,
|
||||
When: rev.When,
|
||||
SizeKB: fmt.Sprintf("%.1f", float64(rev.Size)/1024),
|
||||
}
|
||||
}
|
||||
|
||||
// tplOptions converts stored post templates for the dropdown and the
|
||||
// embedded JSON blob.
|
||||
func tplOptions(list []templates.PostTemplate) []tplOption {
|
||||
out := make([]tplOption, 0, len(list))
|
||||
for _, tpl := range list {
|
||||
tags := tpl.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
out = append(out, tplOption{
|
||||
Name: tpl.Name,
|
||||
Title: tpl.Title,
|
||||
Slug: tpl.Slug,
|
||||
Tags: tags,
|
||||
Body: tpl.Body,
|
||||
Fields: tpl.Fields,
|
||||
FieldsText: fieldsText(tpl.Fields),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fieldsText renders a sorted "key = value" summary of template fields.
|
||||
func fieldsText(fields map[string]string) string {
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(fields))
|
||||
for _, key := range slices.Sorted(maps.Keys(fields)) {
|
||||
parts = append(parts, key+" = "+fields[key])
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// templatesJSON renders the template list as a JS literal.
|
||||
func templatesJSON(list []tplOption) template.JS {
|
||||
raw, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
slog.Warn("admin: cannot encode post templates", "error", err)
|
||||
return template.JS("[]")
|
||||
}
|
||||
// A template body is free-text admin content, and encoding/json/v2
|
||||
// escapes only what JSON requires: a literal "</script>" inside the
|
||||
// JSON would end the script element the literal is embedded in. "<"
|
||||
// cannot occur outside a string in JSON, so escaping it as a unicode
|
||||
// escape inside the literal closes the hole while staying valid JSON.
|
||||
return template.JS(strings.ReplaceAll(string(raw), "<", `\u003c`))
|
||||
}
|
||||
Reference in New Issue
Block a user