Files

481 lines
17 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// 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)
}