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