Files
selfpost/internal/web/handlers/handlers_apps.go
T
mixandClaude Opus 5.5 bdeb01681d local-time: the panel in the container's zone, named on Settings
Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.

Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.

Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.

Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.

Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 01:34:28 +03:00

709 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.Local().Format("2006-01-02 15:04")
}
// 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 HTTP cache, private
// and shared (RFC 9111 § 5.2.2.5): the password on it is shown once. A
// browser's history - the back/forward cache - is not what it governs: § 6,
// a history mechanism "might honor" no-store. So the page's field is also
// marked secret, and panel.js empties it when the page is left (pagehide).
// No Pragma: in a response it "was never specified" (§ 5.4). Assumptions
// D-B13.
func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
}
// 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."
}
}