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>
363 lines
11 KiB
Go
363 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
|
"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"
|
|
)
|
|
|
|
type inboundRow struct {
|
|
store.InboundDomain
|
|
DNS health.Status
|
|
Upstream string
|
|
TLSLabel string
|
|
RcptLabel string
|
|
}
|
|
|
|
// Inbound is delegated to domain administrators separately from outbound (plan
|
|
// § Who sees what), so its handlers come in three strengths:
|
|
//
|
|
// - requireInbound the list: anyone who reaches at least one inbound
|
|
// domain — and the list itself is filtered;
|
|
// - requireInboundDomain one domain (its page, DNS re-check, upstream,
|
|
// recipients): the global role, or a domain user
|
|
// this very inbound domain is assigned to;
|
|
// - requireInboundGlobal adding and deleting a domain: the global role only.
|
|
//
|
|
// All three answer 404 — for a missing domain, for another tenant's, and for a
|
|
// user with no inbound reach alike — so the panel never confirms what exists.
|
|
|
|
func (h *Handlers) requireInbound(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
|
if !h.cfg.InboundEnabled || h.inbound == nil {
|
|
http.NotFound(w, r)
|
|
return auth.Principal{}, false
|
|
}
|
|
p, ok := h.principal(r)
|
|
if !ok || !p.HasInbound() {
|
|
http.NotFound(w, r)
|
|
return auth.Principal{}, false
|
|
}
|
|
return p, true
|
|
}
|
|
|
|
func (h *Handlers) requireInboundGlobal(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
|
|
if !h.cfg.InboundEnabled || h.inbound == nil {
|
|
http.NotFound(w, r)
|
|
return auth.Principal{}, false
|
|
}
|
|
return h.requireGlobal(w, r)
|
|
}
|
|
|
|
// requireInboundDomain resolves the {id} of the request to an inbound domain
|
|
// the principal may work with.
|
|
func (h *Handlers) requireInboundDomain(w http.ResponseWriter, r *http.Request) (store.InboundDomain, bool) {
|
|
p, ok := h.requireInbound(w, r)
|
|
if !ok {
|
|
return store.InboundDomain{}, false
|
|
}
|
|
d, ok := h.lookupInbound(w, r)
|
|
if !ok {
|
|
return store.InboundDomain{}, false
|
|
}
|
|
if !p.CanAccessInboundDomain(d.ID) {
|
|
http.NotFound(w, r)
|
|
return store.InboundDomain{}, false
|
|
}
|
|
return d, true
|
|
}
|
|
|
|
// assignedInboundDomains lists the inbound domains a principal reaches. The
|
|
// filter is in the query (ListInboundDomainsForUser), not in the template.
|
|
func (h *Handlers) assignedInboundDomains(p auth.Principal) ([]store.InboundDomain, error) {
|
|
if p.IsGlobal() {
|
|
return h.inbound.List()
|
|
}
|
|
return h.store.ListInboundDomainsForUser(p.ID)
|
|
}
|
|
|
|
func (h *Handlers) lookupInbound(w http.ResponseWriter, r *http.Request) (store.InboundDomain, bool) {
|
|
if h.inbound == nil {
|
|
http.NotFound(w, r)
|
|
return store.InboundDomain{}, false
|
|
}
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
http.NotFound(w, r)
|
|
return store.InboundDomain{}, false
|
|
}
|
|
d, err := h.inbound.Get(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrInboundDomainNotFound) {
|
|
http.NotFound(w, r)
|
|
return store.InboundDomain{}, false
|
|
}
|
|
logf("panel: get inbound domain %d: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return store.InboundDomain{}, false
|
|
}
|
|
return d, true
|
|
}
|
|
|
|
// HandleInboundList is the inbound-relay domain list: every inbound domain for
|
|
// the global role, the assigned ones for a domain administrator.
|
|
func (h *Handlers) HandleInboundList(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireInbound(w, r); !ok {
|
|
return
|
|
}
|
|
h.renderInboundList(w, r, http.StatusOK, "", "")
|
|
}
|
|
|
|
func (h *Handlers) renderInboundList(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
|
p, _ := h.principal(r)
|
|
list, err := h.assignedInboundDomains(p)
|
|
if err != nil {
|
|
logf("panel: inbound list: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data := h.pageBase(r)
|
|
data["Title"] = "SelfPost — inbound"
|
|
data["Active"] = "inbound"
|
|
data["Domains"] = h.inboundRows(list)
|
|
data["Error"] = formErr
|
|
data["FormName"] = formName
|
|
if r.URL.Query().Get("deleted") != "" {
|
|
data["Flash"] = "Inbound domain deleted."
|
|
}
|
|
h.view.Render(w, status, "inbound", data)
|
|
}
|
|
|
|
func (h *Handlers) inboundRows(domains []store.InboundDomain) []inboundRow {
|
|
rows := make([]inboundRow, len(domains))
|
|
var wg sync.WaitGroup
|
|
for i, d := range domains {
|
|
rows[i] = inboundRow{
|
|
InboundDomain: d,
|
|
DNS: health.StatusUnknown,
|
|
Upstream: inboundUpstream(d),
|
|
TLSLabel: tlsLabel(d.TLSMode),
|
|
RcptLabel: rcptLabel(d),
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if h.dns != nil && h.cfg.Hostname != "" {
|
|
rows[i].DNS = h.dns.InboundMX(d.Name, h.cfg.Hostname, false).Status
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
return rows
|
|
}
|
|
|
|
func inboundUpstream(d store.InboundDomain) string {
|
|
if d.Host == "" {
|
|
return "—"
|
|
}
|
|
return fmt.Sprintf("%s:%d", d.Host, d.Port)
|
|
}
|
|
|
|
func tlsLabel(mode string) string {
|
|
switch mode {
|
|
case store.TLSModeEncrypt:
|
|
return "required"
|
|
case store.TLSModeNone:
|
|
return "off"
|
|
default:
|
|
return "opportunistic"
|
|
}
|
|
}
|
|
|
|
func tlsStatusClass(mode string) string {
|
|
if mode == store.TLSModeEncrypt {
|
|
return "ok"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func rcptLabel(d store.InboundDomain) string {
|
|
if d.RecipientMode == store.RecipientModeAny {
|
|
return "any"
|
|
}
|
|
n := d.RecipientCount
|
|
if n == 1 {
|
|
return "1 listed"
|
|
}
|
|
return fmt.Sprintf("%d listed", n)
|
|
}
|
|
|
|
// HandleAddInbound validates the name, creates the inbound domain, and
|
|
// redirects to its page.
|
|
func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireInboundGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderInboundList(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
|
return
|
|
}
|
|
raw := r.PostFormValue("name")
|
|
name := validate.NormalizeDomain(raw)
|
|
if err := validate.Domain(name); err != nil {
|
|
h.renderInboundList(w, r, http.StatusBadRequest, err.Error(), raw)
|
|
return
|
|
}
|
|
d, err := h.inbound.Add(name)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrInboundDomainExists) {
|
|
h.renderInboundList(w, r, http.StatusConflict, "That inbound domain is already configured.", raw)
|
|
return
|
|
}
|
|
logf("panel: add inbound domain %q: %v", name, err)
|
|
h.renderInboundList(w, r, http.StatusInternalServerError,
|
|
"Could not add the domain. Please check the logs and try again.", raw)
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d", d.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handlers) HandleInboundDetail(w http.ResponseWriter, r *http.Request) {
|
|
d, ok := h.requireInboundDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
h.renderInboundDetail(w, r, http.StatusOK, d, inboundDetailView{})
|
|
}
|
|
|
|
type inboundDetailView struct {
|
|
FormErr string
|
|
TransportErr string
|
|
RecipientErr string
|
|
}
|
|
|
|
func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, status int, d store.InboundDomain, extra inboundDetailView) {
|
|
mx := dnscheck.Result{Status: health.StatusUnknown}
|
|
if h.dns != nil && h.cfg.Hostname != "" {
|
|
mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false)
|
|
}
|
|
data := h.pageBase(r)
|
|
data["Title"] = "SelfPost — " + d.Name
|
|
data["Active"] = "inbound"
|
|
data["Domain"] = d
|
|
data["MX"] = mx
|
|
data["MXValue"] = "10 " + strings.TrimSuffix(h.cfg.Hostname, ".") + "."
|
|
data["Hostname"] = h.cfg.Hostname
|
|
data["TLSLabel"] = tlsLabel(d.TLSMode)
|
|
data["TLSClass"] = tlsStatusClass(d.TLSMode)
|
|
data["RecipientText"] = strings.Join(d.Recipients, "\n")
|
|
data["Flash"] = inboundFlash(r)
|
|
data["FormErr"] = extra.FormErr
|
|
data["TransportErr"] = extra.TransportErr
|
|
data["RecipientErr"] = extra.RecipientErr
|
|
h.view.Render(w, status, "inbound_domain", data)
|
|
}
|
|
|
|
func inboundFlash(r *http.Request) string {
|
|
switch {
|
|
case r.URL.Query().Get("saved") != "":
|
|
return "Upstream saved."
|
|
case r.URL.Query().Get("recipients") != "":
|
|
return "Recipients saved."
|
|
case r.URL.Query().Get("rechecked") != "":
|
|
return "DNS re-checked."
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) HandleInboundDNSRecheck(w http.ResponseWriter, r *http.Request) {
|
|
d, ok := h.requireInboundDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.dns != nil && h.cfg.Hostname != "" {
|
|
h.dns.InboundMX(d.Name, h.cfg.Hostname, true)
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request) {
|
|
d, ok := h.requireInboundDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: "Invalid form submission."})
|
|
return
|
|
}
|
|
host := r.PostFormValue("host")
|
|
port := r.PostFormValue("port")
|
|
tlsMode := r.PostFormValue("tls_mode")
|
|
if err := h.inbound.SetTransport(d.ID, host, port, tlsMode); err != nil {
|
|
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()})
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1", d.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Request) {
|
|
d, ok := h.requireInboundDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: "Invalid form submission."})
|
|
return
|
|
}
|
|
mode := r.PostFormValue("recipient_mode")
|
|
addrs := splitAddresses(r.PostFormValue("addresses"))
|
|
if err := h.inbound.SetRecipients(d.ID, mode, addrs); err != nil {
|
|
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()})
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?recipients=1", d.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireInboundGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
d, ok := h.lookupInbound(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
data := h.pageBase(r)
|
|
data["Title"] = "SelfPost — delete " + d.Name
|
|
data["Active"] = "inbound"
|
|
data["Domain"] = d
|
|
data["Upstream"] = inboundUpstream(d)
|
|
h.view.Render(w, http.StatusOK, "inbound_delete", data)
|
|
}
|
|
|
|
func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireInboundGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
d, ok := h.lookupInbound(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if h.dns != nil {
|
|
h.dns.Forget(d.Name)
|
|
}
|
|
if err := h.inbound.Delete(d.ID); err != nil {
|
|
if errors.Is(err, store.ErrInboundDomainNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
logf("panel: delete inbound domain %d: %v", d.ID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/inbound/domains?deleted=1", http.StatusSeeOther)
|
|
}
|