From 155b72143864cf19d589712cf1ad902a5f893591 Mon Sep 17 00:00:00 2001 From: Mikhail Yenuchenko Date: Mon, 10 Aug 2026 23:19:09 +0300 Subject: [PATCH] Split internal/web into subpackages before domain-admin growth. Lay out view, auth, validate, and handlers under internal/web while keeping the cmd/panel API unchanged; update roadmap and changelog for web-split closure. Co-authored-by: Cursor --- CHANGELOG.md | 7 + docs/architecture.md | 14 +- docs/development.md | 4 +- docs/plans/domain-admin.md | 2 +- docs/plans/web-split.md | 22 +- docs/roadmap.md | 32 +-- internal/web/auth/auth.go | 122 +++++++++ .../auth_test.go} | 135 +++++++--- internal/web/auth/handlers.go | 239 ++++++++++++++++++ internal/web/auth/middleware.go | 45 ++++ internal/web/{ => auth}/ratelimit.go | 11 +- internal/web/{ => auth}/session.go | 27 +- internal/web/{ => auth}/setup.go | 20 +- internal/web/{ => auth}/token.go | 2 +- internal/web/handlers/handlers.go | 66 +++++ .../web/{ => handlers}/handlers_account.go | 64 ++--- internal/web/{ => handlers}/handlers_apps.go | 106 ++++---- .../web/{ => handlers}/handlers_backup.go | 80 +++--- .../{ => handlers}/handlers_backup_test.go | 17 +- internal/web/{ => handlers}/handlers_dmarc.go | 25 +- internal/web/handlers/handlers_domains.go | 186 ++++++++++++++ .../web/{ => handlers}/handlers_monitor.go | 87 +++---- .../{ => handlers}/handlers_monitor_test.go | 62 +++-- .../web/{ => handlers}/handlers_ratelimit.go | 48 +--- internal/web/handlers/handlers_status.go | 111 ++++++++ internal/web/handlers/testutil_test.go | 16 ++ internal/web/handlers_auth.go | 198 --------------- internal/web/handlers_domains.go | 210 --------------- internal/web/handlers_license.go | 25 -- internal/web/handlers_license_test.go | 6 +- internal/web/handlers_setup.go | 99 -------- internal/web/handlers_status.go | 140 ---------- internal/web/middleware.go | 55 ---- internal/web/session_test.go | 84 ------ internal/web/{ => validate}/validate.go | 75 +++--- internal/web/validate/validate_email_test.go | 18 ++ internal/web/validate/validate_test.go | 67 +++++ internal/web/validate_email_test.go | 18 -- internal/web/validate_test.go | 67 ----- internal/web/{ => view}/static.go | 6 +- internal/web/{ => view}/static/favicon.png | Bin internal/web/{ => view}/static/favicon.svg | 0 internal/web/{ => view}/static/htmx.min.js | 0 .../web/{ => view}/static/logo-compact.svg | 0 internal/web/{ => view}/static/logo.svg | 0 internal/web/{ => view}/static/panel.css | 0 internal/web/{ => view}/static/panel.js | 0 internal/web/{ => view}/static_test.go | 4 +- .../web/{ => view}/templates/account.html | 0 internal/web/{ => view}/templates/backup.html | 0 .../web/{ => view}/templates/dashboard.html | 0 .../web/{ => view}/templates/deliveries.html | 0 .../{ => view}/templates/deliveries_rows.html | 0 .../web/{ => view}/templates/delivery.html | 0 .../{ => view}/templates/domain_delete.html | 0 .../{ => view}/templates/domain_detail.html | 0 .../{ => view}/templates/encrypt_fields.html | 0 internal/web/{ => view}/templates/layout.html | 0 internal/web/{ => view}/templates/login.html | 0 .../web/{ => view}/templates/mail_queue.html | 0 .../{ => view}/templates/mail_queue_body.html | 0 internal/web/{ => view}/templates/setup.html | 0 internal/web/{ => view}/templates/status.html | 0 .../web/{ => view}/templates/status_body.html | 0 .../web/{ => view}/templates/system_log.html | 0 .../{ => view}/templates/system_log_body.html | 0 internal/web/{ => view}/templates_test.go | 63 +++-- internal/web/{templates.go => view/view.go} | 61 +++-- internal/web/web.go | 237 ++++++----------- 69 files changed, 1455 insertions(+), 1528 deletions(-) create mode 100644 internal/web/auth/auth.go rename internal/web/{handlers_auth_test.go => auth/auth_test.go} (52%) create mode 100644 internal/web/auth/handlers.go create mode 100644 internal/web/auth/middleware.go rename internal/web/{ => auth}/ratelimit.go (63%) rename internal/web/{ => auth}/session.go (75%) rename internal/web/{ => auth}/setup.go (71%) rename internal/web/{ => auth}/token.go (97%) create mode 100644 internal/web/handlers/handlers.go rename internal/web/{ => handlers}/handlers_account.go (74%) rename internal/web/{ => handlers}/handlers_apps.go (80%) rename internal/web/{ => handlers}/handlers_backup.go (81%) rename internal/web/{ => handlers}/handlers_backup_test.go (89%) rename internal/web/{ => handlers}/handlers_dmarc.go (62%) create mode 100644 internal/web/handlers/handlers_domains.go rename internal/web/{ => handlers}/handlers_monitor.go (83%) rename internal/web/{ => handlers}/handlers_monitor_test.go (86%) rename internal/web/{ => handlers}/handlers_ratelimit.go (57%) create mode 100644 internal/web/handlers/handlers_status.go create mode 100644 internal/web/handlers/testutil_test.go delete mode 100644 internal/web/handlers_auth.go delete mode 100644 internal/web/handlers_domains.go delete mode 100644 internal/web/handlers_license.go delete mode 100644 internal/web/handlers_setup.go delete mode 100644 internal/web/handlers_status.go delete mode 100644 internal/web/middleware.go delete mode 100644 internal/web/session_test.go rename internal/web/{ => validate}/validate.go (63%) create mode 100644 internal/web/validate/validate_email_test.go create mode 100644 internal/web/validate/validate_test.go delete mode 100644 internal/web/validate_email_test.go delete mode 100644 internal/web/validate_test.go rename internal/web/{ => view}/static.go (94%) rename internal/web/{ => view}/static/favicon.png (100%) rename internal/web/{ => view}/static/favicon.svg (100%) rename internal/web/{ => view}/static/htmx.min.js (100%) rename internal/web/{ => view}/static/logo-compact.svg (100%) rename internal/web/{ => view}/static/logo.svg (100%) rename internal/web/{ => view}/static/panel.css (100%) rename internal/web/{ => view}/static/panel.js (100%) rename internal/web/{ => view}/static_test.go (98%) rename internal/web/{ => view}/templates/account.html (100%) rename internal/web/{ => view}/templates/backup.html (100%) rename internal/web/{ => view}/templates/dashboard.html (100%) rename internal/web/{ => view}/templates/deliveries.html (100%) rename internal/web/{ => view}/templates/deliveries_rows.html (100%) rename internal/web/{ => view}/templates/delivery.html (100%) rename internal/web/{ => view}/templates/domain_delete.html (100%) rename internal/web/{ => view}/templates/domain_detail.html (100%) rename internal/web/{ => view}/templates/encrypt_fields.html (100%) rename internal/web/{ => view}/templates/layout.html (100%) rename internal/web/{ => view}/templates/login.html (100%) rename internal/web/{ => view}/templates/mail_queue.html (100%) rename internal/web/{ => view}/templates/mail_queue_body.html (100%) rename internal/web/{ => view}/templates/setup.html (100%) rename internal/web/{ => view}/templates/status.html (100%) rename internal/web/{ => view}/templates/status_body.html (100%) rename internal/web/{ => view}/templates/system_log.html (100%) rename internal/web/{ => view}/templates/system_log_body.html (100%) rename internal/web/{ => view}/templates_test.go (92%) rename internal/web/{templates.go => view/view.go} (74%) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f5c80..e7e9eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Changed + +- `internal/web` split into subpackages (`web/view`, `web/auth`, `web/validate`, + `web/handlers`); the composition root (`web.New`, `web.Config`, `Server.Handler`) + is unchanged for `cmd/panel`. Templates and static assets moved under + `internal/web/view/`. + ### Added - panel: DMARC guidance for send-only relays — the suggested `_dmarc` record diff --git a/docs/architecture.md b/docs/architecture.md index afa414f..aba6fe2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -188,10 +188,10 @@ holds the cookie works after process restart, redeploy, or full backup restore. - **Idle timeout** — sliding window, `PANEL_SESSION_IDLE_DAYS` (default 7); no absolute cap (regular use keeps the session alive indefinitely). - **Renewal** — DB `last_seen` and cookie `Max-Age` update at most once per hour - (`renewThreshold` in [internal/web/session.go](../internal/web/session.go)). + (`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)). - **Password change** — all other sessions are deleted; the current session stays active ([internal/store/sessions.go](../internal/store/sessions.go), - [handlers_account.go](../internal/web/handlers_account.go)). + [handlers_account.go](../internal/web/handlers/handlers_account.go)). Restoring an **older** backup also restores session rows: a session invalidated after that backup was taken can become valid again if the browser still has the @@ -218,7 +218,15 @@ flowchart TB backupcli["selfpost-backup CLI"] end subgraph web ["internal/web — HTTP surface"] - handlers["handlers_*.go, templates, session/security"] + webRoot["web.go — router, security"] + viewPkg["web/view — templates, static"] + authPkg["web/auth — session, login, setup"] + handlersPkg["web/handlers — authenticated pages"] + webRoot --> viewPkg + webRoot --> authPkg + webRoot --> handlersPkg + handlersPkg --> authPkg + handlersPkg --> viewPkg end subgraph services ["Services — multi-store operations + rollback"] domainSvc["internal/domain"] diff --git a/docs/development.md b/docs/development.md index 4c83f3c..072f4f1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -112,7 +112,7 @@ the tree are AGPL-3.0-compatible. | Asset | Version | Repository | License | |---|---|---|---| -| `internal/web/static/htmx.min.js` | 2.0.4 | | 0BSD | +| `internal/web/view/static/htmx.min.js` | 2.0.4 | | 0BSD | ### E2e module (`test/e2e/go.mod`) @@ -354,7 +354,7 @@ to prose. | Mail path | [build/postfix-config.sh](../build/postfix-config.sh) | | Panel routes | [internal/web/web.go](../internal/web/web.go) | | Backup / restore, domain export | [internal/backup/](../internal/backup/), [cmd/selfpost-backup/](../cmd/selfpost-backup/) | -| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/session.go](../internal/web/session.go) | +| Sessions | [internal/store/sessions.go](../internal/store/sessions.go), [internal/web/auth/session.go](../internal/web/auth/session.go) | | Log rotation, reload | [build/logrotate-mail.conf](../build/logrotate-mail.conf), [build/logrotate-loop.sh](../build/logrotate-loop.sh), [build/postfix-cert-reload.sh](../build/postfix-cert-reload.sh) | | Deploy | [deploy/docker-compose.yml](../deploy/docker-compose.yml), [build/Dockerfile](../build/Dockerfile) | | Operator checklist | [§ User-facing deliverables](#user-facing-deliverables); detail — [guide.md](guide.md) | diff --git a/docs/plans/domain-admin.md b/docs/plans/domain-admin.md index 4384722..69c50e2 100644 --- a/docs/plans/domain-admin.md +++ b/docs/plans/domain-admin.md @@ -23,7 +23,7 @@ list: deletion, its own L2 limit); - the domain's DKIM/DNS status; - the send log filtered to the domain — the filter already exists in the log - ([sendLogData](../../internal/web/handlers_monitor.go)). + ([sendLogData](../../internal/web/handlers/handlers_monitor.go)). What stays outside the role is what is global by nature: diff --git a/docs/plans/web-split.md b/docs/plans/web-split.md index e2845a2..2638d12 100644 --- a/docs/plans/web-split.md +++ b/docs/plans/web-split.md @@ -1,6 +1,6 @@ # Plan: web-split (splitting `internal/web`) -**Status:** agreed +**Status:** done (see [CHANGELOG](../CHANGELOG.md) `[Unreleased]`) **Version:** `1.x`; an internal refactor, it does not force a break on its own. --- @@ -42,11 +42,25 @@ domains. The refactor is cheaper before that growth than after it. The order is a recommendation, not a blocker. +## Chosen scheme + +**Horizontal split into four packages** (decided at implementation): + +``` +internal/web/ # Config, Server, New, Handler — composition root; security.go +internal/web/view/ # embed templates/static, render/renderFragment, staticHandler +internal/web/auth/ # session, login/logout/setup, requireAuth, currentUser +internal/web/validate/ # shared form validation (avoids auth ↔ handlers import cycle) +internal/web/handlers/ # all authenticated page handlers (handlers_*.go) +``` + +`cmd/panel` keeps importing only `internal/web`. Subpackages are not exported +beyond what the composition root needs. + ## Done when -The decision is made deliberately when the work starts — either the package is -split along the chosen scheme, or it is settled that it stays flat. After a -split: `build`/`vet`/`test` green, the panel's behaviour unchanged. +The package is split along the scheme above. After the split: `build`/`vet`/`test` +green, the panel's behaviour unchanged. ## Risks diff --git a/docs/roadmap.md b/docs/roadmap.md index 63b3997..ed9ada0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,17 +26,15 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | ID | Topic | Status | Plan | |---|---|---|---| -| web-split | Splitting `internal/web` | **agreed** | [plans/web-split.md](plans/web-split.md) | | domain-admin | Domain administrator role | **agreed** | [plans/domain-admin.md](plans/domain-admin.md) | | inbound-relay | Inbound relay (backup-MX / forwarding) | **agreed** | [plans/inbound-relay.md](plans/inbound-relay.md) | | contributing | `CONTRIBUTING.md` | candidate | — | | visual-style | Обновление визуального стиля | candidate | — | | dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | [plans/dmarc-reports.md](plans/dmarc-reports.md) | -**Recommended order** (not binding): **web-split → domain-admin → -inbound-relay** — first the package split, then role-wide authorisation, then -the new vertical slice of the inbound relay. Deviating is allowed; there are no -hard phases here. +**Recommended order** (not binding): **domain-admin → +inbound-relay** — role-wide authorisation first, then the inbound relay vertical +slice. Deviating is allowed; there are no hard phases here. After a context reset, pick an item marked `agreed` or `in progress`, then work the checklist in its linked plan. @@ -59,8 +57,7 @@ engine stays outside the image, only the attachment point is provided. **Dependencies / risks:** a finished outbound path; open relay and backscatter; a wider attack surface (port 25 accepting mail). -**Order:** recommended after [web-split](plans/web-split.md) and -[domain-admin](plans/domain-admin.md). +**Order:** recommended after [domain-admin](plans/domain-admin.md). **Version:** target bump `1.x`; `2.x` possible — to be settled once the implementation lands. @@ -80,32 +77,13 @@ assigned domains (one or several). **Done when:** see [plans/domain-admin.md](plans/domain-admin.md). **Dependencies / risks:** a users table, the role in the session, authorisation -in every handler, setup and backup. **Order:** recommended after -[web-split](plans/web-split.md), before +in every handler, setup and backup. **Order:** recommended **before** [inbound-relay](plans/inbound-relay.md). **Version:** `1.x` MINOR, given a compatible migration of the current administrator into a global one. --- -## web-split - -**Goal:** deliberately split `internal/web` (or settle on keeping the package -flat) before it grows under inbound-relay and domain-admin. - -**Boundary:** an internal refactor; the panel's behaviour for the operator does -not change. - -**Done when:** the package is split along the chosen scheme, or it is settled -that it stays flat — see [plans/web-split.md](plans/web-split.md). - -**Dependencies / risks:** exporting a package-private API. **Order:** -recommended **first** among the agreed features (before domain-admin and -inbound-relay). -**Version:** `1.x`; on its own it does not force a break. - ---- - ## contributing **Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run diff --git a/internal/web/auth/auth.go b/internal/web/auth/auth.go new file mode 100644 index 0000000..cc4bff3 --- /dev/null +++ b/internal/web/auth/auth.go @@ -0,0 +1,122 @@ +// Package auth implements the panel's login sessions, one-time setup flow, +// and authentication middleware. +package auth + +import ( + "log" + "net" + "net/http" + "strings" + "time" + + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/view" +) + +// Config holds auth-specific panel configuration. +type Config struct { + CookieSecure bool + Hostname string + SessionIdleDays int + TrustedProxyCIDRs []*net.IPNet +} + +// Module handles login, logout, setup, and session middleware. +type Module struct { + store *store.Store + cfg Config + view *view.Engine + sessions *sessionStore + setup *setupManager + + loginLimiter *rateLimiter + setupLimiter *rateLimiter + + trustedProxies []*net.IPNet +} + +// New builds the auth module. setupTokenPath is where the current setup token +// is mirrored on disk (security.md). +func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Module { + idleDays := cfg.SessionIdleDays + if idleDays <= 0 { + idleDays = 7 + } + m := &Module{ + store: st, + cfg: cfg, + view: v, + sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour), + setupLimiter: newRateLimiter(10, time.Minute), + loginLimiter: newRateLimiter(10, 15*time.Minute), + trustedProxies: cfg.TrustedProxyCIDRs, + } + m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath) + return m +} + +// Bootstrap runs once at startup. If setup is not complete it mints and +// announces the first setup token (security.md). +func (m *Module) Bootstrap() error { + return m.setup.bootstrap() +} + +// AllowLoginAttempt reports whether a login or account-password change attempt +// from r is within the rate limit (security.md). +func (m *Module) AllowLoginAttempt(r *http.Request) bool { + return m.loginLimiter.Allow(clientIP(r, m.trustedProxies)) +} + +// SessionToken returns the session token the request carries, if exactly one +// cookie of that name is present. +func (m *Module) SessionToken(r *http.Request) (string, bool) { + return m.sessionToken(r) +} + +// RenameSession updates the username carried by a session. +func (m *Module) RenameSession(token, username string) { + m.sessions.Rename(token, username) +} + +// DestroyOtherSessions invalidates every session except keep. +func (m *Module) DestroyOtherSessions(keep string) { + m.sessions.DestroyOthers(keep) +} + +func logf(format string, args ...any) { + log.Printf(format, args...) +} + +// clientIP extracts the peer IP for rate-limiting. By default it is the +// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches +// one of trustedProxies, the last entry of X-Forwarded-For is used instead — +// that is the address the trusted proxy itself appended, so a client can't +// forge it by sending its own XFF header. +func clientIP(r *http.Request, trustedProxies []*net.IPNet) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + + if len(trustedProxies) > 0 { + if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil { + return ip.String() + } + } + } + } + + return host +} + +func ipInAny(ip net.IP, nets []*net.IPNet) bool { + for _, n := range nets { + if n.Contains(ip) { + return true + } + } + return false +} diff --git a/internal/web/handlers_auth_test.go b/internal/web/auth/auth_test.go similarity index 52% rename from internal/web/handlers_auth_test.go rename to internal/web/auth/auth_test.go index 227c8a5..2c59422 100644 --- a/internal/web/handlers_auth_test.go +++ b/internal/web/auth/auth_test.go @@ -1,72 +1,95 @@ -package web +package auth import ( "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" + "time" + + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/view" ) -// The __Host- prefix is only valid on a Secure cookie: getting this condition -// backwards would make the development instance fail to log in at all, and -// silently — the browser discards the Set-Cookie and the panel just shows the -// login form again. +func newTestSessionStore(t *testing.T) *sessionStore { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { st.Close() }) + return newSessionStore(st, 7*24*time.Hour) +} + +func mustView(t *testing.T) *view.Engine { + t.Helper() + v, err := view.New("test") + if err != nil { + t.Fatalf("view: %v", err) + } + return v +} + +func testModule(t *testing.T, cookieSecure bool) *Module { + t.Helper() + st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { st.Close() }) + return New(st, Config{CookieSecure: cookieSecure}, mustView(t), "") +} + func TestSessionCookieNameFollowsCookieSecure(t *testing.T) { - secure := &Server{cfg: Config{CookieSecure: true}} + secure := testModule(t, true) if got := secure.sessionCookie(); got != "__Host-selfpost_session" { t.Errorf("with TLS the cookie is named %q, want the __Host- prefixed name", got) } - plain := &Server{cfg: Config{CookieSecure: false}} + plain := testModule(t, false) if got := plain.sessionCookie(); got != "selfpost_session" { t.Errorf("without TLS the cookie is named %q, want the bare name", got) } } -// A neighbouring host on the same registrable domain can set a cookie by the -// same name; the browser then sends both, oldest first. Picking one at random -// would leave the administrator in a login loop with no explanation, so the -// request counts as signed out instead. func TestSessionTokenRejectsDuplicates(t *testing.T) { - s := &Server{cfg: Config{CookieSecure: false}} + m := testModule(t, false) r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil) r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"}) r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "the-real-session"}) - if token, ok := s.sessionToken(r); ok { + if token, ok := m.sessionToken(r); ok { t.Fatalf("duplicate cookies accepted, token = %q", token) } } func TestSessionTokenReadsOneCookie(t *testing.T) { - s := &Server{cfg: Config{CookieSecure: true}} + m := testModule(t, true) r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil) r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: "the-real-session"}) - token, ok := s.sessionToken(r) + token, ok := m.sessionToken(r) if !ok || token != "the-real-session" { t.Fatalf("sessionToken = %q, %t; want the cookie's value", token, ok) } } -// A cookie under the other deployment's name is not this deployment's session: -// after an upgrade the pre-14 cookie must not be honoured as if it were the -// prefixed one. func TestSessionTokenIgnoresTheOtherName(t *testing.T) { - s := &Server{cfg: Config{CookieSecure: true}} + m := testModule(t, true) r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil) r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "left-over-from-an-older-build"}) - if _, ok := s.sessionToken(r); ok { + if _, ok := m.sessionToken(r); ok { t.Fatal("the unprefixed cookie was accepted on a TLS deployment") } } func TestRequireAuthRejectsDuplicateCookies(t *testing.T) { - s := &Server{cfg: Config{CookieSecure: false}, sessions: newTestSessionStore(t)} - token := s.sessions.Create("admin") + m := testModule(t, false) + token := m.sessions.Create("admin") reached := false - h := s.requireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) + h := m.RequireAuth(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) r := httptest.NewRequest(http.MethodGet, "http://panel.example.com/domains", nil) r.AddCookie(&http.Cookie{Name: "selfpost_session", Value: "planted-by-a-neighbour"}) @@ -82,19 +105,17 @@ func TestRequireAuthRejectsDuplicateCookies(t *testing.T) { } } -// Signing out has to expire the cookie under both names, or the cookie left -// over from a pre-__Host- build stays in the browser for the rest of its life. func TestLogoutClearsBothCookieNames(t *testing.T) { - s := &Server{cfg: Config{CookieSecure: true}, sessions: newTestSessionStore(t)} - token := s.sessions.Create("admin") + m := testModule(t, true) + token := m.sessions.Create("admin") r := httptest.NewRequest(http.MethodPost, "http://panel.example.com/logout", nil) r.Host = "panel.example.com" r.AddCookie(&http.Cookie{Name: "__Host-selfpost_session", Value: token}) rec := httptest.NewRecorder() - s.handleLogout(rec, r) + m.HandleLogout(rec, r) - if _, ok := s.sessions.Lookup(token); ok { + if _, ok := m.sessions.Lookup(token); ok { t.Error("the session survived sign-out") } set := rec.Header().Values("Set-Cookie") @@ -110,3 +131,59 @@ func TestLogoutClearsBothCookieNames(t *testing.T) { } } } + +func TestSessionRename(t *testing.T) { + s := newTestSessionStore(t) + token := s.Create("admin") + + s.Rename(token, "operator") + + name, ok := s.Lookup(token) + if !ok { + t.Fatal("session lost after rename") + } + if name != "operator" { + t.Fatalf("session username = %q, want %q", name, "operator") + } +} + +func TestSessionDestroyOthers(t *testing.T) { + s := newTestSessionStore(t) + keep := s.Create("admin") + other := s.Create("admin") + + s.DestroyOthers(keep) + + if _, ok := s.Lookup(keep); !ok { + t.Fatal("current session was destroyed") + } + if _, ok := s.Lookup(other); ok { + t.Fatal("other session survived") + } +} + +func TestSessionLookupRejectsExpired(t *testing.T) { + s := newTestSessionStore(t) + s.idle = -time.Minute + token := s.Create("admin") + + if _, ok := s.Lookup(token); ok { + t.Fatal("expired session was accepted") + } +} + +func TestSessionTouchThrottled(t *testing.T) { + s := newTestSessionStore(t) + token := s.Create("admin") + + if s.Touch(token) { + t.Fatal("touch renewed a session created moments ago") + } + + if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil { + t.Fatalf("renew session: %v", err) + } + if !s.Touch(token) { + t.Fatal("touch did not renew a session past the throttle window") + } +} diff --git a/internal/web/auth/handlers.go b/internal/web/auth/handlers.go new file mode 100644 index 0000000..194014f --- /dev/null +++ b/internal/web/auth/handlers.go @@ -0,0 +1,239 @@ +package auth + +import ( + "errors" + "net/http" + "strings" + + "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/validate" + "golang.org/x/crypto/bcrypt" +) + +const ( + sessionCookieBase = "selfpost_session" + sessionCookiePrefixed = "__Host-" + sessionCookieBase +) + +func (m *Module) sessionCookie() string { + if m.cfg.CookieSecure { + return sessionCookiePrefixed + } + return sessionCookieBase +} + +func (m *Module) sessionToken(r *http.Request) (string, bool) { + name := m.sessionCookie() + var token string + var n int + for _, c := range r.Cookies() { + if c.Name == name { + n++ + token = c.Value + } + } + switch n { + case 0: + return "", false + case 1: + return token, true + default: + logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+ + "another host on this domain is overwriting the session cookie, clear the cookies for the parent domain", + r.Method, r.URL.Path, n, name) + return "", false + } +} + +func (m *Module) clearSessionCookies(w http.ResponseWriter) { + for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: m.cfg.CookieSecure || name == sessionCookiePrefixed, + SameSite: http.SameSiteLaxMode, + }) + } +} + +// HandleLogin serves the login form (GET) and authenticates (POST). +func (m *Module) HandleLogin(w http.ResponseWriter, r *http.Request) { + exists, err := m.store.AdminExists() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !exists { + m.view.Render(w, http.StatusOK, "login", map[string]any{ + "Title": "SelfPost — Sign in", + "Active": "login", + "SetupHint": true, + }) + return + } + + switch r.Method { + case http.MethodGet: + m.renderLogin(w, http.StatusOK, "") + case http.MethodPost: + m.submitLogin(w, r) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (m *Module) renderLogin(w http.ResponseWriter, status int, formErr string) { + m.view.Render(w, status, "login", map[string]any{ + "Title": "SelfPost — Sign in", + "Active": "login", + "Error": formErr, + }) +} + +func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) { + if !m.loginLimiter.Allow(clientIP(r, m.trustedProxies)) { + m.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.") + return + } + if err := r.ParseForm(); err != nil { + m.renderLogin(w, http.StatusBadRequest, "Invalid form submission.") + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + + admin, err := m.store.GetAdmin() + if err != nil { + if !errors.Is(err, store.ErrNoAdmin) { + logf("panel: login: get admin failed: %v", err) + } + m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") + return + } + + pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) + if username != admin.Username || pwErr != nil { + m.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") + return + } + + token := m.sessions.Create(admin.Username) + m.setSessionCookie(w, token) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (m *Module) setSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: m.sessionCookie(), + Value: token, + Path: "/", + MaxAge: m.sessions.MaxAge(), + HttpOnly: true, + Secure: m.cfg.CookieSecure, + SameSite: http.SameSiteLaxMode, + }) +} + +// HandleLogout destroys the session and clears the cookie. +func (m *Module) HandleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + name := m.sessionCookie() + for _, c := range r.Cookies() { + if c.Name == name { + m.sessions.Destroy(c.Value) + } + } + m.clearSessionCookies(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// HandleSetup serves the one-time administrator creation flow at +// /setup/ (security.md). +func (m *Module) HandleSetup(w http.ResponseWriter, r *http.Request) { + if !m.setupLimiter.Allow(clientIP(r, m.trustedProxies)) { + http.Error(w, "too many requests", http.StatusTooManyRequests) + return + } + + token := strings.TrimPrefix(r.URL.Path, "/setup/") + if token == "" || strings.Contains(token, "/") { + http.NotFound(w, r) + return + } + if !m.setup.validate(token) { + http.NotFound(w, r) + return + } + + switch r.Method { + case http.MethodGet: + m.renderSetupForm(w, http.StatusOK, token, "") + case http.MethodPost: + m.submitSetup(w, r, token) + default: + w.Header().Set("Allow", "GET, POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (m *Module) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) { + m.view.Render(w, status, "setup", map[string]any{ + "Title": "SelfPost — Create administrator", + "Active": "setup", + "Token": token, + "Error": formErr, + }) +} + +func (m *Module) submitSetup(w http.ResponseWriter, r *http.Request, token string) { + if err := r.ParseForm(); err != nil { + m.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.") + return + } + username := strings.TrimSpace(r.PostFormValue("username")) + password := r.PostFormValue("password") + confirm := r.PostFormValue("password_confirm") + + if err := validate.Username(username); err != nil { + m.renderSetupForm(w, http.StatusBadRequest, token, err.Error()) + return + } + if password != confirm { + m.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.") + return + } + if err := validate.AdminPassword(password); err != nil { + m.renderSetupForm(w, http.StatusBadRequest, token, err.Error()) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + logf("panel: setup: hashing password failed: %v", err) + m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.") + return + } + + if err := m.store.CreateAdmin(username, string(hash)); err != nil { + if exists, _ := m.store.AdminExists(); exists { + m.setup.complete() + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + logf("panel: setup: create admin failed: %v", err) + m.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.") + return + } + + m.setup.complete() + logf("panel: administrator %q created; setup link is now disabled", username) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} diff --git a/internal/web/auth/middleware.go b/internal/web/auth/middleware.go new file mode 100644 index 0000000..9abdd3a --- /dev/null +++ b/internal/web/auth/middleware.go @@ -0,0 +1,45 @@ +package auth + +import ( + "context" + "net/http" +) + +type ctxKey int + +const usernameKey ctxKey = 0 + +// RequireAuth wraps a handler so only requests with a valid session cookie +// reach it; everyone else is redirected to the login page. The authenticated +// username is stashed in the request context for downstream handlers. +func (m *Module) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, ok := m.sessionToken(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + username, ok := m.sessions.Lookup(token) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + if isSessionActivity(r) && m.sessions.Touch(token) { + m.setSessionCookie(w, token) + } + ctx := context.WithValue(r.Context(), usernameKey, username) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func isSessionActivity(r *http.Request) bool { + return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "") +} + +// CurrentUser returns the authenticated username from the request context. +func CurrentUser(r *http.Request) string { + if v, ok := r.Context().Value(usernameKey).(string); ok { + return v + } + return "" +} diff --git a/internal/web/ratelimit.go b/internal/web/auth/ratelimit.go similarity index 63% rename from internal/web/ratelimit.go rename to internal/web/auth/ratelimit.go index 664ad82..5658fc8 100644 --- a/internal/web/ratelimit.go +++ b/internal/web/auth/ratelimit.go @@ -1,4 +1,4 @@ -package web +package auth import ( "sync" @@ -6,9 +6,7 @@ import ( ) // rateLimiter is a simple fixed-window per-key counter used to throttle the -// setup and login routes (security.md). Keys are client IPs. It is not a -// precise sliding window — a coarse backstop against brute-force and log noise -// is all these routes need. +// setup and login routes (security.md). Keys are client IPs. type rateLimiter struct { max int window time.Duration @@ -30,8 +28,6 @@ func newRateLimiter(max int, window time.Duration) *rateLimiter { } } -// Allow records an attempt for key and reports whether it is within the limit. -// The current window is reset lazily once it elapses. func (r *rateLimiter) Allow(key string) bool { now := time.Now() r.mu.Lock() @@ -50,9 +46,6 @@ func (r *rateLimiter) Allow(key string) bool { return true } -// sweep drops expired buckets so the map cannot grow without bound. Called -// under the lock while a window is being reset, which is often enough given the -// low request volume of these routes. func (r *rateLimiter) sweep(now time.Time) { for k, b := range r.buckets { if now.After(b.windowEnds) { diff --git a/internal/web/session.go b/internal/web/auth/session.go similarity index 75% rename from internal/web/session.go rename to internal/web/auth/session.go index daa0a8f..29c588d 100644 --- a/internal/web/session.go +++ b/internal/web/auth/session.go @@ -1,4 +1,4 @@ -package web +package auth import ( "crypto/sha256" @@ -22,10 +22,7 @@ const renewThreshold = time.Hour // cookie. type sessionStore struct { store *store.Store - // idle is the sliding inactivity window (PANEL_SESSION_IDLE_DAYS). There is - // no absolute cap: an administrator who keeps coming back stays signed in - // indefinitely, deliberately. - idle time.Duration + idle time.Duration } func newSessionStore(st *store.Store, idle time.Duration) *sessionStore { @@ -51,10 +48,6 @@ func (s *sessionStore) Create(username string) string { if err := s.store.CreateSession(hashToken(token), username, now.Add(s.idle)); err != nil { logf("panel: session: create failed: %v", err) } - // Opportunistic cleanup: a session nobody ever came back to otherwise sits - // in the table forever. Piggybacking on Create (the one write every login - // already pays for) avoids a dedicated background sweep for what is, on a - // single-admin panel, a handful of rows at most. if _, err := s.store.DeleteExpiredSessions(now); err != nil { logf("panel: session: prune expired failed: %v", err) } @@ -86,10 +79,7 @@ func (s *sessionStore) Lookup(token string) (string, bool) { } // Touch extends a session's sliding expiry if it has been at least -// renewThreshold since the last extension, and reports whether it did so — -// the caller uses that to decide whether the response needs a fresh -// Set-Cookie. It assumes the caller has just confirmed the session is valid -// (e.g. via Lookup); it does nothing for a token that no longer exists. +// renewThreshold since the last extension, and reports whether it did so. func (s *sessionStore) Touch(token string) bool { hash := hashToken(token) row, found, err := s.store.LookupSession(hash) @@ -100,8 +90,6 @@ func (s *sessionStore) Touch(token string) bool { if !found { return false } - // expiresAt = lastRenewal + idle, so this recovers when the session was - // last extended without a separate column. lastRenewal := row.ExpiresAt.Add(-s.idle) now := time.Now() if now.Sub(lastRenewal) < renewThreshold { @@ -114,19 +102,14 @@ func (s *sessionStore) Touch(token string) bool { return true } -// Rename updates the username carried by a session, keeping its expiry. It is -// used when the administrator renames their own account so the current -// session keeps working under the new name. +// Rename updates the username carried by a session, keeping its expiry. func (s *sessionStore) Rename(token, username string) { if err := s.store.RenameSession(hashToken(token), username); err != nil { logf("panel: session: rename failed: %v", err) } } -// DestroyOthers invalidates every session except keep. It is called when the -// administrator changes their password: a stolen cookie issued under the old -// password must stop working, while the admin performing the change stays -// signed in. +// DestroyOthers invalidates every session except keep. func (s *sessionStore) DestroyOthers(keep string) { if err := s.store.DeleteOtherSessions(hashToken(keep)); err != nil { logf("panel: session: destroy others failed: %v", err) diff --git a/internal/web/setup.go b/internal/web/auth/setup.go similarity index 71% rename from internal/web/setup.go rename to internal/web/auth/setup.go index e162f05..8f28880 100644 --- a/internal/web/setup.go +++ b/internal/web/auth/setup.go @@ -1,4 +1,4 @@ -package web +package auth import ( "crypto/subtle" @@ -32,8 +32,6 @@ func newSetupManager(st *store.Store, hostname, tokenPath string) *setupManager return &setupManager{store: st, hostname: hostname, tokenPath: tokenPath} } -// bootstrap runs once at startup. If setup is already complete it clears any -// stale token file; otherwise it mints and announces the first token. func (m *setupManager) bootstrap() error { done, err := m.store.AdminExists() if err != nil { @@ -49,9 +47,6 @@ func (m *setupManager) bootstrap() error { return nil } -// activeToken returns the current valid setup token, regenerating and -// re-announcing it if none exists or it has expired. It returns ("", false) -// once setup is complete — callers must treat that as "route gone" (404). func (m *setupManager) activeToken() (string, bool) { done, err := m.store.AdminExists() if err != nil { @@ -69,10 +64,6 @@ func (m *setupManager) activeToken() (string, bool) { return m.token, true } -// validate reports whether provided matches the active token, using a -// constant-time comparison to avoid leaking a correct prefix via timing -// (security.md). A mismatch does NOT regenerate or invalidate the token: failed -// attempts must not let an attacker DoS a legitimate setup (security.md). func (m *setupManager) validate(provided string) bool { token, ok := m.activeToken() if !ok { @@ -81,8 +72,6 @@ func (m *setupManager) validate(provided string) bool { return subtle.ConstantTimeCompare([]byte(provided), []byte(token)) == 1 } -// complete marks setup as finished: the admin row now exists, so drop the -// in-memory token and remove the on-disk copy. func (m *setupManager) complete() { m.mu.Lock() m.token = "" @@ -91,16 +80,12 @@ func (m *setupManager) complete() { m.clearTokenFile() } -// regenerateLocked mints a fresh token, announces it and mirrors it to disk. -// Caller holds m.mu. func (m *setupManager) regenerateLocked() { - m.token = randomToken(16) // 128 bits of entropy (security.md) + m.token = randomToken(16) m.expiresAt = time.Now().Add(setupTokenTTL) m.announce(m.token) } -// announce prints the setup link to the container log and writes it to the -// token file so it can be read either way (security.md). func (m *setupManager) announce(token string) { url := m.setupURL(token) logf("panel: ==================================================================") @@ -112,7 +97,6 @@ func (m *setupManager) announce(token string) { if m.tokenPath == "" { return } - // 0600: the token is a bearer secret for creating the admin. if err := os.WriteFile(m.tokenPath, []byte(url+"\n"), 0o600); err != nil { logf("panel: setup: could not write token file %s: %v", m.tokenPath, err) } diff --git a/internal/web/token.go b/internal/web/auth/token.go similarity index 97% rename from internal/web/token.go rename to internal/web/auth/token.go index 56b578f..0d4f4b3 100644 --- a/internal/web/token.go +++ b/internal/web/auth/token.go @@ -1,4 +1,4 @@ -package web +package auth import ( "crypto/rand" diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go new file mode 100644 index 0000000..3e4ec83 --- /dev/null +++ b/internal/web/handlers/handlers.go @@ -0,0 +1,66 @@ +// Package handlers implements the panel's authenticated HTTP handlers. +package handlers + +import ( + "log" + + "github.com/mixeme/selfpost/internal/app" + "github.com/mixeme/selfpost/internal/dnscheck" + "github.com/mixeme/selfpost/internal/domain" + "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/view" +) + +// Config holds handler-specific panel configuration. +type Config struct { + Hostname string + SubmissionEnabled bool + MailLogPath string + DataDir string + DBPath string + Version string + TLSCertFile string + OpenDKIMSocket string + JournalSocket string +} + +// Handlers holds dependencies for authenticated panel routes. +type Handlers struct { + store *store.Store + domains *domain.Service + apps *app.Service + cfg Config + view *view.Engine + dns *dnscheck.Checker + machine *health.MachineSampler + auth *auth.Module +} + +// New builds authenticated panel handlers. +func New( + st *store.Store, + domains *domain.Service, + apps *app.Service, + cfg Config, + v *view.Engine, + dns *dnscheck.Checker, + machine *health.MachineSampler, + a *auth.Module, +) *Handlers { + return &Handlers{ + store: st, + domains: domains, + apps: apps, + cfg: cfg, + view: v, + dns: dns, + machine: machine, + auth: a, + } +} + +func logf(format string, args ...any) { + log.Printf(format, args...) +} diff --git a/internal/web/handlers_account.go b/internal/web/handlers/handlers_account.go similarity index 74% rename from internal/web/handlers_account.go rename to internal/web/handlers/handlers_account.go index 0f5e5ed..1f920bc 100644 --- a/internal/web/handlers_account.go +++ b/internal/web/handlers/handlers_account.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "context" @@ -9,26 +9,28 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/validate" "golang.org/x/crypto/bcrypt" ) -// handleAccount serves the administrator's own account settings: the username +// HandleAccount serves the administrator's own account settings: the username // and password chosen during setup are the only panel credentials // (security.md), and until now they could be changed only by recreating the // state. Changing them here never touches application SASL logins, which are a // separate identity system (architecture.md § Mail path). -func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - admin, err := s.store.GetAdmin() + admin, err := h.store.GetAdmin() if err != nil { logf("panel: account: get admin failed: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - s.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail) + h.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail) case http.MethodPost: - s.submitAccount(w, r) + h.submitAccount(w, r) default: w.Header().Set("Allow", "GET, POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -38,16 +40,16 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) { // renderAccount draws the settings form. formUsername and formDMARCEmail // repopulate fields after a rejected submission; password fields are never // repopulated. -func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) { +func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) { var reportAuth dnscheck.Result if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - reportAuth = s.dns.ReportAuth(ctx, hub) + reportAuth = h.dns.ReportAuth(ctx, hub) cancel() } - s.render(w, status, "account", map[string]any{ + h.view.Render(w, status, "account", map[string]any{ "Title": "SelfPost — settings", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "account", "FormUsername": formUsername, "FormDMARCEmail": formDMARCEmail, @@ -88,14 +90,14 @@ func accountFlash(r *http.Request) string { // out of their own panel, and the attempt is throttled on the same limiter as // the login form so this route cannot be used to brute-force the password past // that limit (security.md). -func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) { - if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) { - s.renderAccount(w, r, http.StatusTooManyRequests, - "Too many attempts. Please wait and try again.", currentUser(r), "") +func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) { + if !h.auth.AllowLoginAttempt(r) { + h.renderAccount(w, r, http.StatusTooManyRequests, + "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "") return } if err := r.ParseForm(); err != nil { - s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r), "") + h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "") return } @@ -105,7 +107,7 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) { confirm := r.PostFormValue("new_password_confirm") dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email")) - admin, err := s.store.GetAdmin() + admin, err := h.store.GetAdmin() if err != nil { logf("panel: account: get admin failed: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -116,20 +118,20 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) { } if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil { - s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail) + h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail) return } renaming := username != admin.Username if renaming { - if err := validateUsername(username); err != nil { - s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) + if err := validate.Username(username); err != nil { + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) return } } - if err := validateEmail(dmarcEmail); err != nil { - s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) + if err := validate.Email(dmarcEmail); err != nil { + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) return } @@ -138,16 +140,16 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) { repassword := password != "" || confirm != "" if repassword { if password != confirm { - s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail) + h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail) return } - if err := validateAdminPassword(password); err != nil { - s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) + if err := validate.AdminPassword(password); err != nil { + h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail) return } } if !renaming && !repassword && !emailChanging { - s.renderAccount(w, r, http.StatusBadRequest, + h.renderAccount(w, r, http.StatusBadRequest, "Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail) return } @@ -157,29 +159,29 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) { newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { logf("panel: account: hashing password failed: %v", err) - s.renderAccount(w, r, http.StatusInternalServerError, + h.renderAccount(w, r, http.StatusInternalServerError, "Internal error. Please try again.", username, dmarcEmail) return } hash = string(newHash) } - if err := s.store.UpdateAdmin(username, hash, dmarcEmail); err != nil { + if err := h.store.UpdateAdmin(username, hash, dmarcEmail); err != nil { logf("panel: account: update admin failed: %v", err) msg := "Could not save the changes. Please check the logs and try again." if errors.Is(err, store.ErrNoAdmin) { msg = "There is no administrator account to update." } - s.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail) + h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail) return } - if token, ok := s.sessionToken(r); ok { + if token, ok := h.auth.SessionToken(r); ok { if renaming { - s.sessions.Rename(token, username) + h.auth.RenameSession(token, username) } if repassword { - s.sessions.DestroyOthers(token) + h.auth.DestroyOtherSessions(token) } } diff --git a/internal/web/handlers_apps.go b/internal/web/handlers/handlers_apps.go similarity index 80% rename from internal/web/handlers_apps.go rename to internal/web/handlers/handlers_apps.go index aa99997..ef243cd 100644 --- a/internal/web/handlers_apps.go +++ b/internal/web/handlers/handlers_apps.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "errors" @@ -10,6 +10,8 @@ import ( "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/validate" ) // newCred carries a freshly generated login/password to the template so it can @@ -48,28 +50,28 @@ type appRateLimitView struct { WindowVal string // window seconds, defaulted when unset } -// handleDomainDetail shows a single domain: its DKIM DNS record (product.md) +// HandleDomainDetail shows a single domain: its DKIM DNS record (product.md) // and its applications with the controls to add, edit, delete and re-issue // credentials (product.md). -func (s *Server) handleDomainDetail(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } - s.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard}) + h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard}) } // renderDomainDetail renders the domain page. view supplies request-specific // extras (form error/values, a one-time credential); everything else is loaded // fresh from the stores so the page always reflects committed state. -func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) { - record, err := s.domains.DKIMRecord(d) +func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) { + 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 := s.apps.List(d.ID) + 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) @@ -77,7 +79,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat } appViews := make([]appRateLimitView, 0, len(apps)) for _, a := range apps { - rl, ok, err := s.apps.RateLimit(a.ID) + rl, ok, err := h.apps.RateLimit(a.ID) if err != nil { logf("panel: application %d: rate limit: %v", a.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -92,7 +94,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat }) } - domainRL, domainRLok, err := s.domains.RateLimit(d.ID) + 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) @@ -102,7 +104,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat // What DNS actually publishes for the domain today, checked against the key // this server signs with. Cached by the checker, so re-rendering the page // after a form post costs nothing. - admin, err := s.store.GetAdmin() + admin, err := h.store.GetAdmin() if err != nil { logf("panel: domain %d: get admin: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -110,7 +112,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat } profileEmail := admin.DMARCReportEmail reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail) - dns, srv := s.domainDNS(d, record, profileEmail, false) + dns, srv := h.domainDNS(d, record, profileEmail, false) reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail) dmarcMode := "inherit" dmarcCustom := "" @@ -132,9 +134,9 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat dmarcSource = "settings" } - s.render(w, status, "domain_detail", map[string]any{ + h.view.Render(w, status, "domain_detail", map[string]any{ "Title": "SelfPost — " + d.Name, - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "domains", "Domain": d, "Record": record, @@ -144,7 +146,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat // this server expects rather than leaving it to the documentation. The // same builders phrase the suggestions in the check messages, so the // page and the checks below it never recommend different records. - "SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs), + "SPFExample": dnscheck.SPFExample(h.cfg.Hostname, srv.IPs), "DMARCName": dnscheck.DMARCRecordName(d.Name), "DMARCExample": dnscheck.DMARCExample(reportEmail), "DMARCSource": dmarcSource, @@ -159,8 +161,8 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat // Client connection settings (the same for every domain on this // instance): the hostname clients connect to, and whether the optional // submission listener is enabled in this deployment. - "Hostname": s.cfg.Hostname, - "SubmissionEnabled": s.cfg.SubmissionEnabled, + "Hostname": h.cfg.Hostname, + "SubmissionEnabled": h.cfg.SubmissionEnabled, "Apps": appViews, "Error": view.FormErr, "FormLogin": view.FormLogin, @@ -172,7 +174,7 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat "List": store.AddressModeList, "RateLimitErr": view.RateLimitErr, "ExportErr": view.ExportErr, - "MinPwLen": minSecretFilePasswordLen, + "MinPwLen": validate.MinSecretFilePasswordLen, "DomainHasRL": domainRLok && domainRL.Active(), "DomainRLIPs": strings.Join(domainRL.AllowedIPs, "\n"), "DomainRLMax": intOrBlank(domainRL.MaxMessages), @@ -186,9 +188,9 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat // 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 (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) { - srv := s.dns.Server(s.cfg.Hostname, false) - return s.dns.Domain(dnscheck.Query{ +func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail 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, @@ -198,26 +200,26 @@ func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmai }, force), srv } -// handleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and +// HandleDomainDNSRecheck re-runs the domain's DNS checks ignoring the cache and // returns to its page, which then renders the fresh result. -func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } - record, err := s.domains.DKIMRecord(d) + 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 } - admin, err := s.store.GetAdmin() + admin, err := h.store.GetAdmin() if err != nil { logf("panel: domain %d: get admin: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return } - s.domainDNS(d, record, admin.DMARCReportEmail, true) + h.domainDNS(d, record, admin.DMARCReportEmail, true) http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) } @@ -260,17 +262,17 @@ func detailFlash(r *http.Request) string { } } -// handleAddApplication creates an application on a domain and renders the page +// HandleAddApplication creates an application on a domain and renders the page // back with the generated password shown once (product.md, security.md). Because the // password cannot be recovered later, this deliberately renders inline rather // than redirecting. -func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleAddApplication(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } if err := r.ParseForm(); err != nil { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard}) return } @@ -284,25 +286,25 @@ func (s *Server) handleAddApplication(w http.ResponseWriter, r *http.Request) { FormAddrs: r.PostFormValue("addresses"), } - a, password, err := s.apps.Create(d.ID, login, mode, addrs) + a, password, err := h.apps.Create(d.ID, login, mode, addrs) if err != nil { repopulate.FormErr = applicationErrorMessage(err) status := http.StatusBadRequest if errors.Is(err, store.ErrLoginExists) { status = http.StatusConflict } - s.renderDomainDetail(w, r, status, d, repopulate) + h.renderDomainDetail(w, r, status, d, repopulate) return } - s.renderDomainDetail(w, r, http.StatusCreated, d, detailView{ + h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{ FormMode: store.AddressModeWildcard, NewCred: &newCred{Login: a.Login, Password: password}, }) } -// handleUpdateAppMode switches an application's address mode / list (product.md). -func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { - a, ok := s.lookupApplication(w, r) +// HandleUpdateAppMode switches an application's address mode / list (product.md). +func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) { + a, ok := h.lookupApplication(w, r) if !ok { return } @@ -313,13 +315,13 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { mode := r.PostFormValue("mode") addrs := splitAddresses(r.PostFormValue("addresses")) - if err := s.apps.UpdateMode(a.ID, mode, addrs); err != nil { - d, derr := s.domains.Get(a.DomainID) + if err := h.apps.UpdateMode(a.ID, mode, addrs); err != nil { + d, derr := h.domains.Get(a.DomainID) if derr != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormErr: fmt.Sprintf("Could not update %s: %s", a.Login, applicationErrorMessage(err)), FormMode: store.AddressModeWildcard, }) @@ -328,38 +330,38 @@ func (s *Server) handleUpdateAppMode(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther) } -// handleRegenPassword issues a new password for an application and shows it once +// HandleRegenPassword issues a new password for an application and shows it once // (product.md, security.md). Rendered inline, like creation, so the password is visible. -func (s *Server) handleRegenPassword(w http.ResponseWriter, r *http.Request) { - a, ok := s.lookupApplication(w, r) +func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) { + a, ok := h.lookupApplication(w, r) if !ok { return } - d, err := s.domains.Get(a.DomainID) + d, err := h.domains.Get(a.DomainID) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } - password, err := s.apps.RegeneratePassword(a.ID) + 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 } - s.renderDomainDetail(w, r, http.StatusOK, d, detailView{ + h.renderDomainDetail(w, r, http.StatusOK, d, detailView{ FormMode: store.AddressModeWildcard, NewCred: &newCred{Login: a.Login, Password: password}, }) } -// handleDeleteApplication removes an application and returns to its domain page +// HandleDeleteApplication removes an application and returns to its domain page // (product.md). -func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) { - a, ok := s.lookupApplication(w, r) +func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Request) { + a, ok := h.lookupApplication(w, r) if !ok { return } - if err := s.apps.Delete(a.ID); err != nil { + 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 @@ -369,13 +371,13 @@ func (s *Server) handleDeleteApplication(w http.ResponseWriter, r *http.Request) // lookupApplication resolves the {aid} path value to an application, writing a // 404 for a bad id or missing application. -func (s *Server) lookupApplication(w http.ResponseWriter, r *http.Request) (store.Application, bool) { +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 := s.apps.Get(id) + a, err := h.apps.Get(id) if err != nil { if errors.Is(err, store.ErrApplicationNotFound) { http.NotFound(w, r) diff --git a/internal/web/handlers_backup.go b/internal/web/handlers/handlers_backup.go similarity index 81% rename from internal/web/handlers_backup.go rename to internal/web/handlers/handlers_backup.go index 4b1d45f..8355360 100644 --- a/internal/web/handlers_backup.go +++ b/internal/web/handlers/handlers_backup.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "bytes" @@ -13,6 +13,8 @@ import ( "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/secretfile" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/validate" ) // maxImportBytes caps a domain-import upload. A domain export is a small JSON @@ -22,35 +24,35 @@ import ( // covers both forms. const maxImportBytes = 1 << 20 // 1 MiB -// handleBackupPage renders the backup/migration screen: the full-server backup +// HandleBackupPage renders the backup/migration screen: the full-server backup // and the domain import are separate actions with different risk, so each gets // its own card here rather than sharing a block on the domain list. -func (s *Server) handleBackupPage(w http.ResponseWriter, r *http.Request) { - s.renderBackupPage(w, r, http.StatusOK, "") +func (h *Handlers) HandleBackupPage(w http.ResponseWriter, r *http.Request) { + h.renderBackupPage(w, r, http.StatusOK, "") } // renderBackupPage draws the page; importErr surfaces a failed domain import // (architecture.md § Persistence) next to the form that produced it. -func (s *Server) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) { - s.renderBackupPageWith(w, r, status, importErr, "") +func (h *Handlers) renderBackupPage(w http.ResponseWriter, r *http.Request, status int, importErr string) { + h.renderBackupPageWith(w, r, status, importErr, "") } // renderBackupPageWith is renderBackupPage with the second of the page's two // error slots: backupErr belongs to the full-backup card (a rejected encryption // password), importErr to the import card, so neither message appears under the // wrong form. -func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) { - s.render(w, status, "backup", map[string]any{ +func (h *Handlers) renderBackupPageWith(w http.ResponseWriter, r *http.Request, status int, importErr, backupErr string) { + h.view.Render(w, status, "backup", map[string]any{ "Title": "SelfPost — backup", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "backup", "ImportErr": importErr, "BackupErr": backupErr, - "MinPwLen": minSecretFilePasswordLen, + "MinPwLen": validate.MinSecretFilePasswordLen, }) } -// handleBackup streams a full-server backup as a download (architecture.md § +// HandleBackup streams a full-server backup as a download (architecture.md § // Persistence). It is an authenticated admin action (this handler sits behind // the auth middleware). The archive carries DKIM private keys, the admin // password hash and SASL credentials, so it is served with no-store and as an @@ -58,10 +60,10 @@ func (s *Server) renderBackupPageWith(w http.ResponseWriter, r *http.Request, st // "encrypt with a password", the archive is wrapped in a .spbk envelope on the // way out, so the file that lands on their disk — wherever it is copied // afterwards — is useless without the password. -func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) { password, pwErr := secretFilePassword(r) if pwErr != "" { - s.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) + h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr) return } @@ -99,9 +101,9 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { } if err := backup.Create(sink, backup.Params{ - DataDir: s.cfg.DataDir, - DBPath: s.cfg.DBPath, - Version: s.cfg.Version, + DataDir: h.cfg.DataDir, + DBPath: h.cfg.DBPath, + Version: h.cfg.Version, }); err != nil { logf("panel: full backup failed: %v", err) return @@ -113,26 +115,26 @@ func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) { } } -// handleExportDomain streams a single-domain export as a secret download +// HandleExportDomain streams a single-domain export as a secret download // (architecture.md § Persistence). Like the full backup it is POST-only (state // is not changed, but the response contains the domain's DKIM private key and // application passwords, so it must not be prefetchable or cached). Like the // full backup it can be encrypted with a password, in which case the download // is a .spde envelope instead of plain JSON. -func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } password, pwErr := secretFilePassword(r) if pwErr != "" { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, ExportErr: pwErr, }) return } - exp, err := s.domains.Export(d.ID) + exp, err := h.domains.Export(d.ID) if err != nil { logf("panel: export domain %d: %v", d.ID, err) http.Error(w, "export failed", http.StatusInternalServerError) @@ -174,22 +176,22 @@ func (s *Server) handleExportDomain(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(body) } -// handleImportDomain accepts an uploaded domain-export file and re-creates the +// HandleImportDomain accepts an uploaded domain-export file and re-creates the // domain on this instance (architecture.md § Persistence). The domain name is // normalised and validated here (security.md); the domain service validates // the selector, each login and address, and the DKIM key before writing // anything. On success it redirects to the new domain's page; on failure it // re-renders the backup page, where the import form lives, with a friendly // message. -func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxImportBytes) if err := r.ParseMultipartForm(maxImportBytes); err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).") + h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file (too large or not a valid upload).") return } file, _, err := r.FormFile("file") if err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.") + h.renderBackupPage(w, r, http.StatusBadRequest, "Choose a domain export file to import.") return } defer file.Close() @@ -201,7 +203,7 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { head := make([]byte, secretfile.MagicLen) n, err := io.ReadFull(file, head) if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { - s.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.") + h.renderBackupPage(w, r, http.StatusBadRequest, "Could not read the uploaded file.") return } source := io.MultiReader(bytes.NewReader(head[:n]), file) @@ -209,16 +211,16 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { if secretfile.HasMagic(head[:n]) { if password == "" { - s.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.") + h.renderBackupPage(w, r, http.StatusBadRequest, "That file is encrypted — enter the password it was exported with.") return } env, err := secretfile.NewReader(source, password) if err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) + h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) return } if env.Type() != secretfile.TypeDomainExport { - s.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.") + h.renderBackupPage(w, r, http.StatusBadRequest, "That file is an encrypted "+env.Type().String()+", not a domain export.") return } // Read the whole plaintext first: authentication of the last chunk is @@ -226,12 +228,12 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { // accept a truncated document before ever reaching it. plain, err := io.ReadAll(env) if err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) + h.renderBackupPage(w, r, http.StatusBadRequest, decryptErrorMessage(err)) return } source = bytes.NewReader(plain) } else if password != "" { - s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.") + h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not encrypted — leave the password empty.") return } @@ -239,23 +241,23 @@ func (s *Server) handleImportDomain(w http.ResponseWriter, r *http.Request) { dec := json.NewDecoder(source) dec.DisallowUnknownFields() if err := dec.Decode(&exp); err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.") + h.renderBackupPage(w, r, http.StatusBadRequest, "That file is not a valid SelfPost domain export.") return } // Normalise and validate the domain name before it reaches the service, the // same gate the add-domain form uses (security.md). - exp.Domain = normalizeDomain(exp.Domain) - if err := validateDomain(exp.Domain); err != nil { - s.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error()) + exp.Domain = validate.NormalizeDomain(exp.Domain) + if err := validate.Domain(exp.Domain); err != nil { + h.renderBackupPage(w, r, http.StatusBadRequest, "Invalid domain in export file: "+err.Error()) return } - d, err := s.domains.Import(exp) + d, err := h.domains.Import(exp) if err != nil { logf("panel: import domain %q: %v", exp.Domain, err) status, msg := importErrorMessage(err) - s.renderBackupPage(w, r, status, msg) + h.renderBackupPage(w, r, status, msg) return } http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther) @@ -276,8 +278,8 @@ func secretFilePassword(r *http.Request) (password, errMsg string) { return "", "" } password = r.PostFormValue("password") - if len([]rune(password)) < minSecretFilePasswordLen { - return "", fmt.Sprintf("The encryption password must be at least %d characters.", minSecretFilePasswordLen) + if len([]rune(password)) < validate.MinSecretFilePasswordLen { + return "", fmt.Sprintf("The encryption password must be at least %d characters.", validate.MinSecretFilePasswordLen) } if password != r.PostFormValue("password_confirm") { return "", "The two passwords do not match." diff --git a/internal/web/handlers_backup_test.go b/internal/web/handlers/handlers_backup_test.go similarity index 89% rename from internal/web/handlers_backup_test.go rename to internal/web/handlers/handlers_backup_test.go index e614e04..2660cf7 100644 --- a/internal/web/handlers_backup_test.go +++ b/internal/web/handlers/handlers_backup_test.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "errors" @@ -10,6 +10,7 @@ import ( "testing" "github.com/mixeme/selfpost/internal/secretfile" + "github.com/mixeme/selfpost/internal/web/validate" ) // postForm builds the kind of request the backup and export forms submit. @@ -24,8 +25,8 @@ func postForm(values url.Values) *http.Request { // the archive is sealed — and leaving the box unticked has to keep producing // the plain archive earlier versions produced. func TestSecretFilePassword(t *testing.T) { - long := strings.Repeat("x", minSecretFilePasswordLen) - short := strings.Repeat("x", minSecretFilePasswordLen-1) + long := strings.Repeat("x", validate.MinSecretFilePasswordLen) + short := strings.Repeat("x", validate.MinSecretFilePasswordLen-1) tests := []struct { name string @@ -104,13 +105,9 @@ func TestDecryptErrorMessage(t *testing.T) { // forgets to include the partial (or the data it needs) loses the option // silently, since the plain download still works. func TestBackupPageOffersEncryption(t *testing.T) { - tmpl, err := loadTemplates() - if err != nil { - t.Fatalf("loadTemplates: %v", err) - } - s := &Server{tmpl: tmpl, cfg: Config{Version: "test"}} + h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}} rec := httptest.NewRecorder() - s.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil), + h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil), http.StatusOK, "", "The two passwords do not match.") body := rec.Body.String() @@ -118,7 +115,7 @@ func TestBackupPageOffersEncryption(t *testing.T) { `name="encrypt"`, `name="password"`, `name="password_confirm"`, `name="import_password"`, "data-encrypt-toggle", "data-encrypt-fields", "data-import-password-fields", - fmt.Sprintf("at least %d characters", minSecretFilePasswordLen), + fmt.Sprintf("at least %d characters", validate.MinSecretFilePasswordLen), "The two passwords do not match.", } { if !strings.Contains(body, want) { diff --git a/internal/web/handlers_dmarc.go b/internal/web/handlers/handlers_dmarc.go similarity index 62% rename from internal/web/handlers_dmarc.go rename to internal/web/handlers/handlers_dmarc.go index 938bf15..12c1e74 100644 --- a/internal/web/handlers_dmarc.go +++ b/internal/web/handlers/handlers_dmarc.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "database/sql" @@ -6,21 +6,22 @@ import ( "net/http" "strings" + "github.com/mixeme/selfpost/internal/web/validate" ) -// handleDomainDMARC saves per-domain DMARC rua= settings. -func (s *Server) handleDomainDMARC(w http.ResponseWriter, r *http.Request) { +// HandleDomainDMARC saves per-domain DMARC rua= settings. +func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - d, ok := s.lookupDomain(w, r) + d, ok := h.lookupDomain(w, r) if !ok { return } if err := r.ParseForm(); err != nil { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."}) + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."}) return } @@ -32,25 +33,25 @@ func (s *Server) handleDomainDMARC(w http.ResponseWriter, r *http.Request) { rua = sql.NullString{Valid: true, String: ""} case "custom": email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email")) - if err := validateEmail(email); err != nil { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()}) + if err := validate.Email(email); err != nil { + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()}) return } if email == "" { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."}) + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."}) return } rua = sql.NullString{Valid: true, String: email} default: - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."}) + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."}) return } - if err := s.store.UpdateDomainDMARCRua(d.ID, rua); err != nil { + if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil { logf("panel: domain %d: save dmarc rua: %v", d.ID, err) - s.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."}) + h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."}) return } - s.dns.Forget(d.Name) + h.dns.Forget(d.Name) http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther) } diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go new file mode 100644 index 0000000..4c29b89 --- /dev/null +++ b/internal/web/handlers/handlers_domains.go @@ -0,0 +1,186 @@ +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) { + domains, err := h.domains.List() + if err != nil { + logf("panel: dashboard: list domains: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.view.Render(w, status, "dashboard", map[string]any{ + "Title": "SelfPost", + "User": auth.CurrentUser(r), + "Active": "domains", + "Domains": h.domainRows(domains), + "Error": formErr, + "FormName": formName, + "Flash": dashboardFlash(r), + }) +} + +func (h *Handlers) domainRows(domains []store.Domain) []domainRow { + profileEmail := "" + if admin, err := h.store.GetAdmin(); err == nil { + profileEmail = admin.DMARCReportEmail + } + 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 + } + dns, _ := h.domainDNS(d, record, profileEmail, 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) { + 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 + } + http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther) +} + +// HandleDeleteConfirm shows the cascade warning before a domain is removed. +func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) { + 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, + }) +} + +// HandleDeleteDomain performs the deletion and returns to the domain list. +func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) { + 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, "/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 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 + } + http.Redirect(w, r, "/status?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 + } + 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 +} diff --git a/internal/web/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go similarity index 83% rename from internal/web/handlers_monitor.go rename to internal/web/handlers/handlers_monitor.go index ba1e808..fb4d936 100644 --- a/internal/web/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "errors" @@ -12,6 +12,7 @@ import ( "github.com/mixeme/selfpost/internal/mailhdr" "github.com/mixeme/selfpost/internal/postfix" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" ) // sendLogPageSize bounds each send-log page (product.md's monitoring screens @@ -24,38 +25,38 @@ const ( deliveryLogLines = 200 ) -// handleDeliveries renders the Deliveries page over the send log: server-side +// HandleDeliveries renders the Deliveries page over the send log: server-side // filters by domain/application and pagination (architecture.md § // Persistence). The row table itself is the "deliveries_rows" fragment, shared -// verbatim with handleDeliveriesRows so the initial page and its HTMX-polled +// verbatim with HandleDeliveriesRows so the initial page and its HTMX-polled // refreshes never diverge. -func (s *Server) handleDeliveries(w http.ResponseWriter, r *http.Request) { - data, err := s.sendLogData(r) +func (h *Handlers) HandleDeliveries(w http.ResponseWriter, r *http.Request) { + data, err := h.sendLogData(r) if err != nil { logf("panel: send log: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } data["Title"] = "SelfPost — deliveries" - data["User"] = currentUser(r) + data["User"] = auth.CurrentUser(r) data["Active"] = "deliveries" - s.render(w, http.StatusOK, "deliveries", data) + h.view.Render(w, http.StatusOK, "deliveries", data) } -// handleDeliveriesRows serves the HTMX polling fragment for the delivery table +// HandleDeliveriesRows serves the HTMX polling fragment for the delivery table // (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not // JSON). -func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) { - data, err := s.sendLogData(r) +func (h *Handlers) HandleDeliveriesRows(w http.ResponseWriter, r *http.Request) { + data, err := h.sendLogData(r) if err != nil { logf("panel: send log rows: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - s.renderFragment(w, http.StatusOK, "deliveries_rows", data) + h.view.RenderFragment(w, http.StatusOK, "deliveries_rows", data) } -// handleDelivery renders one send-log row in full. The log itself carries only +// HandleDelivery renders one send-log row in full. The log itself carries only // what identifies a message at a glance — when, who to and from, what about, // how it ended — and every remaining field (domain, application, queue id, when // the status was last reported) lives here, one page per row, so widening the @@ -67,13 +68,13 @@ func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) { // its history side by side, and the mail.log lines for its queue id under both. // The queue id used to be printed here as something to go and search the system // log for by hand; the search is done for the operator instead. -func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { http.NotFound(w, r) return } - row, err := s.store.GetSendLog(id) + row, err := h.store.GetSendLog(id) if err != nil { // A row pruned on the retention window is gone, not broken. if errors.Is(err, store.ErrSendLogNotFound) { @@ -85,10 +86,10 @@ func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) { return } row.Subject = mailhdr.DecodeSubject(row.Subject) - logRows, logNote := s.deliveryLog(row) - s.render(w, http.StatusOK, "delivery", map[string]any{ + logRows, logNote := h.deliveryLog(row) + h.view.Render(w, http.StatusOK, "delivery", map[string]any{ "Title": "SelfPost — delivery", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "deliveries", "Row": row, // The status in the panel's own badge vocabulary, so the headline reads @@ -223,11 +224,11 @@ type deliveryLogRow struct { // have aged out of the log — so none of them is an error on the page. Only a // log that cannot be read at all is reported as a fault, and that one is // logged for the operator as well. -func (s *Server) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) { +func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) { if row.QueueID == "" { return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it." } - lines, err := logtail.QueueLines(s.cfg.MailLogPath, row.QueueID, deliveryLogLines) + lines, err := logtail.QueueLines(h.cfg.MailLogPath, row.QueueID, deliveryLogLines) if err != nil && !errors.Is(err, fs.ErrNotExist) { logf("panel: delivery log %s: %v", row.QueueID, err) return nil, "Could not read the mail log." @@ -267,7 +268,7 @@ func deliveriesBackURL(r *http.Request) string { // sendLogData reads the domain/app filters and page number off the query // string, queries the store, and assembles everything the template needs // (filter dropdown options plus the current selection, rows, and pagination). -func (s *Server) sendLogData(r *http.Request) (map[string]any, error) { +func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) { q := r.URL.Query() filter := store.SendLogFilter{ Domain: q.Get("domain"), @@ -275,11 +276,11 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) { } page := parsePage(q.Get("p")) - total, err := s.store.CountSendLog(filter) + total, err := h.store.CountSendLog(filter) if err != nil { return nil, err } - rows, err := s.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize) + rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize) if err != nil { return nil, err } @@ -289,7 +290,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) { for i := range rows { rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject) } - domains, err := s.store.ListDomains() + domains, err := h.store.ListDomains() if err != nil { return nil, err } @@ -297,7 +298,7 @@ func (s *Server) sendLogData(r *http.Request) (map[string]any, error) { for i, d := range domains { domainNames[i] = d.Name } - logins, err := s.store.ListApplicationLogins() + logins, err := h.store.ListApplicationLogins() if err != nil { return nil, err } @@ -331,23 +332,23 @@ func parsePage(v string) int { return n } -// handleMailQueue renders the Mail queue page (architecture.md § Panel HTTP +// HandleMailQueue renders the Mail queue page (architecture.md § Panel HTTP // surface). -func (s *Server) handleMailQueue(w http.ResponseWriter, r *http.Request) { +func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) { out, errText := readQueue() - s.render(w, http.StatusOK, "mail_queue", map[string]any{ + h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{ "Title": "SelfPost — mail queue", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "mail_queue", "Output": out, "Error": errText, }) } -// handleMailQueueBody serves the HTMX polling fragment for the queue view. -func (s *Server) handleMailQueueBody(w http.ResponseWriter, r *http.Request) { +// HandleMailQueueBody serves the HTMX polling fragment for the queue view. +func (h *Handlers) HandleMailQueueBody(w http.ResponseWriter, r *http.Request) { out, errText := readQueue() - s.renderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{ + h.view.RenderFragment(w, http.StatusOK, "mail_queue_body", map[string]any{ "Output": out, "Error": errText, }) @@ -365,30 +366,30 @@ func readQueue() (string, string) { return out, "" } -// handleSystemLog renders the System log page over mail.log (architecture.md § +// HandleSystemLog renders the System log page over mail.log (architecture.md § // Panel HTTP surface). -func (s *Server) handleSystemLog(w http.ResponseWriter, r *http.Request) { - lines, errText := s.readLogTail() - s.render(w, http.StatusOK, "system_log", map[string]any{ +func (h *Handlers) HandleSystemLog(w http.ResponseWriter, r *http.Request) { + lines, errText := h.readLogTail() + h.view.Render(w, http.StatusOK, "system_log", map[string]any{ "Title": "SelfPost — system log", - "User": currentUser(r), + "User": auth.CurrentUser(r), "Active": "system_log", "Lines": lines, "Error": errText, }) } -// handleSystemLogBody serves the HTMX polling fragment for the log-tail view. -func (s *Server) handleSystemLogBody(w http.ResponseWriter, r *http.Request) { - lines, errText := s.readLogTail() - s.renderFragment(w, http.StatusOK, "system_log_body", map[string]any{ +// HandleSystemLogBody serves the HTMX polling fragment for the log-tail view. +func (h *Handlers) HandleSystemLogBody(w http.ResponseWriter, r *http.Request) { + lines, errText := h.readLogTail() + h.view.RenderFragment(w, http.StatusOK, "system_log_body", map[string]any{ "Lines": lines, "Error": errText, }) } -func (s *Server) readLogTail() ([]string, string) { - lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines) +func (h *Handlers) readLogTail() ([]string, string) { + lines, err := logtail.TailLines(h.cfg.MailLogPath, logTailLines) if err != nil { if errors.Is(err, fs.ErrNotExist) { // Rotation renamed the file away; Postfix recreates it on reload @@ -396,7 +397,7 @@ func (s *Server) readLogTail() ([]string, string) { // than a failure worth alarming the operator about. return nil, "" } - logf("panel: tail %s: %v", s.cfg.MailLogPath, err) + logf("panel: tail %s: %v", h.cfg.MailLogPath, err) return nil, "Could not read the mail log." } return lines, "" diff --git a/internal/web/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go similarity index 86% rename from internal/web/handlers_monitor_test.go rename to internal/web/handlers/handlers_monitor_test.go index ec8d01b..aaf49ab 100644 --- a/internal/web/handlers_monitor_test.go +++ b/internal/web/handlers/handlers_monitor_test.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "net/http" @@ -16,9 +16,9 @@ import ( // recreate it on reload (spec B.2); a missing file in that window is a normal, // transient gap, not an operator-facing failure. func TestReadLogTailMissingFileIsNotAnError(t *testing.T) { - s := &Server{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}} + h := &Handlers{cfg: Config{MailLogPath: filepath.Join(t.TempDir(), "mail.log")}} - lines, errText := s.readLogTail() + lines, errText := h.readLogTail() if lines != nil { t.Errorf("lines = %v, want nil", lines) } @@ -31,9 +31,9 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) { // when, from, to, subject and status, and links each row to the page carrying // the rest. A column added back here is one the table has no width for. func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) { - s, row := serverWithDelivery(t) + h, row := serverWithDelivery(t) - out := getBody(t, s.handleDeliveries, "/deliveries") + out := getBody(t, h.HandleDeliveries, "/deliveries") for _, want := range []string{ row.CreatedAt.Format("2006-01-02 15:04:05"), "noreply@bs.example.ru", "public@example.ru", @@ -56,11 +56,11 @@ func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) { // decoded them, and those rows are still in the send log. Decoding on the way // out is what keeps them readable, so the encoding must not survive to the page. func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) { - s, _ := serverWithDelivery(t) + h, _ := serverWithDelivery(t) for name, out := range map[string]string{ - "log": getBody(t, s.handleDeliveries, "/deliveries"), - "rows": getBody(t, s.handleDeliveriesRows, "/deliveries/rows"), + "log": getBody(t, h.HandleDeliveries, "/deliveries"), + "rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"), } { if strings.Contains(out, "=?utf-8?Q?") { t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out) @@ -74,9 +74,9 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) { // Everything the log dropped has to be somewhere, and that somewhere is the // per-row page — including for a row still holding an encoded subject. func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) { - s, row := serverWithDelivery(t) + h, row := serverWithDelivery(t) - out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2") + out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2") for _, want := range []string{ "bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка", "noreply@bs.example.ru", "public@example.ru", "sent", @@ -95,9 +95,9 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) { // journal holds, stated as the steps they stand for, so a row is readable as // what happened to the message rather than as a list of fields. func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) { - s, row := serverWithDelivery(t) + h, row := serverWithDelivery(t) - out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)) + out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)) for _, want := range []string{ "Accepted and queued", "Delivered", row.CreatedAt.Format("2006-01-02 15:04:05"), @@ -121,19 +121,19 @@ func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) { // for is drawn as one that has not happened rather than dated with the moment // the row was written. func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) { - s, _ := serverWithDelivery(t) - if err := s.store.InsertQueued(store.SendLogEntry{ + h, _ := serverWithDelivery(t) + if err := h.store.InsertQueued(store.SendLogEntry{ QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C", From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going", }); err != nil { t.Fatalf("insert: %v", err) } - rows, err := s.store.QuerySendLog(store.SendLogFilter{}, 1, 0) + rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0) if err != nil || len(rows) != 1 { t.Fatalf("query: %v (%d rows)", err, len(rows)) } - out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(rows[0].ID)) + out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(rows[0].ID)) for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} { if !strings.Contains(out, want) { t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out) @@ -146,14 +146,14 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) { // lines — as a table of when and what, so the seconds between the connection // and the reply line up down one edge. func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) { - s, row := serverWithDelivery(t) - s.cfg.MailLogPath = writeMailLog(t, + h, row := serverWithDelivery(t) + h.cfg.MailLogPath = writeMailLog(t, "2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]", "2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=, size=500, nrcpt=1 (queue active)", "2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=, dsn=2.0.0, status=sent (250 OK)", ) - out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)) + out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)) for _, want := range []string{ "Time", "Message", // The stamp is split off into its own cell, without the microseconds @@ -174,10 +174,10 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) { // A line whose head is not a timestamp still has to show in full; the format is // the log's, not ours, and a line we cannot split is a line we must not drop. func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) { - s, row := serverWithDelivery(t) - s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=, status=sent (250 OK)") + h, row := serverWithDelivery(t) + h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=, status=sent (250 OK)") - out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)) + out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)) if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") { t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out) } @@ -186,10 +186,10 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) { // Rows outlive mail.log, and a message the milter refused never reached the // queue at all. Neither is a fault, so neither may render as an error. func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) { - s, row := serverWithDelivery(t) - s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created + h, row := serverWithDelivery(t) + h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created - out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID)) + out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)) if !strings.Contains(out, "rotated away") { t.Errorf("delivery page does not explain the empty delivery log:\n%s", out) } @@ -201,13 +201,13 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) { // Send-log rows are pruned on the retention window, so a bookmarked delivery // that no longer exists is a 404, not a 500. func TestDeliveryPageNotFound(t *testing.T) { - s, _ := serverWithDelivery(t) + h, _ := serverWithDelivery(t) for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, path, nil) req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/")) - s.handleDelivery(rec, req) + h.HandleDelivery(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("GET %s = %d, want 404", path, rec.Code) } @@ -216,7 +216,7 @@ func TestDeliveryPageNotFound(t *testing.T) { // serverWithDelivery builds a panel over a store holding one delivery, written // the way the journal-milter wrote them before it decoded subjects itself. -func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) { +func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) { t.Helper() st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { @@ -242,11 +242,7 @@ func serverWithDelivery(t *testing.T) (*Server, store.SendLogRow) { t.Fatalf("query: %v (%d rows)", err, len(rows)) } - tmpl, err := loadTemplates() - if err != nil { - t.Fatalf("loadTemplates: %v", err) - } - return &Server{store: st, tmpl: tmpl, cfg: Config{Version: "test"}}, rows[0] + return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0] } // getBody runs one handler over a GET and returns the page it wrote, failing diff --git a/internal/web/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go similarity index 57% rename from internal/web/handlers_ratelimit.go rename to internal/web/handlers/handlers_ratelimit.go index 410b7ec..e7e9550 100644 --- a/internal/web/handlers_ratelimit.go +++ b/internal/web/handlers/handlers_ratelimit.go @@ -1,4 +1,4 @@ -package web +package handlers import ( "fmt" @@ -10,15 +10,8 @@ import ( "github.com/mixeme/selfpost/internal/store" ) -// defaultRateLimitWindowSeconds is the sliding-window length used when an -// admin sets a message ceiling but leaves the window blank (guide § Rate -// limiting, matching the level-1 default hour; guide § Environment variables: -// RATE_LIMIT_WINDOW_SECONDS). const defaultRateLimitWindowSeconds = 3600 -// rateLimitInput is the validated result of a rate-limit form submission. -// clear means "remove the differentiated limit" (guide § Rate limiting: an -// empty IP binding leaves only level 1). type rateLimitInput struct { clear bool ips []string @@ -26,10 +19,6 @@ type rateLimitInput struct { windowSeconds int } -// parseRateLimitForm validates a rate-limit submission on the server -// (security.md). It returns clear=true when the admin removes the limit or -// leaves the IP binding empty; otherwise it requires a positive ceiling and -// window. The returned error's message is safe to show to the admin. func parseRateLimitForm(r *http.Request) (rateLimitInput, error) { if err := r.ParseForm(); err != nil { return rateLimitInput{}, fmt.Errorf("invalid form submission") @@ -42,8 +31,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) { return rateLimitInput{}, err } if len(ips) == 0 { - // No IP binding: the differentiated limit does not apply (guide § Rate - // limiting). return rateLimitInput{clear: true}, nil } maxMessages, err := parsePositiveInt(r.PostFormValue("max_messages"), 0) @@ -57,10 +44,6 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) { return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil } -// parseIPList parses the allowed-IP field (IPs separated by newlines, commas or -// whitespace) into a deduplicated list of canonical addresses, rejecting any -// token that is not a valid IP (security.md). The values are only ever stored as -// SQLite parameters and compared in the milter, never written to a config file. func parseIPList(raw string) ([]string, error) { fields := strings.FieldsFunc(raw, func(r rune) bool { return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';' @@ -81,8 +64,6 @@ func parseIPList(raw string) ([]string, error) { return out, nil } -// parsePositiveInt parses a trimmed integer field, returning def when it is -// blank. A non-numeric value returns an error. func parsePositiveInt(raw string, def int) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { @@ -91,23 +72,20 @@ func parsePositiveInt(raw string, def int) (int, error) { return strconv.Atoi(raw) } -// handleDomainRateLimit saves or clears a domain-level differentiated rate -// limit (guide § Rate limiting). No reload is needed — the milter reads the -// row live. -func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) +func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) { + d, ok := h.lookupDomain(w, r) if !ok { return } in, err := parseRateLimitForm(r) if err != nil { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, RateLimitErr: err.Error(), }) return } - if err := s.applyRateLimit(in, s.domains.SaveRateLimit, s.domains.ClearRateLimit, d.ID); err != nil { + if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil { logf("panel: domain %d: save rate limit: %v", d.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -115,27 +93,25 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther) } -// handleAppRateLimit saves or clears an application-level differentiated rate -// limit (guide § Rate limiting). -func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) { - a, ok := s.lookupApplication(w, r) +func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) { + a, ok := h.lookupApplication(w, r) if !ok { return } - d, err := s.domains.Get(a.DomainID) + d, err := h.domains.Get(a.DomainID) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } in, err := parseRateLimitForm(r) if err != nil { - s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ + h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{ FormMode: store.AddressModeWildcard, RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()), }) return } - if err := s.applyRateLimit(in, s.apps.SaveRateLimit, s.apps.ClearRateLimit, a.ID); err != nil { + if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil { logf("panel: application %d: save rate limit: %v", a.ID, err) http.Error(w, "internal error", http.StatusInternalServerError) return @@ -143,9 +119,7 @@ func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther) } -// applyRateLimit dispatches a validated input to the save or clear method of the -// relevant service, keyed by the domain or application id. -func (s *Server) applyRateLimit( +func (h *Handlers) applyRateLimit( in rateLimitInput, save func(id int64, ips []string, maxMessages, windowSeconds int) error, clear func(id int64) error, diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go new file mode 100644 index 0000000..c731860 --- /dev/null +++ b/internal/web/handlers/handlers_status.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/web/auth" +) + +func (h *Handlers) HandleStatus(w http.ResponseWriter, r *http.Request) { + data := h.statusBody() + srv := h.dns.Server(h.cfg.Hostname, false) + + data["Title"] = "SelfPost — status" + data["User"] = auth.CurrentUser(r) + data["Active"] = "status" + data["Flash"] = statusFlash(r) + data["Hostname"] = h.cfg.Hostname + data["PTR"] = srv.PTR + h.view.Render(w, http.StatusOK, "status", data) +} + +func (h *Handlers) HandleStatusFragment(w http.ResponseWriter, _ *http.Request) { + h.view.RenderFragment(w, http.StatusOK, "status_body", h.statusBody()) +} + +func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) { + h.dns.Server(h.cfg.Hostname, true) + http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther) +} + +func (h *Handlers) statusBody() map[string]any { + procs, procErr := health.Processes() + procStatus := health.StatusUnknown + if procErr != nil { + logf("panel: status: supervisorctl: %v", procErr) + } else { + for _, p := range procs { + procStatus = health.Worst(procStatus, p.Status) + } + } + + queueText, queueErr := readQueue() + queueStatus := health.StatusOK + if queueErr != "" { + queueStatus = health.StatusWarn + } + + cert := health.CheckCertificate(h.cfg.TLSCertFile) + sockets := []health.Socket{ + health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true), + health.CheckSocket("send-log", h.cfg.JournalSocket, false), + } + socketStatus := health.StatusUnknown + for _, sock := range sockets { + socketStatus = health.Worst(socketStatus, sock.Status) + } + + machine := h.machine.Sample() + + overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status) + return map[string]any{ + "Processes": procs, + "ProcessError": procErr != nil, + "ProcessStatus": procStatus, + "QueueSummary": queueSummary(queueText), + "QueueError": queueErr, + "QueueStatus": queueStatus, + "Machine": machine, + "Cert": cert, + "Sockets": sockets, + "SocketStatus": socketStatus, + "OverallStatus": overall, + "OverallHeading": overallHeading(overall), + } +} + +func queueSummary(out string) string { + lines := strings.Split(strings.TrimSpace(out), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if line := strings.TrimSpace(lines[i]); line != "" { + return strings.TrimSpace(strings.TrimPrefix(line, "--")) + } + } + return "" +} + +func overallHeading(worst health.Status) string { + switch worst { + case health.StatusError: + return "A component needs attention — see the details below." + case health.StatusWarn: + return "Running, with warnings below." + case health.StatusOK: + return "All components are running normally." + default: + return "Some checks could not be performed." + } +} + +func statusFlash(r *http.Request) string { + switch { + case r.URL.Query().Get("reloaded") != "": + return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it." + case r.URL.Query().Get("rechecked") != "": + return "DNS re-checked." + default: + return "" + } +} diff --git a/internal/web/handlers/testutil_test.go b/internal/web/handlers/testutil_test.go new file mode 100644 index 0000000..a3c0bf6 --- /dev/null +++ b/internal/web/handlers/testutil_test.go @@ -0,0 +1,16 @@ +package handlers + +import ( + "testing" + + "github.com/mixeme/selfpost/internal/web/view" +) + +func mustView(t *testing.T) *view.Engine { + t.Helper() + v, err := view.New("test") + if err != nil { + t.Fatalf("view: %v", err) + } + return v +} diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go deleted file mode 100644 index 72c546e..0000000 --- a/internal/web/handlers_auth.go +++ /dev/null @@ -1,198 +0,0 @@ -package web - -import ( - "errors" - "net/http" - "strings" - - "github.com/mixeme/selfpost/internal/store" - "golang.org/x/crypto/bcrypt" -) - -// The panel session cookie's two possible names. In the production shape — -// TLS in front, so CookieSecure — it carries the __Host- prefix, which turns -// what the cookie's attributes merely promise into something the browser -// enforces: Secure, Path=/ and, the point of the exercise, no Domain -// attribute, so no other host may set a cookie by this name. -// The prefix is only valid on a Secure cookie, so a development instance on -// plain HTTP has to keep the bare name: with the prefix the browser would -// discard the Set-Cookie outright and logging in would silently never stick. -const ( - sessionCookieBase = "selfpost_session" - sessionCookiePrefixed = "__Host-" + sessionCookieBase -) - -// sessionCookie is the session cookie's name for this deployment. -func (s *Server) sessionCookie() string { - if s.cfg.CookieSecure { - return sessionCookiePrefixed - } - return sessionCookieBase -} - -// sessionToken returns the session token the request carries, if exactly one -// cookie of that name is present. -// -// It walks r.Cookies() rather than calling r.Cookie, which silently returns -// the first match. Two cookies with the same name mean somebody other than -// this panel set one of them — a host on the same registrable domain can, -// with Domain=example.com, and the browser will then send both — and RFC 6265 -// makes the older one come first, so "the first match" is precisely the -// attacker's. The value cannot be forged into a valid session, so the effect -// is denial of service, not compromise; refusing the request and saying so in -// the log is what makes it diagnosable instead of an endless login loop. The -// __Host- prefix prevents this outright, but only where it applies — this -// check also covers the plain-HTTP development shape. -func (s *Server) sessionToken(r *http.Request) (string, bool) { - name := s.sessionCookie() - var token string - var n int - for _, c := range r.Cookies() { - if c.Name == name { - n++ - token = c.Value - } - } - switch n { - case 0: - return "", false - case 1: - return token, true - default: - logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+ - "another host on this domain is overwriting the session cookie, clear the cookies for the parent domain", - r.Method, r.URL.Path, n, name) - return "", false - } -} - -// clearSessionCookies expires the session cookie under both names, so an -// upgrade that switches to the __Host- prefix does not leave the old cookie -// sitting in the browser until it is closed. -func (s *Server) clearSessionCookies(w http.ResponseWriter) { - for _, name := range []string{sessionCookieBase, sessionCookiePrefixed} { - http.SetCookie(w, &http.Cookie{ - Name: name, - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - // The prefixed name is only accepted at all when Secure is set, - // including on the expiring copy. - Secure: s.cfg.CookieSecure || name == sessionCookiePrefixed, - SameSite: http.SameSiteLaxMode, - }) - } -} - -// handleLogin serves the login form (GET) and authenticates (POST). Until an -// administrator exists there is nobody to log in, so it points at setup. -func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { - exists, err := s.store.AdminExists() - if err != nil { - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - if !exists { - // No admin yet: login is meaningless. Send a clear message rather than - // a failing form. - s.render(w, http.StatusOK, "login", map[string]any{ - "Title": "SelfPost — Sign in", - "Active": "login", - "SetupHint": true, - }) - return - } - - switch r.Method { - case http.MethodGet: - s.renderLogin(w, http.StatusOK, "") - case http.MethodPost: - s.submitLogin(w, r) - default: - w.Header().Set("Allow", "GET, POST") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (s *Server) renderLogin(w http.ResponseWriter, status int, formErr string) { - // Active names the page for the layout even though no navigation is drawn - // here: it is what puts page-login on
, which the stylesheet uses to - // give the signed-out pages a column the width of their own card. - s.render(w, status, "login", map[string]any{ - "Title": "SelfPost — Sign in", - "Active": "login", - "Error": formErr, - }) -} - -func (s *Server) submitLogin(w http.ResponseWriter, r *http.Request) { - // Brute-force throttle by client IP (security.md). - if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) { - s.renderLogin(w, http.StatusTooManyRequests, "Too many attempts. Please wait and try again.") - return - } - if err := r.ParseForm(); err != nil { - s.renderLogin(w, http.StatusBadRequest, "Invalid form submission.") - return - } - username := strings.TrimSpace(r.PostFormValue("username")) - password := r.PostFormValue("password") - - admin, err := s.store.GetAdmin() - if err != nil { - if !errors.Is(err, store.ErrNoAdmin) { - logf("panel: login: get admin failed: %v", err) - } - s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") - return - } - - // Always run bcrypt so timing does not distinguish "wrong user" from - // "wrong password", and compare the username too. - pwErr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)) - if username != admin.Username || pwErr != nil { - s.renderLogin(w, http.StatusUnauthorized, "Invalid username or password.") - return - } - - token := s.sessions.Create(admin.Username) - s.setSessionCookie(w, token) - http.Redirect(w, r, "/", http.StatusSeeOther) -} - -// setSessionCookie (re)issues the session cookie with a fresh Max-Age equal -// to the sliding idle window (plan B.1), so the browser-side expiry tracks -// whatever the database row was just set to — at login, and again whenever -// requireAuth extends an active session. -func (s *Server) setSessionCookie(w http.ResponseWriter, token string) { - http.SetCookie(w, &http.Cookie{ - Name: s.sessionCookie(), - Value: token, - Path: "/", - MaxAge: s.sessions.MaxAge(), - HttpOnly: true, - Secure: s.cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - }) -} - -// handleLogout destroys the session and clears the cookie. -func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", "POST") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - // Destroy every token presented under the session cookie's name: if a - // shadowing duplicate is present (see sessionToken) one of them is the - // real session, and a value that names no session is simply not found. - name := s.sessionCookie() - for _, c := range r.Cookies() { - if c.Name == name { - s.sessions.Destroy(c.Value) - } - } - s.clearSessionCookies(w) - http.Redirect(w, r, "/login", http.StatusSeeOther) -} diff --git a/internal/web/handlers_domains.go b/internal/web/handlers_domains.go deleted file mode 100644 index 9acd6e4..0000000 --- a/internal/web/handlers_domains.go +++ /dev/null @@ -1,210 +0,0 @@ -package web - -import ( - "errors" - "fmt" - "net/http" - "strconv" - "sync" - - "github.com/mixeme/selfpost/internal/health" - "github.com/mixeme/selfpost/internal/store" -) - -// 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 (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { - s.renderDashboard(w, r, http.StatusOK, "", "") -} - -// renderDashboard renders the domain list. formErr and formName repopulate the -// add-domain form after a rejected submission; flash surfaces a one-shot status -// message keyed by a redirect query flag (never reflected user input). -func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) { - domains, err := s.domains.List() - if err != nil { - logf("panel: dashboard: list domains: %v", err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - s.render(w, status, "dashboard", map[string]any{ - "Title": "SelfPost", - "User": currentUser(r), - "Active": "domains", - "Domains": s.domainRows(domains), - "Error": formErr, - "FormName": formName, - "Flash": dashboardFlash(r), - }) -} - -// domainRows attaches each domain's DNS verdict to its row. The checks run -// concurrently rather than one after another: each carries its own timeout, so -// in series a dead resolver would multiply that wait by the number of domains -// and the list would look hung. The checker caches results for a few minutes, -// so a repeat view of the list costs no lookups at all, and it is the same -// cache the domain page fills — opening a domain after the list is free. -func (s *Server) domainRows(domains []store.Domain) []domainRow { - profileEmail := "" - if admin, err := s.store.GetAdmin(); err == nil { - profileEmail = admin.DMARCReportEmail - } - 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 := s.domains.DKIMRecord(d) - if err != nil { - // Without the expected key there is nothing to compare DNS - // against; leave the row unknown rather than accusing the - // domain of a misconfiguration this server caused. - logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err) - return - } - dns, _ := s.domainDNS(d, record, profileEmail, false) - rows[i].DNS = dns.Overall - }() - } - wg.Wait() - return rows -} - -// dashboardFlash maps a fixed redirect flag to a fixed message, so status text -// after a redirect is never attacker-influenced. -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 (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "") - return - } - raw := r.PostFormValue("name") - name := normalizeDomain(raw) - if err := validateDomain(name); err != nil { - s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw) - return - } - - d, err := s.domains.Add(name) - if err != nil { - if errors.Is(err, store.ErrDomainExists) { - s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw) - return - } - logf("panel: add domain %q: %v", name, err) - s.renderDashboard(w, r, http.StatusInternalServerError, - "Could not add the domain. Please check the logs and try again.", raw) - return - } - http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther) -} - -// handleDeleteConfirm shows the cascade warning before a domain is removed: the -// panel must explicitly state that all bound applications go with it (product.md). -func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) { - d, ok := s.lookupDomain(w, r) - if !ok { - return - } - s.render(w, http.StatusOK, "domain_delete", map[string]any{ - "Title": "SelfPost — delete " + d.Name, - "User": currentUser(r), - "Active": "domains", - "Domain": d, - }) -} - -// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload) -// and returns to the domain list. -func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) { - id, ok := parseDomainID(w, r) - if !ok { - return - } - // Drop any cached DNS verdict for the name while it is still resolvable, so - // re-adding the domain later starts from a fresh check instead of a stale - // one from before it was removed. - if d, err := s.domains.Get(id); err == nil { - defer s.dns.Forget(d.Name) - } - if err := s.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, "/domains?deleted=1", http.StatusSeeOther) -} - -// handleReload re-applies both the OpenDKIM configuration and the Postfix -// sender map on demand (architecture.md § Panel HTTP surface). Each Resync -// regenerates its files from the database and reloads its daemon, so the -// button doubles as a drift-recovery. The button lives on the status page: it -// is a "put the daemons back in the state the database describes" action, -// which belongs with the rest of the server-health screen rather than in the -// domain list's top bar. -func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) { - if err := s.domains.Resync(); err != nil { - logf("panel: manual reload (opendkim): %v", err) - http.Error(w, "reload failed", http.StatusInternalServerError) - return - } - if err := s.apps.Resync(); err != nil { - logf("panel: manual reload (postfix): %v", err) - http.Error(w, "reload failed", http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther) -} - -// lookupDomain resolves the {id} path value to a domain, writing a 404 for a -// bad id or a missing domain and reporting ok=false in that case. -func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) { - id, ok := parseDomainID(w, r) - if !ok { - return store.Domain{}, false - } - d, err := s.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 - } - 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 -} diff --git a/internal/web/handlers_license.go b/internal/web/handlers_license.go deleted file mode 100644 index 0664f90..0000000 --- a/internal/web/handlers_license.go +++ /dev/null @@ -1,25 +0,0 @@ -package web - -import ( - "net/http" - - "github.com/mixeme/selfpost/internal/legal" -) - -// handleLicense serves the AGPL-3.0 text so every interactive page's -// "License" footer link works without leaving the panel (AGPL Appropriate -// Legal Notices). Unauthenticated on purpose: the notice must be reachable -// from the login and setup screens too. -func (s *Server) handleLicense(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(http.StatusOK) - if r.Method == http.MethodHead { - return - } - _, _ = w.Write(legal.License) -} diff --git a/internal/web/handlers_license_test.go b/internal/web/handlers_license_test.go index a7ff806..63b14b7 100644 --- a/internal/web/handlers_license_test.go +++ b/internal/web/handlers_license_test.go @@ -10,10 +10,9 @@ import ( ) func TestLicenseHandlerServesAGPL(t *testing.T) { - s := &Server{} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/license", nil) - s.handleLicense(rec, req) + handleLicense(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -32,10 +31,9 @@ func TestLicenseHandlerServesAGPL(t *testing.T) { } func TestLicenseHandlerRejectsNonGET(t *testing.T) { - s := &Server{} rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/license", nil) - s.handleLicense(rec, req) + handleLicense(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("status = %d, want 405", rec.Code) } diff --git a/internal/web/handlers_setup.go b/internal/web/handlers_setup.go deleted file mode 100644 index 06fcf2a..0000000 --- a/internal/web/handlers_setup.go +++ /dev/null @@ -1,99 +0,0 @@ -package web - -import ( - "net/http" - "strings" - - "golang.org/x/crypto/bcrypt" -) - -// handleSetup serves the one-time administrator creation flow at -// /setup/ (security.md). Once an administrator exists the whole route -// returns 404; an invalid or expired token is indistinguishable from a missing -// page, also 404. -func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { - // Route-specific rate limit, separate from login (security.md). - if !s.setupLimiter.Allow(clientIP(r, s.trustedProxies)) { - http.Error(w, "too many requests", http.StatusTooManyRequests) - return - } - - token := strings.TrimPrefix(r.URL.Path, "/setup/") - // Reject nested/garbage paths outright. - if token == "" || strings.Contains(token, "/") { - http.NotFound(w, r) - return - } - if !s.setup.validate(token) { - http.NotFound(w, r) - return - } - - switch r.Method { - case http.MethodGet: - s.renderSetupForm(w, http.StatusOK, token, "") - case http.MethodPost: - s.submitSetup(w, r, token) - default: - w.Header().Set("Allow", "GET, POST") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (s *Server) renderSetupForm(w http.ResponseWriter, status int, token, formErr string) { - s.render(w, status, "setup", map[string]any{ - "Title": "SelfPost — Create administrator", - "Active": "setup", - "Token": token, - "Error": formErr, - }) -} - -func (s *Server) submitSetup(w http.ResponseWriter, r *http.Request, token string) { - if err := r.ParseForm(); err != nil { - s.renderSetupForm(w, http.StatusBadRequest, token, "Invalid form submission.") - return - } - username := strings.TrimSpace(r.PostFormValue("username")) - password := r.PostFormValue("password") - confirm := r.PostFormValue("password_confirm") - - if err := validateUsername(username); err != nil { - s.renderSetupForm(w, http.StatusBadRequest, token, err.Error()) - return - } - if password != confirm { - s.renderSetupForm(w, http.StatusBadRequest, token, "Passwords do not match.") - return - } - if err := validateAdminPassword(password); err != nil { - s.renderSetupForm(w, http.StatusBadRequest, token, err.Error()) - return - } - - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - logf("panel: setup: hashing password failed: %v", err) - s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.") - return - } - - if err := s.store.CreateAdmin(username, string(hash)); err != nil { - // A concurrent submission may have already created the admin; the - // id=1 / non-empty-table guard makes this the second writer. Treat it - // as "setup already done" rather than an error. - if exists, _ := s.store.AdminExists(); exists { - s.setup.complete() - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - logf("panel: setup: create admin failed: %v", err) - s.renderSetupForm(w, http.StatusInternalServerError, token, "Internal error. Please try again.") - return - } - - // Setup is now permanently complete: burn the token (security.md). - s.setup.complete() - logf("panel: administrator %q created; setup link is now disabled", username) - http.Redirect(w, r, "/login", http.StatusSeeOther) -} diff --git a/internal/web/handlers_status.go b/internal/web/handlers_status.go deleted file mode 100644 index 2216652..0000000 --- a/internal/web/handlers_status.go +++ /dev/null @@ -1,140 +0,0 @@ -package web - -import ( - "net/http" - "strings" - - "github.com/mixeme/selfpost/internal/health" -) - -// handleStatus renders the server status page: the panel's landing page and the -// one screen that answers "is the service healthy and will mail be accepted". -// The cheap local checks live in the polled "status_body" -// fragment; the hostname/PTR lookup and the configuration reload sit outside it, -// because neither belongs on a five-second timer. -func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { - data := s.statusBody() - srv := s.dns.Server(s.cfg.Hostname, false) - - data["Title"] = "SelfPost — status" - data["User"] = currentUser(r) - data["Active"] = "status" - data["Flash"] = statusFlash(r) - data["Hostname"] = s.cfg.Hostname - data["PTR"] = srv.PTR - s.render(w, http.StatusOK, "status", data) -} - -// handleStatusFragment serves the HTMX polling fragment for the local checks -// (architecture.md § Panel HTTP surface: fragment endpoints return HTML, not -// JSON). -func (s *Server) handleStatusFragment(w http.ResponseWriter, _ *http.Request) { - s.renderFragment(w, http.StatusOK, "status_body", s.statusBody()) -} - -// handleStatusRecheck forces a fresh hostname/PTR lookup, bypassing the cache, -// and returns to the page. DNS is the one part of this screen that talks to the -// network, so it refreshes on demand rather than with the poll. -func (s *Server) handleStatusRecheck(w http.ResponseWriter, r *http.Request) { - s.dns.Server(s.cfg.Hostname, true) - http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther) -} - -// statusBody collects the local checks the fragment renders. Each one -// reports its own problem rather than failing the page, so a broken component -// costs one line and not the whole screen. -func (s *Server) statusBody() map[string]any { - procs, procErr := health.Processes() - procStatus := health.StatusUnknown - if procErr != nil { - // Outside the container (or if the control socket is gone) there is - // nothing to report — "unknown", not "everything is broken". - logf("panel: status: supervisorctl: %v", procErr) - } else { - for _, p := range procs { - procStatus = health.Worst(procStatus, p.Status) - } - } - - queueText, queueErr := readQueue() - queueStatus := health.StatusOK - if queueErr != "" { - queueStatus = health.StatusWarn - } - - cert := health.CheckCertificate(s.cfg.TLSCertFile) - sockets := []health.Socket{ - // OpenDKIM signs every outgoing message and Postfix is configured to - // tempfail without it: a missing socket stops mail. - health.CheckSocket("OpenDKIM", s.cfg.OpenDKIMSocket, true), - // The journal-milter only records the send log and fails open. - health.CheckSocket("send-log", s.cfg.JournalSocket, false), - } - socketStatus := health.StatusUnknown - for _, sock := range sockets { - socketStatus = health.Worst(socketStatus, sock.Status) - } - - // Resource usage of the machine underneath. It is graded like the rest — - // a processor that is fully busy or a machine out of memory delays or - // kills the mail path — so it counts towards the headline verdict, and - // its rates are measured against the previous poll (internal/health). - machine := s.machine.Sample() - - overall := health.Worst(procStatus, queueStatus, cert.Status, socketStatus, machine.Status) - return map[string]any{ - "Processes": procs, - "ProcessError": procErr != nil, - "ProcessStatus": procStatus, - "QueueSummary": queueSummary(queueText), - "QueueError": queueErr, - "QueueStatus": queueStatus, - "Machine": machine, - "Cert": cert, - "Sockets": sockets, - "SocketStatus": socketStatus, - "OverallStatus": overall, - "OverallHeading": overallHeading(overall), - } -} - -// queueSummary reduces postqueue's listing to the one line worth showing on -// the status page; the full listing has its own screen (architecture.md § -// Panel HTTP surface). postqueue prints either "Mail queue is empty" or a -// trailing "-- N Kbytes in M Requests." -func queueSummary(out string) string { - lines := strings.Split(strings.TrimSpace(out), "\n") - for i := len(lines) - 1; i >= 0; i-- { - if line := strings.TrimSpace(lines[i]); line != "" { - return strings.TrimSpace(strings.TrimPrefix(line, "--")) - } - } - return "" -} - -// overallHeading turns the worst check into the page's one-line verdict. -func overallHeading(worst health.Status) string { - switch worst { - case health.StatusError: - return "A component needs attention — see the details below." - case health.StatusWarn: - return "Running, with warnings below." - case health.StatusOK: - return "All components are running normally." - default: - return "Some checks could not be performed." - } -} - -// statusFlash maps a fixed redirect flag to a fixed message, so status text -// after a redirect is never attacker-influenced. -func statusFlash(r *http.Request) string { - switch { - case r.URL.Query().Get("reloaded") != "": - return "Configuration regenerated from the database; OpenDKIM and Postfix have re-read it." - case r.URL.Query().Get("rechecked") != "": - return "DNS re-checked." - default: - return "" - } -} diff --git a/internal/web/middleware.go b/internal/web/middleware.go deleted file mode 100644 index 0397b02..0000000 --- a/internal/web/middleware.go +++ /dev/null @@ -1,55 +0,0 @@ -package web - -import ( - "context" - "net/http" -) - -type ctxKey int - -const usernameKey ctxKey = 0 - -// requireAuth wraps a handler so only requests with a valid session cookie -// reach it; everyone else is redirected to the login page. The authenticated -// username is stashed in the request context for downstream handlers. -// -// It also extends the sliding session (plan B.1) on activity, defined as -// everything except a GET request carrying HX-Request: the four monitoring -// fragments (/status/fragment, /mail-queue/body, /system-log/body, -// /deliveries/rows) -// poll on a timer regardless of whether anyone is looking at the tab, so -// counting those as activity would make "N days idle" mean "N days since a -// browser tab was last open" instead. -func (s *Server) requireAuth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, ok := s.sessionToken(r) - if !ok { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - username, ok := s.sessions.Lookup(token) - if !ok { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - if isSessionActivity(r) && s.sessions.Touch(token) { - s.setSessionCookie(w, token) - } - ctx := context.WithValue(r.Context(), usernameKey, username) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// isSessionActivity reports whether a request counts as administrator -// activity for the sliding session timeout, per requireAuth's doc comment. -func isSessionActivity(r *http.Request) bool { - return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "") -} - -// currentUser returns the authenticated username from the request context. -func currentUser(r *http.Request) string { - if v, ok := r.Context().Value(usernameKey).(string); ok { - return v - } - return "" -} diff --git a/internal/web/session_test.go b/internal/web/session_test.go deleted file mode 100644 index 3644d26..0000000 --- a/internal/web/session_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package web - -import ( - "path/filepath" - "testing" - "time" - - "github.com/mixeme/selfpost/internal/store" -) - -func newTestSessionStore(t *testing.T) *sessionStore { - t.Helper() - st, err := store.Open(filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatalf("open store: %v", err) - } - t.Cleanup(func() { st.Close() }) - return newSessionStore(st, 7*24*time.Hour) -} - -func TestSessionRename(t *testing.T) { - s := newTestSessionStore(t) - token := s.Create("admin") - - s.Rename(token, "operator") - - name, ok := s.Lookup(token) - if !ok { - t.Fatal("session lost after rename") - } - if name != "operator" { - t.Fatalf("session username = %q, want %q", name, "operator") - } -} - -// A password change must invalidate every other session (so a cookie captured -// under the old password stops working) while keeping the one performing the -// change signed in. -func TestSessionDestroyOthers(t *testing.T) { - s := newTestSessionStore(t) - keep := s.Create("admin") - other := s.Create("admin") - - s.DestroyOthers(keep) - - if _, ok := s.Lookup(keep); !ok { - t.Fatal("current session was destroyed") - } - if _, ok := s.Lookup(other); ok { - t.Fatal("other session survived") - } -} - -// A session past its sliding idle expiry must not be honoured. -func TestSessionLookupRejectsExpired(t *testing.T) { - s := newTestSessionStore(t) - s.idle = -time.Minute // already expired the instant it's created - token := s.Create("admin") - - if _, ok := s.Lookup(token); ok { - t.Fatal("expired session was accepted") - } -} - -// Touch must not rewrite the expiry (or report a renewal) inside the -// once-an-hour throttle window, so an active tab's polling doesn't turn into -// a database write per request. -func TestSessionTouchThrottled(t *testing.T) { - s := newTestSessionStore(t) - token := s.Create("admin") - - if s.Touch(token) { - t.Fatal("touch renewed a session created moments ago") - } - - // Back-date the session's last renewal by rewriting its expiry, as if it - // had been created (or last renewed) 2 hours ago rather than moments ago. - if err := s.store.RenewSession(hashToken(token), time.Now().Add(-2*time.Hour).Add(s.idle)); err != nil { - t.Fatalf("renew session: %v", err) - } - if !s.Touch(token) { - t.Fatal("touch did not renew a session past the throttle window") - } -} diff --git a/internal/web/validate.go b/internal/web/validate/validate.go similarity index 63% rename from internal/web/validate.go rename to internal/web/validate/validate.go index 4951197..6ba5cde 100644 --- a/internal/web/validate.go +++ b/internal/web/validate/validate.go @@ -1,4 +1,5 @@ -package web +// Package validate holds shared server-side form validation for the panel. +package validate import ( "fmt" @@ -6,24 +7,24 @@ import ( "unicode" ) -// minAdminPasswordLen is the floor for the administrator password. The panel is +// MinAdminPasswordLen is the floor for the administrator password. The panel is // public (security.md), so this is deliberately not tiny. -const minAdminPasswordLen = 12 +const MinAdminPasswordLen = 12 const ( minUsernameLen = 3 maxUsernameLen = 64 ) -// minSecretFilePasswordLen is the floor for the password protecting an +// MinSecretFilePasswordLen is the floor for the password protecting an // encrypted backup or domain export. Such a file is offline and can be attacked // at leisure, so the floor matches the administrator password's rather than the // weaker "any password is better than none". -const minSecretFilePasswordLen = minAdminPasswordLen +const MinSecretFilePasswordLen = MinAdminPasswordLen -// validateUsername enforces a strict server-side whitelist (security.md): -// letters, digits, dot, dash, underscore. Client validation is never trusted. -func validateUsername(u string) error { +// Username enforces a strict server-side whitelist (security.md): letters, +// digits, dot, dash, underscore. Client validation is never trusted. +func Username(u string) error { if len(u) < minUsernameLen || len(u) > maxUsernameLen { return fmt.Errorf("username must be %d-%d characters", minUsernameLen, maxUsernameLen) } @@ -35,11 +36,11 @@ func validateUsername(u string) error { return nil } -// validateAdminPassword enforces a minimum length. Composition rules beyond -// length tend to reduce entropy in practice, so length is the sole gate. -func validateAdminPassword(p string) error { - if len(p) < minAdminPasswordLen { - return fmt.Errorf("password must be at least %d characters", minAdminPasswordLen) +// AdminPassword enforces a minimum length. Composition rules beyond length tend +// to reduce entropy in practice, so length is the sole gate. +func AdminPassword(p string) error { + if len(p) < MinAdminPasswordLen { + return fmt.Errorf("password must be at least %d characters", MinAdminPasswordLen) } return nil } @@ -50,23 +51,19 @@ func isASCIILetterOrDigit(r rune) bool { const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name -// normalizeDomain lower-cases and trims a domain name. Domain names are +// NormalizeDomain lower-cases and trims a domain name. Domain names are // case-insensitive, and the generated OpenDKIM tables/keys use the canonical // lower-case form, so we normalise before both validation and storage. -func normalizeDomain(name string) string { +func NormalizeDomain(name string) string { return strings.ToLower(strings.TrimSpace(name)) } -// validateDomain enforces a strict server-side whitelist for sending-domain -// names (security.md). The result is safe to write verbatim into the OpenDKIM +// Domain enforces a strict server-side whitelist for sending-domain names +// (security.md). The result is safe to write verbatim into the OpenDKIM // KeyTable/SigningTable and to use as a filesystem path segment: only // lower-case letters, digits, '.' and '-' are allowed, in valid DNS label -// shape. Input must already be normalised with normalizeDomain. -// -// This is deliberately stricter than "any string DNS might accept" — no -// leading/trailing dots or hyphens, no empty or over-long labels, and at least -// two labels so single-word hostnames cannot be registered as sending domains. -func validateDomain(name string) error { +// shape. Input must already be normalised with NormalizeDomain. +func Domain(name string) error { if name == "" { return fmt.Errorf("domain is required") } @@ -78,14 +75,14 @@ func validateDomain(name string) error { return fmt.Errorf("domain must include at least one dot (e.g. example.com)") } for _, label := range labels { - if err := validateDomainLabel(label); err != nil { + if err := domainLabel(label); err != nil { return err } } return nil } -func validateDomainLabel(label string) error { +func domainLabel(label string) error { if len(label) == 0 { return fmt.Errorf("domain must not contain an empty label") } @@ -109,20 +106,20 @@ func validateDomainLabel(label string) error { // freemailDomains lists public mail hosts that cannot publish _report._dmarc // authorisation for third-party sending domains. var freemailDomains = map[string]struct{}{ - "gmail.com": {}, - "googlemail.com": {}, - "outlook.com": {}, - "hotmail.com": {}, - "live.com": {}, - "yahoo.com": {}, - "icloud.com": {}, - "me.com": {}, - "proton.me": {}, - "protonmail.com": {}, + "gmail.com": {}, + "googlemail.com": {}, + "outlook.com": {}, + "hotmail.com": {}, + "live.com": {}, + "yahoo.com": {}, + "icloud.com": {}, + "me.com": {}, + "proton.me": {}, + "protonmail.com": {}, } -// validateEmail checks a DMARC rua= mailbox. Empty is allowed (policy-only). -func validateEmail(addr string) error { +// Email checks a DMARC rua= mailbox. Empty is allowed (policy-only). +func Email(addr string) error { addr = strings.TrimSpace(addr) if addr == "" { return nil @@ -132,8 +129,8 @@ func validateEmail(addr string) error { return fmt.Errorf("enter a valid email address") } local := addr[:at] - domain := normalizeDomain(addr[at+1:]) - if err := validateDomain(domain); err != nil { + domain := NormalizeDomain(addr[at+1:]) + if err := Domain(domain); err != nil { return fmt.Errorf("email domain is invalid: %w", err) } for _, r := range local { diff --git a/internal/web/validate/validate_email_test.go b/internal/web/validate/validate_email_test.go new file mode 100644 index 0000000..9e09be0 --- /dev/null +++ b/internal/web/validate/validate_email_test.go @@ -0,0 +1,18 @@ +package validate + +import "testing" + +func TestValidateEmail(t *testing.T) { + if err := Email(""); err != nil { + t.Errorf("empty: %v", err) + } + if err := Email("reports@mail.example.com"); err != nil { + t.Errorf("valid: %v", err) + } + if err := Email("bad"); err == nil { + t.Error("bad address accepted") + } + if err := Email("x@gmail.com"); err == nil { + t.Error("gmail accepted") + } +} diff --git a/internal/web/validate/validate_test.go b/internal/web/validate/validate_test.go new file mode 100644 index 0000000..6a20bf4 --- /dev/null +++ b/internal/web/validate/validate_test.go @@ -0,0 +1,67 @@ +package validate + +import "testing" + +func TestNormalizeDomain(t *testing.T) { + cases := map[string]string{ + " Example.COM ": "example.com", + "MAIL.Example.Org": "mail.example.org", + "example.com": "example.com", + } + for in, want := range cases { + if got := NormalizeDomain(in); got != want { + t.Errorf("NormalizeDomain(%q) = %q, want %q", in, got, want) + } + } +} + +func TestValidateDomainValid(t *testing.T) { + valid := []string{ + "example.com", + "mail.example.com", + "a.co", + "sub-domain.example.co.uk", + "x1.y2.z3", + "1example.com", + } + for _, d := range valid { + if err := Domain(d); err != nil { + t.Errorf("Domain(%q) unexpected error: %v", d, err) + } + } +} + +func TestValidateDomainInvalid(t *testing.T) { + invalid := []string{ + "", + "localhost", + "example", + ".example.com", + "example.com.", + "exa mple.com", + "example..com", + "-example.com", + "example-.com", + "example.com\n", + "exa*mple.com", + "exa_mple.com", + "Example.com", + "пример.рф", + "example.c/m", + } + for _, d := range invalid { + if err := Domain(d); err == nil { + t.Errorf("Domain(%q) = nil, want error", d) + } + } +} + +func TestValidateDomainLongLabelRejected(t *testing.T) { + label := make([]byte, 64) + for i := range label { + label[i] = 'a' + } + if err := Domain(string(label) + ".com"); err == nil { + t.Error("expected error for over-long label") + } +} diff --git a/internal/web/validate_email_test.go b/internal/web/validate_email_test.go deleted file mode 100644 index 46114fe..0000000 --- a/internal/web/validate_email_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package web - -import "testing" - -func TestValidateEmail(t *testing.T) { - if err := validateEmail(""); err != nil { - t.Errorf("empty: %v", err) - } - if err := validateEmail("reports@mail.example.com"); err != nil { - t.Errorf("valid: %v", err) - } - if err := validateEmail("bad"); err == nil { - t.Error("bad address accepted") - } - if err := validateEmail("x@gmail.com"); err == nil { - t.Error("gmail accepted") - } -} diff --git a/internal/web/validate_test.go b/internal/web/validate_test.go deleted file mode 100644 index b7556e4..0000000 --- a/internal/web/validate_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package web - -import "testing" - -func TestNormalizeDomain(t *testing.T) { - cases := map[string]string{ - " Example.COM ": "example.com", - "MAIL.Example.Org": "mail.example.org", - "example.com": "example.com", - } - for in, want := range cases { - if got := normalizeDomain(in); got != want { - t.Errorf("normalizeDomain(%q) = %q, want %q", in, got, want) - } - } -} - -func TestValidateDomainValid(t *testing.T) { - valid := []string{ - "example.com", - "mail.example.com", - "a.co", - "sub-domain.example.co.uk", - "x1.y2.z3", - "1example.com", - } - for _, d := range valid { - if err := validateDomain(d); err != nil { - t.Errorf("validateDomain(%q) unexpected error: %v", d, err) - } - } -} - -func TestValidateDomainInvalid(t *testing.T) { - invalid := []string{ - "", // empty - "localhost", // single label - "example", // single label - ".example.com", // leading dot -> empty label - "example.com.", // trailing dot -> empty label - "exa mple.com", // space - "example..com", // empty label - "-example.com", // label starts with '-' - "example-.com", // label ends with '-' - "example.com\n", // newline (config injection attempt) - "exa*mple.com", // disallowed char - "exa_mple.com", // underscore not allowed in domains - "Example.com", // upper-case (must be normalised first) - "пример.рф", // non-ASCII - "example.c/m", // slash (path-traversal attempt) - } - for _, d := range invalid { - if err := validateDomain(d); err == nil { - t.Errorf("validateDomain(%q) = nil, want error", d) - } - } -} - -func TestValidateDomainLongLabelRejected(t *testing.T) { - label := make([]byte, 64) - for i := range label { - label[i] = 'a' - } - if err := validateDomain(string(label) + ".com"); err == nil { - t.Error("expected error for over-long label") - } -} diff --git a/internal/web/static.go b/internal/web/view/static.go similarity index 94% rename from internal/web/static.go rename to internal/web/view/static.go index d4b70d5..ba798b8 100644 --- a/internal/web/static.go +++ b/internal/web/view/static.go @@ -1,4 +1,4 @@ -package web +package view import ( "crypto/sha256" @@ -39,7 +39,7 @@ func buildStaticETags() map[string]string { return etags } -// staticHandler serves the embedded assets under /static/ with a content ETag. +// StaticHandler serves the embedded assets under /static/ with a content ETag. // // Cache-Control is no-cache rather than a max-age: it lets the browser keep the // copy but requires it to revalidate, so an asset that changed is picked up on @@ -47,7 +47,7 @@ func buildStaticETags() map[string]string { // handful of small files on a single-operator panel that trade is the right way // round — correctness after a deploy matters more than saving the round trip. // http.ServeContent answers the conditional request from the ETag we set here. -func staticHandler() http.Handler { +func StaticHandler() http.Handler { files := http.FileServer(http.FS(assetsFS)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if etag, ok := staticETags[path.Clean(r.URL.Path)]; ok { diff --git a/internal/web/static/favicon.png b/internal/web/view/static/favicon.png similarity index 100% rename from internal/web/static/favicon.png rename to internal/web/view/static/favicon.png diff --git a/internal/web/static/favicon.svg b/internal/web/view/static/favicon.svg similarity index 100% rename from internal/web/static/favicon.svg rename to internal/web/view/static/favicon.svg diff --git a/internal/web/static/htmx.min.js b/internal/web/view/static/htmx.min.js similarity index 100% rename from internal/web/static/htmx.min.js rename to internal/web/view/static/htmx.min.js diff --git a/internal/web/static/logo-compact.svg b/internal/web/view/static/logo-compact.svg similarity index 100% rename from internal/web/static/logo-compact.svg rename to internal/web/view/static/logo-compact.svg diff --git a/internal/web/static/logo.svg b/internal/web/view/static/logo.svg similarity index 100% rename from internal/web/static/logo.svg rename to internal/web/view/static/logo.svg diff --git a/internal/web/static/panel.css b/internal/web/view/static/panel.css similarity index 100% rename from internal/web/static/panel.css rename to internal/web/view/static/panel.css diff --git a/internal/web/static/panel.js b/internal/web/view/static/panel.js similarity index 100% rename from internal/web/static/panel.js rename to internal/web/view/static/panel.js diff --git a/internal/web/static_test.go b/internal/web/view/static_test.go similarity index 98% rename from internal/web/static_test.go rename to internal/web/view/static_test.go index 17b71f2..3954af0 100644 --- a/internal/web/static_test.go +++ b/internal/web/view/static_test.go @@ -1,4 +1,4 @@ -package web +package view import ( "net/http" @@ -13,7 +13,7 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor r.Header.Set(k, v) } rec := httptest.NewRecorder() - staticHandler().ServeHTTP(rec, r) + StaticHandler().ServeHTTP(rec, r) return rec } diff --git a/internal/web/templates/account.html b/internal/web/view/templates/account.html similarity index 100% rename from internal/web/templates/account.html rename to internal/web/view/templates/account.html diff --git a/internal/web/templates/backup.html b/internal/web/view/templates/backup.html similarity index 100% rename from internal/web/templates/backup.html rename to internal/web/view/templates/backup.html diff --git a/internal/web/templates/dashboard.html b/internal/web/view/templates/dashboard.html similarity index 100% rename from internal/web/templates/dashboard.html rename to internal/web/view/templates/dashboard.html diff --git a/internal/web/templates/deliveries.html b/internal/web/view/templates/deliveries.html similarity index 100% rename from internal/web/templates/deliveries.html rename to internal/web/view/templates/deliveries.html diff --git a/internal/web/templates/deliveries_rows.html b/internal/web/view/templates/deliveries_rows.html similarity index 100% rename from internal/web/templates/deliveries_rows.html rename to internal/web/view/templates/deliveries_rows.html diff --git a/internal/web/templates/delivery.html b/internal/web/view/templates/delivery.html similarity index 100% rename from internal/web/templates/delivery.html rename to internal/web/view/templates/delivery.html diff --git a/internal/web/templates/domain_delete.html b/internal/web/view/templates/domain_delete.html similarity index 100% rename from internal/web/templates/domain_delete.html rename to internal/web/view/templates/domain_delete.html diff --git a/internal/web/templates/domain_detail.html b/internal/web/view/templates/domain_detail.html similarity index 100% rename from internal/web/templates/domain_detail.html rename to internal/web/view/templates/domain_detail.html diff --git a/internal/web/templates/encrypt_fields.html b/internal/web/view/templates/encrypt_fields.html similarity index 100% rename from internal/web/templates/encrypt_fields.html rename to internal/web/view/templates/encrypt_fields.html diff --git a/internal/web/templates/layout.html b/internal/web/view/templates/layout.html similarity index 100% rename from internal/web/templates/layout.html rename to internal/web/view/templates/layout.html diff --git a/internal/web/templates/login.html b/internal/web/view/templates/login.html similarity index 100% rename from internal/web/templates/login.html rename to internal/web/view/templates/login.html diff --git a/internal/web/templates/mail_queue.html b/internal/web/view/templates/mail_queue.html similarity index 100% rename from internal/web/templates/mail_queue.html rename to internal/web/view/templates/mail_queue.html diff --git a/internal/web/templates/mail_queue_body.html b/internal/web/view/templates/mail_queue_body.html similarity index 100% rename from internal/web/templates/mail_queue_body.html rename to internal/web/view/templates/mail_queue_body.html diff --git a/internal/web/templates/setup.html b/internal/web/view/templates/setup.html similarity index 100% rename from internal/web/templates/setup.html rename to internal/web/view/templates/setup.html diff --git a/internal/web/templates/status.html b/internal/web/view/templates/status.html similarity index 100% rename from internal/web/templates/status.html rename to internal/web/view/templates/status.html diff --git a/internal/web/templates/status_body.html b/internal/web/view/templates/status_body.html similarity index 100% rename from internal/web/templates/status_body.html rename to internal/web/view/templates/status_body.html diff --git a/internal/web/templates/system_log.html b/internal/web/view/templates/system_log.html similarity index 100% rename from internal/web/templates/system_log.html rename to internal/web/view/templates/system_log.html diff --git a/internal/web/templates/system_log_body.html b/internal/web/view/templates/system_log_body.html similarity index 100% rename from internal/web/templates/system_log_body.html rename to internal/web/view/templates/system_log_body.html diff --git a/internal/web/templates_test.go b/internal/web/view/templates_test.go similarity index 92% rename from internal/web/templates_test.go rename to internal/web/view/templates_test.go index 09a5fe9..767a6dc 100644 --- a/internal/web/templates_test.go +++ b/internal/web/view/templates_test.go @@ -1,4 +1,4 @@ -package web +package view import ( "bytes" @@ -18,11 +18,11 @@ import ( // every page template must resolve it. This is what makes "the nav is on every // authenticated page" a structural property instead of a checklist item. func TestEveryPageResolvesNav(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } - for name, page := range tmpl.pages { + for name, page := range engine.Pages() { if page.Lookup("nav") == nil { t.Errorf("page %q does not resolve the shared nav template", name) } @@ -37,16 +37,16 @@ func TestEveryPageResolvesNav(t *testing.T) { // asserted here: the long pages produce a list, and a page that defines nothing // produces nothing at all. func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } // Anchors the index links to, taken from the page's own cards. wantAnchors := map[string]string{ "status": `href="#certificate"`, "domain_detail": `href="#danger"`, } - for name, page := range tmpl.pages { + for name, page := range engine.Pages() { var buf bytes.Buffer // The domain page's index hides the freshly generated credential entry // unless one is on the page, so the data map carries the key it reads. @@ -68,15 +68,15 @@ func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) { // nothing about rendering the page says so. Every anchor the index offers must // name an element the same page defines an id for. func TestSectionLinksPointAtCardsThatExist(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } // The pages that carry an index; both are checked with a credential shown, // which is the domain page's one conditional entry. for _, name := range []string{"status", "domain_detail"} { var index bytes.Buffer - if err := tmpl.pages[name].ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil { + if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil { t.Fatalf("execute sections for %q: %v", name, err) } // The cards are spread over the page's template files, so the ids are @@ -108,9 +108,9 @@ func TestSectionLinksPointAtCardsThatExist(t *testing.T) { // Appropriate Legal Notices (copyright, licence, source, no warranty) must // appear on every page, including the signed-out ones. func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("9.9.9-test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } legalBits := []string{ "Copyright © 2026 Mikhail Yenuchenko", @@ -121,9 +121,9 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { "No warranty", } rendered := 0 - for name := range tmpl.pages { + for name := range engine.Pages() { var buf bytes.Buffer - err := tmpl.pages[name].ExecuteTemplate(&buf, "layout.html", map[string]any{ + err := engine.Page(name).ExecuteTemplate(&buf, "layout.html", map[string]any{ "Title": "t", "User": "admin", "Active": "", "Version": "9.9.9-test", "Copyright": "Copyright © 2026 Mikhail Yenuchenko", "SourceURL": "https://github.com/mixeme/selfpost", @@ -152,7 +152,7 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { // Signed out (login, setup) the version must not be advertised, but the // Appropriate Legal Notices must still be present. var buf bytes.Buffer - if err := tmpl.pages["login"].ExecuteTemplate(&buf, "layout.html", map[string]any{ + if err := engine.Page("login").ExecuteTemplate(&buf, "layout.html", map[string]any{ "Title": "t", "Active": "", "Version": "9.9.9-test", "Copyright": "Copyright © 2026 Mikhail Yenuchenko", "SourceURL": "https://github.com/mixeme/selfpost", @@ -171,14 +171,13 @@ func TestLayoutShowsTheVersionOnlyWhenSignedIn(t *testing.T) { } func TestRenderSuppliesTheVersion(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("9.9.9-test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } - s := &Server{tmpl: tmpl, cfg: Config{Version: "9.9.9-test"}} rec := httptest.NewRecorder() data := map[string]any{"Title": "t", "User": "admin"} - s.render(rec, http.StatusOK, "backup", data) + engine.Render(rec, http.StatusOK, "backup", data) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) @@ -202,12 +201,12 @@ func TestRenderSuppliesTheVersion(t *testing.T) { } func TestNavMarksActivePage(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } var buf bytes.Buffer - err = tmpl.pages["dashboard"].ExecuteTemplate(&buf, "nav", map[string]any{ + err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{ "User": "admin", "Active": "mail_queue", }) @@ -229,12 +228,12 @@ func TestNavMarksActivePage(t *testing.T) { } func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } var buf bytes.Buffer - if err := tmpl.pages["status"].ExecuteTemplate(&buf, "nav", map[string]any{ + if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{ "User": "admin", "Active": "status", }); err != nil { @@ -258,12 +257,12 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) { // silently comes back at the measure, with its table squeezed into two thirds // of the column — so the set is asserted here, in both directions. func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) { - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } wide := map[string]bool{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true} - for name, page := range tmpl.pages { + for name, page := range engine.Pages() { var buf bytes.Buffer if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil { t.Fatalf("execute the wide block of %s: %v", name, err) @@ -394,12 +393,12 @@ func TestStatusPageWithoutMachineMetrics(t *testing.T) { func renderStatusPage(t *testing.T, data map[string]any) string { t.Helper() - tmpl, err := loadTemplates() + engine, err := New("test") if err != nil { - t.Fatalf("loadTemplates: %v", err) + t.Fatalf("New: %v", err) } var buf bytes.Buffer - if err := tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", data); err != nil { + if err := engine.Page("status").ExecuteTemplate(&buf, "layout.html", data); err != nil { t.Fatalf("execute status page: %v", err) } return buf.String() @@ -460,7 +459,7 @@ func statusPageData() map[string]any { } // dnscheckResult mirrors dnscheck.Result's shape for the template test, so the -// web package's template tests do not depend on the checker's constructor. +// view package's template tests do not depend on the checker's constructor. type dnscheckResult struct { Status health.Status Detail string diff --git a/internal/web/templates.go b/internal/web/view/view.go similarity index 74% rename from internal/web/templates.go rename to internal/web/view/view.go index 991f6ff..469a711 100644 --- a/internal/web/templates.go +++ b/internal/web/view/view.go @@ -1,24 +1,26 @@ -package web +// Package view embeds the panel's HTML templates and static assets and renders +// pages and HTMX polling fragments. +package view import ( "bytes" + "embed" "fmt" "html/template" + "log" "net/http" "github.com/mixeme/selfpost/internal/legal" ) -// templates holds the parsed page and fragment templates. Each page is parsed -// together with the shared base layout so {{ template "base" . }} works. -// Fragments (HTMX polling targets, architecture.md § Panel HTTP surface) are -// parsed standalone, without the layout, so they can be swapped into an -// existing page as an HTML snippet rather than a full document. Rendering -// always goes through html/template, which auto-escapes all interpolated data -// regardless (security.md). -type templates struct { +//go:embed templates/*.html static/* +var assetsFS embed.FS + +// Engine holds parsed page and fragment templates. +type Engine struct { pages map[string]*template.Template fragments map[string]*template.Template + version string } // pageFiles maps a logical page name to its template files. Every page @@ -52,10 +54,12 @@ var fragmentFiles = map[string]string{ "status_body": "templates/status_body.html", } -func loadTemplates() (*templates, error) { - t := &templates{ +// New parses embedded templates. version is stamped into every page footer. +func New(version string) (*Engine, error) { + e := &Engine{ pages: make(map[string]*template.Template), fragments: make(map[string]*template.Template), + version: version, } for name, files := range pageFiles { patterns := append([]string{"templates/layout.html"}, files...) @@ -63,22 +67,33 @@ func loadTemplates() (*templates, error) { if err != nil { return nil, fmt.Errorf("parse template %s: %w", name, err) } - t.pages[name] = tmpl + e.pages[name] = tmpl } for name, file := range fragmentFiles { tmpl, err := template.ParseFS(assetsFS, file) if err != nil { return nil, fmt.Errorf("parse fragment %s: %w", name, err) } - t.fragments[name] = tmpl + e.fragments[name] = tmpl } - return t, nil + return e, nil } -// render writes a page using the base layout. Rendering to a buffer first means +// Page returns a parsed page template by logical name. It is exported for +// template guard tests that assert structural properties across all pages. +func (e *Engine) Page(name string) *template.Template { + return e.pages[name] +} + +// Pages returns all parsed page templates keyed by logical name. +func (e *Engine) Pages() map[string]*template.Template { + return e.pages +} + +// Render writes a page using the base layout. Rendering to a buffer first means // a template error yields a clean 500 instead of a half-written page. -func (s *Server) render(w http.ResponseWriter, status int, page string, data any) { - tmpl, ok := s.tmpl.pages[page] +func (e *Engine) Render(w http.ResponseWriter, status int, page string, data any) { + tmpl, ok := e.pages[page] if !ok { http.Error(w, "template not found", http.StatusInternalServerError) return @@ -92,13 +107,13 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any if _, has := m["Active"]; !has { m["Active"] = "" } - m["Version"] = s.cfg.Version + m["Version"] = e.version m["Copyright"] = legal.CopyrightLine m["SourceURL"] = legal.SourceURL } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil { - logf("panel: render %s: %v", page, err) + log.Printf("panel: render %s: %v", page, err) http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -107,18 +122,18 @@ func (s *Server) render(w http.ResponseWriter, status int, page string, data any _, _ = buf.WriteTo(w) } -// renderFragment writes an HTMX polling fragment as a bare HTML snippet, with +// RenderFragment writes an HTMX polling fragment as a bare HTML snippet, with // no surrounding layout (architecture.md § Panel HTTP surface: fragment // endpoints return HTML, not JSON). -func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) { - tmpl, ok := s.tmpl.fragments[name] +func (e *Engine) RenderFragment(w http.ResponseWriter, status int, name string, data any) { + tmpl, ok := e.fragments[name] if !ok { http.Error(w, "template not found", http.StatusInternalServerError) return } var buf bytes.Buffer if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil { - logf("panel: render fragment %s: %v", name, err) + log.Printf("panel: render fragment %s: %v", name, err) http.Error(w, "internal error", http.StatusInternalServerError) return } diff --git a/internal/web/web.go b/internal/web/web.go index 003c6ea..2341775 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -4,27 +4,25 @@ package web import ( - "embed" "log" "net" "net/http" - "strings" - "time" "github.com/mixeme/selfpost/internal/app" "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" + "github.com/mixeme/selfpost/internal/legal" "github.com/mixeme/selfpost/internal/store" + "github.com/mixeme/selfpost/internal/web/auth" + "github.com/mixeme/selfpost/internal/web/handlers" + "github.com/mixeme/selfpost/internal/web/view" ) -//go:embed templates/*.html static/* -var assetsFS embed.FS - // Config holds the panel's HTTP-facing configuration. type Config struct { // Hostname is the server's external hostname, used to build the absolute - // setup link shown in the logs (security.md; guide В§ Environment + // setup link shown in the logs (security.md; guide § Environment // variables for SELFPOST_HOSTNAME). Hostname string // CookieSecure sets the Secure attribute on the session cookie. It defaults @@ -33,16 +31,16 @@ type Config struct { CookieSecure bool // SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also // runs the 587/STARTTLS submission listener next to the primary 465 one - // (architecture.md В§ Mail path). The panel only reports it on the domain + // (architecture.md § Mail path). The panel only reports it on the domain // page's connection settings; it is a deploy-time flag, not something the // panel can verify. SubmissionEnabled bool // MailLogPath is where Postfix's delivery log lives, read by the mail.log - // monitoring view (architecture.md В§ Panel HTTP surface). It is the same path + // monitoring view (architecture.md § Panel HTTP surface). It is the same path // the log-tailer role follows in cmd/panel. MailLogPath string // DataDir and DBPath locate the persistent state a full backup archives - // (architecture.md В§ Persistence); Version is stamped into the backup + // (architecture.md § Persistence); Version is stamped into the backup // manifest. They mirror the panel's own configuration. DataDir string DBPath string @@ -53,7 +51,7 @@ type Config struct { // honoured, so the header can't be spoofed by anyone but a trusted proxy. // Empty (the default) keeps rate-limiting keyed on RemoteAddr only. TrustedProxyCIDRs []*net.IPNet - // TLSCertFile is the certificate Postfix serves on 465/587 (guide В§ + // TLSCertFile is the certificate Postfix serves on 465/587 (guide § // Environment variables), read read-only by the status page to report how // much validity is left. TLSCertFile string @@ -69,156 +67,107 @@ type Config struct { SessionIdleDays int // DNSResolvers are the recursive resolvers the deliverability checks query // (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The - // checks must not go through the system resolver — see dnscheck's - // externalResolver — so this is how a closed network points them at its own. + // checks must not go through the system resolver — see dnscheck's + // externalResolver — so this is how a closed network points them at its own. DNSResolvers []string } // Server is the panel HTTP application. type Server struct { - store *store.Store - domains *domain.Service - apps *app.Service cfg Config - tmpl *templates - sessions *sessionStore - setup *setupManager - dns *dnscheck.Checker - // machine reads the host's CPU, memory and network counters for the - // status page. It has to be one shared sampler for the whole server: - // CPU and throughput are differences between successive readings, so a - // per-request sampler would never have a previous one to subtract. - machine health.MachineSampler - - loginLimiter *rateLimiter - setupLimiter *rateLimiter - - trustedProxies []*net.IPNet + auth *auth.Module + handlers *handlers.Handlers } // New builds the panel server. setupTokenPath is where the current setup token // is mirrored on disk (security.md); domains is the sending-domain service -// that owns DKIM keys and the OpenDKIM tables (architecture.md В§ OpenDKIM); +// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM); // apps owns application SASL accounts and the Postfix sender map -// (architecture.md В§ Mail path). +// (architecture.md § Mail path). func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) { - tmpl, err := loadTemplates() + v, err := view.New(cfg.Version) if err != nil { return nil, err } - idleDays := cfg.SessionIdleDays - if idleDays <= 0 { - idleDays = 7 - } - s := &Server{ - store: st, - domains: domains, - apps: apps, - cfg: cfg, - tmpl: tmpl, - sessions: newSessionStore(st, time.Duration(idleDays)*24*time.Hour), - // Published-DNS checks for the status page and the domain pages. The - // checker caches its own results, so page views do not each pay for a - // round of lookups. - dns: dnscheck.New(cfg.DNSResolvers), - // Setup: a handful of attempts per minute per IP is plenty for a - // legitimate admin and blunts automated probing (security.md). - setupLimiter: newRateLimiter(10, time.Minute), - // Login: throttle brute-force by IP (security.md). - loginLimiter: newRateLimiter(10, 15*time.Minute), - - trustedProxies: cfg.TrustedProxyCIDRs, - } - s.setup = newSetupManager(st, cfg.Hostname, setupTokenPath) - return s, nil + a := auth.New(st, auth.Config{ + CookieSecure: cfg.CookieSecure, + Hostname: cfg.Hostname, + SessionIdleDays: cfg.SessionIdleDays, + TrustedProxyCIDRs: cfg.TrustedProxyCIDRs, + }, v, setupTokenPath) + h := handlers.New(st, domains, apps, handlers.Config{ + Hostname: cfg.Hostname, + SubmissionEnabled: cfg.SubmissionEnabled, + MailLogPath: cfg.MailLogPath, + DataDir: cfg.DataDir, + DBPath: cfg.DBPath, + Version: cfg.Version, + TLSCertFile: cfg.TLSCertFile, + OpenDKIMSocket: cfg.OpenDKIMSocket, + JournalSocket: cfg.JournalSocket, + }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) + return &Server{cfg: cfg, auth: a, handlers: h}, nil } // Start performs first-run bootstrapping: if there is no administrator yet, it // generates and announces the setup link (security.md). Safe to call once at // server startup. func (s *Server) Start() error { - return s.setup.bootstrap() + return s.auth.Bootstrap() } // Handler returns the panel's HTTP handler (router). func (s *Server) Handler() http.Handler { mux := http.NewServeMux() + h := s.handlers - // Health check stays unauthenticated for the container/orchestrator. mux.HandleFunc("/healthz", handleHealth) + mux.HandleFunc("/license", handleLicense) + mux.Handle("/static/", view.StaticHandler()) + mux.HandleFunc("/setup/", s.auth.HandleSetup) + mux.HandleFunc("/login", s.auth.HandleLogin) + mux.HandleFunc("/logout", s.auth.HandleLogout) - // AGPL Appropriate Legal Notices: the licence text itself, reachable - // without a session so the login and setup footers can link to it. - mux.HandleFunc("/license", s.handleLicense) - - // Vendored static assets (HTMX). Served from the embedded FS, with a - // content ETag so a replaced asset survives the browser cache (static.go). - mux.Handle("/static/", staticHandler()) - - // One-time administrator setup (security.md). - mux.HandleFunc("/setup/", s.handleSetup) - - // Authentication. - mux.HandleFunc("/login", s.handleLogin) - mux.HandleFunc("/logout", s.handleLogout) - - // Authenticated panel. Everything not matched by a more specific pattern - // above falls through to this sub-mux, wrapped once in the auth middleware. authed := http.NewServeMux() - - // The landing page is the server status: the first thing an - // administrator should see after logging in is whether the service is - // healthy, not the domain list. handleLogin still redirects to "/". authed.HandleFunc("GET /{$}", redirectToStatus) - authed.HandleFunc("GET /status", s.handleStatus) - authed.HandleFunc("GET /status/fragment", s.handleStatusFragment) - authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck) + authed.HandleFunc("GET /status", h.HandleStatus) + authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment) + authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck) - authed.HandleFunc("GET /domains", s.handleDashboard) - authed.HandleFunc("POST /domains", s.handleAddDomain) - authed.HandleFunc("POST /domains/import", s.handleImportDomain) - authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail) - authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck) - authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm) - authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain) - authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication) - authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit) - authed.HandleFunc("POST /domains/{id}/dmarc", s.handleDomainDMARC) - authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain) - authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode) - authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword) - authed.HandleFunc("POST /applications/{aid}/ratelimit", s.handleAppRateLimit) - authed.HandleFunc("POST /applications/{aid}/delete", s.handleDeleteApplication) - authed.HandleFunc("POST /reload", s.handleReload) + authed.HandleFunc("GET /domains", h.HandleDashboard) + authed.HandleFunc("POST /domains", h.HandleAddDomain) + authed.HandleFunc("POST /domains/import", h.HandleImportDomain) + authed.HandleFunc("GET /domains/{id}", h.HandleDomainDetail) + authed.HandleFunc("POST /domains/{id}/dns-recheck", h.HandleDomainDNSRecheck) + authed.HandleFunc("GET /domains/{id}/delete", h.HandleDeleteConfirm) + authed.HandleFunc("POST /domains/{id}/delete", h.HandleDeleteDomain) + authed.HandleFunc("POST /domains/{id}/applications", h.HandleAddApplication) + authed.HandleFunc("POST /domains/{id}/ratelimit", h.HandleDomainRateLimit) + authed.HandleFunc("POST /domains/{id}/dmarc", h.HandleDomainDMARC) + authed.HandleFunc("POST /domains/{id}/export", h.HandleExportDomain) + authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode) + authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword) + authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit) + authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication) + authed.HandleFunc("POST /reload", h.HandleReload) - // Administrator's own panel credentials. - authed.HandleFunc("/account", s.handleAccount) + authed.HandleFunc("/account", h.HandleAccount) - // Backup and migration: the page with both actions (architecture.md В§ - // Persistence-B), and the full-server backup download itself. - authed.HandleFunc("GET /backup", s.handleBackupPage) - authed.HandleFunc("POST /backup", s.handleBackup) + authed.HandleFunc("GET /backup", h.HandleBackupPage) + authed.HandleFunc("POST /backup", h.HandleBackup) - // Monitoring screens (architecture.md В§ Panel HTTP surface): each page and - // its HTMX polling fragment (architecture.md В§ Panel HTTP surface — the /rows - // and /body endpoints return HTML, not JSON). - authed.HandleFunc("GET /deliveries", s.handleDeliveries) - authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows) - authed.HandleFunc("GET /deliveries/{id}", s.handleDelivery) - authed.HandleFunc("GET /mail-queue", s.handleMailQueue) - authed.HandleFunc("GET /mail-queue/body", s.handleMailQueueBody) - authed.HandleFunc("GET /system-log", s.handleSystemLog) - authed.HandleFunc("GET /system-log/body", s.handleSystemLogBody) + authed.HandleFunc("GET /deliveries", h.HandleDeliveries) + authed.HandleFunc("GET /deliveries/rows", h.HandleDeliveriesRows) + authed.HandleFunc("GET /deliveries/{id}", h.HandleDelivery) + authed.HandleFunc("GET /mail-queue", h.HandleMailQueue) + authed.HandleFunc("GET /mail-queue/body", h.HandleMailQueueBody) + authed.HandleFunc("GET /system-log", h.HandleSystemLog) + authed.HandleFunc("GET /system-log/body", h.HandleSystemLogBody) - mux.Handle("/", s.requireAuth(authed)) - - // Security headers and the origin check wrap everything, including the - // unauthenticated login and setup routes. + mux.Handle("/", s.auth.RequireAuth(authed)) return s.secure(mux) } -// redirectToStatus points the panel root at the status page, so there is one -// canonical URL for that content instead of two. func redirectToStatus(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/status", http.StatusSeeOther) } @@ -233,44 +182,20 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok\n")) } -// clientIP extracts the peer IP for rate-limiting. By default it is the -// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches -// one of trustedProxies, the last entry of X-Forwarded-For is used instead — -// that is the address the trusted proxy itself appended, so a client can't -// forge it by sending its own XFF header. With no trusted -// proxies configured, behind a reverse proxy this is the proxy's own address, -// which is an acceptable backstop for a single-admin panel. -func clientIP(r *http.Request, trustedProxies []*net.IPNet) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - host = r.RemoteAddr +func handleLicense(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return } - - if len(trustedProxies) > 0 { - if peer := net.ParseIP(host); peer != nil && ipInAny(peer, trustedProxies) { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.Split(xff, ",") - if ip := net.ParseIP(strings.TrimSpace(parts[len(parts)-1])); ip != nil { - return ip.String() - } - } - } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return } - - return host + _, _ = w.Write(legal.License) } -func ipInAny(ip net.IP, nets []*net.IPNet) bool { - for _, n := range nets { - if n.Contains(ip) { - return true - } - } - return false -} - -// logf is a thin wrapper so handlers log with a consistent prefix. func logf(format string, args ...any) { log.Printf(format, args...) } -