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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user