release: 1.3.0: 1.3.0-1.3.1: security pass, queue persist, full backup layout
[1.3.0] Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration. Added: - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex - docs: agreed roadmap item **queue-retries** — show this Postfix's retry Security: - The independent security review of the send-log authorization and Fixed: - test (e2e): send-log status scrapers follow the badge markup in - security (panel): the Deliveries list is scoped to a domain administrator's - mail (level-2 rate limit): the ceiling is no longer overshot by messages that - panel (sign-in): a session that cannot be written to the database no longer - panel (applications): deleting an application removes its SASL credentials … (2 more in CHANGELOG) Changed: - docs: operator and as-built docs aligned with the code after a full - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance - docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7) - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in - ci: the release image is published only for a **published** GitHub Release … (9 more in CHANGELOG) [1.3.1] Retry policy in the panel, persistent Postfix queue, and self-contained full backups. Mail queue and delivery history show this Postfix's first retry, backoff cap, and queue lifetime. Deferred mail survives container recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`; the archive layout breaks 1.3.0 flat backups (unpack those with `tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a tag bump; no schema migration. Added: - Mail queue and a delivery's history show this Postfix's retry policy - docs: **Plan checklists** in [development.md](docs/development.md) — format, - docs: **Implementation checklists** with model routing in - docs: roadmap candidates **send-log-retention** (panel Settings for delivery - Postfix queue under `/data/postfix/queue` — deferred and active mail survive … (1 more in CHANGELOG) Changed: - **Breaking:** full-backup archive layout — paths are prefixed with `data/`; - docs: operator guide, architecture, security, and backup UI updated for - docs: HTML mockups for a full panel UI refresh Changed: - docs: panel UI mockups after review — Status restored to live readings - docs: panel UI design system and mockups rebuilt as separate pages - docs: panel UI mockups — add-domain sits in the list card (Domains and Fixed: - ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped Squashed CHANGELOG sections: [1.3.0], [1.3.1]. Full detail: CHANGELOG.md (1.3.0, 1.3.1).
This commit is contained in:
@@ -32,17 +32,7 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
|
||||
if p.IsGlobal() {
|
||||
return h.store.ListDomains()
|
||||
}
|
||||
all, err := h.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []store.Domain
|
||||
for _, d := range all {
|
||||
if p.CanAccessDomain(d.ID) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return h.store.ListDomainsForUser(p.ID)
|
||||
}
|
||||
|
||||
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// route is one entry of the authenticated mux, named the way web.go registers
|
||||
// it so a route added there without a guard is visible as a missing case here.
|
||||
type route struct {
|
||||
method string
|
||||
target string
|
||||
handler func(*Handlers) http.HandlerFunc
|
||||
// pathValues are the {id}-style segments the router would have bound.
|
||||
pathValues map[string]string
|
||||
}
|
||||
|
||||
// globalOnlyRoutes is every page and action that only a global administrator
|
||||
// may reach: the panel's users, the whole-server backup and domain import, the
|
||||
// machine-wide status and log views, and the domain lifecycle. A domain
|
||||
// administrator is answered 404 rather than 403 so the panel does not confirm
|
||||
// that the page exists (security.md).
|
||||
var globalOnlyRoutes = []route{
|
||||
{"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", "/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", "/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", "/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},
|
||||
|
||||
{"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},
|
||||
}
|
||||
|
||||
// A domain administrator has an account on the panel, so authentication is not
|
||||
// what keeps them off these pages — the per-handler role check is. Each of them
|
||||
// is reached here with a valid session for a principal that owns a domain, the
|
||||
// case the send-log leak (P0, code-review.md) showed is easy to get wrong.
|
||||
func TestGlobalOnlyRoutesAnswerADomainAdmin404(t *testing.T) {
|
||||
h, domains := serverWithTwoDomains(t)
|
||||
p := domainAdmin(t, h.store, "global-only", domains["first.example.ru"].ID)
|
||||
|
||||
for _, rt := range globalOnlyRoutes {
|
||||
rec := call(h, rt, p)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s as a domain administrator = %d, want 404:\n%s",
|
||||
rt.method, rt.target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same 404 covers a request that carries no principal at all: the auth
|
||||
// middleware normally redirects those, but a handler must not depend on
|
||||
// middleware it cannot see for the role it enforces itself.
|
||||
func TestGlobalOnlyRoutesAnswerAnUnknownPrincipal404(t *testing.T) {
|
||||
h, _ := serverWithTwoDomains(t)
|
||||
|
||||
for _, rt := range globalOnlyRoutes {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(rt.method, rt.target, nil)
|
||||
for k, v := range rt.pathValues {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
rt.handler(h)(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s %s with no principal = %d, want 404", rt.method, rt.target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 404s above would also pass if a handler were broken into always returning
|
||||
// one, so at least the two pages that need nothing but the store and the view
|
||||
// have to be shown opening for a global administrator.
|
||||
func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) {
|
||||
h, _ := serverWithTwoDomains(t)
|
||||
|
||||
for _, target := range []string{"/users", "/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",
|
||||
target, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getRoute(t *testing.T, target string) route {
|
||||
t.Helper()
|
||||
for _, rt := range globalOnlyRoutes {
|
||||
if rt.method == http.MethodGet && rt.target == target {
|
||||
return rt
|
||||
}
|
||||
}
|
||||
t.Fatalf("no GET %s among the global-only routes", target)
|
||||
return route{}
|
||||
}
|
||||
|
||||
func call(h *Handlers, rt route, p auth.Principal) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(rt.method, rt.target, nil)
|
||||
req = auth.RequestWithPrincipal(req, p)
|
||||
for k, v := range rt.pathValues {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
rt.handler(h)(rec, req)
|
||||
return rec
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestE2ESendLogStatusMarkupDrift renders the real deliveries_rows fragment and
|
||||
// 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")
|
||||
|
||||
// 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)</span>`)
|
||||
containsCell := func(html, needle string) bool {
|
||||
return strings.Contains(html, `<span class="st st-`) && strings.Contains(html, `">`+needle+`</span>`)
|
||||
}
|
||||
|
||||
if statusCellPattern.FindStringSubmatch(rendered) == nil {
|
||||
t.Fatalf("e2e statusCellPattern does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
|
||||
}
|
||||
if !containsCell(rendered, "sent") {
|
||||
t.Fatalf("e2e containsCell does not match rendered send-log rows:\n%s", snippet(rendered, `class="status"`))
|
||||
}
|
||||
}
|
||||
|
||||
func snippet(s, needle string) string {
|
||||
i := strings.Index(s, needle)
|
||||
if i < 0 {
|
||||
if len(s) > 200 {
|
||||
return s[:200] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
start := i - 20
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := i + 120
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
return s[start:end]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
@@ -20,6 +21,7 @@ type Config struct {
|
||||
MailLogPath string
|
||||
DataDir string
|
||||
DBPath string
|
||||
DeployRoot string
|
||||
Version string
|
||||
TLSCertFile string
|
||||
OpenDKIMSocket string
|
||||
@@ -28,6 +30,10 @@ type Config struct {
|
||||
// and used to cap domain/app level-2 ceilings (guide § Rate limiting).
|
||||
RateLimitMessagesPerIP int
|
||||
RateLimitWindowSeconds int
|
||||
// RetryPolicy is this Postfix's deferred-mail timings, snapshotted once
|
||||
// when the HTTP role starts (architecture.md). The Mail queue card and
|
||||
// delivery history read it from here; they never call postconf.
|
||||
RetryPolicy postfix.RetryPolicy
|
||||
}
|
||||
|
||||
// Handlers holds dependencies for authenticated panel routes.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/backup"
|
||||
@@ -70,6 +71,11 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", pwErr)
|
||||
return
|
||||
}
|
||||
if err := backup.ValidateDeployRoot(h.cfg.DeployRoot); err != nil {
|
||||
logf("panel: full backup: %v", err)
|
||||
h.renderBackupPageWith(w, r, http.StatusBadRequest, "", deployBackupErr(err))
|
||||
return
|
||||
}
|
||||
|
||||
stamp := time.Now().UTC().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("selfpost-backup-%s.tar.gz", stamp)
|
||||
@@ -105,9 +111,13 @@ func (h *Handlers) HandleBackup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := backup.Create(sink, backup.Params{
|
||||
DataDir: h.cfg.DataDir,
|
||||
DBPath: h.cfg.DBPath,
|
||||
Version: h.cfg.Version,
|
||||
DataDir: h.cfg.DataDir,
|
||||
DBPath: h.cfg.DBPath,
|
||||
Version: h.cfg.Version,
|
||||
DeployRoot: h.cfg.DeployRoot,
|
||||
OnWarn: func(msg string) {
|
||||
logf("panel: full backup: %s", msg)
|
||||
},
|
||||
}); err != nil {
|
||||
logf("panel: full backup failed: %v", err)
|
||||
return
|
||||
@@ -310,6 +320,14 @@ func decryptErrorMessage(err error) string {
|
||||
}
|
||||
}
|
||||
|
||||
// deployBackupErr phrases a pre-flight backup failure for the operator.
|
||||
func deployBackupErr(err error) string {
|
||||
if strings.Contains(err.Error(), "DeployRoot") || strings.Contains(err.Error(), "deploy root") {
|
||||
return "Full backup needs the project directory mounted read-only at /selfpost-deploy — add <code>.:/selfpost-deploy:ro</code> to docker-compose.yml and recreate the container."
|
||||
}
|
||||
return "Could not create the backup: " + err.Error()
|
||||
}
|
||||
|
||||
// importErrorMessage maps a domain-import failure (already logged by the caller)
|
||||
// to an HTTP status and a user-facing message. Duplicate domain/login are called
|
||||
// out specifically; other failures — validation errors describing what is wrong
|
||||
|
||||
@@ -124,10 +124,10 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
|
||||
"Title": "SelfPost — delete " + d.Name,
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
"Title": "SelfPost — delete " + d.Name,
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "domains",
|
||||
"Domain": d,
|
||||
"IsGlobal": true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -107,15 +108,15 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
row.Subject = mailhdr.DecodeSubject(row.Subject)
|
||||
logRows, logNote := h.deliveryLog(row)
|
||||
h.view.Render(w, http.StatusOK, "delivery", map[string]any{
|
||||
"Title": "SelfPost — delivery",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "deliveries",
|
||||
"Title": "SelfPost — delivery",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "deliveries",
|
||||
"IsGlobal": p.IsGlobal(),
|
||||
"Row": row,
|
||||
"Row": row,
|
||||
// The status in the panel's own badge vocabulary, so the headline reads
|
||||
// the same way as every other health signal in the panel.
|
||||
"Level": deliveryLevel(row.Status),
|
||||
"Events": deliveryEvents(row),
|
||||
"Events": deliveryEvents(row, h.cfg.RetryPolicy),
|
||||
// The mail.log lines for this message, and — when there are none — the
|
||||
// reason, which is a normal outcome rather than a failure.
|
||||
"LogRows": logRows,
|
||||
@@ -173,7 +174,9 @@ type deliveryEvent struct {
|
||||
// timestamps *are* the history, and stating them as steps is what makes a row
|
||||
// whose created_at and updated_at differ by six hours legible as "queued for
|
||||
// six hours, then delivered" rather than as two dates in a list of fields.
|
||||
func deliveryEvents(row store.SendLogRow) []deliveryEvent {
|
||||
// policy supplies the human intervals for deferred and bounced copy, the same
|
||||
// strings the Mail queue card prints, so the two cannot drift.
|
||||
func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy) []deliveryEvent {
|
||||
// A rejected message has no second step, and its first one is not an
|
||||
// acceptance: the journal-milter refused it, so Postfix never queued it.
|
||||
if row.Status == store.StatusRejected {
|
||||
@@ -217,7 +220,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
|
||||
Level: "warn",
|
||||
Status: store.StatusDeferred,
|
||||
Title: "Deferred, will be retried",
|
||||
Detail: "The receiving server could not take the message yet. Postfix keeps it queued and retries until it is delivered or the queue lifetime runs out.",
|
||||
Detail: fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.",
|
||||
policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime()),
|
||||
})
|
||||
case store.StatusBounced:
|
||||
return append(events, deliveryEvent{
|
||||
@@ -225,7 +229,8 @@ func deliveryEvents(row store.SendLogRow) []deliveryEvent {
|
||||
Level: "error",
|
||||
Status: store.StatusBounced,
|
||||
Title: "Bounced",
|
||||
Detail: "Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after the queue lifetime. The reason is in the delivery log below.",
|
||||
Detail: fmt.Sprintf("Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after %s in the queue. The reason is in the delivery log below.",
|
||||
policy.QueueLifetime()),
|
||||
})
|
||||
default:
|
||||
// A status the log-tailer learns to write before this switch does.
|
||||
@@ -299,16 +304,22 @@ func deliveriesBackURL(r *http.Request) string {
|
||||
// sendLogData reads the domain/app filters and page number off the query
|
||||
// string, queries the store, and assembles everything the template needs
|
||||
// (filter dropdown options plus the current selection, rows, and pagination).
|
||||
//
|
||||
// The invariant this function owes the journal: a principal who is not global
|
||||
// only ever reads rows for the domains assigned to them. That scope is stated
|
||||
// to the store as SendLogFilter.Domains and holds for every number of
|
||||
// assignments, including none — a domain administrator whose last domain was
|
||||
// deleted gets an empty log, not the whole one. The query parameters are
|
||||
// filters *within* that scope and can only narrow it: both are checked against
|
||||
// the assigned domains and their applications before the query runs, because a
|
||||
// dropdown that offers only permitted values is a courtesy to the browser, not
|
||||
// a check on the request.
|
||||
func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
return nil, errors.New("no principal")
|
||||
}
|
||||
q := r.URL.Query()
|
||||
filter := store.SendLogFilter{
|
||||
Domain: q.Get("domain"),
|
||||
AppLogin: q.Get("app"),
|
||||
}
|
||||
|
||||
assigned, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
@@ -316,31 +327,6 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
}
|
||||
allowedNames := domainNameSet(assigned)
|
||||
|
||||
if !p.IsGlobal() {
|
||||
if filter.Domain != "" && !allowedNames[filter.Domain] {
|
||||
filter.Domain = ""
|
||||
}
|
||||
if filter.Domain == "" && len(assigned) == 1 {
|
||||
filter.Domain = assigned[0].Name
|
||||
}
|
||||
}
|
||||
|
||||
page := parsePage(q.Get("p"))
|
||||
|
||||
total, err := h.store.CountSendLog(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := make([]sendLogRow, len(rows))
|
||||
for i := range rows {
|
||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
||||
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
|
||||
}
|
||||
|
||||
domainNames := make([]string, 0, len(assigned))
|
||||
for _, d := range assigned {
|
||||
domainNames = append(domainNames, d.Name)
|
||||
@@ -362,8 +348,37 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
|
||||
}
|
||||
sort.Strings(logins)
|
||||
|
||||
if !p.IsGlobal() && filter.AppLogin != "" && !loginSet[filter.AppLogin] {
|
||||
filter.AppLogin = ""
|
||||
filter := store.SendLogFilter{
|
||||
Domain: q.Get("domain"),
|
||||
AppLogin: q.Get("app"),
|
||||
// A global administrator reads the whole journal, including rows left
|
||||
// behind by a domain that has since been deleted.
|
||||
Domains: domainNames,
|
||||
AllDomains: p.IsGlobal(),
|
||||
}
|
||||
if !p.IsGlobal() {
|
||||
if filter.Domain != "" && !allowedNames[filter.Domain] {
|
||||
filter.Domain = ""
|
||||
}
|
||||
if filter.AppLogin != "" && !loginSet[filter.AppLogin] {
|
||||
filter.AppLogin = ""
|
||||
}
|
||||
}
|
||||
|
||||
page := parsePage(q.Get("p"))
|
||||
|
||||
total, err := h.store.CountSendLog(filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := make([]sendLogRow, len(rows))
|
||||
for i := range rows {
|
||||
rows[i].Subject = mailhdr.DecodeSubject(rows[i].Subject)
|
||||
view[i] = sendLogRow{SendLogRow: rows[i], Level: deliveryLevel(rows[i].Status)}
|
||||
}
|
||||
|
||||
lastPage := 1
|
||||
@@ -402,13 +417,18 @@ func (h *Handlers) HandleMailQueue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
out, errText := readQueue()
|
||||
policy := h.cfg.RetryPolicy
|
||||
h.view.Render(w, http.StatusOK, "mail_queue", map[string]any{
|
||||
"Title": "SelfPost — mail queue",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "mail_queue",
|
||||
"IsGlobal": true,
|
||||
"Output": out,
|
||||
"Error": errText,
|
||||
"Title": "SelfPost — mail queue",
|
||||
"User": auth.CurrentUser(r),
|
||||
"Active": "mail_queue",
|
||||
"IsGlobal": true,
|
||||
"Output": out,
|
||||
"Error": errText,
|
||||
"FirstRetry": policy.FirstRetry(),
|
||||
"BackoffCap": policy.BackoffCap(),
|
||||
"QueueLifetime": policy.QueueLifetime(),
|
||||
"RetryFromDefaults": policy.FromDefaults,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
@@ -129,7 +131,7 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
@@ -215,6 +217,174 @@ func TestDeliveryPageNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A domain administrator reads the journal of the domains assigned to them and
|
||||
// nothing else. The list used to be scoped only when exactly one domain was
|
||||
// assigned, which meant two assignments read as none at all.
|
||||
func TestSendLogScopedToAssignedDomains(t *testing.T) {
|
||||
h, domains := serverWithTwoDomains(t)
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
username string
|
||||
domainIDs []int64
|
||||
want []string
|
||||
unwanted []string
|
||||
}{
|
||||
"global sees both": {
|
||||
"", nil, []string{"First message", "Second message"}, nil,
|
||||
},
|
||||
"one assigned domain": {
|
||||
"one-domain", []int64{domains["first.example.ru"].ID},
|
||||
[]string{"First message"}, []string{"Second message", "second-app"},
|
||||
},
|
||||
"two assigned domains": {
|
||||
"two-domains", []int64{domains["first.example.ru"].ID, domains["second.example.ru"].ID},
|
||||
[]string{"First message", "Second message"}, nil,
|
||||
},
|
||||
// Every assigned domain deleted cascades the assignments away. That
|
||||
// leaves a principal entitled to nothing, which is an empty log — the
|
||||
// case that used to hand over the whole journal.
|
||||
"no assigned domains": {
|
||||
"no-domains", nil, []string{"No messages logged yet."},
|
||||
[]string{"First message", "Second message"},
|
||||
},
|
||||
} {
|
||||
var p auth.Principal
|
||||
if tc.username == "" {
|
||||
p = globalPrincipal
|
||||
} else {
|
||||
p = domainAdmin(t, h.store, tc.username, tc.domainIDs...)
|
||||
}
|
||||
for view, handler := range map[string]http.HandlerFunc{
|
||||
"page": h.HandleDeliveries,
|
||||
"fragment": h.HandleDeliveriesRows,
|
||||
} {
|
||||
out := getBodyAs(t, handler, "/deliveries", p)
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tc.unwanted {
|
||||
if strings.Contains(out, unwanted) {
|
||||
t.Errorf("%s (%s): leaks %q:\n%s", name, view, unwanted, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The filter dropdowns offer only permitted values, so a leak through them can
|
||||
// only come from a hand-written URL — which is exactly why the values are
|
||||
// checked against the principal's own domains and applications rather than
|
||||
// trusted for having been rendered by us.
|
||||
func TestSendLogIgnoresForgedFilters(t *testing.T) {
|
||||
h, domains := serverWithTwoDomains(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",
|
||||
} {
|
||||
out := getBodyAs(t, h.HandleDeliveries, target, p)
|
||||
if strings.Contains(out, "Second message") {
|
||||
t.Errorf("GET %s leaks another domain's journal:\n%s", target, out)
|
||||
}
|
||||
if !strings.Contains(out, "First message") {
|
||||
t.Errorf("GET %s hid the principal's own journal:\n%s", target, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The detail page has always checked membership; keep it checked, because the
|
||||
// list and the page are two ways to the same row.
|
||||
func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
|
||||
h, domains := serverWithTwoDomains(t)
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: "second.example.ru", AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/deliveries/"+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)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("delivery page for a foreign domain = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// serverWithTwoDomains builds a panel over a store holding two domains, one
|
||||
// application and one delivered message each, so a scoping test can tell "my
|
||||
// rows" from "every row" by reading the page.
|
||||
func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
|
||||
t.Helper()
|
||||
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
|
||||
domains := make(map[string]store.Domain, 2)
|
||||
for _, d := range []struct{ name, app, subject string }{
|
||||
{"first.example.ru", "first-app", "First message"},
|
||||
{"second.example.ru", "second-app", "Second message"},
|
||||
} {
|
||||
dom, err := st.AddDomain(d.name, "mail")
|
||||
if err != nil {
|
||||
t.Fatalf("add domain %s: %v", d.name, err)
|
||||
}
|
||||
if _, err := st.AddApplication(dom.ID, d.app, store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatalf("add application %s: %v", d.app, err)
|
||||
}
|
||||
if err := st.InsertQueued(store.SendLogEntry{
|
||||
QueueID: "Q" + d.app, Domain: d.name, AppLogin: d.app,
|
||||
From: "noreply@" + d.name, To: "public@example.net", Subject: d.subject,
|
||||
}); err != nil {
|
||||
t.Fatalf("insert %s: %v", d.subject, err)
|
||||
}
|
||||
domains[d.name] = dom
|
||||
}
|
||||
|
||||
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains
|
||||
}
|
||||
|
||||
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
|
||||
|
||||
func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...int64) auth.Principal {
|
||||
t.Helper()
|
||||
const hash = "test-hash"
|
||||
if len(domainIDs) == 0 {
|
||||
placeholder, err := st.AddDomain(username+".placeholder.invalid", "mail")
|
||||
if err != nil {
|
||||
t.Fatalf("add placeholder domain: %v", err)
|
||||
}
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("create domain admin %s: %v", username, err)
|
||||
}
|
||||
if err := st.DeleteDomain(placeholder.ID); err != nil {
|
||||
t.Fatalf("delete placeholder domain: %v", err)
|
||||
}
|
||||
domainIDs = nil
|
||||
u, err := st.GetUser(id)
|
||||
if err != nil {
|
||||
t.Fatalf("get domain admin %s: %v", username, err)
|
||||
}
|
||||
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
|
||||
}
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("create domain admin %s: %v", username, err)
|
||||
}
|
||||
u, err := st.GetUser(id)
|
||||
if err != nil {
|
||||
t.Fatalf("get domain admin %s: %v", username, err)
|
||||
}
|
||||
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
|
||||
}
|
||||
|
||||
// serverWithDelivery builds a panel over a store holding one delivery, written
|
||||
// the way the journal-milter wrote them before it decoded subjects itself.
|
||||
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
||||
@@ -238,7 +408,7 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
||||
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
||||
rows, err := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
@@ -246,18 +416,20 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
|
||||
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
|
||||
}
|
||||
|
||||
// getBody runs one handler over a GET and returns the page it wrote, failing
|
||||
// the test on any non-200. The path's {id} is bound by hand because these calls
|
||||
// bypass the router that would otherwise fill it in.
|
||||
// getBody runs one handler over a GET as the global administrator.
|
||||
func getBody(t *testing.T, h http.HandlerFunc, target string) string {
|
||||
t.Helper()
|
||||
return getBodyAs(t, h, target, globalPrincipal)
|
||||
}
|
||||
|
||||
// getBodyAs runs one handler over a GET as the given principal and returns the
|
||||
// page it wrote, failing the test on any non-200. The path's {id} is bound by
|
||||
// hand because these calls bypass the router that would otherwise fill it in.
|
||||
func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal) string {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||
req = auth.RequestWithPrincipal(req, auth.Principal{
|
||||
ID: 1,
|
||||
Username: "admin",
|
||||
Role: auth.RoleGlobal,
|
||||
})
|
||||
req = auth.RequestWithPrincipal(req, p)
|
||||
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
|
||||
req.SetPathValue("id", rest)
|
||||
}
|
||||
@@ -280,3 +452,90 @@ func writeMailLog(t *testing.T, lines ...string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// fixtureRetryPolicy is a distinctive policy so tests can tell the Config
|
||||
// snapshot from live postconf and from compiled-in defaults (5 minutes / 5 days).
|
||||
func fixtureRetryPolicy() postfix.RetryPolicy {
|
||||
return postfix.RetryPolicy{
|
||||
QueueRunDelay: 10 * time.Minute,
|
||||
MinimalBackoff: 10 * time.Minute,
|
||||
MaximalBackoff: 4000 * time.Second,
|
||||
MaximalQueueLifetime: 2 * 24 * time.Hour,
|
||||
BounceQueueLifetime: 2 * 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// The retry card sits on the page itself, outside the HTMX poll, and prints
|
||||
// whatever policy was cached on Config — never a live postconf.
|
||||
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
|
||||
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
|
||||
|
||||
out := getBody(t, h.HandleMailQueue, "/mail-queue")
|
||||
for _, want := range []string{
|
||||
"How delivery retries work",
|
||||
"id=\"retry-policy\"",
|
||||
">10 minutes<",
|
||||
"doubling, cap about 1 hour 7 minutes",
|
||||
">2 days<",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("mail queue is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, ">5 minutes<") || strings.Contains(out, ">5 days<") {
|
||||
t.Errorf("mail queue shows stock defaults instead of the fixture:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "compiled-in defaults") {
|
||||
t.Error("a fixture policy must not show the fallback note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
|
||||
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
|
||||
|
||||
out := getBody(t, h.HandleMailQueueBody, "/mail-queue/body")
|
||||
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
|
||||
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
|
||||
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
|
||||
|
||||
out := getBody(t, h.HandleMailQueue, "/mail-queue")
|
||||
if !strings.Contains(out, "compiled-in defaults") {
|
||||
t.Errorf("fallback note missing:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
h.cfg.RetryPolicy = fixtureRetryPolicy()
|
||||
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusDeferred); err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
|
||||
for _, want := range []string{
|
||||
"first after 10 minutes",
|
||||
"up to about 1 hour 7 minutes",
|
||||
"for up to 2 days",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("deferred history is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) {
|
||||
h, row := serverWithDelivery(t)
|
||||
h.cfg.RetryPolicy = fixtureRetryPolicy()
|
||||
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil {
|
||||
t.Fatalf("update status: %v", err)
|
||||
}
|
||||
|
||||
out := getBody(t, h.HandleDelivery, "/deliveries/"+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)
|
||||
}
|
||||
}
|
||||
|
||||
+24
-24
@@ -14,8 +14,8 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// HandleAccount serves the signed-in user's account settings.
|
||||
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
// HandleSettings serves the signed-in user's panel settings.
|
||||
func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
p, ok := h.principal(r)
|
||||
@@ -25,20 +25,20 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
u, err := h.store.GetUser(p.ID)
|
||||
if err != nil {
|
||||
logf("panel: account: get user failed: %v", err)
|
||||
logf("panel: settings: get user failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderAccount(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal())
|
||||
case http.MethodPost:
|
||||
h.submitAccount(w, r)
|
||||
h.submitSettings(w, r)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
|
||||
func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) {
|
||||
var reportAuth dnscheck.Result
|
||||
if showDMARC && formDMARCEmail != "" {
|
||||
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
||||
@@ -58,13 +58,13 @@ func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status
|
||||
data["ReportAuthDNS"] = reportAuth
|
||||
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
|
||||
data["Error"] = formErr
|
||||
data["Flash"] = accountFlash(r)
|
||||
data["Flash"] = settingsFlash(r)
|
||||
data["L1Messages"] = h.l1Messages()
|
||||
data["L1Window"] = h.l1Window()
|
||||
h.view.Render(w, status, "settings", data)
|
||||
}
|
||||
|
||||
func accountFlash(r *http.Request) string {
|
||||
func settingsFlash(r *http.Request) string {
|
||||
switch r.URL.Query().Get("updated") {
|
||||
case "username":
|
||||
return "Username changed."
|
||||
@@ -85,16 +85,16 @@ func accountFlash(r *http.Request) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.auth.AllowLoginAttempt(r) {
|
||||
p, _ := h.principal(r)
|
||||
h.renderAccount(w, r, http.StatusTooManyRequests,
|
||||
h.renderSettings(w, r, http.StatusTooManyRequests,
|
||||
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
p, _ := h.principal(r)
|
||||
h.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
user, err := h.store.GetUser(p.ID)
|
||||
if err != nil {
|
||||
logf("panel: account: get user failed: %v", err)
|
||||
logf("panel: settings: get user failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -123,21 +123,21 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
|
||||
h.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
|
||||
renaming := username != user.Username
|
||||
if renaming {
|
||||
if err := validate.Username(username); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if p.IsGlobal() {
|
||||
if err := validate.Email(dmarcEmail); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
|
||||
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -147,16 +147,16 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
repassword := password != "" || confirm != ""
|
||||
if repassword {
|
||||
if password != confirm {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
if err := validate.AdminPassword(password); err != nil {
|
||||
h.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
}
|
||||
if !renaming && !repassword && !emailChanging {
|
||||
h.renderAccount(w, r, http.StatusBadRequest,
|
||||
h.renderSettings(w, r, http.StatusBadRequest,
|
||||
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
@@ -165,8 +165,8 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if repassword {
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
logf("panel: account: hashing password failed: %v", err)
|
||||
h.renderAccount(w, r, http.StatusInternalServerError,
|
||||
logf("panel: settings: hashing password failed: %v", err)
|
||||
h.renderSettings(w, r, http.StatusInternalServerError,
|
||||
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
@@ -174,17 +174,17 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
|
||||
logf("panel: account: update user failed: %v", err)
|
||||
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.renderAccount(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
logf("panel: user %d account updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
|
||||
logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging)
|
||||
http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -102,10 +102,6 @@ func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||
return
|
||||
}
|
||||
if r.PostFormValue("action") == "delete" {
|
||||
h.submitUserDelete(w, r, u)
|
||||
return
|
||||
}
|
||||
h.submitUserUpdate(w, r, u)
|
||||
default:
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
@@ -291,6 +287,58 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
|
||||
// removed — the same pattern as HandleDeleteConfirm for domains, so a single
|
||||
// mis-click on Delete cannot remove a user (P3, code-review.md).
|
||||
func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
uid, ok := parseUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u, err := h.store.GetUser(uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUserNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: get user %d: %v", uid, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := h.pageBase(r)
|
||||
data["Title"] = "SelfPost — delete " + u.Username
|
||||
data["Active"] = "users"
|
||||
data["TargetID"] = u.ID
|
||||
data["TargetUsername"] = u.Username
|
||||
h.view.Render(w, http.StatusOK, "user_delete", data)
|
||||
}
|
||||
|
||||
// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm
|
||||
// and returns to the user list.
|
||||
func (h *Handlers) HandleUserDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
return
|
||||
}
|
||||
uid, ok := parseUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u, err := h.store.GetUser(uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrUserNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logf("panel: get user %d: %v", uid, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.submitUserDelete(w, r, u)
|
||||
}
|
||||
|
||||
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
|
||||
Reference in New Issue
Block a user