Files
volumen/internal/admin/settings_routes.go
T
petrbalvin f8ed33df83
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
Initial commit
Assisted-by: GLM 5.3
2026-09-29 10:03:32 +02:00

180 lines
8.0 KiB
Go

// 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)
}