panel-redesign: paths follow the menu; Account and Server settings split

Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.

Routes (web.go)
  - /status -> /overview; /domains -> /outbound/domains with the domain's
    settings actions under .../settings/ (reports, ratelimit, export);
    /deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
    /outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
    /server/log; /backup -> /server/backup (import under it); /users ->
    /server/users; recheck and reload under /server/health/.
  - One fragment name: <page>/fragment everywhere.
  - Everything under /server/ is registered through server(), which wraps
    the handler in globalOnly: the subtree is guarded once, where a new
    route cannot forget it. Handlers keep their own requireGlobal.
  - GET / leads to /overview, /outbound/domains or /inbound/domains.
  - The /account -> /settings redirect is gone with /settings.

Account and Server > Settings
  - handlers_account.go: GET /account and three POSTs - profile (username,
    e-mail; no password asked, as designed), password (current password,
    rate-limited like a sign-in, signs out other sessions), dmarc (the
    user's default: hosted / account / custom / none, each validated).
    This replaces step 1's bridge through the old Settings form.
  - handlers_settings.go is the instance's page only: log retention and the
    read-only rate limits, global role.
  - One legacy template serves both pages by a flag until stage 2 gives each
    its own: a new legacy page cannot be added to the ratchet.

Still to come inside stage 1, and why @routes stays on the ratchet:
  - step 4: the application routes (one POST, under the domain);
  - step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.

Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).

Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.

go test ./...  green except the two Windows-only failures in internal/domain
and internal/logtail.

