180 lines
8.0 KiB
Go
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)
|
||
|
|
}
|