// Copyright (c) 2026 Petr BalvĂ­n (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, "", 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) }