Roadmap 13/27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-09 21:51:55 +03:00
co-authored by Claude Opus 5.5
parent 3b86f6c3b6
commit bd746c61d7
55 changed files with 1006 additions and 692 deletions
+31 -242
View File
@@ -1,283 +1,72 @@
package handlers
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
"golang.org/x/crypto/bcrypt"
)
// HandleSettings serves the signed-in user's panel settings.
func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
// Server › Settings holds what is true of the whole instance, whoever is
// signed in: log retention and the read-only view of the rate limits. A
// user's own name, e-mail, password and DMARC default are on Account
// (handlers_account.go). Before placing a setting here, ask whose it is.
// HandleServerSettings shows and saves the instance settings (global role).
func (h *Handlers) HandleServerSettings(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
switch r.Method {
case http.MethodGet:
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
u, err := h.store.GetUser(p.ID)
if err != nil {
logf("panel: settings: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal())
h.renderServerSettings(w, r, http.StatusOK, "", h.sendLogRetentionDays())
case http.MethodPost:
h.submitSettings(w, r)
h.submitServerSettings(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, formSendLogRetentionDays int, showDMARC bool) {
var reportAuth dnscheck.Result
if showDMARC && formDMARCEmail != "" {
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
reportAuth = h.dns.ReportAuth(ctx, hub)
cancel()
}
}
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, formRetention int) {
data := h.pageBase(r)
data["Title"] = "SelfPost — settings"
data["Active"] = "settings"
data["FormUsername"] = formUsername
data["FormDMARCEmail"] = formDMARCEmail
data["FormSendLogRetentionDays"] = formSendLogRetentionDays
data["ShowDMARC"] = showDMARC
data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
data["ReportAuthExample"] = dnscheck.ReportAuthExample()
data["ReportAuthDNS"] = reportAuth
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
data["AccountPage"] = false
data["FormSendLogRetentionDays"] = formRetention
data["Error"] = formErr
data["Flash"] = settingsFlash(r)
if r.URL.Query().Has("saved") {
data["Flash"] = "Settings saved."
}
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
if h.dmarc != nil && h.cfg.DMARCEnabled {
data["HostedReportAddress"] = h.dmarc.DefaultHostedSuggestion()
}
h.view.Render(w, status, "settings", data)
}
func settingsFlash(r *http.Request) string {
q := r.URL.Query()
var parts []string
if q.Has("u") {
parts = append(parts, "username")
}
if q.Has("p") {
parts = append(parts, "password")
}
if q.Has("e") {
parts = append(parts, "DMARC report address")
}
if q.Has("r") {
parts = append(parts, "send log retention")
}
if len(parts) == 0 {
return ""
}
msg := strings.Join(parts, ", ") + " updated."
msg = strings.ToUpper(msg[:1]) + msg[1:]
if q.Has("p") {
msg += " Any other signed-in sessions were signed out."
}
return msg
}
func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
currentRetention := h.sendLogRetentionDays()
if !h.auth.AllowLoginAttempt(r) {
h.renderSettings(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
return
}
func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
current := h.sendLogRetentionDays()
if err := r.ParseForm(); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
h.renderServerSettings(w, r, http.StatusBadRequest, "Invalid form submission.", current)
return
}
user, err := h.store.GetUser(p.ID)
days, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("send_log_retention_days")))
if err != nil {
logf("panel: settings: get user failed: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
h.renderServerSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", current)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
current := r.PostFormValue("current_password")
password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm")
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
formRetention := currentRetention
if !p.IsGlobal() {
dmarcEmail = user.Email
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
h.renderSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", username, dmarcEmail, currentRetention, true)
return
}
formRetention = parsed
}
if username == "" {
username = user.Username
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, formRetention, p.IsGlobal())
if err := store.ValidateSendLogRetentionDays(days); err != nil {
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), days)
return
}
renaming := username != user.Username
if renaming {
if err := validate.Username(username); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, p.IsGlobal())
return
}
}
if p.IsGlobal() {
if err := validate.Email(dmarcEmail); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, true)
return
}
}
emailChanging := p.IsGlobal() && dmarcEmail != user.Email
retentionChanging := false
if p.IsGlobal() && formRetention != currentRetention {
if err := store.ValidateSendLogRetentionDays(formRetention); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, true)
return
}
retentionChanging = true
}
repassword := password != "" || confirm != ""
if repassword {
if password != confirm {
h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, formRetention, p.IsGlobal())
return
}
if err := validate.AdminPassword(password); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, p.IsGlobal())
return
}
}
if !renaming && !repassword && !emailChanging && !retentionChanging {
h.renderSettings(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, password, DMARC report address, or send log retention.", username, dmarcEmail, formRetention, p.IsGlobal())
if err := h.store.SetSendLogRetentionDays(days); err != nil {
logf("panel: settings: set send-log retention failed: %v", err)
h.renderServerSettings(w, r, http.StatusInternalServerError,
"Could not save send log retention. Please check the logs and try again.", days)
return
}
hash := user.PasswordHash
if repassword {
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
logf("panel: settings: hashing password failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username, dmarcEmail, formRetention, p.IsGlobal())
return
}
hash = string(newHash)
}
if renaming || repassword || emailChanging {
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
logf("panel: settings: update user failed: %v", err)
msg := "Could not save the changes. Please check the logs and try again."
if errors.Is(err, store.ErrUserNotFound) {
msg = "There is no user account to update."
}
if errors.Is(err, store.ErrUserExists) {
msg = "That username is already in use."
h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, formRetention, p.IsGlobal())
return
}
h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, formRetention, p.IsGlobal())
return
}
}
// Until the Account page replaces this form (stage 2), its one "default
// report address" field stands for both halves of the 2.0 model: it is the
// account e-mail, and the user's DMARC default is "my account e-mail" while
// it is filled and "none" once it is cleared.
if emailChanging {
mode := store.DMARCDefaultNone
if dmarcEmail != "" {
mode = store.DMARCDefaultAccount
}
if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil {
logf("panel: settings: set dmarc default failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal())
return
}
}
if retentionChanging {
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
logf("panel: settings: set send-log retention failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Could not save send log retention. Please check the logs and try again.", username, dmarcEmail, formRetention, true)
return
}
}
if emailChanging && h.dmarc != nil && h.dmarc.Enabled() {
if err := h.dmarc.Resync(); err != nil {
logf("panel: settings: dmarc resync: %v", err)
}
}
if token, ok := h.auth.SessionToken(r); ok {
if renaming {
h.auth.RenameSession(token, username)
}
if repassword {
h.auth.DestroyOtherSessions(token)
}
}
logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t, retention: %t)", user.ID, renaming, repassword, emailChanging, retentionChanging)
http.Redirect(w, r, "/settings?"+updatedQuery(renaming, repassword, emailChanging, retentionChanging), http.StatusSeeOther)
}
func updatedQuery(renamed, repassword, emailChanged, retentionChanged bool) string {
var params []string
if renamed {
params = append(params, "u=1")
}
if repassword {
params = append(params, "p=1")
}
if emailChanged {
params = append(params, "e=1")
}
if retentionChanged {
params = append(params, "r=1")
}
return strings.Join(params, "&")
logf("panel: instance settings updated (send-log retention: %d days)", days)
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
}
// sendLogRetentionDays returns the effective delivery-journal retention window.