From bd746c61d7ff2049802ef03e769efd91ce656498 Mon Sep 17 00:00:00 2001
From: Mix
Date: Fri, 9 Oct 2026 21:51:55 +0300
Subject: [PATCH] panel-redesign: paths follow the menu; Account and Server
settings split
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.
Routes (web.go)
- /status -> /overview; /domains -> /outbound/domains with the domain's
settings actions under .../settings/ (reports, ratelimit, export);
/deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
/outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
/server/log; /backup -> /server/backup (import under it); /users ->
/server/users; recheck and reload under /server/health/.
- One fragment name: /fragment everywhere.
- Everything under /server/ is registered through server(), which wraps
the handler in globalOnly: the subtree is guarded once, where a new
route cannot forget it. Handlers keep their own requireGlobal.
- GET / leads to /overview, /outbound/domains or /inbound/domains.
- The /account -> /settings redirect is gone with /settings.
Account and Server > Settings
- handlers_account.go: GET /account and three POSTs - profile (username,
e-mail; no password asked, as designed), password (current password,
rate-limited like a sign-in, signs out other sessions), dmarc (the
user's default: hosted / account / custom / none, each validated).
This replaces step 1's bridge through the old Settings form.
- handlers_settings.go is the instance's page only: log retention and the
read-only rate limits, global role.
- One legacy template serves both pages by a flag until stage 2 gives each
its own: a new legacy page cannot be added to the ratchet.
Still to come inside stage 1, and why @routes stays on the ratchet:
- step 4: the application routes (one POST, under the domain);
- step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.
Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).
Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.
go test ./... green except the two Windows-only failures in internal/domain
and internal/logtail.
Roadmap 13/27.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 28 +-
cmd/panel/restore_test.go | 8 +-
docs/plans/panel-redesign.md | 2 +-
docs/roadmap.md | 2 +-
internal/web/handlers/authz_test.go | 50 ++--
.../web/handlers/e2e_markup_probe_test.go | 2 +-
internal/web/handlers/handlers_account.go | 276 ++++++++++++++++++
.../web/handlers/handlers_account_test.go | 172 +++++++++++
internal/web/handlers/handlers_apps.go | 6 +-
internal/web/handlers/handlers_backup.go | 2 +-
internal/web/handlers/handlers_backup_test.go | 4 +-
.../web/handlers/handlers_delegation_test.go | 42 +--
internal/web/handlers/handlers_dmarc.go | 2 +-
internal/web/handlers/handlers_dmarc_test.go | 36 +--
internal/web/handlers/handlers_domains.go | 6 +-
internal/web/handlers/handlers_inbound.go | 10 +-
.../web/handlers/handlers_inbound_test.go | 24 +-
internal/web/handlers/handlers_monitor.go | 4 +-
.../web/handlers/handlers_monitor_test.go | 48 +--
internal/web/handlers/handlers_ratelimit.go | 10 +-
internal/web/handlers/handlers_settings.go | 273 ++---------------
.../web/handlers/handlers_settings_test.go | 73 ++---
internal/web/handlers/handlers_status.go | 2 +-
internal/web/handlers/handlers_users.go | 6 +-
internal/web/home_test.go | 42 ++-
internal/web/security.go | 2 +-
internal/web/view/templates/backup.html | 4 +-
internal/web/view/templates/dashboard.html | 8 +-
internal/web/view/templates/deliveries.html | 4 +-
.../web/view/templates/deliveries_rows.html | 8 +-
internal/web/view/templates/delivery.html | 2 +-
internal/web/view/templates/dmarc.html | 6 +-
internal/web/view/templates/dmarc_domain.html | 8 +-
internal/web/view/templates/dmarc_report.html | 2 +-
.../web/view/templates/domain_delete.html | 4 +-
.../web/view/templates/domain_detail.html | 24 +-
internal/web/view/templates/help.html | 4 +-
internal/web/view/templates/inbound.html | 8 +-
.../web/view/templates/inbound_delete.html | 4 +-
.../web/view/templates/inbound_domain.html | 10 +-
.../web/view/templates/layout_legacy.html | 31 +-
.../web/view/templates/mail_queue_body.html | 2 +-
internal/web/view/templates/settings.html | 183 ++++++------
internal/web/view/templates/status.html | 2 +-
internal/web/view/templates/status_body.html | 6 +-
.../web/view/templates/system_log_body.html | 2 +-
internal/web/view/templates/user_delete.html | 4 +-
internal/web/view/templates/user_form.html | 6 +-
internal/web/view/templates/users.html | 4 +-
internal/web/view/templates_test.go | 40 +--
internal/web/web.go | 148 ++++++----
test/e2e/main_test.go | 8 +-
test/e2e/negative_test.go | 6 +-
test/e2e/panel_client.go | 26 +-
test/e2e/process_check.go | 2 +-
55 files changed, 1006 insertions(+), 692 deletions(-)
create mode 100644 internal/web/handlers/handlers_account.go
create mode 100644 internal/web/handlers/handlers_account_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 214199c..4d5689d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,19 +48,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
tenant's domain, a missing one and a user with no inbound reach all get the
same 404. A menu group with nothing assigned is absent, and `/` leads an
inbound-only administrator to Inbound.
+- panel-redesign (stage 1): **Account** (`/account`, every role) and
+ **Server › Settings** (`/server/settings`, global role) replace the one
+ Settings page. Account has three forms, each its own POST: profile
+ (username, e-mail), password, and the default DMARC report address
+ (SelfPost hosted, my account e-mail, another address, none). Saving the
+ profile no longer asks for the password; changing the password still does.
### Changed
+- **Breaking — every panel URL changed.** A path now reads like the menu
+ (`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`, `/account`); the
+ old paths are removed, not redirected — see *Removed*. Polled fragments all
+ answer `/fragment`. Everything under `/server/` is guarded for the
+ global role where the routes are registered, in addition to each handler's
+ own check. The application routes and Server › Health follow in the next
+ two steps.
- **Breaking — the database starts from zero.** The 1.x migration chain
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
`/data/selfpost.db` is refused at start-up with an explicit error and left
untouched; nothing is migrated. A domain can be carried over with its
export file.
- The panel role `domain_admin` is stored as `domain`.
-- Until the Account page replaces it, the old Settings form's "default report
- address" field sets the account e-mail and switches the user's DMARC
- default between that e-mail and none; a domain's "Same as Settings" makes
- it follow the user who chose it.
+- On the old domain page, "Same as Settings" makes the domain follow the
+ default report address of the user who chose it.
- The old panel is untouched on screen but steps aside in the tree: its
stylesheet is `legacy.css` and its layout `layout_legacy.html` until the
last old page is restyled. The 2.0 menu paths in the new shell do not
@@ -70,6 +81,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Removed
+- Old panel paths, GET and POST alike, without redirects: `/status`,
+ `/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything
+ under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`,
+ `…/ratelimit`, `…/ratelimit/recalc`, `…/dmarc`, `…/export`), `/inbound` and
+ `/inbound/{id}/…`, `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}`,
+ `/deliveries`, `/deliveries/rows`, `/deliveries/{id}`, `/mail-queue`,
+ `/mail-queue/body`, `/system-log`, `/system-log/body`, `/backup`, `/users`
+ and everything under it, `/settings`, and the `/account` → `/settings`
+ redirect.
- The instance setting `dmarc_report_email` (a mirror of the global user's
profile field) and the unused `rate_limits.allowed_ips` column.
diff --git a/cmd/panel/restore_test.go b/cmd/panel/restore_test.go
index 5575d1b..ef562eb 100644
--- a/cmd/panel/restore_test.go
+++ b/cmd/panel/restore_test.go
@@ -74,7 +74,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
r := restoreFromOwnBackup(t, "")
- body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
+ body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
for _, want := range []string{restoreDomain, restoreSubject} {
if !strings.Contains(body, want) {
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
@@ -128,7 +128,7 @@ func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
r := restoreFromOwnBackup(t, "")
- rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
+ rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
if rec.Code != http.StatusOK {
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
}
@@ -141,7 +141,7 @@ func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
r := restoreFromOwnBackup(t, "a-long-enough-password")
- body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
+ body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
if !strings.Contains(body, restoreSubject) {
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
}
@@ -384,7 +384,7 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
form.Set("password", password)
form.Set("password_confirm", password)
}
- rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
+ rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
if rec.Code != http.StatusOK {
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
}
diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md
index fc35947..82e5c79 100644
--- a/docs/plans/panel-redesign.md
+++ b/docs/plans/panel-redesign.md
@@ -507,7 +507,7 @@ only when both are done. Every stage-2 step ends with the evidence of
- [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus**
- [x] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus**
-- [ ] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
+- [x] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
- [ ] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus**
- [ ] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus**
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 30d57a7..10e9616 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — |
-| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 12/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
+| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 13/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
diff --git a/internal/web/handlers/authz_test.go b/internal/web/handlers/authz_test.go
index 7a96172..e8fe848 100644
--- a/internal/web/handlers/authz_test.go
+++ b/internal/web/handlers/authz_test.go
@@ -26,38 +26,38 @@ type route struct {
var globalOnlyRoutes = []route{
{"GET", "/server/components", func(h *Handlers) http.HandlerFunc { return h.HandleComponents }, nil},
- {"GET", "/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
- {"GET", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
- {"POST", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
- {"GET", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
- {"POST", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
- {"GET", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}},
- {"POST", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}},
+ {"GET", "/server/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
+ {"GET", "/server/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
+ {"POST", "/server/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
+ {"GET", "/server/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
+ {"POST", "/server/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
+ {"GET", "/server/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}},
+ {"POST", "/server/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}},
- {"GET", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
- {"POST", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
- {"POST", "/domains/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil},
+ {"GET", "/server/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
+ {"POST", "/server/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
+ {"POST", "/server/backup/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil},
- {"GET", "/status", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
- {"GET", "/status/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
- {"POST", "/status/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil},
+ {"GET", "/overview", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
+ {"GET", "/overview/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
+ {"POST", "/server/health/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil},
- {"GET", "/mail-queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
- {"GET", "/mail-queue/body", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
- {"GET", "/system-log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
- {"GET", "/system-log/body", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil},
+ {"GET", "/outbound/queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
+ {"GET", "/outbound/queue/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
+ {"GET", "/server/log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
+ {"GET", "/server/log/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil},
- {"POST", "/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil},
- {"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
- {"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
- {"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
+ {"POST", "/outbound/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil},
+ {"GET", "/outbound/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
+ {"POST", "/outbound/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
+ {"POST", "/server/health/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
// Inbound: only adding and deleting a domain are global. The list and the
// pages of one inbound domain are delegated per domain and have their own
// table in handlers_delegation_test.go.
- {"POST", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil},
- {"GET", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, map[string]string{"id": "1"}},
- {"POST", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, map[string]string{"id": "1"}},
+ {"POST", "/inbound/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil},
+ {"GET", "/inbound/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, map[string]string{"id": "1"}},
+ {"POST", "/inbound/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, map[string]string{"id": "1"}},
}
// A domain administrator has an account on the panel, so authentication is not
@@ -102,7 +102,7 @@ func TestGlobalOnlyRoutesAnswerAnUnknownPrincipal404(t *testing.T) {
func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) {
h, _ := serverWithTwoDomains(t)
- for _, target := range []string{"/users", "/backup"} {
+ for _, target := range []string{"/server/users", "/server/backup"} {
rt := getRoute(t, target)
if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK {
t.Errorf("GET %s as a global administrator = %d, want 200:\n%s",
diff --git a/internal/web/handlers/e2e_markup_probe_test.go b/internal/web/handlers/e2e_markup_probe_test.go
index 17c8a9f..c2a50a9 100644
--- a/internal/web/handlers/e2e_markup_probe_test.go
+++ b/internal/web/handlers/e2e_markup_probe_test.go
@@ -10,7 +10,7 @@ import (
// checks whether the e2e gate's HTML scrapers still match it.
func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
h, _ := serverWithDelivery(t)
- rendered := getBody(t, h.HandleDeliveriesRows, "/deliveries/rows")
+ rendered := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment")
// These mirror test/e2e/main_test.go — keep in sync when fixing the e2e gate.
statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)`)
diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go
new file mode 100644
index 0000000..290929d
--- /dev/null
+++ b/internal/web/handlers/handlers_account.go
@@ -0,0 +1,276 @@
+package handlers
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/mixeme/selfpost/internal/dnscheck"
+ "github.com/mixeme/selfpost/internal/store"
+ "github.com/mixeme/selfpost/internal/web/validate"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// Account is the signed-in user's own half of what used to be one Settings
+// page: who they are to the panel (username, e-mail), their password, and their
+// default DMARC report address. Every role has it. Each part is its own form
+// and its own POST, so changing one never asks for the others.
+//
+// The e-mail is the user's — what the panel writes to. The DMARC default is a
+// separate choice that may use it: a domain whose report address is "this
+// user's default" resolves to whatever is chosen here (store.DMARCDefault).
+
+// accountForm is what the page shows back: the stored values, or the ones just
+// submitted when a form is re-rendered with an error.
+type accountForm struct {
+ Err string
+ Username string
+ Email string
+ DMARCMode string
+ DMARCAddress string
+}
+
+func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
+ p, ok := h.principal(r)
+ if !ok {
+ http.NotFound(w, r)
+ return store.User{}, false
+ }
+ u, err := h.store.GetUser(p.ID)
+ if err != nil {
+ logf("panel: account: get user %d: %v", p.ID, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return store.User{}, false
+ }
+ return u, true
+}
+
+func formOf(u store.User) accountForm {
+ return accountForm{Username: u.Username, Email: u.Email, DMARCMode: u.DMARCDefaultMode, DMARCAddress: u.DMARCDefaultAddress}
+}
+
+// HandleAccount shows the Account page.
+func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
+ u, ok := h.accountUser(w, r)
+ if !ok {
+ return
+ }
+ h.renderAccount(w, r, http.StatusOK, u, formOf(u))
+}
+
+func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
+ data := h.pageBase(r)
+ data["Title"] = "SelfPost — account"
+ data["Active"] = "account"
+ data["AccountPage"] = true
+ data["Role"] = u.Role
+ data["FormUsername"] = f.Username
+ data["FormEmail"] = f.Email
+ data["FormDMARCMode"] = f.DMARCMode
+ data["FormDMARCAddress"] = f.DMARCAddress
+ data["AccountEmail"] = u.Email
+ data["Error"] = f.Err
+ data["Flash"] = accountFlash(r)
+ data["DMARCIngestEnabled"] = h.dmarc != nil && h.cfg.DMARCEnabled
+
+ // The stored default, not the one being typed: the authorization record
+ // belongs to the address domains are actually told to publish.
+ def := u.DMARCDefault()
+ if addr := def.Resolve(""); addr != "" && def.Mode != store.DMARCDefaultHosted {
+ if hub := dnscheck.EmailDomain(addr); hub != "" {
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ data["ReportAuthDNS"] = h.dns.ReportAuth(ctx, hub)
+ cancel()
+ data["ReportAuthName"] = dnscheck.ReportAuthRecordName(hub)
+ data["ReportAuthExample"] = dnscheck.ReportAuthExample()
+ data["ReportAuthHub"] = hub
+ }
+ }
+ h.view.Render(w, status, "settings", data)
+}
+
+func accountFlash(r *http.Request) string {
+ switch r.URL.Query().Get("done") {
+ case "profile":
+ return "Profile saved."
+ case "password":
+ return "Password changed. Any other signed-in sessions were signed out."
+ case "dmarc":
+ return "Default report address saved."
+ }
+ return ""
+}
+
+// HandleAccountProfile saves the username and the account e-mail.
+func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request) {
+ u, ok := h.accountUser(w, r)
+ if !ok {
+ return
+ }
+ f := formOf(u)
+ fail := func(status int, msg string) {
+ f.Err = msg
+ h.renderAccount(w, r, status, u, f)
+ }
+ if err := r.ParseForm(); err != nil {
+ fail(http.StatusBadRequest, "Invalid form submission.")
+ return
+ }
+ f.Username = strings.TrimSpace(r.PostFormValue("username"))
+ f.Email = strings.TrimSpace(r.PostFormValue("email"))
+ if f.Username == "" {
+ f.Username = u.Username
+ }
+ if f.Username != u.Username {
+ if err := validate.Username(f.Username); err != nil {
+ fail(http.StatusBadRequest, err.Error())
+ return
+ }
+ }
+ if err := validate.Email(f.Email); err != nil {
+ fail(http.StatusBadRequest, err.Error())
+ return
+ }
+ if err := h.store.UpdateUser(u.ID, f.Username, u.PasswordHash, f.Email); err != nil {
+ if errors.Is(err, store.ErrUserExists) {
+ fail(http.StatusConflict, "That username is already in use.")
+ return
+ }
+ logf("panel: account: update user %d: %v", u.ID, err)
+ fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
+ return
+ }
+ if f.Username != u.Username {
+ if token, ok := h.auth.SessionToken(r); ok {
+ h.auth.RenameSession(token, f.Username)
+ }
+ }
+ // The e-mail is the report address of every domain that follows this
+ // user's "my account e-mail" default.
+ if f.Email != u.Email && u.DMARCDefaultMode == store.DMARCDefaultAccount {
+ h.resyncDMARC("account e-mail")
+ }
+ logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email)
+ http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
+}
+
+// HandleAccountPassword changes the password. It asks for the current one and
+// is rate-limited like a sign-in: the form is a place to guess a password from
+// an unattended session.
+func (h *Handlers) HandleAccountPassword(w http.ResponseWriter, r *http.Request) {
+ u, ok := h.accountUser(w, r)
+ if !ok {
+ return
+ }
+ f := formOf(u)
+ fail := func(status int, msg string) {
+ f.Err = msg
+ h.renderAccount(w, r, status, u, f)
+ }
+ if !h.auth.AllowLoginAttempt(r) {
+ fail(http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ fail(http.StatusBadRequest, "Invalid form submission.")
+ return
+ }
+ current := r.PostFormValue("current_password")
+ password := r.PostFormValue("new_password")
+ confirm := r.PostFormValue("new_password_confirm")
+ if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(current)); err != nil {
+ fail(http.StatusUnauthorized, "Current password is incorrect.")
+ return
+ }
+ if password != confirm {
+ fail(http.StatusBadRequest, "New passwords do not match.")
+ return
+ }
+ if err := validate.AdminPassword(password); err != nil {
+ fail(http.StatusBadRequest, err.Error())
+ return
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
+ if err != nil {
+ logf("panel: account: hashing password failed: %v", err)
+ fail(http.StatusInternalServerError, "Internal error. Please try again.")
+ return
+ }
+ if err := h.store.UpdateUser(u.ID, u.Username, string(hash), u.Email); err != nil {
+ logf("panel: account: update password of user %d: %v", u.ID, err)
+ fail(http.StatusInternalServerError, "Could not save the password. Please check the logs and try again.")
+ return
+ }
+ if token, ok := h.auth.SessionToken(r); ok {
+ h.auth.DestroyOtherSessions(token)
+ }
+ logf("panel: user %d changed their password", u.ID)
+ http.Redirect(w, r, "/account?done=password", http.StatusSeeOther)
+}
+
+// HandleAccountDMARC saves the user's default report address: SelfPost hosted,
+// the account e-mail, another address, or none.
+func (h *Handlers) HandleAccountDMARC(w http.ResponseWriter, r *http.Request) {
+ u, ok := h.accountUser(w, r)
+ if !ok {
+ return
+ }
+ f := formOf(u)
+ fail := func(status int, msg string) {
+ f.Err = msg
+ h.renderAccount(w, r, status, u, f)
+ }
+ if err := r.ParseForm(); err != nil {
+ fail(http.StatusBadRequest, "Invalid form submission.")
+ return
+ }
+ f.DMARCMode = strings.TrimSpace(r.PostFormValue("dmarc_default"))
+ f.DMARCAddress = strings.TrimSpace(r.PostFormValue("dmarc_default_address"))
+ switch f.DMARCMode {
+ case store.DMARCDefaultNone:
+ case store.DMARCDefaultHosted:
+ if h.dmarc == nil || !h.dmarc.Enabled() {
+ fail(http.StatusBadRequest, "SelfPost-hosted reports are not enabled on this server.")
+ return
+ }
+ case store.DMARCDefaultAccount:
+ if u.Email == "" {
+ fail(http.StatusBadRequest, "Your account has no e-mail yet. Save one under Profile first, or choose another address.")
+ return
+ }
+ case store.DMARCDefaultCustom:
+ if f.DMARCAddress == "" {
+ fail(http.StatusBadRequest, "Enter the report address or choose another option.")
+ return
+ }
+ if err := validate.Email(f.DMARCAddress); err != nil {
+ fail(http.StatusBadRequest, err.Error())
+ return
+ }
+ default:
+ fail(http.StatusBadRequest, "Choose where your DMARC reports go.")
+ return
+ }
+ if err := h.store.SetDMARCDefault(u.ID, f.DMARCMode, f.DMARCAddress); err != nil {
+ logf("panel: account: set dmarc default of user %d: %v", u.ID, err)
+ fail(http.StatusInternalServerError, "Could not save the report address. Please check the logs and try again.")
+ return
+ }
+ h.resyncDMARC("default report address")
+ logf("panel: user %d set their default report address to %q", u.ID, f.DMARCMode)
+ http.Redirect(w, r, "/account?done=dmarc", http.StatusSeeOther)
+}
+
+// resyncDMARC rebuilds the ingest allow-list after something that changes
+// where a domain's reports go. A failure is logged, not shown: the setting is
+// saved, and the next resync picks it up.
+func (h *Handlers) resyncDMARC(why string) {
+ if h.dmarc == nil || !h.dmarc.Enabled() {
+ return
+ }
+ if err := h.dmarc.Resync(); err != nil {
+ logf("panel: dmarc resync after %s change: %v", why, err)
+ }
+}
diff --git a/internal/web/handlers/handlers_account_test.go b/internal/web/handlers/handlers_account_test.go
new file mode 100644
index 0000000..2cd4a6d
--- /dev/null
+++ b/internal/web/handlers/handlers_account_test.go
@@ -0,0 +1,172 @@
+package handlers
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/mixeme/selfpost/internal/store"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// Account is every user's own page: each part is its own form and its own
+// POST, and none of them touches what another saves.
+func TestAccountPageShowsThreeForms(t *testing.T) {
+ h, _ := settingsServer(t)
+ out := getBody(t, h.HandleAccount, "/account")
+ for _, want := range []string{
+ `action="/account/profile"`, `name="email"`,
+ `action="/account/password"`, `name="current_password"`,
+ `action="/account/dmarc"`, `name="dmarc_default"`,
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("account page missing %q", want)
+ }
+ }
+ for _, gone := range []string{`name="send_log_retention_days"`, `value="hosted"`} {
+ if strings.Contains(out, gone) {
+ t.Errorf("account page shows %q (instance setting, or hosted reports with ingest off)", gone)
+ }
+ }
+}
+
+// The profile form saves the name and the e-mail without asking for the
+// password and without changing the password or the DMARC default.
+func TestAccountProfile(t *testing.T) {
+ h, _ := settingsServer(t)
+ before, _ := h.store.GetUser(globalPrincipal.ID)
+
+ rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
+ url.Values{"username": {"operator"}, "email": {"mix@example.org"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/account?done=profile" {
+ t.Fatalf("POST /account/profile = %d to %q:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
+ }
+ u, _ := h.store.GetUser(globalPrincipal.ID)
+ if u.Username != "operator" || u.Email != "mix@example.org" {
+ t.Errorf("profile = %q / %q", u.Username, u.Email)
+ }
+ if u.PasswordHash != before.PasswordHash || u.DMARCDefaultMode != store.DMARCDefaultNone {
+ t.Errorf("saving the profile changed the password or the DMARC default (%q)", u.DMARCDefaultMode)
+ }
+
+ for _, bad := range []url.Values{
+ {"username": {"operator"}, "email": {"not-an-address"}},
+ {"username": {"x y"}, "email": {""}},
+ } {
+ if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, bad); rec.Code != http.StatusBadRequest {
+ t.Errorf("POST /account/profile %v = %d, want 400", bad, rec.Code)
+ }
+ }
+ // A name that is taken is refused and nothing is half-saved.
+ if _, err := h.store.CreateUser("taken", "h", store.RoleGlobal, store.Reach{}); err != nil {
+ t.Fatal(err)
+ }
+ rec = postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"taken"}, "email": {"other@example.org"}})
+ if u, _ = h.store.GetUser(globalPrincipal.ID); rec.Code != http.StatusConflict || u.Username != "operator" || u.Email != "mix@example.org" {
+ t.Errorf("a taken username = %d, profile now %q / %q", rec.Code, u.Username, u.Email)
+ }
+}
+
+func TestAccountPassword(t *testing.T) {
+ h, password := settingsServer(t)
+ const next = "another-long-password-2"
+ hashOf := func() string {
+ u, _ := h.store.GetUser(globalPrincipal.ID)
+ return u.PasswordHash
+ }
+ before := hashOf()
+ for name, form := range map[string]url.Values{
+ "wrong current password": {"current_password": {"nope"}, "new_password": {next}, "new_password_confirm": {next}},
+ "mismatch": {"current_password": {password}, "new_password": {next}, "new_password_confirm": {next + "x"}},
+ "too weak": {"current_password": {password}, "new_password": {"short"}, "new_password_confirm": {"short"}},
+ "empty": {"current_password": {password}},
+ } {
+ rec := postFormAs(h.HandleAccountPassword, globalPrincipal, "/account/password", nil, form)
+ if rec.Code == http.StatusSeeOther || hashOf() != before {
+ t.Errorf("%s: %d, password changed: %v", name, rec.Code, hashOf() != before)
+ }
+ }
+ rec := postFormAs(h.HandleAccountPassword, globalPrincipal, "/account/password", nil,
+ url.Values{"current_password": {password}, "new_password": {next}, "new_password_confirm": {next}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /account/password = %d:\n%s", rec.Code, rec.Body.String())
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(hashOf()), []byte(next)); err != nil {
+ t.Errorf("the new password was not stored: %v", err)
+ }
+}
+
+// The default report address is the user's choice between four modes; the
+// address field belongs to "another address" alone.
+func TestAccountDMARCDefault(t *testing.T) {
+ h, _ := settingsServer(t)
+ save := func(form url.Values) (int, store.User) {
+ rec := postFormAs(h.HandleAccountDMARC, globalPrincipal, "/account/dmarc", nil, form)
+ u, _ := h.store.GetUser(globalPrincipal.ID)
+ return rec.Code, u
+ }
+
+ // "My account e-mail" needs an e-mail.
+ if code, u := save(url.Values{"dmarc_default": {"account"}}); code != http.StatusBadRequest || u.DMARCDefaultMode != store.DMARCDefaultNone {
+ t.Errorf("account e-mail without an e-mail = %d, mode %q", code, u.DMARCDefaultMode)
+ }
+ // Hosted needs report ingest, which this server has off.
+ if code, u := save(url.Values{"dmarc_default": {"hosted"}}); code != http.StatusBadRequest || u.DMARCDefaultMode != store.DMARCDefaultNone {
+ t.Errorf("hosted with ingest off = %d, mode %q", code, u.DMARCDefaultMode)
+ }
+ for _, bad := range []url.Values{
+ {"dmarc_default": {"custom"}},
+ {"dmarc_default": {"custom"}, "dmarc_default_address": {"nope"}},
+ {"dmarc_default": {"profile"}},
+ {},
+ } {
+ if code, _ := save(bad); code != http.StatusBadRequest {
+ t.Errorf("%v = %d, want 400", bad, code)
+ }
+ }
+
+ code, u := save(url.Values{"dmarc_default": {"custom"}, "dmarc_default_address": {"dmarc@hub.example"}})
+ if code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultCustom || u.DMARCDefaultAddress != "dmarc@hub.example" {
+ t.Errorf("another address = %d, %q %q", code, u.DMARCDefaultMode, u.DMARCDefaultAddress)
+ }
+ // A domain that follows this user now resolves to it.
+ d, _ := h.store.AddDomain("example.com", "mail")
+ h.followCreator(d.ID, globalPrincipal.ID)
+ d, _ = h.store.GetDomain(d.ID)
+ if rua, err := h.domainReportAddress(d); err != nil || rua != "dmarc@hub.example" {
+ t.Errorf("a following domain reports to %q, %v", rua, err)
+ }
+
+ if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"admin"}, "email": {"mix@example.org"}}); rec.Code != http.StatusSeeOther {
+ t.Fatalf("save e-mail = %d", rec.Code)
+ }
+ code, u = save(url.Values{"dmarc_default": {"account"}, "dmarc_default_address": {"left-over@hub.example"}})
+ if code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultAccount || u.DMARCDefaultAddress != "" {
+ t.Errorf("my account e-mail = %d, %q %q", code, u.DMARCDefaultMode, u.DMARCDefaultAddress)
+ }
+ if rua, _ := h.domainReportAddress(d); rua != "mix@example.org" {
+ t.Errorf("the following domain reports to %q, want the account e-mail", rua)
+ }
+ if code, u = save(url.Values{"dmarc_default": {"none"}}); code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultNone {
+ t.Errorf("none = %d, %q", code, u.DMARCDefaultMode)
+ }
+}
+
+// A domain administrator has the same Account page; it is not a Server page.
+func TestAccountIsOpenToADomainAdministrator(t *testing.T) {
+ h, _ := settingsServer(t)
+ d, _ := h.store.AddDomain("example.com", "mail")
+ p := domainAdmin(t, h.store, "ops", d.ID)
+ rec := postFormAs(h.HandleAccountProfile, p, "/account/profile", nil, url.Values{"username": {"ops"}, "email": {"ops@example.org"}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /account/profile as a domain administrator = %d:\n%s", rec.Code, rec.Body.String())
+ }
+ if u, _ := h.store.GetUser(p.ID); u.Email != "ops@example.org" {
+ t.Errorf("e-mail = %q", u.Email)
+ }
+ // It changed their own row and nobody else's.
+ if admin, _ := h.store.GetUser(globalPrincipal.ID); admin.Email != "" {
+ t.Errorf("another user's e-mail was changed to %q", admin.Email)
+ }
+}
diff --git a/internal/web/handlers/handlers_apps.go b/internal/web/handlers/handlers_apps.go
index ee31772..d05e343 100644
--- a/internal/web/handlers/handlers_apps.go
+++ b/internal/web/handlers/handlers_apps.go
@@ -290,7 +290,7 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
return
}
h.domainDNS(d, record, reportEmail, true)
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
// intOrBlank renders a non-positive number as an empty string so an unset field
@@ -420,7 +420,7 @@ func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
})
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
}
// HandleRegenPassword issues a new password for an application and shows it once
@@ -459,7 +459,7 @@ func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Reques
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
}
// lookupApplication resolves the {aid} path value to an application, writing a
diff --git a/internal/web/handlers/handlers_backup.go b/internal/web/handlers/handlers_backup.go
index 400e65e..7b42f6c 100644
--- a/internal/web/handlers/handlers_backup.go
+++ b/internal/web/handlers/handlers_backup.go
@@ -284,7 +284,7 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
h.followCreator(d.ID, p.ID)
}
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?imported=1", d.ID), http.StatusSeeOther)
}
// secretFilePassword reads the "encrypt this download with a password" controls
diff --git a/internal/web/handlers/handlers_backup_test.go b/internal/web/handlers/handlers_backup_test.go
index 2660cf7..5154895 100644
--- a/internal/web/handlers/handlers_backup_test.go
+++ b/internal/web/handlers/handlers_backup_test.go
@@ -15,7 +15,7 @@ import (
// postForm builds the kind of request the backup and export forms submit.
func postForm(values url.Values) *http.Request {
- r := httptest.NewRequest(http.MethodPost, "/backup", strings.NewReader(values.Encode()))
+ r := httptest.NewRequest(http.MethodPost, "/server/backup", strings.NewReader(values.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
@@ -107,7 +107,7 @@ func TestDecryptErrorMessage(t *testing.T) {
func TestBackupPageOffersEncryption(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
rec := httptest.NewRecorder()
- h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
+ h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/server/backup", nil),
http.StatusOK, "", "The two passwords do not match.")
body := rec.Body.String()
diff --git a/internal/web/handlers/handlers_delegation_test.go b/internal/web/handlers/handlers_delegation_test.go
index e25d9b4..15added 100644
--- a/internal/web/handlers/handlers_delegation_test.go
+++ b/internal/web/handlers/handlers_delegation_test.go
@@ -120,7 +120,7 @@ func newInboundStand(t *testing.T) inboundStand {
}
func (s inboundStand) call(rt inboundRoute, p *auth.Principal, id int64) *httptest.ResponseRecorder {
- return send(rt.handler(s.h), p, rt.method, "/inbound/"+idStr(id)+rt.suffix, map[string]string{"id": idStr(id)}, rt.form)
+ return send(rt.handler(s.h), p, rt.method, "/inbound/domains/"+idStr(id)+rt.suffix, map[string]string{"id": idStr(id)}, rt.form)
}
// Every route of one inbound domain, for every kind of user: 404 for another
@@ -131,7 +131,7 @@ func TestInboundDomainRoutesAreDelegatedPerDomain(t *testing.T) {
s := newInboundStand(t)
const missing = 9999
for _, rt := range inboundDomainRoutes {
- name := rt.method + " /inbound/{id}" + rt.suffix
+ name := rt.method + " /inbound/domains/{id}" + rt.suffix
denied := []struct {
who string
p *auth.Principal
@@ -186,12 +186,12 @@ func TestAddingAndDeletingInboundDomainsStaysGlobal(t *testing.T) {
for _, p := range []*auth.Principal{&s.ownsA, &s.allIn, &s.outboundOnly, nil} {
for _, rt := range inboundGlobalRoutes {
if rec := s.call(rt, p, s.a.ID); rec.Code != http.StatusNotFound {
- t.Errorf("%s /inbound/{id}%s as %+v = %d, want 404", rt.method, rt.suffix, p, rec.Code)
+ t.Errorf("%s /inbound/domains/{id}%s as %+v = %d, want 404", rt.method, rt.suffix, p, rec.Code)
}
}
- rec := send(s.h.HandleAddInbound, p, "POST", "/inbound", nil, url.Values{"name": {"new.example.com"}})
+ rec := send(s.h.HandleAddInbound, p, "POST", "/inbound/domains", nil, url.Values{"name": {"new.example.com"}})
if rec.Code != http.StatusNotFound {
- t.Errorf("POST /inbound as %+v = %d, want 404", p, rec.Code)
+ t.Errorf("POST /inbound/domains as %+v = %d, want 404", p, rec.Code)
}
}
list, err := s.h.store.ListInboundDomains()
@@ -200,11 +200,11 @@ func TestAddingAndDeletingInboundDomainsStaysGlobal(t *testing.T) {
}
// The global role still can.
- if rec := send(s.h.HandleAddInbound, &s.global, "POST", "/inbound", nil, url.Values{"name": {"new.example.com"}}); rec.Code != http.StatusSeeOther {
- t.Errorf("POST /inbound as the global role = %d:\n%s", rec.Code, rec.Body.String())
+ if rec := send(s.h.HandleAddInbound, &s.global, "POST", "/inbound/domains", nil, url.Values{"name": {"new.example.com"}}); rec.Code != http.StatusSeeOther {
+ t.Errorf("POST /inbound/domains as the global role = %d:\n%s", rec.Code, rec.Body.String())
}
if rec := s.call(inboundGlobalRoutes[1], &s.global, s.a.ID); rec.Code != http.StatusSeeOther {
- t.Errorf("POST /inbound/{id}/delete as the global role = %d:\n%s", rec.Code, rec.Body.String())
+ t.Errorf("POST /inbound/domains/{id}/delete as the global role = %d:\n%s", rec.Code, rec.Body.String())
}
}
@@ -213,7 +213,7 @@ func TestAddingAndDeletingInboundDomainsStaysGlobal(t *testing.T) {
func TestInboundListShowsOnlyTheAssignedDomains(t *testing.T) {
s := newInboundStand(t)
list := func(p *auth.Principal) *httptest.ResponseRecorder {
- return send(s.h.HandleInboundList, p, "GET", "/inbound", nil, nil)
+ return send(s.h.HandleInboundList, p, "GET", "/inbound/domains", nil, nil)
}
rec := list(&s.ownsA)
@@ -266,7 +266,7 @@ func TestInboundListWithNothingLeftIs404(t *testing.T) {
t.Fatal(err)
}
p := auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, InboundDomains: u.InboundDomainIDs}
- if rec := send(s.h.HandleInboundList, &p, "GET", "/inbound", nil, nil); rec.Code != http.StatusNotFound {
+ if rec := send(s.h.HandleInboundList, &p, "GET", "/inbound/domains", nil, nil); rec.Code != http.StatusNotFound {
t.Errorf("list after the only assigned inbound domain was deleted = %d, want 404", rec.Code)
}
}
@@ -297,13 +297,13 @@ func TestOutboundRoutesAnswerAnotherTenant404(t *testing.T) {
handler http.HandlerFunc
pathValues map[string]string
}{
- {"GET", "/domains/{id}", h.HandleDomainDetail, did},
- {"POST", "/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
- {"POST", "/domains/{id}/applications", h.HandleAddApplication, did},
- {"POST", "/domains/{id}/ratelimit", h.HandleDomainRateLimit, did},
- {"POST", "/domains/{id}/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
- {"POST", "/domains/{id}/dmarc", h.HandleDomainDMARC, did},
- {"POST", "/domains/{id}/export", h.HandleExportDomain, did},
+ {"GET", "/outbound/domains/{id}", h.HandleDomainDetail, did},
+ {"POST", "/outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
+ {"POST", "/outbound/domains/{id}/applications", h.HandleAddApplication, did},
+ {"POST", "/outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit, did},
+ {"POST", "/outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
+ {"POST", "/outbound/domains/{id}/settings/reports", h.HandleDomainDMARC, did},
+ {"POST", "/outbound/domains/{id}/settings/export", h.HandleExportDomain, did},
{"POST", "/applications/{aid}/mode", h.HandleUpdateAppMode, aid},
{"POST", "/applications/{aid}/authips", h.HandleAppAuthIPs, aid},
{"POST", "/applications/{aid}/password", h.HandleRegenPassword, aid},
@@ -350,7 +350,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
for k, v := range extra {
form[k] = v
}
- rec := send(h.HandleUserNew, &root, "POST", "/users/new", nil, form)
+ rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, form)
u, _ := st.GetUserByUsername(username)
return rec, u
}
@@ -374,7 +374,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
update := func(u store.User, form url.Values) store.User {
t.Helper()
form.Set("username", u.Username)
- rec := send(h.HandleUserEdit, &root, "POST", "/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}, form)
+ rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}, form)
if rec.Code != http.StatusSeeOther {
t.Fatalf("update %s = %d:\n%s", u.Username, rec.Code, rec.Body.String())
}
@@ -401,7 +401,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
h.cfg.InboundEnabled = true
// Emptying both lists is refused and changes nothing.
- rec = send(h.HandleUserEdit, &root, "POST", "/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
+ rec = send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
url.Values{"username": {u.Username}, "role": {"domain"}})
if after, _ := st.GetUser(u.ID); rec.Code != http.StatusBadRequest || after.Reach().Empty() {
t.Errorf("emptying both lists = %d, reach now %+v", rec.Code, after.Reach())
@@ -415,7 +415,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
// The form shows both lists with what is ticked.
other, _ := st.GetUserByUsername("inbound-only")
- body := send(h.HandleUserEdit, &root, "GET", "/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String()
+ body := send(h.HandleUserEdit, &root, "GET", "/server/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String()
for _, want := range []string{
`name="all_domains"`, `name="all_inbound_domains"`, "out.example.com",
`name="inbound_domain_ids" value="` + idStr(in.ID) + `" checked`,
diff --git a/internal/web/handlers/handlers_dmarc.go b/internal/web/handlers/handlers_dmarc.go
index 412745b..1ad2f2e 100644
--- a/internal/web/handlers/handlers_dmarc.go
+++ b/internal/web/handlers/handlers_dmarc.go
@@ -72,5 +72,5 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
}
}
h.dns.Forget(d.Name)
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
}
diff --git a/internal/web/handlers/handlers_dmarc_test.go b/internal/web/handlers/handlers_dmarc_test.go
index 16013ee..75018d4 100644
--- a/internal/web/handlers/handlers_dmarc_test.go
+++ b/internal/web/handlers/handlers_dmarc_test.go
@@ -47,7 +47,7 @@ func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
}
save := func(values url.Values) store.Domain {
t.Helper()
- rec := postFormAs(h.HandleDomainDMARC, p, "/domains/1/dmarc", map[string]string{"id": "1"}, values)
+ rec := postFormAs(h.HandleDomainDMARC, p, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"}, values)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
}
@@ -78,40 +78,6 @@ func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
}
-// Until the Account page replaces the old Settings form, its single address
-// field is the account e-mail and switches the user's DMARC default between
-// "my account e-mail" and "none". Nothing is written to the instance settings.
-func TestSettingsFormMapsToAccountEmailAndDefault(t *testing.T) {
- h, password := settingsServer(t)
- submit := func(email string) store.User {
- t.Helper()
- rec := postFormAs(h.HandleSettings, globalPrincipal, "/settings", nil, url.Values{
- "username": {"admin"}, "current_password": {password}, "dmarc_report_email": {email},
- })
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("POST /settings (%q) = %d, want 303:\n%s", email, rec.Code, rec.Body.String())
- }
- u, err := h.store.GetUser(globalPrincipal.ID)
- if err != nil {
- t.Fatalf("GetUser: %v", err)
- }
- return u
- }
-
- u := submit("mix@example.org")
- if u.Email != "mix@example.org" || u.DMARCDefaultMode != store.DMARCDefaultAccount {
- t.Fatalf("after setting the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
- }
- if v, err := h.store.GetSetting("dmarc_report_email"); err == nil && v != "" {
- t.Fatalf("the address was mirrored into the instance settings: %q", v)
- }
-
- u = submit("")
- if u.Email != "" || u.DMARCDefaultMode != store.DMARCDefaultNone {
- t.Fatalf("after clearing the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
- }
-}
-
// A new domain takes its report address from whoever created it.
func TestFollowCreator(t *testing.T) {
h, _ := settingsServer(t)
diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go
index c26e451..ad98300 100644
--- a/internal/web/handlers/handlers_domains.go
+++ b/internal/web/handlers/handlers_domains.go
@@ -114,7 +114,7 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
return
}
h.followCreator(d.ID, p.ID)
- http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d", d.ID), http.StatusSeeOther)
}
// followCreator makes a new domain take its DMARC report address from the
@@ -172,7 +172,7 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/outbound/domains?deleted=1", http.StatusSeeOther)
}
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
@@ -198,7 +198,7 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
return
}
}
- http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/overview?reloaded=1", http.StatusSeeOther)
}
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
diff --git a/internal/web/handlers/handlers_inbound.go b/internal/web/handlers/handlers_inbound.go
index e3d80d3..6630b32 100644
--- a/internal/web/handlers/handlers_inbound.go
+++ b/internal/web/handlers/handlers_inbound.go
@@ -222,7 +222,7 @@ func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) {
"Could not add the domain. Please check the logs and try again.", raw)
return
}
- http.Redirect(w, r, fmt.Sprintf("/inbound/%d", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDetail(w http.ResponseWriter, r *http.Request) {
@@ -282,7 +282,7 @@ func (h *Handlers) HandleInboundDNSRecheck(w http.ResponseWriter, r *http.Reques
if h.dns != nil && h.cfg.Hostname != "" {
h.dns.InboundMX(d.Name, h.cfg.Hostname, true)
}
- http.Redirect(w, r, fmt.Sprintf("/inbound/%d?rechecked=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request) {
@@ -301,7 +301,7 @@ func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()})
return
}
- http.Redirect(w, r, fmt.Sprintf("/inbound/%d?saved=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Request) {
@@ -319,7 +319,7 @@ func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Reques
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()})
return
}
- http.Redirect(w, r, fmt.Sprintf("/inbound/%d?recipients=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?recipients=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Request) {
@@ -358,5 +358,5 @@ func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, "/inbound?deleted=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/inbound/domains?deleted=1", http.StatusSeeOther)
}
diff --git a/internal/web/handlers/handlers_inbound_test.go b/internal/web/handlers/handlers_inbound_test.go
index abf95bb..20f4615 100644
--- a/internal/web/handlers/handlers_inbound_test.go
+++ b/internal/web/handlers/handlers_inbound_test.go
@@ -51,16 +51,16 @@ func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Pri
req = httptest.NewRequest(method, target, nil)
}
req = auth.RequestWithPrincipal(req, p)
- if rest, ok := strings.CutPrefix(req.URL.Path, "/inbound/"); ok {
+ if rest, ok := strings.CutPrefix(req.URL.Path, "/inbound/domains/"); ok {
id, _, _ := strings.Cut(rest, "/")
if id != "" && id != "delete" {
req.SetPathValue("id", id)
}
}
switch {
- case method == http.MethodGet && target == "/inbound":
+ case method == http.MethodGet && target == "/inbound/domains":
h.HandleInboundList(rec, req)
- case method == http.MethodPost && target == "/inbound":
+ case method == http.MethodPost && target == "/inbound/domains":
h.HandleAddInbound(rec, req)
case strings.HasSuffix(target, "/delete") && method == http.MethodGet:
h.HandleInboundDeleteConfirm(rec, req)
@@ -78,7 +78,7 @@ func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Pri
func TestInboundListAndAdd(t *testing.T) {
h, _ := inboundHandlers(t)
- rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
+ rec := inboundCall(h, http.MethodGet, "/inbound/domains", nil, globalPrincipal)
if rec.Code != http.StatusOK {
t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String())
}
@@ -86,12 +86,12 @@ func TestInboundListAndAdd(t *testing.T) {
t.Fatal("list missing add form")
}
- rec = inboundCall(h, http.MethodPost, "/inbound", url.Values{"name": {"lists.example.com"}}, globalPrincipal)
+ rec = inboundCall(h, http.MethodPost, "/inbound/domains", url.Values{"name": {"lists.example.com"}}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("add = %d %s", rec.Code, rec.Body.String())
}
- rec = inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
+ rec = inboundCall(h, http.MethodGet, "/inbound/domains", nil, globalPrincipal)
if !strings.Contains(rec.Body.String(), "lists.example.com") {
t.Fatalf("list missing domain:\n%s", rec.Body.String())
}
@@ -100,7 +100,7 @@ func TestInboundListAndAdd(t *testing.T) {
func TestInboundDisabledIs404(t *testing.T) {
h, _ := inboundHandlers(t)
h.cfg.InboundEnabled = false
- rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
+ rec := inboundCall(h, http.MethodGet, "/inbound/domains", nil, globalPrincipal)
if rec.Code != http.StatusNotFound {
t.Fatalf("disabled inbound = %d, want 404", rec.Code)
}
@@ -114,14 +114,14 @@ func TestInboundTransportAndRecipients(t *testing.T) {
}
id := itoa(d.ID)
- rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/upstream", url.Values{
+ rec := inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/upstream", url.Values{
"host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"encrypt"},
}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("upstream = %d %s", rec.Code, rec.Body.String())
}
- rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
+ rec = inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/recipients", url.Values{
"recipient_mode": {"list"},
"addresses": {"staff@lists.example.com\nabuse@other.com"},
}, globalPrincipal)
@@ -129,7 +129,7 @@ func TestInboundTransportAndRecipients(t *testing.T) {
t.Fatalf("foreign recipient = %d, want 400", rec.Code)
}
- rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
+ rec = inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/recipients", url.Values{
"recipient_mode": {"list"},
"addresses": {"staff@lists.example.com"},
}, globalPrincipal)
@@ -137,7 +137,7 @@ func TestInboundTransportAndRecipients(t *testing.T) {
t.Fatalf("recipients = %d %s", rec.Code, rec.Body.String())
}
- rec = inboundCall(h, http.MethodGet, "/inbound/"+id, nil, globalPrincipal)
+ rec = inboundCall(h, http.MethodGet, "/inbound/domains/"+id, nil, globalPrincipal)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "10.0.0.8") {
t.Fatalf("detail =\n%s", rec.Body.String())
}
@@ -150,7 +150,7 @@ func TestInboundDelete(t *testing.T) {
t.Fatal(err)
}
id := itoa(d.ID)
- rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/delete", nil, globalPrincipal)
+ rec := inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/delete", nil, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
}
diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go
index 0ae73df..258ff72 100644
--- a/internal/web/handlers/handlers_monitor.go
+++ b/internal/web/handlers/handlers_monitor.go
@@ -295,9 +295,9 @@ func deliveriesBackURL(r *http.Request) string {
}
}
if len(back) == 0 {
- return "/deliveries"
+ return "/outbound/log"
}
- return "/deliveries?" + back.Encode()
+ return "/outbound/log?" + back.Encode()
}
// sendLogData reads the domain/app filters and page number off the query
diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go
index 091e777..fbe8e7e 100644
--- a/internal/web/handlers/handlers_monitor_test.go
+++ b/internal/web/handlers/handlers_monitor_test.go
@@ -37,11 +37,11 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
h, row := serverWithDelivery(t)
- out := getBody(t, h.HandleDeliveries, "/deliveries")
+ out := getBody(t, h.HandleDeliveries, "/outbound/log")
for _, want := range []string{
row.CreatedAt.Format("2006-01-02 15:04:05"),
"noreply@bs.example.ru", "public@example.ru",
- "Проверка", ">sent<", `href="/deliveries/` + itoa(row.ID),
+ "Проверка", ">sent<", `href="/outbound/log/` + itoa(row.ID),
} {
if !strings.Contains(out, want) {
t.Errorf("delivery log is missing %q:\n%s", want, out)
@@ -63,8 +63,8 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
h, _ := serverWithDelivery(t)
for name, out := range map[string]string{
- "log": getBody(t, h.HandleDeliveries, "/deliveries"),
- "rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
+ "log": getBody(t, h.HandleDeliveries, "/outbound/log"),
+ "rows": getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment"),
} {
if strings.Contains(out, "=?utf-8?Q?") {
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
@@ -80,11 +80,11 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
h, row := serverWithDelivery(t)
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+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",
- `href="/deliveries?domain=bs.example.ru&p=2"`,
+ `href="/outbound/log?domain=bs.example.ru&p=2"`,
} {
if !strings.Contains(out, want) {
t.Errorf("delivery page is missing %q:\n%s", want, out)
@@ -101,7 +101,7 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
h, row := serverWithDelivery(t)
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
for _, want := range []string{
"Accepted and queued", "Delivered",
row.CreatedAt.Format("2006-01-02 15:04:05"),
@@ -137,7 +137,7 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(rows[0].ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+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)
@@ -157,7 +157,7 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
"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, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
for _, want := range []string{
"| Time | ", "Message | ",
// The stamp is split off into its own cell, without the microseconds
@@ -181,7 +181,7 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=, status=sent (250 OK)")
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+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)
}
@@ -197,7 +197,7 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
}
h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
if !strings.Contains(out, "rotated away") {
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
}
@@ -214,10 +214,10 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
func TestDeliveryPageNotFound(t *testing.T) {
h, _ := serverWithDelivery(t)
- for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
+ for _, path := range []string{"/outbound/log/999999", "/outbound/log/abc", "/outbound/log/0"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
- req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
+ req.SetPathValue("id", strings.TrimPrefix(path, "/outbound/log/"))
h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, rec.Code)
@@ -266,7 +266,7 @@ func TestSendLogScopedToAssignedDomains(t *testing.T) {
"page": h.HandleDeliveries,
"fragment": h.HandleDeliveriesRows,
} {
- out := getBodyAs(t, handler, "/deliveries", p)
+ out := getBodyAs(t, handler, "/outbound/log", p)
for _, want := range tc.want {
if !strings.Contains(out, want) {
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
@@ -290,9 +290,9 @@ func TestSendLogIgnoresForgedFilters(t *testing.T) {
p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID)
for _, target := range []string{
- "/deliveries?domain=second.example.ru",
- "/deliveries?app=second-app",
- "/deliveries?domain=second.example.ru&app=second-app",
+ "/outbound/log?domain=second.example.ru",
+ "/outbound/log?app=second-app",
+ "/outbound/log?domain=second.example.ru&app=second-app",
} {
out := getBodyAs(t, h.HandleDeliveries, target, p)
if strings.Contains(out, "Second message") {
@@ -314,7 +314,7 @@ func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
}
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil)
+ req := httptest.NewRequest(http.MethodGet, "/outbound/log/"+itoa(rows[0].ID), nil)
req.SetPathValue("id", itoa(rows[0].ID))
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
h.HandleDelivery(rec, req)
@@ -443,7 +443,7 @@ func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
req = auth.RequestWithPrincipal(req, p)
- if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
+ if rest, ok := strings.CutPrefix(req.URL.Path, "/outbound/log/"); ok && rest != "rows" {
req.SetPathValue("id", rest)
}
h(rec, req)
@@ -483,7 +483,7 @@ func fixtureRetryPolicy() postfix.RetryPolicy {
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
- out := getBody(t, h.HandleMailQueue, "/mail-queue")
+ out := getBody(t, h.HandleMailQueue, "/outbound/queue")
for _, want := range []string{
"How delivery retries work",
"id=\"retry-policy\"",
@@ -506,7 +506,7 @@ func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
- out := getBody(t, h.HandleMailQueueBody, "/mail-queue/body")
+ out := getBody(t, h.HandleMailQueueBody, "/outbound/queue/fragment")
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
}
@@ -515,7 +515,7 @@ func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
- out := getBody(t, h.HandleMailQueue, "/mail-queue")
+ out := getBody(t, h.HandleMailQueue, "/outbound/queue")
if !strings.Contains(out, "compiled-in defaults") {
t.Errorf("fallback note missing:\n%s", out)
}
@@ -528,7 +528,7 @@ func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) {
t.Fatalf("update status: %v", err)
}
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
for _, want := range []string{
"first after 10 minutes",
"up to about 1 hour 7 minutes",
@@ -547,7 +547,7 @@ func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) {
t.Fatalf("update status: %v", err)
}
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
if !strings.Contains(out, "gave up after 2 days in the queue") {
t.Errorf("bounced history does not use the fixture lifetime:\n%s", out)
}
diff --git a/internal/web/handlers/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go
index 651804e..08c2b70 100644
--- a/internal/web/handlers/handlers_ratelimit.go
+++ b/internal/web/handlers/handlers_ratelimit.go
@@ -162,7 +162,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
@@ -188,7 +188,7 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppAuthIPs(w http.ResponseWriter, r *http.Request) {
@@ -214,7 +214,7 @@ func (h *Handlers) HandleAppAuthIPs(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?authips=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?authips=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
@@ -230,7 +230,7 @@ func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Re
})
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?recalculated=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
@@ -246,7 +246,7 @@ func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Reque
})
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) recalcRateLimit(scope string, refID int64) error {
diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go
index b8bb25c..25e1f55 100644
--- a/internal/web/handlers/handlers_settings.go
+++ b/internal/web/handlers/handlers_settings.go
@@ -1,283 +1,72 @@
package handlers
import (
- "context"
- "errors"
"net/http"
"strconv"
"strings"
- "time"
- "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"
)
-// HandleSettings serves the signed-in user's panel settings.
-func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
+// Server › Settings holds what is true of the whole instance, whoever is
+// signed in: log retention and the read-only view of the rate limits. A
+// user's own name, e-mail, password and DMARC default are on Account
+// (handlers_account.go). Before placing a setting here, ask whose it is.
+
+// HandleServerSettings shows and saves the instance settings (global role).
+func (h *Handlers) HandleServerSettings(w http.ResponseWriter, r *http.Request) {
+ if _, ok := h.requireGlobal(w, r); !ok {
+ return
+ }
switch r.Method {
case http.MethodGet:
- p, ok := h.principal(r)
- if !ok {
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- u, err := h.store.GetUser(p.ID)
- if err != nil {
- logf("panel: settings: get user failed: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal())
+ h.renderServerSettings(w, r, http.StatusOK, "", h.sendLogRetentionDays())
case http.MethodPost:
- h.submitSettings(w, r)
+ h.submitServerSettings(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
-func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, formSendLogRetentionDays int, showDMARC bool) {
- var reportAuth dnscheck.Result
- if showDMARC && formDMARCEmail != "" {
- if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
- ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
- reportAuth = h.dns.ReportAuth(ctx, hub)
- cancel()
- }
- }
+func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, formRetention int) {
data := h.pageBase(r)
data["Title"] = "SelfPost — settings"
data["Active"] = "settings"
- data["FormUsername"] = formUsername
- data["FormDMARCEmail"] = formDMARCEmail
- data["FormSendLogRetentionDays"] = formSendLogRetentionDays
- data["ShowDMARC"] = showDMARC
- data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
- data["ReportAuthExample"] = dnscheck.ReportAuthExample()
- data["ReportAuthDNS"] = reportAuth
- data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
+ data["AccountPage"] = false
+ data["FormSendLogRetentionDays"] = formRetention
data["Error"] = formErr
- data["Flash"] = settingsFlash(r)
+ if r.URL.Query().Has("saved") {
+ data["Flash"] = "Settings saved."
+ }
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
- data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
- if h.dmarc != nil && h.cfg.DMARCEnabled {
- data["HostedReportAddress"] = h.dmarc.DefaultHostedSuggestion()
- }
h.view.Render(w, status, "settings", data)
}
-func settingsFlash(r *http.Request) string {
- q := r.URL.Query()
- var parts []string
- if q.Has("u") {
- parts = append(parts, "username")
- }
- if q.Has("p") {
- parts = append(parts, "password")
- }
- if q.Has("e") {
- parts = append(parts, "DMARC report address")
- }
- if q.Has("r") {
- parts = append(parts, "send log retention")
- }
- if len(parts) == 0 {
- return ""
- }
- msg := strings.Join(parts, ", ") + " updated."
- msg = strings.ToUpper(msg[:1]) + msg[1:]
- if q.Has("p") {
- msg += " Any other signed-in sessions were signed out."
- }
- return msg
-}
-
-func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
- p, ok := h.principal(r)
- if !ok {
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- currentRetention := h.sendLogRetentionDays()
-
- if !h.auth.AllowLoginAttempt(r) {
- h.renderSettings(w, r, http.StatusTooManyRequests,
- "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
- return
- }
+func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
+ current := h.sendLogRetentionDays()
if err := r.ParseForm(); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
+ h.renderServerSettings(w, r, http.StatusBadRequest, "Invalid form submission.", current)
return
}
-
- user, err := h.store.GetUser(p.ID)
+ days, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("send_log_retention_days")))
if err != nil {
- logf("panel: settings: get user failed: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ h.renderServerSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", current)
return
}
-
- username := strings.TrimSpace(r.PostFormValue("username"))
- current := r.PostFormValue("current_password")
- password := r.PostFormValue("new_password")
- confirm := r.PostFormValue("new_password_confirm")
- dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
- formRetention := currentRetention
- if !p.IsGlobal() {
- dmarcEmail = user.Email
- } else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
- parsed, err := strconv.Atoi(raw)
- if err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", username, dmarcEmail, currentRetention, true)
- return
- }
- formRetention = parsed
- }
- if username == "" {
- username = user.Username
- }
-
- if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
- h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, formRetention, p.IsGlobal())
+ if err := store.ValidateSendLogRetentionDays(days); err != nil {
+ h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), days)
return
}
-
- renaming := username != user.Username
- if renaming {
- if err := validate.Username(username); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- }
-
- if p.IsGlobal() {
- if err := validate.Email(dmarcEmail); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, true)
- return
- }
- }
-
- emailChanging := p.IsGlobal() && dmarcEmail != user.Email
-
- retentionChanging := false
- if p.IsGlobal() && formRetention != currentRetention {
- if err := store.ValidateSendLogRetentionDays(formRetention); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, true)
- return
- }
- retentionChanging = true
- }
-
- repassword := password != "" || confirm != ""
- if repassword {
- if password != confirm {
- h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- if err := validate.AdminPassword(password); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- }
- if !renaming && !repassword && !emailChanging && !retentionChanging {
- h.renderSettings(w, r, http.StatusBadRequest,
- "Nothing to change: enter a new username, password, DMARC report address, or send log retention.", username, dmarcEmail, formRetention, p.IsGlobal())
+ if err := h.store.SetSendLogRetentionDays(days); err != nil {
+ logf("panel: settings: set send-log retention failed: %v", err)
+ h.renderServerSettings(w, r, http.StatusInternalServerError,
+ "Could not save send log retention. Please check the logs and try again.", days)
return
}
-
- hash := user.PasswordHash
- if repassword {
- newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
- if err != nil {
- logf("panel: settings: hashing password failed: %v", err)
- h.renderSettings(w, r, http.StatusInternalServerError,
- "Internal error. Please try again.", username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- hash = string(newHash)
- }
-
- if renaming || repassword || emailChanging {
- if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
- logf("panel: settings: update user failed: %v", err)
- msg := "Could not save the changes. Please check the logs and try again."
- if errors.Is(err, store.ErrUserNotFound) {
- msg = "There is no user account to update."
- }
- if errors.Is(err, store.ErrUserExists) {
- msg = "That username is already in use."
- h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- }
-
- // Until the Account page replaces this form (stage 2), its one "default
- // report address" field stands for both halves of the 2.0 model: it is the
- // account e-mail, and the user's DMARC default is "my account e-mail" while
- // it is filled and "none" once it is cleared.
- if emailChanging {
- mode := store.DMARCDefaultNone
- if dmarcEmail != "" {
- mode = store.DMARCDefaultAccount
- }
- if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil {
- logf("panel: settings: set dmarc default failed: %v", err)
- h.renderSettings(w, r, http.StatusInternalServerError,
- "Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- }
-
- if retentionChanging {
- if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
- logf("panel: settings: set send-log retention failed: %v", err)
- h.renderSettings(w, r, http.StatusInternalServerError,
- "Could not save send log retention. Please check the logs and try again.", username, dmarcEmail, formRetention, true)
- return
- }
- }
-
- if emailChanging && h.dmarc != nil && h.dmarc.Enabled() {
- if err := h.dmarc.Resync(); err != nil {
- logf("panel: settings: dmarc resync: %v", err)
- }
- }
-
- if token, ok := h.auth.SessionToken(r); ok {
- if renaming {
- h.auth.RenameSession(token, username)
- }
- if repassword {
- h.auth.DestroyOtherSessions(token)
- }
- }
-
- logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t, retention: %t)", user.ID, renaming, repassword, emailChanging, retentionChanging)
- http.Redirect(w, r, "/settings?"+updatedQuery(renaming, repassword, emailChanging, retentionChanging), http.StatusSeeOther)
-}
-
-func updatedQuery(renamed, repassword, emailChanged, retentionChanged bool) string {
- var params []string
- if renamed {
- params = append(params, "u=1")
- }
- if repassword {
- params = append(params, "p=1")
- }
- if emailChanged {
- params = append(params, "e=1")
- }
- if retentionChanged {
- params = append(params, "r=1")
- }
- return strings.Join(params, "&")
+ logf("panel: instance settings updated (send-log retention: %d days)", days)
+ http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
}
// sendLogRetentionDays returns the effective delivery-journal retention window.
diff --git a/internal/web/handlers/handlers_settings_test.go b/internal/web/handlers/handlers_settings_test.go
index 93589f3..fbfc845 100644
--- a/internal/web/handlers/handlers_settings_test.go
+++ b/internal/web/handlers/handlers_settings_test.go
@@ -2,7 +2,6 @@ package handlers
import (
"net/http"
- "net/http/httptest"
"net/url"
"path/filepath"
"strings"
@@ -20,37 +19,35 @@ func TestSettingsPageShowsSendLogRetention(t *testing.T) {
t.Fatalf("SetSendLogRetentionDays: %v", err)
}
- out := getBody(t, h.HandleSettings, "/settings")
+ out := getBody(t, h.HandleServerSettings, "/server/settings")
for _, want := range []string{
`id="deliveries-retention"`,
`name="send_log_retention_days"`,
`value="45"`,
"Send log retention",
+ `action="/server/settings"`,
+ `id="rate-limits"`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings page missing %q:\n%s", want, out)
}
}
+ // What belongs to a user is not on the instance's page.
+ for _, gone := range []string{`name="current_password"`, `name="username"`, `name="dmarc_default"`} {
+ if strings.Contains(out, gone) {
+ t.Errorf("the instance settings page still carries the user's %s", gone)
+ }
+ }
}
+// The instance settings ask for no password: they are the server's, and the
+// page is behind the global role.
func TestSubmitSettingsSavesSendLogRetention(t *testing.T) {
- h, password := settingsServer(t)
-
- values := url.Values{
- "username": {"admin"},
- "current_password": {password},
- "send_log_retention_days": {"120"},
+ h, _ := settingsServer(t)
+ rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"120"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/settings?saved=1" {
+ t.Fatalf("POST /server/settings = %d to %q, want 303:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
- req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(values.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req = auth.RequestWithPrincipal(req, globalPrincipal)
-
- rec := httptest.NewRecorder()
- h.HandleSettings(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("POST /settings = %d, want 303:\n%s", rec.Code, rec.Body.String())
- }
-
got, err := h.store.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
@@ -61,25 +58,35 @@ func TestSubmitSettingsSavesSendLogRetention(t *testing.T) {
}
func TestSubmitSettingsRejectsOutOfRangeRetention(t *testing.T) {
- h, password := settingsServer(t)
-
- values := url.Values{
- "username": {"admin"},
- "current_password": {password},
- "send_log_retention_days": {"3"},
- }
- req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(values.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req = auth.RequestWithPrincipal(req, globalPrincipal)
-
- rec := httptest.NewRecorder()
- h.HandleSettings(rec, req)
- if rec.Code != http.StatusBadRequest {
- t.Fatalf("POST /settings = %d, want 400:\n%s", rec.Code, rec.Body.String())
+ h, _ := settingsServer(t)
+ for _, bad := range []string{"3", "nine", ""} {
+ rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {bad}})
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST /server/settings (%q) = %d, want 400:\n%s", bad, rec.Code, rec.Body.String())
+ }
}
+ rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"3"}})
if !strings.Contains(rec.Body.String(), "between 7 and 365") {
t.Errorf("expected range error in body:\n%s", rec.Body.String())
}
+ if got, _ := h.store.GetSendLogRetentionDays(90); got != 90 {
+ t.Errorf("a refused value changed the retention to %d", got)
+ }
+}
+
+func TestServerSettingsIsGlobalOnly(t *testing.T) {
+ h, _ := settingsServer(t)
+ d, err := h.store.AddDomain("example.com", "mail")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p := domainAdmin(t, h.store, "ops", d.ID)
+ if rec := postFormAs(h.HandleServerSettings, p, "/server/settings", nil, url.Values{"send_log_retention_days": {"120"}}); rec.Code != http.StatusNotFound {
+ t.Errorf("POST /server/settings as a domain administrator = %d, want 404", rec.Code)
+ }
+ if got, _ := h.store.GetSendLogRetentionDays(90); got != 90 {
+ t.Errorf("a domain administrator changed the retention to %d", got)
+ }
}
func settingsServer(t *testing.T) (*Handlers, string) {
diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go
index 2518116..247e782 100644
--- a/internal/web/handlers/handlers_status.go
+++ b/internal/web/handlers/handlers_status.go
@@ -33,7 +33,7 @@ func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
return
}
h.dns.Server(h.cfg.Hostname, true)
- http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/overview?rechecked=1", http.StatusSeeOther)
}
func (h *Handlers) statusBody() map[string]any {
diff --git a/internal/web/handlers/handlers_users.go b/internal/web/handlers/handlers_users.go
index 8a2c4e6..48c8e27 100644
--- a/internal/web/handlers/handlers_users.go
+++ b/internal/web/handlers/handlers_users.go
@@ -196,7 +196,7 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
return
}
- http.Redirect(w, r, "/users?done=created", http.StatusSeeOther)
+ http.Redirect(w, r, "/server/users?done=created", http.StatusSeeOther)
}
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -289,7 +289,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
}
}
- http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
+ http.Redirect(w, r, "/server/users?done=updated", http.StatusSeeOther)
}
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
@@ -363,7 +363,7 @@ func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u st
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
- http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther)
+ http.Redirect(w, r, "/server/users?done=deleted", http.StatusSeeOther)
}
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
diff --git a/internal/web/home_test.go b/internal/web/home_test.go
index 786cece..2427152 100644
--- a/internal/web/home_test.go
+++ b/internal/web/home_test.go
@@ -3,6 +3,7 @@ package web
import (
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"github.com/mixeme/selfpost/internal/web/auth"
@@ -15,10 +16,10 @@ func TestRedirectHomeFollowsTheReach(t *testing.T) {
p auth.Principal
want string
}{
- {"global", auth.Principal{Role: auth.RoleGlobal}, "/status"},
- {"outbound", auth.Principal{Role: auth.RoleDomain, Domains: []int64{1}}, "/domains"},
- {"both", auth.Principal{Role: auth.RoleDomain, AllDomains: true, AllInbound: true}, "/domains"},
- {"inbound only", auth.Principal{Role: auth.RoleDomain, InboundDomains: []int64{1}}, "/inbound"},
+ {"global", auth.Principal{Role: auth.RoleGlobal}, "/overview"},
+ {"outbound", auth.Principal{Role: auth.RoleDomain, Domains: []int64{1}}, "/outbound/domains"},
+ {"both", auth.Principal{Role: auth.RoleDomain, AllDomains: true, AllInbound: true}, "/outbound/domains"},
+ {"inbound only", auth.Principal{Role: auth.RoleDomain, InboundDomains: []int64{1}}, "/inbound/domains"},
} {
rec := httptest.NewRecorder()
redirectHome(rec, auth.RequestWithPrincipal(httptest.NewRequest(http.MethodGet, "/", nil), tc.p))
@@ -27,3 +28,36 @@ func TestRedirectHomeFollowsTheReach(t *testing.T) {
}
}
}
+
+// Everything under /server/ is the global role's, and the subtree is guarded
+// where the routes are registered: a domain administrator — whatever they are
+// assigned — and a request with no principal get 404 from the router itself,
+// before any handler runs. The handlers here are nil; reaching one would panic.
+func TestServerSubtreeIsGlobalOnly(t *testing.T) {
+ _, authed := (&Server{cfg: Config{InboundEnabled: true, DMARCEnabled: true}}).muxes()
+ everything := auth.Principal{Role: auth.RoleDomain, AllDomains: true, AllInbound: true}
+ checked := 0
+ for _, pattern := range routes.always {
+ method, target := concrete(pattern)
+ if !strings.HasPrefix(target, "/server/") {
+ continue
+ }
+ if got := answers(authed, method, target); got == "" {
+ continue // not built yet; TestRoutesFollowNavigation reports it
+ }
+ checked++
+ for who, req := range map[string]*http.Request{
+ "a domain administrator with All on both lists": auth.RequestWithPrincipal(httptest.NewRequest(method, target, nil), everything),
+ "no principal": httptest.NewRequest(method, target, nil),
+ } {
+ rec := httptest.NewRecorder()
+ authed.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("%s %s as %s = %d, want 404", method, target, who, rec.Code)
+ }
+ }
+ }
+ if checked < 15 {
+ t.Fatalf("only %d /server/ routes were checked", checked)
+ }
+}
diff --git a/internal/web/security.go b/internal/web/security.go
index d3b9120..fc55ccf 100644
--- a/internal/web/security.go
+++ b/internal/web/security.go
@@ -89,7 +89,7 @@ func (s *Server) secure(next http.Handler) http.Handler {
// judged per *origin*, so they tell that neighbour apart from the panel.
//
// Read-only methods are exempt: every GET route in the panel is a read (the
-// delete confirmation at GET /domains/{id}/delete only renders a form), so
+// delete confirmation at GET /outbound/domains/{id}/delete only renders a form), so
// there is nothing for a cross-origin GET to change.
func originAllowed(r *http.Request) bool {
switch r.Method {
diff --git a/internal/web/view/templates/backup.html b/internal/web/view/templates/backup.html
index 04645c2..27fe744 100644
--- a/internal/web/view/templates/backup.html
+++ b/internal/web/view/templates/backup.html
@@ -16,7 +16,7 @@
the download is then a .spbk file (SelfPost backup) that only
the password opens.
{{if .BackupErr}}{{.BackupErr}}
{{end}}
-
@@ -30,7 +30,7 @@
application passwords come across, so the published DNS record needs no
change. The export file is a secret, like a full backup.
{{if .ImportErr}}{{.ImportErr}}
{{end}}
-