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)
|
||||
}
|
||||
Reference in New Issue
Block a user