Files
selfpost/internal/web/handlers/handlers_apps.go
T
mixandClaude Opus 5.5 bd746c61d7 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>
2026-10-09 21:51:55 +03:00

515 lines
17 KiB
Go

package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
)
// newCred carries a freshly generated login/password to the template so it can
// be shown exactly once (security.md). It is never read back from storage.
type newCred struct {
Login string
Password string
}
// detailView holds the one-shot, request-specific extras layered on top of a
// domain's persistent state when rendering its page: an application-form error,
// the values to repopulate that form, and any just-issued credential to show
// once.
type detailView struct {
FormErr string
FormLogin string
FormMode string
FormAddrs string
NewCred *newCred
// RateLimitErr surfaces a validation error from a domain- or
// application-level rate-limit form (guide § Rate limiting) as a page
// banner.
RateLimitErr string
// ExportErr surfaces a rejected encryption password from the export card.
ExportErr string
}
// appRateLimitView pairs an application with its differentiated rate-limit
// settings for the domain page. store.Application is embedded so the existing
// template fields (Login, AddressMode, Addresses, ID) resolve unchanged.
type appRateLimitView struct {
store.Application
HasLimit bool
AuthIPsText string
MaxText string
WindowVal string
Mode string
AutoMultiplier string
AutoUpdated string
IsAuto bool
Stats sendStatsView
}
// sendStatsView is the template-facing send statistics block.
type sendStatsView struct {
Total int64
PeakPerHour int64
AvgPerHour string
}
// HandleDomainDetail shows a single domain: its DKIM DNS record (product.md)
// and its applications with the controls to add, edit, delete and re-issue
// credentials (product.md).
func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
}
// renderDomainDetail renders the domain page. view supplies request-specific
// extras (form error/values, a one-time credential); everything else is loaded
// fresh from the stores so the page always reflects committed state.
func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
apps, err := h.apps.List(d.ID)
if err != nil {
logf("panel: domain %d: list applications: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appViews := make([]appRateLimitView, 0, len(apps))
retention := h.sendLogRetentionDays()
for _, a := range apps {
rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil {
logf("panel: application %d: rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appStats, err := h.store.AppSendStats(a.Login, retention, a.CreatedAt)
if err != nil {
logf("panel: application %d: send stats: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
mode := store.RateLimitModeManual
if ok && rl.Mode != "" {
mode = rl.Mode
}
mult := rl.AutoMultiplier
if mult <= 0 {
mult = store.DefaultAutoMultiplier
}
appViews = append(appViews, appRateLimitView{
Application: a,
HasLimit: ok && rl.Active(),
AuthIPsText: strings.Join(a.AuthAllowedIPs, "\n"),
MaxText: intOrBlank(rl.MaxMessages),
WindowVal: windowOrDefault(rl.WindowSeconds),
Mode: mode,
AutoMultiplier: formatMultiplier(mult),
IsAuto: ok && rl.IsAuto(),
AutoUpdated: formatAutoUpdated(rl.AutoUpdatedAt),
Stats: formatSendStats(appStats),
})
}
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domainStats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt)
if err != nil {
logf("panel: domain %d: send stats: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domainMode := store.RateLimitModeManual
domainMult := store.DefaultAutoMultiplier
if domainRLok {
if domainRL.Mode != "" {
domainMode = domainRL.Mode
}
if domainRL.AutoMultiplier > 0 {
domainMult = domainRL.AutoMultiplier
}
}
// What DNS actually publishes for the domain today, checked against the key
// this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing.
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
dns, srv := h.domainDNS(d, record, reportEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
hostedAddr := h.hostedDMARCAddress(d.Name)
// The form's "inherit" is a domain that follows a user's default. What it
// offers next to that choice is the address following would give: the
// followed user's when the domain already follows one, otherwise the
// signed-in user's own — choosing it makes the domain follow them.
dmarcMode := "inherit"
dmarcCustom := ""
profileEmail := reportEmail
if !d.DMARCRuaUserID.Valid {
profileEmail = ""
if p, ok := h.principal(r); ok {
if def, err := h.store.GetDMARCDefault(p.ID); err == nil {
profileEmail = def.Resolve(hostedAddr)
}
}
switch {
case d.DMARCRua == "":
dmarcMode = "none"
case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr):
dmarcMode = "hosted"
default:
dmarcMode = "custom"
dmarcCustom = d.DMARCRua
}
}
dmarcSource := "policy"
switch {
case dmarcMode == "hosted":
dmarcSource = "hosted"
case dmarcMode == "custom":
dmarcSource = "custom"
case dmarcMode == "none":
dmarcSource = "none"
case reportEmail != "":
dmarcSource = "settings"
}
data := h.pageBase(r)
data["Title"] = "SelfPost — " + d.Name
data["Active"] = "domains"
data["Domain"] = d
data["Record"] = record
data["DNS"] = dns
data["SPFExample"] = dnscheck.SPFExample(h.cfg.Hostname, srv.IPs)
data["DMARCName"] = dnscheck.DMARCRecordName(d.Name)
data["DMARCExample"] = dnscheck.DMARCExample(reportEmail)
data["DMARCSource"] = dmarcSource
data["ProfileDMARCEmail"] = profileEmail
data["ResolvedDMARCEmail"] = reportEmail
data["DMARCRuaMode"] = dmarcMode
data["DMARCRuaCustom"] = dmarcCustom
data["HostedDMARCEmail"] = hostedAddr
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
data["ReportAuthName"] = reportAuthName
data["ReportAuthValue"] = reportAuthValue
data["NeedsReportAuth"] = needsReportAuth
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname))
data["Hostname"] = h.cfg.Hostname
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
data["Apps"] = appViews
data["Error"] = view.FormErr
data["FormLogin"] = view.FormLogin
data["FormMode"] = view.FormMode
data["FormAddrs"] = view.FormAddrs
data["NewCred"] = view.NewCred
data["Flash"] = detailFlash(r)
data["Wildcard"] = store.AddressModeWildcard
data["List"] = store.AddressModeList
data["RateLimitErr"] = view.RateLimitErr
data["ExportErr"] = view.ExportErr
data["MinPwLen"] = validate.MinSecretFilePasswordLen
data["DomainHasRL"] = domainRLok && domainRL.Active()
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
data["DomainRLMaxNum"] = domainRL.MaxMessages
data["DomainRLMode"] = domainMode
data["DomainRLAuto"] = domainRLok && domainRL.IsAuto()
data["DomainRLMultiplier"] = formatMultiplier(domainMult)
data["DomainRLAutoUpdated"] = formatAutoUpdated(domainRL.AutoUpdatedAt)
data["DomainStats"] = formatSendStats(domainStats)
data["StatsWindowDays"] = domainStats.WindowDays
data["StatsRetentionWarning"] = retention < store.StatsWindowDays
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
data["DefaultAutoMultiplier"] = store.DefaultAutoMultiplier
data["MinAutoMultiplier"] = store.MinAutoMultiplier
data["MaxAutoMultiplier"] = store.MaxAutoMultiplier
h.view.Render(w, status, "domain_detail", data)
}
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
// records. The server's own address comes from the (separately
// cached) hostname check, so the SPF heuristic knows which IP it is looking for
// and no extra environment variable is needed. That server result is returned
// alongside, because the page's suggested SPF record is built from the same
// addresses. force bypasses the cache, for the Re-check button.
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, reportEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
srv := h.dns.Server(h.cfg.Hostname, false)
return h.dns.Domain(dnscheck.Query{
Name: d.Name,
Selector: d.DKIMSelector,
ExpectedDKIM: record.Value,
Hostname: srv.Hostname,
ServerIPs: srv.IPs,
DMARCReportEmail: reportEmail,
}, force), srv
}
// HandleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and
// returns to its page, which then renders the fresh result.
func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.domainDNS(d, record, reportEmail, true)
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
// intOrBlank renders a non-positive number as an empty string so an unset field
// shows blank rather than "0".
func intOrBlank(n int) string {
if n <= 0 {
return ""
}
return strconv.Itoa(n)
}
// windowOrDefault renders the window seconds, substituting the default when
// unset so the form always suggests a sensible value.
func windowOrDefault(n int) string {
if n <= 0 {
return strconv.Itoa(defaultRateLimitWindowSeconds)
}
return strconv.Itoa(n)
}
func formatSendStats(s store.SendStats) sendStatsView {
return sendStatsView{
Total: s.Total,
PeakPerHour: s.PeakPerHour,
AvgPerHour: fmt.Sprintf("%.1f", s.AvgPerHour),
}
}
func formatMultiplier(v float64) string {
return strconv.FormatFloat(v, 'f', 1, 64)
}
func formatAutoUpdated(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04 UTC")
}
// detailFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func detailFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("appdeleted") != "":
return "Application deleted."
case r.URL.Query().Get("modeupdated") != "":
return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated."
case r.URL.Query().Get("authips") != "":
return "Client IP restriction updated."
case r.URL.Query().Get("recalculated") != "":
return "Auto rate limit recalculated."
case r.URL.Query().Get("dmarc") != "":
return "DMARC report settings updated."
case r.URL.Query().Get("imported") != "":
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
// HandleAddApplication creates an application on a domain and renders the page
// back with the generated password shown once (product.md, security.md). Because the
// password cannot be recovered later, this deliberately renders inline rather
// than redirecting.
func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d,
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
return
}
login := strings.TrimSpace(r.PostFormValue("login"))
mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
repopulate := detailView{
FormLogin: login,
FormMode: mode,
FormAddrs: r.PostFormValue("addresses"),
}
a, password, err := h.apps.Create(d.ID, login, mode, addrs)
if err != nil {
repopulate.FormErr = applicationErrorMessage(err)
status := http.StatusBadRequest
if errors.Is(err, store.ErrLoginExists) {
status = http.StatusConflict
}
h.renderDomainDetail(w, r, status, d, repopulate)
return
}
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
}
// HandleUpdateAppMode switches an application's address mode / list (product.md).
func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
mode := r.PostFormValue("mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil {
d, derr := h.domains.Get(a.DomainID)
if derr != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)),
FormMode: store.AddressModeWildcard,
})
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
}
// HandleRegenPassword issues a new password for an application and shows it once
// (product.md, security.md). Rendered inline, like creation, so the password is visible.
func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
d, err := h.domains.Get(a.DomainID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
password, err := h.apps.RegeneratePassword(a.ID)
if err != nil {
logf("panel: regenerate password for application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
}
// HandleDeleteApplication removes an application and returns to its domain page
// (product.md).
func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
if err := h.apps.Delete(a.ID); err != nil {
logf("panel: delete application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
}
// lookupApplication resolves the {aid} path value to an application, writing a
// 404 for a bad id or missing application.
func (h *Handlers) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) {
id, err := strconv.ParseInt(r.PathValue("aid"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return store.Application{}, false
}
a, err := h.apps.Get(id)
if err != nil {
if errors.Is(err, store.ErrApplicationNotFound) {
http.NotFound(w, r)
return store.Application{}, false
}
logf("panel: get application %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Application{}, false
}
p, ok := h.principal(r)
if !ok || !p.CanAccessApp(a) {
http.NotFound(w, r)
return store.Application{}, false
}
return a, true
}
// splitAddresses turns the textarea/field input (addresses separated by
// newlines, commas or whitespace) into a raw slice. Normalisation and
// validation happen in the app service (security.md).
func splitAddresses(s string) []string {
return strings.FieldsFunc(s, func(r rune) bool {
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
})
}
// applicationErrorMessage turns a service error into a user-facing message,
// passing through the validation errors (which are safe, fixed strings) and
// masking anything unexpected.
func applicationErrorMessage(err error) string {
switch {
case errors.Is(err, store.ErrLoginExists):
return "That login is already in use. Choose another."
case errors.Is(err, store.ErrDomainNotFound), errors.Is(err, store.ErrApplicationNotFound):
return "The item no longer exists."
default:
// Validation errors from the app service are safe to surface verbatim;
// they describe what the admin must fix (login/address rules).
return err.Error()
}
}