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>
244 lines
7.7 KiB
Go
244 lines
7.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mixeme/selfpost/internal/health"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
"github.com/mixeme/selfpost/internal/web/validate"
|
|
"github.com/mixeme/selfpost/internal/web/view"
|
|
)
|
|
|
|
// HandleDashboard is the list of sending domains with the verdict of each DNS
|
|
// check, their application counts and the mail of the statistics window, plus —
|
|
// for the global role — the add-domain form (product.md).
|
|
func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
h.renderDashboard(w, r, http.StatusOK, "", "")
|
|
}
|
|
|
|
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
|
p, ok := h.principal(r)
|
|
if !ok {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
domains, err := h.assignedDomains(p)
|
|
if err != nil {
|
|
logf("panel: domains: list domains: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
retention := h.sendLogRetentionDays()
|
|
_, _, days := store.StatsWindow(retention, time.Now())
|
|
page := view.NewOutDomains(h.shellMeta(r), p.IsGlobal(), days).
|
|
WithRows(h.outDomainRows(domains, retention, p.IsGlobal())).
|
|
WithResult(dashboardFlash(r), formErr, formName)
|
|
h.view.Render(w, status, "out-domains", page)
|
|
}
|
|
|
|
// outDomainRows is one row per domain: the verdict of each DNS check (cached for
|
|
// a few minutes, as on the domain page), the selector, the applications and the
|
|
// mail of the statistics window. The checks run side by side.
|
|
func (h *Handlers) outDomainRows(domains []store.Domain, retention int, canDelete bool) []view.OutDomainRow {
|
|
rows := make([]view.OutDomainRow, len(domains))
|
|
var wg sync.WaitGroup
|
|
for i, d := range domains {
|
|
rows[i] = view.OutDomainRow{
|
|
Name: d.Name, Href: view.DomainHref(d.ID), Selector: d.DKIMSelector, Apps: d.AppCount,
|
|
DNS: dnsTags(health.StatusUnknown, health.StatusUnknown, health.StatusUnknown), Activity: "—",
|
|
}
|
|
if canDelete {
|
|
rows[i].DeleteHref = view.DomainHref(d.ID) + "/delete"
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if stats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt); err != nil {
|
|
logf("panel: domains: domain %d: send stats: %v", d.ID, err)
|
|
} else {
|
|
rows[i].Activity = view.FormatActivity(stats.Total, stats.PeakPerHour)
|
|
}
|
|
record, err := h.domains.DKIMRecord(d)
|
|
if err != nil {
|
|
logf("panel: domains: domain %d: dkim record: %v", d.ID, err)
|
|
return
|
|
}
|
|
reportEmail, err := h.domainReportAddress(d)
|
|
if err != nil {
|
|
logf("panel: domains: domain %d: dmarc report address: %v", d.ID, err)
|
|
return
|
|
}
|
|
dns, _ := h.domainDNS(d, record, reportEmail, false)
|
|
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
return rows
|
|
}
|
|
|
|
func dashboardFlash(r *http.Request) string {
|
|
if r.URL.Query().Get("deleted") != "" {
|
|
return "Domain deleted."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
|
|
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
|
// publish is shown (product.md).
|
|
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
|
p, ok := h.requireGlobal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
|
return
|
|
}
|
|
raw := r.PostFormValue("name")
|
|
name := validate.NormalizeDomain(raw)
|
|
if err := validate.Domain(name); err != nil {
|
|
h.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
|
return
|
|
}
|
|
|
|
d, err := h.domains.Add(name)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrDomainExists) {
|
|
h.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
|
return
|
|
}
|
|
logf("panel: add domain %q: %v", name, err)
|
|
h.renderDashboard(w, r, http.StatusInternalServerError,
|
|
"Could not add the domain. Please check the logs and try again.", raw)
|
|
return
|
|
}
|
|
h.followCreator(d.ID, p.ID)
|
|
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d", d.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
// followCreator makes a new domain take its DMARC report address from the
|
|
// default of whoever created it (plan § Account e-mail). The domain exists
|
|
// either way: a failure here leaves it with no report address, which its page
|
|
// shows and its DMARC form can change.
|
|
func (h *Handlers) followCreator(domainID, userID int64) {
|
|
if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil {
|
|
logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err)
|
|
return
|
|
}
|
|
if h.dmarc != nil && h.dmarc.Enabled() {
|
|
if err := h.dmarc.Resync(); err != nil {
|
|
logf("panel: domain %d: dmarc resync: %v", domainID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
|
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
d, ok := h.lookupDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
apps, err := h.store.ListApplicationsByDomain(d.ID)
|
|
if err != nil {
|
|
logf("panel: delete domain %d: list applications: %v", d.ID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
logins := make([]string, len(apps))
|
|
for i, a := range apps {
|
|
logins[i] = a.Login
|
|
}
|
|
h.view.Render(w, http.StatusOK, "out-domain-delete", view.NewOutDomainDelete(h.shellMeta(r), d.ID, d.Name, logins))
|
|
}
|
|
|
|
// HandleDeleteDomain performs the deletion and returns to the domain list.
|
|
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
id, ok := parseDomainID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if d, err := h.domains.Get(id); err == nil {
|
|
defer h.dns.Forget(d.Name)
|
|
}
|
|
if err := h.domains.Delete(id); err != nil {
|
|
if errors.Is(err, store.ErrDomainNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
logf("panel: delete domain %d: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/outbound/domains?deleted=1", http.StatusSeeOther)
|
|
}
|
|
|
|
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
|
|
// sender map on demand (architecture.md § Panel HTTP surface).
|
|
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
if err := h.domains.Resync(); err != nil {
|
|
logf("panel: manual reload (opendkim): %v", err)
|
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := h.apps.Resync(); err != nil {
|
|
logf("panel: manual reload (postfix): %v", err)
|
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if h.cfg.InboundEnabled && h.inbound != nil {
|
|
if err := h.inbound.Resync(); err != nil {
|
|
logf("panel: manual reload (inbound): %v", err)
|
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
http.Redirect(w, r, "/server/health?reloaded=1", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
|
id, ok := parseDomainID(w, r)
|
|
if !ok {
|
|
return store.Domain{}, false
|
|
}
|
|
d, err := h.domains.Get(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrDomainNotFound) {
|
|
http.NotFound(w, r)
|
|
return store.Domain{}, false
|
|
}
|
|
logf("panel: get domain %d: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return store.Domain{}, false
|
|
}
|
|
p, ok := h.principal(r)
|
|
if !ok || !p.CanAccessDomain(d.ID) {
|
|
http.NotFound(w, r)
|
|
return store.Domain{}, false
|
|
}
|
|
return d, true
|
|
}
|
|
|
|
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
http.NotFound(w, r)
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|