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