Stage 1, step 1. BREAKING: a 1.x database is not migrated.
Schema
- internal/store/migrations/ holds one file, 0001_init.sql, equal to the
2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
admin table created and dropped, no ALTERs.
- users.email replaces users.dmarc_report_email: the address is the
user's own. The default DMARC report address is a user's setting
(dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
the instance setting that mirrored the global user's field is gone.
- domains.dmarc_rua_user_id: a domain follows a named user's default, or
has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
leaves it at "no reports", never at someone else's default.
- user_inbound_domains, users.all_domains, users.all_inbound_domains: in
the schema for the inbound delegation of the next step.
- users.role is 'global' / 'domain' (was 'domain_admin');
rate_limits.allowed_ips, unused since 1.9.0, is dropped.
Opening a database
- The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
file that has a schema version without it (ErrForeignSchema) and one
newer than the build. A 1.x file at user_version 10 would otherwise read
as already migrated. The refused file is left untouched.
Code
- Store.DomainDMARCRua is the one place a domain's report address is
resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
DNS re-check go through it.
- The old templates keep working on the new model until their pages are
restyled: the Settings form's address field is the account e-mail and
switches the user's default between it and none; a domain's "Same as
Settings" makes it follow the user who chose it; a new or imported
domain follows its creator.
- Domain export/import: a domain that follows a user exports no address.
Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.
docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.
go test ./... (Windows)
ok for every package except two that fail the same way before this commit:
internal/domain TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
internal/logtail TestFollowTailsAndRotates (rename of an open file)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
295 lines
9.6 KiB
Go
295 lines
9.6 KiB
Go
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) {
|
|
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())
|
|
case http.MethodPost:
|
|
h.submitSettings(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()
|
|
}
|
|
}
|
|
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["Error"] = formErr
|
|
data["Flash"] = settingsFlash(r)
|
|
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
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
|
|
return
|
|
}
|
|
|
|
user, 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
|
|
}
|
|
|
|
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())
|
|
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())
|
|
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, "&")
|
|
}
|
|
|
|
// sendLogRetentionDays returns the effective delivery-journal retention window.
|
|
func (h *Handlers) sendLogRetentionDays() int {
|
|
days, err := h.store.GetSendLogRetentionDays(h.cfg.SendLogRetentionEnvDefault)
|
|
if err != nil {
|
|
logf("panel: send-log retention: %v", err)
|
|
if h.cfg.SendLogRetentionEnvDefault > 0 {
|
|
return h.cfg.SendLogRetentionEnvDefault
|
|
}
|
|
return store.SendLogRetentionDaysDefault
|
|
}
|
|
return days
|
|
}
|