Follows the design and guard commits before it. Written by Sonnet,
reviewed by Opus.
The report address
- Baseline: users.dmarc_default_mode, users.dmarc_default_address and
domains.dmarc_rua_user_id are gone. domains.dmarc_rua alone says
where reports go: '' is no reports (a new domain), the address
SelfPost derives for the domain is the hosted choice (while hosted
reports are on), anything else is a typed address.
- Removed: the user default and its store functions, a domain
following a user, keep / inherit, followCreator, POST /account/dmarc
with its handler, the DMARC box of Account, the user-delete item
about domains losing their address.
- Domain settings offers the three choices; beside the typed address a
"Use my e-mail" button fills in the profile e-mail (panel.js: one
generic data-fill hook; absent without a profile e-mail; it fills,
it does not save).
- The DMARC record's note names the address and no user. This closes
L4 of the security review.
- Domain export / import carries the address as a plain string.
- The ingest allow-list is the generic mailbox plus each domain's
address on this host; the generic mailbox is always open now.
- dev/migrate1x (not in the tree): a 1.x domain that inherited the
instance default gets that address as its own, so no published rua=
changes.
Row actions
- row_action partial (RowAction: Label, Post, Confirm, Danger);
panel.css has the mockup's three rules. A row of the Applications
table is Edit, New password, Delete; the confirmation texts are the
ones the application page's head uses. Both roles, with the checks
of the application page.
Tests cover: a new domain has no reports; each choice saved and shown
selected; hosted refused when hosted reports are off; a bad address
refused with what was typed kept; an untouched save changes nothing in
each state; the fill button with and without a profile e-mail; deleting
a user leaves every domain's address; the row's forms, targets and
confirmations for both roles and a 404 for a stranger.
Known: if hosted reports are switched off later, a stored hosted address
reads as a typed one. docs/guide.md still describes the old choices -
the documentation step.
Evidence
- docs/assets/panel-redesign/evidence/account.png,
out-domain-settings.png, out-domain.png, user-delete.png, help.png:
1440 px wide, headless Edge, real templates served locally with
their fixtures - NOT from the container.
- panel/audit.js on out-domain, out-domain-settings, account, help,
components and user-delete at 1440 and 1280: 1420 texts checked,
problems: [].
- In a browser: the fill button sets the field and does not submit;
the row buttons have the font, size and colour of the Edit link,
Delete the red of the other delete links; data-confirm fires on
both. Hover and focus states were not observed.
- go test -count=1 ./internal/web/... ./internal/store/...
./internal/dmarc/... ./internal/app/... ./internal/inbound/...
./cmd/panel: ok (ten packages; dev/migrate1x also ok).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
705 lines
26 KiB
Go
705 lines
26 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/app"
|
|
"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"
|
|
"github.com/mixeme/selfpost/internal/web/view"
|
|
)
|
|
|
|
// 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.renderDomain(w, r, http.StatusOK, d)
|
|
}
|
|
|
|
// 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)
|
|
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
|
|
}
|
|
retention := h.sendLogRetentionDays()
|
|
rows := make([]view.OutAppRow, 0, len(apps))
|
|
for _, a := range apps {
|
|
row, err := h.outAppRow(d, a, retention)
|
|
if err != nil {
|
|
logf("panel: application %d: %v", a.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 {
|
|
logf("panel: domain %d: send stats: %v", d.ID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
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.
|
|
reportEmail := d.DMARCRua
|
|
dns, srv := h.domainDNS(d, record, reportEmail, false)
|
|
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)),
|
|
}
|
|
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), 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)))
|
|
}
|
|
|
|
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, for the note
|
|
// of its DMARC record: the hosted address or the one typed for the domain,
|
|
// written out, or "no reports".
|
|
func (h *Handlers) reportSource(d store.Domain) string {
|
|
if d.DMARCRua == "" {
|
|
return "no reports"
|
|
}
|
|
return d.DMARCRua
|
|
}
|
|
|
|
// 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),
|
|
Actions: view.AppRowActions(d.ID, a.ID, a.Login),
|
|
}, 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, mine := 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, mine).
|
|
WithExport(validate.MinSecretFilePasswordLen).
|
|
WithRateLimit(rate).
|
|
WithResult("", sv.Err)
|
|
h.view.Render(w, status, "out-domain-settings", page)
|
|
}
|
|
|
|
// reportAddressForm is the report-address form of a domain: the three choices
|
|
// of the select, the one the stored address stands for, the address typed for
|
|
// the domain (only in the typed choice) and the button that fills it with the
|
|
// signed-in user's profile e-mail ("" when the profile has none). SelfPost
|
|
// hosted is offered only while hosted reports are enabled.
|
|
func (h *Handlers) reportAddressForm(r *http.Request, d store.Domain) (options []view.Option, selected, custom, mine string) {
|
|
options = append(options, view.Option{Value: ruaNone, Label: "No reports"})
|
|
if hosted := h.hostedDMARCAddress(d.Name); hosted != "" {
|
|
options = append(options, view.Option{Value: ruaHosted, Label: "SelfPost hosted — " + hosted})
|
|
}
|
|
options = append(options, view.Option{Value: ruaCustom, Label: "A specific address"})
|
|
|
|
selected = h.reportChoice(d)
|
|
if selected == ruaCustom {
|
|
custom = d.DMARCRua
|
|
}
|
|
if p, ok := h.principal(r); ok {
|
|
if u, err := h.store.GetUser(p.ID); err == nil {
|
|
mine = u.Email
|
|
}
|
|
}
|
|
return options, selected, custom, mine
|
|
}
|
|
|
|
// 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
|
|
// 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
|
|
}
|
|
h.domainDNS(d, record, d.DMARCRua, true)
|
|
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
// limitChoice names which of the form's three limit choices an application has:
|
|
// the domain limit (none of its own), manual, or auto.
|
|
func limitChoice(rl store.RateLimit, configured bool) string {
|
|
switch {
|
|
case !configured:
|
|
return "domain"
|
|
case rl.IsAuto():
|
|
return store.RateLimitModeAuto
|
|
default:
|
|
return store.RateLimitModeManual
|
|
}
|
|
}
|
|
|
|
// 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 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("appsaved") != "":
|
|
return "Application saved."
|
|
case r.URL.Query().Get("ratelimit") != "":
|
|
return "Rate limit 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 ""
|
|
}
|
|
}
|
|
|
|
// The application form is ONE post (plan § Routes): who the application may
|
|
// send as, which client IPs may use it and its rate limit are parsed and
|
|
// validated together and saved together, or not at all (app.Service
|
|
// CreateWithSettings / SaveSettings). The same form, empty, adds an
|
|
// application; filled, it edits one. The login is given once, at creation.
|
|
|
|
// parseApplicationForm reads the form's three parts. Every field is checked
|
|
// here or by the service before anything is written.
|
|
func (h *Handlers) parseApplicationForm(r *http.Request) (app.Settings, error) {
|
|
set := app.Settings{
|
|
Mode: r.PostFormValue("mode"),
|
|
Addresses: splitAddresses(r.PostFormValue("addresses")),
|
|
}
|
|
restrict, ips, err := parseAppAuthIPsForm(r)
|
|
if err != nil {
|
|
return app.Settings{}, err
|
|
}
|
|
set.AuthIPRestrict, set.AuthAllowedIPs = restrict, ips
|
|
|
|
switch mode := strings.TrimSpace(r.PostFormValue("rl_mode")); mode {
|
|
case "", "domain":
|
|
// Use the domain limit: the application has none of its own.
|
|
case store.RateLimitModeAuto:
|
|
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
|
|
if err != nil {
|
|
return app.Settings{}, err
|
|
}
|
|
set.Limit = app.Limit{Mode: mode, AutoMultiplier: mult, WindowSeconds: h.l1Window()}
|
|
case store.RateLimitModeManual:
|
|
maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0)
|
|
if err != nil || maxMessages <= 0 {
|
|
return app.Settings{}, fmt.Errorf("enter a message limit greater than zero, or use the domain limit")
|
|
}
|
|
if l1 := h.l1Messages(); maxMessages > l1 {
|
|
return app.Settings{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1)
|
|
}
|
|
window, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
|
if err != nil || window <= 0 {
|
|
return app.Settings{}, fmt.Errorf("enter a time window greater than zero seconds")
|
|
}
|
|
set.Limit = app.Limit{Mode: mode, MaxMessages: maxMessages, WindowSeconds: window}
|
|
default:
|
|
return app.Settings{}, fmt.Errorf("choose the domain limit, manual or auto")
|
|
}
|
|
return set, nil
|
|
}
|
|
|
|
func (h *Handlers) recalcApp(appID int64) error {
|
|
return h.recalcRateLimit(store.RateLimitScopeApp, appID)
|
|
}
|
|
|
|
// noStore keeps a page that shows a secret out of every cache and out of the
|
|
// browser's history copy: the password on it is shown once.
|
|
func noStore(w http.ResponseWriter) {
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("Pragma", "no-cache")
|
|
}
|
|
|
|
// 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.renderApplicationForm(w, r, http.StatusOK, d, nil, nil, "")
|
|
}
|
|
|
|
// HandleApplicationCreate creates an application with all of its settings and
|
|
// answers with the generated password, shown once (product.md, security.md).
|
|
// The password cannot be recovered later, so the page is the response to this
|
|
// POST — it has no GET path — and is never stored by the browser.
|
|
func (h *Handlers) HandleApplicationCreate(w http.ResponseWriter, r *http.Request) {
|
|
d, ok := h.lookupDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderApplicationForm(w, r, http.StatusBadRequest, d, nil, nil, "Invalid form submission.")
|
|
return
|
|
}
|
|
submitted := submittedAppForm(r)
|
|
set, err := h.parseApplicationForm(r)
|
|
if err != nil {
|
|
h.renderApplicationForm(w, r, http.StatusBadRequest, d, nil, &submitted, err.Error())
|
|
return
|
|
}
|
|
a, password, err := h.apps.CreateWithSettings(d.ID, submitted.Login, set, h.recalcApp)
|
|
if err != nil {
|
|
status := http.StatusBadRequest
|
|
if errors.Is(err, store.ErrLoginExists) {
|
|
status = http.StatusConflict
|
|
}
|
|
h.renderApplicationForm(w, r, status, d, nil, &submitted, applicationErrorMessage(err))
|
|
return
|
|
}
|
|
h.renderPasswordOnce(w, r, http.StatusCreated, d, a, password, true)
|
|
}
|
|
|
|
// HandleApplicationEdit shows an application's form.
|
|
func (h *Handlers) HandleApplicationEdit(w http.ResponseWriter, r *http.Request) {
|
|
a, d, ok := h.lookupDomainApplication(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
h.renderApplicationForm(w, r, http.StatusOK, d, &a, nil, "")
|
|
}
|
|
|
|
// HandleApplicationSave saves an application's form: sender, client IPs and
|
|
// rate limit in one step.
|
|
func (h *Handlers) HandleApplicationSave(w http.ResponseWriter, r *http.Request) {
|
|
a, d, ok := h.lookupDomainApplication(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
fail := func(msg string) {
|
|
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")
|
|
return
|
|
}
|
|
// The login is the SASL account's name and is not renamed by this form.
|
|
if login := strings.TrimSpace(r.PostFormValue("login")); login != "" && login != a.Login {
|
|
fail("the login of an application cannot be changed; add a new application instead")
|
|
return
|
|
}
|
|
set, err := h.parseApplicationForm(r)
|
|
if err != nil {
|
|
fail(err.Error())
|
|
return
|
|
}
|
|
if err := h.apps.SaveSettings(a.ID, set, h.recalcApp); err != nil {
|
|
fail(applicationErrorMessage(err))
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?appsaved=1", d.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
// HandleRegenPassword issues a new password for an application and shows it once
|
|
// (product.md, security.md): the response to the POST, never cached.
|
|
func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
|
|
a, d, ok := h.lookupDomainApplication(w, r)
|
|
if !ok {
|
|
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.renderPasswordOnce(w, r, http.StatusOK, d, a, password, false)
|
|
}
|
|
|
|
// 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.lookupDomainApplication(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)
|
|
}
|
|
|
|
// lookupDomainApplication resolves /outbound/domains/{id}/applications/{aid}
|
|
// to the application and its domain. The application must belong to the domain
|
|
// named in the path: an id from another domain is answered 404 like a missing
|
|
// one, whoever asks, so a URL can never address an application through a
|
|
// domain it is not in.
|
|
func (h *Handlers) lookupDomainApplication(w http.ResponseWriter, r *http.Request) (store.Application, store.Domain, bool) {
|
|
d, ok := h.lookupDomain(w, r)
|
|
if !ok {
|
|
return store.Application{}, store.Domain{}, false
|
|
}
|
|
a, ok := h.lookupApplication(w, r)
|
|
if !ok {
|
|
return store.Application{}, store.Domain{}, false
|
|
}
|
|
if a.DomainID != d.ID {
|
|
http.NotFound(w, r)
|
|
return store.Application{}, store.Domain{}, false
|
|
}
|
|
return a, d, true
|
|
}
|
|
|
|
// 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 what the form says. A
|
|
// validation error is the administrator's to read — it says what to fix — and
|
|
// is shown as it is. Anything else is the server's own business (a path, a
|
|
// command's output, SQL): it goes to the log and the page gets a fixed line.
|
|
func applicationErrorMessage(err error) string {
|
|
var validation *app.ValidationError
|
|
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."
|
|
case errors.As(err, &validation):
|
|
return err.Error()
|
|
default:
|
|
logf("panel: application: %v", err)
|
|
return "Could not save the application. Check the logs and try again."
|
|
}
|
|
}
|