panel-redesign: Outbound domains, domain, settings, application on the kit

Stage 2, step 3. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: out-domains, out-domain,
    out-domain-settings, out-app, out-app-created, out-domain-delete,
    with typed page data (view/outbound.go, outbound_app.go), fixtures
    and tests. The engine pages dashboard, domain_detail and
    domain_delete are gone with their templates and are off
    legacy_pages.txt. GET .../settings has its own handler.
  - Handlers pass typed data; routes, POST targets, field names,
    validation, authorization (both roles, another tenant's id is a 404)
    and Cache-Control: no-store on the password page are unchanged.
  - Kit, attribute only: Action.Confirm writes data-confirm on a POST
    action's form (panel.js already reads it).
  - test/e2e/panel_client.go scrapes the new markup (copy fields, the
    application row's Edit link). Not run: no Docker here.

Fixes found on the way
  - Report address of a domain that follows another user's default: the
    select had one "inherit" option, selected, so saving the form
    untouched moved the domain to whoever saved it. It now offers that
    user's default as "keep" (selected, saves nothing) and "My default
    instead" as the change, as the mockup draws it; "keep" on a domain
    that does not follow someone else is refused.
  - panel.js: syncShowWhen stopped at the first matching rule, so the
    auto-limit fields were never toggled with the limit mode. Every
    matching rule applies now.

Differences from the mockups, for the owner:
  - application rows carry Edit only. New password and Delete are POSTs,
    and the kit has no link-styled POST control for a table row; both are
    in the application page's head with a confirmation. Drawing them in
    the row needs a design: change (as Sign out was);
  - the application login is read-only on edit (the handler refuses a
    rename); the submit says Create application on the empty form;
  - multiplier bounds are the store's 1.5-5, default 2.5;
  - "Recalculate" buttons exist where the stored limit is automatic;
  - a domain administrator has no add box and no Delete; Delete domain is
    global-only on the domain page and on settings;
  - a fourth record, Report authorization, appears when the report
    address is on another domain; record states missing / weak / not
    checked are tags with the explanation under the value;
  - refusals are the kit flash above the first box, and those of the
    settings forms now show on the settings page; successful saves still
    redirect to the domain page;
  - the export checkbox is ticked by default, as drawn.

Evidence
  - docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
    out-domain-settings.png, out-app.png, out-app-created.png,
    out-domain-delete.png: 1440 px wide, headless Edge, real templates
    served locally with their fixtures - NOT from the container (no
    Docker on this machine). Taken before the report-address fix, which
    does not change what the fixture renders.
  - panel/audit.js on the six pages at 1440 and 1280 (text inside
    [hidden] blocks skipped): 866 texts checked, problems: [].
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 00:49:20 +03:00
co-authored by Claude Opus 5.5
parent 5c654723c2
commit acdfaaecd7
37 changed files with 2767 additions and 1010 deletions
+356 -236
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -14,74 +15,24 @@ import (
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
)
// 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
// RLMode is the form's limit choice: "domain" (no limit of its own),
// "manual" or "auto".
RLMode string
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).
// HandleDomainDetail shows a single domain: the DNS records it must publish and
// what DNS says about them, its applications and how to connect (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})
h.renderDomain(w, r, http.StatusOK, d)
}
// 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) {
// renderDomain renders the domain page. Everything is loaded fresh from the
// stores, so the page always reflects committed state; the DNS state is cached
// by the checker, so re-rendering after a redirect costs nothing.
func (h *Handlers) renderDomain(w http.ResponseWriter, r *http.Request, status int, d store.Domain) {
p, _ := h.principal(r)
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
@@ -94,49 +45,16 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appViews := make([]appRateLimitView, 0, len(apps))
retention := h.sendLogRetentionDays()
rows := make([]view.OutAppRow, 0, len(apps))
for _, a := range apps {
rl, ok, err := h.apps.RateLimit(a.ID)
row, err := h.outAppRow(d, a, retention)
if err != nil {
logf("panel: application %d: rate limit: %v", a.ID, err)
logf("panel: application %d: %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(),
RLMode: limitChoice(rl, ok),
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
rows = append(rows, row)
}
domainStats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt)
if err != nil {
@@ -144,20 +62,15 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
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
}
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
}
// 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.
// this server signs with.
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
@@ -165,98 +78,217 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
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
}
records := []view.Record{
view.DKIMRecord(d.DKIMSelector, record.Name, record.Value, dnsCheck(dns.DKIM)),
view.SPFRecord(d.Name, dnscheck.SPFExample(h.cfg.Hostname, srv.IPs), dnsCheck(dns.SPF)),
}
dmarcSource := "policy"
switch {
case dmarcMode == "hosted":
dmarcSource = "hosted"
case dmarcMode == "custom":
dmarcSource = "custom"
case dmarcMode == "none":
dmarcSource = "none"
case reportEmail != "":
dmarcSource = "settings"
reports := ""
if h.cfg.DMARCEnabled && reportEmail != "" {
reports = "/outbound/dmarc/domains/" + strconv.FormatInt(d.ID, 10)
}
records = append(records, view.DMARCRecord(view.DMARCRecordInput{
Host: dnscheck.DMARCRecordName(d.Name), Value: dnscheck.DMARCExample(reportEmail), Check: dnsCheck(dns.DMARC),
Source: h.reportSource(d, reportEmail), SettingsHref: view.DomainHref(d.ID) + "/settings", ReportsHref: reports,
SameDomain: reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname)),
}))
if name, value, needs := dnscheck.ExternalReportAuth(d.Name, reportEmail); needs {
records = append(records, view.ReportAuthRecord(name, value, dnsCheck(dns.DMARCReportAuth)))
}
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)
page := view.NewOutDomain(h.shellMeta(r), d.ID, d.Name, p.IsGlobal()).
WithStats(domainStats.Total, domainStats.PeakPerHour, fmt.Sprintf("%.1f", domainStats.AvgPerHour), domainStats.WindowDays).
WithRecords(records, view.FormatAge(dns.CheckedAt, time.Now())).
WithApplications(rows).
WithConnection(h.cfg.Hostname, h.cfg.SubmissionEnabled).
WithSeeAlso("/outbound/log?domain="+url.QueryEscape(d.Name), reports).
WithRateLimit(domainRLok && domainRL.IsAuto(), domainRLok && domainRL.Active() && !domainRL.IsAuto()).
WithResult(detailFlash(r))
h.view.Render(w, status, "out-domain", page)
}
// dnsCheck is a DNS result as the view reads it.
func dnsCheck(r dnscheck.Result) view.DNSCheck {
return view.DNSCheck{Status: string(r.Status), Detail: r.Detail, Found: r.Records}
}
// reportSource says in words where a domain's DMARC reports go: the default of
// the user it follows, the hosted address, an address of its own, or nowhere.
func (h *Handlers) reportSource(d store.Domain, reportEmail string) string {
switch {
case d.DMARCRuaUserID.Valid:
if def, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil && def.Username != "" {
return def.Username + "'s default"
}
return "a user's default"
case reportEmail == "":
return "none"
case strings.EqualFold(reportEmail, h.hostedDMARCAddress(d.Name)):
return "SelfPost hosted"
}
return "a custom address"
}
// outAppRow is one line of the Applications table of a domain.
func (h *Handlers) outAppRow(d store.Domain, a store.Application, retention int) (view.OutAppRow, error) {
rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil {
return view.OutAppRow{}, fmt.Errorf("rate limit: %w", err)
}
stats, err := h.store.AppSendStats(a.Login, retention, a.CreatedAt)
if err != nil {
return view.OutAppRow{}, fmt.Errorf("send stats: %w", err)
}
senders := a.Addresses
if a.AddressMode == store.AddressModeWildcard {
senders = []string{"*@" + d.Name}
}
limits := []view.Tag{{Label: "domain"}}
if ok && rl.Active() {
label := view.FormatRate(rl.MaxMessages, rl.WindowSeconds)
if rl.IsAuto() {
label = "auto · " + label
}
limits = []view.Tag{{Status: "ok", Label: label}}
}
if a.AuthIPRestrict {
label := strconv.Itoa(len(a.AuthAllowedIPs)) + " IPs"
if len(a.AuthAllowedIPs) == 1 {
label = "1 IP"
}
limits = append(limits, view.Tag{Status: "ok", Label: label})
}
return view.OutAppRow{
Login: a.Login, Senders: senders, Activity: view.FormatActivity(stats.Total, stats.PeakPerHour), Limits: limits,
Edit: fmt.Sprintf("%s/applications/%d", view.DomainHref(d.ID), a.ID),
}, nil
}
// settingsView is what a re-shown settings page carries besides the stored
// state: the refusal, and the report-address choice and custom address that
// were submitted (empty: show what is stored).
type settingsView struct {
Err string
RuaMode string
RuaCustom string
}
// HandleDomainSettings shows the settings that are set once and rarely
// touched: where DMARC reports go, the domain's rate limit, the export and the
// delete entry.
func (h *Handlers) HandleDomainSettings(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
h.renderDomainSettings(w, r, http.StatusOK, d, settingsView{})
}
func (h *Handlers) renderDomainSettings(w http.ResponseWriter, r *http.Request, status int, d store.Domain, sv settingsView) {
p, _ := h.principal(r)
rl, ok, 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
}
stats, err := h.store.DomainSendStats(d.Name, h.sendLogRetentionDays(), d.CreatedAt)
if err != nil {
logf("panel: domain %d: send stats: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
mult := rl.AutoMultiplier
if !ok || mult <= 0 {
mult = store.DefaultAutoMultiplier
}
rate := view.DomainRateLimit{
Active: ok && rl.Active(), Auto: ok && rl.IsAuto(),
MaxMessages: intOrBlank(rl.MaxMessages), Window: windowOrDefault(rl.WindowSeconds), Multiplier: formatMultiplier(mult),
Peak: stats.PeakPerHour, Updated: formatAutoUpdated(rl.AutoUpdatedAt),
L1Messages: h.l1Messages(), L1Window: h.l1Window(),
MinMultiplier: formatBound(store.MinAutoMultiplier), MaxMultiplier: formatBound(store.MaxAutoMultiplier),
DefaultMultiplier: formatBound(store.DefaultAutoMultiplier),
}
if rate.Auto {
rate.Computed = intOrBlank(rl.MaxMessages)
}
options, selected, custom, help := h.reportAddressForm(r, d)
if sv.RuaMode != "" {
selected, custom = sv.RuaMode, sv.RuaCustom
}
page := view.NewOutDomainSettings(h.shellMeta(r), d.ID, d.Name, d.AppCount, p.IsGlobal()).
WithReportAddress(options, selected, custom, help).
WithExport(validate.MinSecretFilePasswordLen).
WithRateLimit(rate).
WithResult("", sv.Err)
h.view.Render(w, status, "out-domain-settings", page)
}
// reportAddressForm is the choices of the report-address select for a domain
// and the signed-in user. "inherit" makes the domain follow the default of the
// user who saves the form (HandleDomainDMARC), so what the option says depends
// on whom the domain follows now: the user themselves, someone else, or nobody.
// A domain that follows someone else also offers "keep" — that user's default,
// selected — so saving the form untouched does not take the domain over.
func (h *Handlers) reportAddressForm(r *http.Request, d store.Domain) (options []view.Option, selected, custom string, help view.Text) {
hosted := h.hostedDMARCAddress(d.Name)
me, _ := h.principal(r)
mine, _ := h.store.GetDMARCDefault(me.ID)
address := func(a string) string {
if a == "" {
return "no report address yet"
}
return a
}
myAddr := address(mine.Resolve(hosted))
help = view.Rich("A default belongs to a user and is set under their ", view.Link("/account#dmarc", "Account"),
"; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.")
inherit := view.Option{Value: "inherit", Label: "My default — " + myAddr}
followsOther := d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 != me.ID
switch {
case followsOther:
inherit.Label = "My default instead — " + myAddr + " (you are " + me.Username + ")"
keep := view.Option{Value: "keep", Label: "Another user's default"}
if other, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil {
keep.Label = other.Username + "'s default — " + address(other.Resolve(hosted))
}
options = append(options, keep)
case d.DMARCRuaUserID.Valid:
inherit.Label = me.Username + "'s default — " + myAddr
options = append(options, inherit)
default:
options = append(options, inherit)
}
if hosted != "" {
options = append(options, view.Option{Value: "hosted", Label: "SelfPost hosted (" + hosted + ")"})
}
// The mockup's order: whose default is followed now, hosted, "mine instead".
if followsOther {
options = append(options, inherit)
}
options = append(options, view.Option{Value: "none", Label: "No aggregate reports"}, view.Option{Value: "custom", Label: "Custom address"})
switch {
case followsOther:
selected = "keep"
case d.DMARCRuaUserID.Valid:
selected = "inherit"
case d.DMARCRua == "":
selected = "none"
case hosted != "" && strings.EqualFold(d.DMARCRua, hosted):
selected = "hosted"
default:
selected, custom = "custom", d.DMARCRua
}
return options, selected, custom, help
}
// formatBound writes a multiplier bound the short way: "1.5", "5".
func formatBound(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
// 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
@@ -329,14 +361,6 @@ func windowOrDefault(n int) string {
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)
}
@@ -429,14 +453,129 @@ func noStore(w http.ResponseWriter) {
w.Header().Set("Pragma", "no-cache")
}
// HandleApplicationNew shows the form that adds an application. Until the
// domain page is split (stage 2) the form is a card of that page.
// blankAppForm is the application form as it opens: any address of the domain,
// no client IPs, the domain's limit.
func blankAppForm() view.OutAppForm {
return view.OutAppForm{
Mode: store.AddressModeWildcard, LimitMode: view.LimitDomain,
Window: strconv.Itoa(defaultRateLimitWindowSeconds), Multiplier: formatMultiplier(store.DefaultAutoMultiplier),
}
}
// submittedAppForm reads the application form as it was posted, to show it
// again with its refusal and what was typed.
func submittedAppForm(r *http.Request) view.OutAppForm {
f := blankAppForm()
f.Login = strings.TrimSpace(r.PostFormValue("login"))
if mode := r.PostFormValue("mode"); mode != "" {
f.Mode = mode
}
f.Addresses = r.PostFormValue("addresses")
f.IPRestrict = r.PostFormValue("auth_ip_restrict") != ""
f.AllowedIPs = r.PostFormValue("auth_allowed_ips")
if mode := strings.TrimSpace(r.PostFormValue("rl_mode")); mode != "" {
f.LimitMode = mode
}
f.MaxMessages = r.PostFormValue("max_messages")
if window := r.PostFormValue("window_seconds"); window != "" {
f.Window = window
}
if mult := r.PostFormValue("auto_multiplier"); mult != "" {
f.Multiplier = mult
}
return f
}
// renderApplicationForm renders the application form: empty for a new
// application (a == nil), or the stored one. submitted, when set, replaces the
// fields with what was posted; formErr is the refusal shown above the form.
func (h *Handlers) renderApplicationForm(w http.ResponseWriter, r *http.Request, status int, d store.Domain,
a *store.Application, submitted *view.OutAppForm, formErr string) {
fail := func(what string, err error) {
logf("panel: domain %d: application form: %s: %v", d.ID, what, err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
retention := h.sendLogRetentionDays()
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
if err != nil {
fail("domain rate limit", err)
return
}
rate := view.AppRateLimit{
L1Messages: h.l1Messages(), L1Window: h.l1Window(),
MinMultiplier: formatBound(store.MinAutoMultiplier), MaxMultiplier: formatBound(store.MaxAutoMultiplier),
DefaultMultiplier: formatBound(store.DefaultAutoMultiplier),
}
if domainRLok && domainRL.Active() {
rate.DomainLimit = view.FormatRate(domainRL.MaxMessages, domainRL.WindowSeconds)
}
var (
page *view.OutApp
form = blankAppForm()
state view.OutAppState
)
if a == nil {
page = view.NewOutApp(h.shellMeta(r), d.ID, d.Name, "")
} else {
rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil {
fail("rate limit", err)
return
}
stats, err := h.store.AppSendStats(a.Login, retention, a.CreatedAt)
if err != nil {
fail("send stats", err)
return
}
mult := rl.AutoMultiplier
if mult <= 0 {
mult = store.DefaultAutoMultiplier
}
form = view.OutAppForm{
Login: a.Login, Mode: a.AddressMode, Addresses: strings.Join(a.Addresses, "\n"),
IPRestrict: a.AuthIPRestrict, AllowedIPs: strings.Join(a.AuthAllowedIPs, "\n"),
LimitMode: limitChoice(rl, ok), MaxMessages: intOrBlank(rl.MaxMessages), Window: windowOrDefault(rl.WindowSeconds),
Multiplier: formatMultiplier(mult),
}
state = view.OutAppState{IPs: a.AuthIPRestrict, Limit: ok && rl.Active(), Auto: ok && rl.IsAuto()}
rate.Peak = stats.PeakPerHour
if state.Auto {
rate.Computed, rate.Updated = intOrBlank(rl.MaxMessages), formatAutoUpdated(rl.AutoUpdatedAt)
}
page = view.NewOutApp(h.shellMeta(r), d.ID, d.Name, a.Login).
WithApplication(d.ID, a.ID, stats.Total, stats.PeakPerHour, fmt.Sprintf("%.1f", stats.AvgPerHour), stats.WindowDays)
}
if submitted != nil {
form = *submitted
if a != nil {
form.Login = a.Login // the login is not renamed by this form
}
}
h.view.Render(w, status, "out-app", page.WithForm(form, state, rate).WithResult("", formErr))
}
// renderPasswordOnce answers with the page that shows a password a single time:
// the login and the password, which are not stored, and how to connect. The
// response is the one place the password exists, so it is never cached.
func (h *Handlers) renderPasswordOnce(w http.ResponseWriter, r *http.Request, status int, d store.Domain,
a store.Application, password string, fresh bool) {
senders := a.Addresses
if a.AddressMode == store.AddressModeWildcard {
senders = []string{"*@" + d.Name}
}
noStore(w)
h.view.Render(w, status, "out-app-created", view.NewOutAppCreated(h.shellMeta(r), d.ID, d.Name, a.Login, password, fresh,
h.cfg.Hostname, h.cfg.SubmissionEnabled, senders))
}
// HandleApplicationNew shows the form that adds an application.
func (h *Handlers) HandleApplicationNew(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})
h.renderApplicationForm(w, r, http.StatusOK, d, nil, nil, "")
}
// HandleApplicationCreate creates an application with all of its settings and
@@ -449,47 +588,34 @@ func (h *Handlers) HandleApplicationCreate(w http.ResponseWriter, r *http.Reques
return
}
if err := r.ParseForm(); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d,
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
h.renderApplicationForm(w, r, http.StatusBadRequest, d, nil, nil, "Invalid form submission.")
return
}
login := strings.TrimSpace(r.PostFormValue("login"))
repopulate := detailView{
FormLogin: login,
FormMode: r.PostFormValue("mode"),
FormAddrs: r.PostFormValue("addresses"),
}
submitted := submittedAppForm(r)
set, err := h.parseApplicationForm(r)
if err != nil {
repopulate.FormErr = err.Error()
h.renderDomainDetail(w, r, http.StatusBadRequest, d, repopulate)
h.renderApplicationForm(w, r, http.StatusBadRequest, d, nil, &submitted, err.Error())
return
}
a, password, err := h.apps.CreateWithSettings(d.ID, login, set, h.recalcApp)
a, password, err := h.apps.CreateWithSettings(d.ID, submitted.Login, set, h.recalcApp)
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)
h.renderApplicationForm(w, r, status, d, nil, &submitted, applicationErrorMessage(err))
return
}
noStore(w)
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
h.renderPasswordOnce(w, r, http.StatusCreated, d, a, password, true)
}
// HandleApplicationEdit shows an application's form. Until the domain page is
// split (stage 2) that is the application's Edit panel on the domain page.
// HandleApplicationEdit shows an application's form.
func (h *Handlers) HandleApplicationEdit(w http.ResponseWriter, r *http.Request) {
_, d, ok := h.lookupDomainApplication(w, r)
a, d, ok := h.lookupDomainApplication(w, r)
if !ok {
return
}
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
h.renderApplicationForm(w, r, http.StatusOK, d, &a, nil, "")
}
// HandleApplicationSave saves an application's form: sender, client IPs and
@@ -500,10 +626,8 @@ func (h *Handlers) HandleApplicationSave(w http.ResponseWriter, r *http.Request)
return
}
fail := func(msg string) {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, msg),
})
submitted := submittedAppForm(r)
h.renderApplicationForm(w, r, http.StatusBadRequest, d, &a, &submitted, fmt.Sprintf("%s: %s", a.Login, msg))
}
if err := r.ParseForm(); err != nil {
fail("invalid form submission")
@@ -539,11 +663,7 @@ func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
noStore(w)
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
h.renderPasswordOnce(w, r, http.StatusOK, d, a, password, false)
}
// HandleDeleteApplication removes an application and returns to its domain page