Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.
Routes (web.go)
- /status -> /overview; /domains -> /outbound/domains with the domain's
settings actions under .../settings/ (reports, ratelimit, export);
/deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
/outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
/server/log; /backup -> /server/backup (import under it); /users ->
/server/users; recheck and reload under /server/health/.
- One fragment name: <page>/fragment everywhere.
- Everything under /server/ is registered through server(), which wraps
the handler in globalOnly: the subtree is guarded once, where a new
route cannot forget it. Handlers keep their own requireGlobal.
- GET / leads to /overview, /outbound/domains or /inbound/domains.
- The /account -> /settings redirect is gone with /settings.
Account and Server > Settings
- handlers_account.go: GET /account and three POSTs - profile (username,
e-mail; no password asked, as designed), password (current password,
rate-limited like a sign-in, signs out other sessions), dmarc (the
user's default: hosted / account / custom / none, each validated).
This replaces step 1's bridge through the old Settings form.
- handlers_settings.go is the instance's page only: log retention and the
read-only rate limits, global role.
- One legacy template serves both pages by a flag until stage 2 gives each
its own: a new legacy page cannot be added to the ratchet.
Still to come inside stage 1, and why @routes stays on the ratchet:
- step 4: the application routes (one POST, under the domain);
- step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.
Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).
Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.
go test ./... green except the two Windows-only failures in internal/domain
and internal/logtail.
Roadmap 13/27.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
6.9 KiB
Go
235 lines
6.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"github.com/mixeme/selfpost/internal/health"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
"github.com/mixeme/selfpost/internal/web/auth"
|
|
"github.com/mixeme/selfpost/internal/web/validate"
|
|
)
|
|
|
|
// domainRow is one line of the domain list: the stored domain plus the rolled-up
|
|
// verdict of its published DNS records, so the operator sees which domains still
|
|
// need a record published without opening each one.
|
|
type domainRow struct {
|
|
store.Domain
|
|
DNS health.Status
|
|
}
|
|
|
|
// HandleDashboard is the authenticated landing page: the list of sending
|
|
// domains with their DKIM/selector and application counts, plus 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: dashboard: list domains: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := h.pageBase(r)
|
|
data["Title"] = "SelfPost — domains"
|
|
data["Active"] = "domains"
|
|
data["Domains"] = h.domainRows(domains)
|
|
data["Error"] = formErr
|
|
data["FormName"] = formName
|
|
data["Flash"] = dashboardFlash(r)
|
|
h.view.Render(w, status, "dashboard", data)
|
|
}
|
|
|
|
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
|
rows := make([]domainRow, len(domains))
|
|
var wg sync.WaitGroup
|
|
for i, d := range domains {
|
|
rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
record, err := h.domains.DKIMRecord(d)
|
|
if err != nil {
|
|
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
|
return
|
|
}
|
|
reportEmail, err := h.domainReportAddress(d)
|
|
if err != nil {
|
|
logf("panel: dashboard: domain %d: dmarc report address: %v", d.ID, err)
|
|
return
|
|
}
|
|
dns, _ := h.domainDNS(d, record, reportEmail, false)
|
|
rows[i].DNS = dns.Overall
|
|
}()
|
|
}
|
|
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
|
|
}
|
|
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
|
|
"Title": "SelfPost — delete " + d.Name,
|
|
"User": auth.CurrentUser(r),
|
|
"Active": "domains",
|
|
"Domain": d,
|
|
"IsGlobal": true,
|
|
})
|
|
}
|
|
|
|
// 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, "/overview?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
|
|
}
|