Files
volumen/internal/admin/settings_account.go
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

222 lines
7.8 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"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/i18n"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/session"
)
// passwordPolicy returns the configured length bounds. Validate
// guarantees a minimum of at least one and a maximum at or above it
// before the server starts.
func (a *Admin) passwordPolicy() (int, int) {
return a.deps.Config.Admin.MinPasswordLength, a.deps.Config.Admin.MaxPasswordLength
}
func (a *Admin) handleSettingsPassword(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := a.currentUser(r)
if a.deps.Users.Authenticate(username, r.PostFormValue("current_password")) == nil {
a.renderSettings(w, r, a.tr(r, "Current password is incorrect."), "", http.StatusUnprocessableEntity)
return
}
newPassword := r.PostFormValue("new_password")
if strings.TrimSpace(newPassword) == "" {
a.renderSettings(w, r, a.tr(r, "New password cannot be empty."), "", http.StatusUnprocessableEntity)
return
}
minLen, maxLen := a.passwordPolicy()
if key, n := PasswordError(newPassword, minLen, maxLen); key != "" {
msg := a.tr(r, key)
if n > 0 {
msg = i18n.Admin.N(a.langFor(r), key, n)
}
a.renderSettings(w, r, msg, "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdatePassword(username, newPassword); err != nil {
a.renderSettings(w, r, a.trf(r, "The new password could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
// Every other session dies with the changed fingerprint; this one is
// re-bound, so the device the change was made on stays signed in.
if updated := a.deps.Users.Find(username); updated != nil {
session.FromContext(r.Context()).Set("pv", sessionFingerprint(updated.PasswordHash))
}
a.record(r, "user.password_changed", username, nil)
a.renderSettings(w, r, "", a.tr(r, "Password updated."), http.StatusOK)
}
func (a *Admin) handleSettingsUsername(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
current := a.currentUser(r)
name := strings.TrimSpace(r.PostFormValue("username"))
sess := session.FromContext(r.Context())
switch {
case name == "":
a.renderSettings(w, r, a.tr(r, "Username cannot be empty."), "", http.StatusUnprocessableEntity)
case !usernameRe.MatchString(name):
a.renderSettings(w, r, a.tr(r, "Username may use letters, numbers, dot, dash, underscore."), "", http.StatusUnprocessableEntity)
case name == current:
a.renderSettings(w, r, "", a.tr(r, "Username unchanged."), http.StatusOK)
default:
if _, err := a.deps.Users.Rename(current, name); err != nil {
a.renderSettings(w, r, a.trf(r, "The username could not be changed: %s", err.Error()), "", http.StatusUnprocessableEntity)
return
}
sess.Set("user", name)
a.record(r, "user.renamed", name, nil)
a.renderSettings(w, r, "", a.tr(r, "Username updated."), http.StatusOK)
}
}
func (a *Admin) handleSettingsName(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
name := strings.TrimSpace(r.PostFormValue("name"))
if _, err := a.deps.Users.UpdateName(a.currentUser(r), name); err != nil {
a.renderSettings(w, r, a.trf(r, "The display name could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
notice := a.tr(r, "Display name updated.")
if name == "" {
notice = a.tr(r, "Display name cleared.")
}
a.renderSettings(w, r, "", notice, http.StatusOK)
}
func (a *Admin) handleSettingsFediverse(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
value := strings.TrimSpace(r.PostFormValue("fediverse_creator"))
if value == "" {
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle cleared."), http.StatusOK)
return
}
if !fediverse.Valid(value) {
a.renderSettings(w, r, a.tr(r, "Fediverse handle must look like @user@host."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdateFediverseCreator(a.currentUser(r), value); err != nil {
a.renderSettings(w, r, a.trf(r, "The handle could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "Fediverse handle updated."), http.StatusOK)
}
func (a *Admin) handleSettingsOrcid(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
value := identifiers.NormalizeORCID(r.PostFormValue("orcid"))
if value == "" {
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "ORCID cleared."), http.StatusOK)
return
}
if !identifiers.ValidORCID(value) {
a.renderSettings(w, r, a.tr(r, "ORCID must look like 0000-0002-1825-0097."), "", http.StatusUnprocessableEntity)
return
}
if _, err := a.deps.Users.UpdateOrcid(a.currentUser(r), value); err != nil {
a.renderSettings(w, r, a.trf(r, "The ORCID could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
a.renderSettings(w, r, "", a.tr(r, "ORCID updated."), http.StatusOK)
}
func (a *Admin) handleSettingsPhoto(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
file, header, err := r.FormFile("photo")
if err != nil {
a.renderSettings(w, r, a.tr(r, "No file selected."), "", http.StatusUnprocessableEntity)
return
}
defer file.Close()
raw, err := readLimited(file, int64(a.deps.Config.Admin.MaxUploadBytes))
if err != nil {
a.renderSettings(w, r, a.tr(r, "File is too large."), "", http.StatusUnprocessableEntity)
return
}
if validateImageData(raw) != "" {
a.renderSettings(w, r,
a.tr(r, "Only WebP, AVIF and SVG images are supported."), "", http.StatusUnprocessableEntity)
return
}
username := a.currentUser(r)
url, err := a.deps.Store.StoreUpload(header.Filename, raw)
if err != nil {
a.renderSettings(w, r, a.tr(r, "The photo could not be stored."), "", http.StatusInternalServerError)
return
}
previous := ""
if record := a.deps.Users.Find(username); record != nil {
previous = record.Photo
}
if _, err := a.deps.Users.UpdatePhoto(username, url); err != nil {
a.renderSettings(w, r, a.trf(r, "The profile photo could not be saved: %s", err.Error()), "", http.StatusInternalServerError)
return
}
if previous != "" && previous != url {
a.deleteUnreferencedMedia(previous)
}
a.renderSettings(w, r, "", a.tr(r, "Profile photo updated."), http.StatusOK)
}
func (a *Admin) handleSettingsPhotoRemove(w http.ResponseWriter, r *http.Request) {
if !a.requireCSRF(w, r) {
return
}
username := a.currentUser(r)
previous := ""
if record := a.deps.Users.Find(username); record != nil {
previous = record.Photo
}
if _, err := a.deps.Users.UpdatePhoto(username, ""); err != nil {
a.renderSettings(w, r, a.trf(r, "The profile photo could not be removed: %s", err.Error()), "", http.StatusInternalServerError)
return
}
if previous != "" {
a.deleteUnreferencedMedia(previous)
}
a.renderSettings(w, r, "", a.tr(r, "Profile photo removed."), http.StatusOK)
}
// deleteUnreferencedMedia removes a photo file no user references any
// more.
func (a *Admin) deleteUnreferencedMedia(url string) {
if !strings.HasPrefix(url, "/media/") {
return
}
for _, user := range a.deps.Users.All() {
if user.Photo == url {
return
}
}
a.deps.Store.DeleteMedia(url)
}
// --- users panel ------------------------------------------------------------