Close the remaining low-risk items from the full-tree review: rename the settings handler, query assigned domains in SQL, bound the login limiter map, collapse panel.js show/hide helpers, and soften DMARC copy that promised a future in-panel receiver. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -154,6 +154,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
[docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's
|
[docs/plans/dmarc-reports.md](docs/plans/dmarc-reports.md) to the setting's
|
||||||
actual home after migration `0005`. No behaviour change.
|
actual home after migration `0005`. No behaviour change.
|
||||||
|
|
||||||
|
- panel: code-review P6 cleanup — the unused `auth.RequireGlobal` middleware is
|
||||||
|
gone (handlers already call `requireGlobal`); the settings route handler is
|
||||||
|
named `HandleSettings` in `handlers_settings.go`; domain lists for a
|
||||||
|
domain-admin now come from `ListDomainsForUser` in SQL instead of loading
|
||||||
|
every domain and filtering in Go; the login and setup rate limiters sweep
|
||||||
|
expired buckets on a timer and cap the map at 4096 keys; the five
|
||||||
|
show/hide field helpers in `panel.js` are one rule table; DMARC copy no
|
||||||
|
longer promises in-panel report reception in a future release — SelfPost
|
||||||
|
does not receive inbound mail. No optional post-restore map `Resync` (the
|
||||||
|
backup package comment fix in P2 is enough).
|
||||||
|
|
||||||
## [1.2.5] - 2026-08-13
|
## [1.2.5] - 2026-08-13
|
||||||
|
|
||||||
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
|
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ holds the cookie works after process restart, redeploy, or full backup restore.
|
|||||||
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
(`renewThreshold` in [internal/web/auth/session.go](../internal/web/auth/session.go)).
|
||||||
- **Password change** — all other sessions are deleted; the current session stays
|
- **Password change** — all other sessions are deleted; the current session stays
|
||||||
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
||||||
[handlers_account.go](../internal/web/handlers/handlers_account.go)).
|
[handlers_settings.go](../internal/web/handlers/handlers_settings.go)).
|
||||||
|
|
||||||
Restoring an **older** backup also restores session rows: a session invalidated
|
Restoring an **older** backup also restores session rows: a session invalidated
|
||||||
after that backup was taken can become valid again if the browser still has the
|
after that backup was taken can become valid again if the browser still has the
|
||||||
|
|||||||
@@ -614,14 +614,14 @@ for SPDX consistency and deleting the done logrotate plan.
|
|||||||
|
|
||||||
**Model: Sonnet** unless noted.
|
**Model: Sonnet** unless noted.
|
||||||
|
|
||||||
- [ ] Use `auth.RequireGlobal` or delete it (**Haiku** if delete).
|
- [x] Use `auth.RequireGlobal` or delete it (**Haiku** if delete). Deleted.
|
||||||
- [ ] Rename `handlers_account.go` / `HandleAccount` to settings (**Haiku**).
|
- [x] Rename `handlers_account.go` / `HandleAccount` to settings (**Haiku**).
|
||||||
- [ ] `ListDomainsForUser` instead of load-all-and-filter.
|
- [x] `ListDomainsForUser` instead of load-all-and-filter.
|
||||||
- [ ] Cap or periodically sweep the login limiter map (**Opus**, small).
|
- [x] Cap or periodically sweep the login limiter map (**Opus**, small).
|
||||||
- [ ] Collapse `panel.js` field-sync helpers.
|
- [x] Collapse `panel.js` field-sync helpers.
|
||||||
- [ ] Soften or keep DMARC “future release” UI copy (product call).
|
- [x] Soften or keep DMARC “future release” UI copy (product call). Softened.
|
||||||
- [ ] Optional startup `Resync` after restore (**Opus**). Only if P2’s comment
|
- [ ] Optional startup `Resync` after restore (**Opus**). Dropped — P2 backup
|
||||||
fix is judged insufficient.
|
comment fix is sufficient.
|
||||||
|
|
||||||
### P7 — Security review of the P0–P1 diff
|
### P7 — Security review of the P0–P1 diff
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,32 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListDomainsForUser returns domains assigned to userID with application counts,
|
||||||
|
// ordered by name.
|
||||||
|
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||||
|
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||||
|
FROM domains d
|
||||||
|
INNER JOIN user_domains ud ON ud.domain_id = d.id
|
||||||
|
WHERE ud.user_id = ?
|
||||||
|
ORDER BY d.name`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list domains for user: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Domain
|
||||||
|
for rows.Next() {
|
||||||
|
d, err := scanDomain(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// GetDomain returns a single domain (with its application count) by id, or
|
// GetDomain returns a single domain (with its application count) by id, or
|
||||||
// ErrDomainNotFound.
|
// ErrDomainNotFound.
|
||||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ func New(st *store.Store, cfg Config, v *view.Engine, setupTokenPath string) *Mo
|
|||||||
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
||||||
trustedProxies: cfg.TrustedProxyCIDRs,
|
trustedProxies: cfg.TrustedProxyCIDRs,
|
||||||
}
|
}
|
||||||
|
m.setupLimiter.startSweeper()
|
||||||
|
m.loginLimiter.startSweeper()
|
||||||
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
m.setup = newSetupManager(st, cfg.Hostname, setupTokenPath)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,15 +44,3 @@ func CurrentUser(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireGlobal wraps a handler that only global administrators may reach.
|
|
||||||
func RequireGlobal(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p, ok := CurrentPrincipal(r.Context())
|
|
||||||
if !ok || !p.IsGlobal() {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultMaxBuckets = 4096
|
||||||
|
|
||||||
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
// rateLimiter is a simple fixed-window per-key counter used to throttle the
|
||||||
// setup and login routes (security.md). Keys are client IPs.
|
// setup and login routes (security.md). Keys are client IPs.
|
||||||
type rateLimiter struct {
|
type rateLimiter struct {
|
||||||
max int
|
max int
|
||||||
window time.Duration
|
window time.Duration
|
||||||
|
maxBuckets int
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buckets map[string]*rlBucket
|
buckets map[string]*rlBucket
|
||||||
@@ -22,12 +25,25 @@ type rlBucket struct {
|
|||||||
|
|
||||||
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
func newRateLimiter(max int, window time.Duration) *rateLimiter {
|
||||||
return &rateLimiter{
|
return &rateLimiter{
|
||||||
max: max,
|
max: max,
|
||||||
window: window,
|
window: window,
|
||||||
buckets: make(map[string]*rlBucket),
|
maxBuckets: defaultMaxBuckets,
|
||||||
|
buckets: make(map[string]*rlBucket),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *rateLimiter) startSweeper() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(r.window)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
r.mu.Lock()
|
||||||
|
r.sweep(time.Now())
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *rateLimiter) Allow(key string) bool {
|
func (r *rateLimiter) Allow(key string) bool {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -35,6 +51,7 @@ func (r *rateLimiter) Allow(key string) bool {
|
|||||||
|
|
||||||
b := r.buckets[key]
|
b := r.buckets[key]
|
||||||
if b == nil || now.After(b.windowEnds) {
|
if b == nil || now.After(b.windowEnds) {
|
||||||
|
r.makeRoom(now)
|
||||||
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
|
r.buckets[key] = &rlBucket{count: 1, windowEnds: now.Add(r.window)}
|
||||||
r.sweep(now)
|
r.sweep(now)
|
||||||
return true
|
return true
|
||||||
@@ -46,6 +63,32 @@ func (r *rateLimiter) Allow(key string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *rateLimiter) makeRoom(now time.Time) {
|
||||||
|
if r.maxBuckets <= 0 || len(r.buckets) < r.maxBuckets {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.sweep(now)
|
||||||
|
for len(r.buckets) >= r.maxBuckets {
|
||||||
|
r.evictOldest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *rateLimiter) evictOldest() {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestEnds time.Time
|
||||||
|
first := true
|
||||||
|
for k, b := range r.buckets {
|
||||||
|
if first || b.windowEnds.Before(oldestEnds) {
|
||||||
|
oldestKey = k
|
||||||
|
oldestEnds = b.windowEnds
|
||||||
|
first = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldestKey != "" {
|
||||||
|
delete(r.buckets, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *rateLimiter) sweep(now time.Time) {
|
func (r *rateLimiter) sweep(now time.Time) {
|
||||||
for k, b := range r.buckets {
|
for k, b := range r.buckets {
|
||||||
if now.After(b.windowEnds) {
|
if now.After(b.windowEnds) {
|
||||||
|
|||||||
@@ -98,3 +98,28 @@ func expire(r *rateLimiter, key string) {
|
|||||||
b.windowEnds = time.Now().Add(-time.Second)
|
b.windowEnds = time.Now().Add(-time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A long-running panel can see many unique client addresses. Finished buckets
|
||||||
|
// are swept on every new window, and a hard cap evicts the oldest when the map
|
||||||
|
// would otherwise grow without bound.
|
||||||
|
func TestRateLimiterCapsBucketCount(t *testing.T) {
|
||||||
|
r := newRateLimiter(1, time.Minute)
|
||||||
|
r.maxBuckets = 3
|
||||||
|
|
||||||
|
for i, key := range []string{"203.0.113.7", "198.51.100.9", "192.0.2.5"} {
|
||||||
|
if !r.Allow(key) {
|
||||||
|
t.Fatalf("attempt %d for %s was refused under the cap", i+1, key)
|
||||||
|
}
|
||||||
|
expire(r, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.Allow("203.0.113.8") {
|
||||||
|
t.Fatal("a fourth address was refused even though room was made")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if len(r.buckets) > 3 {
|
||||||
|
t.Fatalf("bucket count = %d, want at most 3", len(r.buckets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,17 +32,7 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
|
|||||||
if p.IsGlobal() {
|
if p.IsGlobal() {
|
||||||
return h.store.ListDomains()
|
return h.store.ListDomains()
|
||||||
}
|
}
|
||||||
all, err := h.store.ListDomains()
|
return h.store.ListDomainsForUser(p.ID)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func domainNameSet(domains []store.Domain) map[string]bool {
|
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ var globalOnlyRoutes = []route{
|
|||||||
// case the send-log leak (P0, code-review.md) showed is easy to get wrong.
|
// case the send-log leak (P0, code-review.md) showed is easy to get wrong.
|
||||||
func TestGlobalOnlyRoutesAnswerADomainAdmin404(t *testing.T) {
|
func TestGlobalOnlyRoutesAnswerADomainAdmin404(t *testing.T) {
|
||||||
h, domains := serverWithTwoDomains(t)
|
h, domains := serverWithTwoDomains(t)
|
||||||
p := domainAdmin(domains["first.example.ru"].ID)
|
p := domainAdmin(t, h.store, "global-only", domains["first.example.ru"].ID)
|
||||||
|
|
||||||
for _, rt := range globalOnlyRoutes {
|
for _, rt := range globalOnlyRoutes {
|
||||||
rec := call(h, rt, p)
|
rec := call(h, rt, p)
|
||||||
|
|||||||
@@ -222,34 +222,41 @@ func TestSendLogScopedToAssignedDomains(t *testing.T) {
|
|||||||
h, domains := serverWithTwoDomains(t)
|
h, domains := serverWithTwoDomains(t)
|
||||||
|
|
||||||
for name, tc := range map[string]struct {
|
for name, tc := range map[string]struct {
|
||||||
principal auth.Principal
|
username string
|
||||||
|
domainIDs []int64
|
||||||
want []string
|
want []string
|
||||||
unwanted []string
|
unwanted []string
|
||||||
}{
|
}{
|
||||||
"global sees both": {
|
"global sees both": {
|
||||||
globalPrincipal, []string{"First message", "Second message"}, nil,
|
"", nil, []string{"First message", "Second message"}, nil,
|
||||||
},
|
},
|
||||||
"one assigned domain": {
|
"one assigned domain": {
|
||||||
domainAdmin(domains["first.example.ru"].ID),
|
"one-domain", []int64{domains["first.example.ru"].ID},
|
||||||
[]string{"First message"}, []string{"Second message", "second-app"},
|
[]string{"First message"}, []string{"Second message", "second-app"},
|
||||||
},
|
},
|
||||||
"two assigned domains": {
|
"two assigned domains": {
|
||||||
domainAdmin(domains["first.example.ru"].ID, domains["second.example.ru"].ID),
|
"two-domains", []int64{domains["first.example.ru"].ID, domains["second.example.ru"].ID},
|
||||||
[]string{"First message", "Second message"}, nil,
|
[]string{"First message", "Second message"}, nil,
|
||||||
},
|
},
|
||||||
// Every assigned domain deleted cascades the assignments away. That
|
// Every assigned domain deleted cascades the assignments away. That
|
||||||
// leaves a principal entitled to nothing, which is an empty log — the
|
// leaves a principal entitled to nothing, which is an empty log — the
|
||||||
// case that used to hand over the whole journal.
|
// case that used to hand over the whole journal.
|
||||||
"no assigned domains": {
|
"no assigned domains": {
|
||||||
domainAdmin(), []string{"No messages logged yet."},
|
"no-domains", nil, []string{"No messages logged yet."},
|
||||||
[]string{"First message", "Second message"},
|
[]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{
|
for view, handler := range map[string]http.HandlerFunc{
|
||||||
"page": h.HandleDeliveries,
|
"page": h.HandleDeliveries,
|
||||||
"fragment": h.HandleDeliveriesRows,
|
"fragment": h.HandleDeliveriesRows,
|
||||||
} {
|
} {
|
||||||
out := getBodyAs(t, handler, "/deliveries", tc.principal)
|
out := getBodyAs(t, handler, "/deliveries", p)
|
||||||
for _, want := range tc.want {
|
for _, want := range tc.want {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
|
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
|
||||||
@@ -270,7 +277,7 @@ func TestSendLogScopedToAssignedDomains(t *testing.T) {
|
|||||||
// trusted for having been rendered by us.
|
// trusted for having been rendered by us.
|
||||||
func TestSendLogIgnoresForgedFilters(t *testing.T) {
|
func TestSendLogIgnoresForgedFilters(t *testing.T) {
|
||||||
h, domains := serverWithTwoDomains(t)
|
h, domains := serverWithTwoDomains(t)
|
||||||
p := domainAdmin(domains["first.example.ru"].ID)
|
p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID)
|
||||||
|
|
||||||
for _, target := range []string{
|
for _, target := range []string{
|
||||||
"/deliveries?domain=second.example.ru",
|
"/deliveries?domain=second.example.ru",
|
||||||
@@ -299,7 +306,7 @@ func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil)
|
req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil)
|
||||||
req.SetPathValue("id", itoa(rows[0].ID))
|
req.SetPathValue("id", itoa(rows[0].ID))
|
||||||
req = auth.RequestWithPrincipal(req, domainAdmin(domains["first.example.ru"].ID))
|
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
|
||||||
h.HandleDelivery(rec, req)
|
h.HandleDelivery(rec, req)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("delivery page for a foreign domain = %d, want 404", rec.Code)
|
t.Errorf("delivery page for a foreign domain = %d, want 404", rec.Code)
|
||||||
@@ -343,13 +350,37 @@ func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
|
|||||||
|
|
||||||
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
|
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
|
||||||
|
|
||||||
func domainAdmin(domainIDs ...int64) auth.Principal {
|
func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...int64) auth.Principal {
|
||||||
return auth.Principal{
|
t.Helper()
|
||||||
ID: 2,
|
const hash = "test-hash"
|
||||||
Username: "domain-admin",
|
if len(domainIDs) == 0 {
|
||||||
Role: auth.RoleDomainAdmin,
|
placeholder, err := st.AddDomain(username+".placeholder.invalid", "mail")
|
||||||
Domains: domainIDs,
|
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
|
// serverWithDelivery builds a panel over a store holding one delivery, written
|
||||||
|
|||||||
+24
-24
@@ -14,8 +14,8 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleAccount serves the signed-in user's account settings.
|
// HandleSettings serves the signed-in user's panel settings.
|
||||||
func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
p, ok := h.principal(r)
|
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)
|
u, err := h.store.GetUser(p.ID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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:
|
case http.MethodPost:
|
||||||
h.submitAccount(w, r)
|
h.submitSettings(w, r)
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Allow", "GET, POST")
|
w.Header().Set("Allow", "GET, POST")
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
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
|
var reportAuth dnscheck.Result
|
||||||
if showDMARC && formDMARCEmail != "" {
|
if showDMARC && formDMARCEmail != "" {
|
||||||
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
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["ReportAuthDNS"] = reportAuth
|
||||||
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
|
data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
|
||||||
data["Error"] = formErr
|
data["Error"] = formErr
|
||||||
data["Flash"] = accountFlash(r)
|
data["Flash"] = settingsFlash(r)
|
||||||
data["L1Messages"] = h.l1Messages()
|
data["L1Messages"] = h.l1Messages()
|
||||||
data["L1Window"] = h.l1Window()
|
data["L1Window"] = h.l1Window()
|
||||||
h.view.Render(w, status, "settings", data)
|
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") {
|
switch r.URL.Query().Get("updated") {
|
||||||
case "username":
|
case "username":
|
||||||
return "Username changed."
|
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) {
|
if !h.auth.AllowLoginAttempt(r) {
|
||||||
p, _ := h.principal(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())
|
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
p, _ := h.principal(r)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
user, err := h.store.GetUser(p.ID)
|
user, err := h.store.GetUser(p.ID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renaming := username != user.Username
|
renaming := username != user.Username
|
||||||
if renaming {
|
if renaming {
|
||||||
if err := validate.Username(username); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.IsGlobal() {
|
if p.IsGlobal() {
|
||||||
if err := validate.Email(dmarcEmail); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,16 +147,16 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
repassword := password != "" || confirm != ""
|
repassword := password != "" || confirm != ""
|
||||||
if repassword {
|
if repassword {
|
||||||
if password != confirm {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := validate.AdminPassword(password); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !renaming && !repassword && !emailChanging {
|
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())
|
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -165,8 +165,8 @@ func (h *Handlers) submitAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
if repassword {
|
if repassword {
|
||||||
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: account: hashing password failed: %v", err)
|
logf("panel: settings: hashing password failed: %v", err)
|
||||||
h.renderAccount(w, r, http.StatusInternalServerError,
|
h.renderSettings(w, r, http.StatusInternalServerError,
|
||||||
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
|
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal())
|
||||||
return
|
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 {
|
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."
|
msg := "Could not save the changes. Please check the logs and try again."
|
||||||
if errors.Is(err, store.ErrUserNotFound) {
|
if errors.Is(err, store.ErrUserNotFound) {
|
||||||
msg = "There is no user account to update."
|
msg = "There is no user account to update."
|
||||||
}
|
}
|
||||||
if errors.Is(err, store.ErrUserExists) {
|
if errors.Is(err, store.ErrUserExists) {
|
||||||
msg = "That username is already in use."
|
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
|
return
|
||||||
}
|
}
|
||||||
h.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
|
h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
|
||||||
return
|
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)
|
http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,145 +46,81 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Address list shown only in list mode -----------------------------
|
// --- Conditional field visibility ------------------------------------
|
||||||
// The "Addresses" field applies to list mode only; in wildcard mode the
|
// Several forms hide a block until a select, checkbox or file input says it
|
||||||
// server ignores it, so hiding it removes a field that does nothing. The
|
// applies. One rule table drives them all so the five near-identical helpers
|
||||||
// toggle runs on load too, because the edit form of an existing application
|
// do not drift.
|
||||||
// may already be set to list mode.
|
var showWhenRules = [
|
||||||
function syncAddressField(select) {
|
{
|
||||||
var form = select.closest("form");
|
match: "select[data-list-mode]",
|
||||||
var field = form && form.querySelector("[data-addresses]");
|
target: "[data-addresses]",
|
||||||
if (!field) {
|
visible: function (el) { return el.value === el.dataset.listMode; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: "select[data-custom-mode]",
|
||||||
|
target: "[data-custom-address]",
|
||||||
|
visible: function (el) { return el.value === el.dataset.customMode; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: "select[data-global-role]",
|
||||||
|
target: "[data-domain-pick]",
|
||||||
|
visible: function (el) { return el.value !== el.dataset.globalRole; }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: "input[data-encrypt-toggle]",
|
||||||
|
target: "[data-encrypt-fields]",
|
||||||
|
visible: function (el) { return el.checked; },
|
||||||
|
clearWhenHidden: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: "input[data-import-file]",
|
||||||
|
target: "[data-import-password-fields]",
|
||||||
|
visible: function (el) {
|
||||||
|
var name = (el.files && el.files[0] && el.files[0].name || "").toLowerCase();
|
||||||
|
return name !== "" && !/\.json$/.test(name);
|
||||||
|
},
|
||||||
|
clearWhenHidden: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function syncShowWhen(control) {
|
||||||
|
var form = control.closest("form");
|
||||||
|
if (!form) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The mode values come from the server (store.AddressModeList), so the
|
for (var i = 0; i < showWhenRules.length; i++) {
|
||||||
// select carries the one that means "list" rather than this script
|
var rule = showWhenRules[i];
|
||||||
// hard-coding it.
|
if (!control.matches(rule.match)) {
|
||||||
field.hidden = select.value !== select.dataset.listMode;
|
continue;
|
||||||
}
|
}
|
||||||
|
var target = form.querySelector(rule.target);
|
||||||
function initAddressFields(root) {
|
if (!target) {
|
||||||
root.querySelectorAll("select[data-list-mode]").forEach(function (select) {
|
return;
|
||||||
syncAddressField(select);
|
}
|
||||||
select.addEventListener("change", function () {
|
var show = rule.visible(control);
|
||||||
syncAddressField(select);
|
target.hidden = !show;
|
||||||
});
|
if (!show && rule.clearWhenHidden) {
|
||||||
});
|
target.querySelectorAll("input").forEach(function (input) {
|
||||||
}
|
input.value = "";
|
||||||
|
});
|
||||||
// --- Custom DMARC rua address shown only for "custom" mode ------------
|
}
|
||||||
// Same idea as the address list: the email field only applies when the
|
|
||||||
// operator picks Custom address. With JavaScript blocked the field stays
|
|
||||||
// visible and the server still ignores it for inherit/none.
|
|
||||||
function syncCustomAddressField(select) {
|
|
||||||
var form = select.closest("form");
|
|
||||||
var field = form && form.querySelector("[data-custom-address]");
|
|
||||||
if (!field) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
field.hidden = select.value !== select.dataset.customMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCustomAddressFields(root) {
|
function initShowWhen(root) {
|
||||||
root.querySelectorAll("select[data-custom-mode]").forEach(function (select) {
|
showWhenRules.forEach(function (rule) {
|
||||||
syncCustomAddressField(select);
|
root.querySelectorAll(rule.match).forEach(function (control) {
|
||||||
select.addEventListener("change", function () {
|
syncShowWhen(control);
|
||||||
syncCustomAddressField(select);
|
control.addEventListener("change", function () {
|
||||||
});
|
syncShowWhen(control);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// --- Domain pick shown only for domain administrators ------------------
|
|
||||||
// Global administrators manage every domain, so the assignment checkboxes
|
|
||||||
// are irrelevant for that role. The toggle runs on load too, because the
|
|
||||||
// edit form of an existing global user should not flash the fieldset.
|
|
||||||
function syncDomainPickField(select) {
|
|
||||||
var form = select.closest("form");
|
|
||||||
var field = form && form.querySelector("[data-domain-pick]");
|
|
||||||
if (!field) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.hidden = select.value === select.dataset.globalRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initDomainPickFields(root) {
|
|
||||||
root.querySelectorAll("select[data-global-role]").forEach(function (select) {
|
|
||||||
syncDomainPickField(select);
|
|
||||||
select.addEventListener("change", function () {
|
|
||||||
syncDomainPickField(select);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Encryption password fields shown only when asked for --------------
|
|
||||||
// The backup, export and import forms carry an optional password block. It
|
|
||||||
// is hidden until the checkbox next to it is ticked, and cleared when it is
|
|
||||||
// unticked, so a password typed and then abandoned is never submitted. With
|
|
||||||
// JavaScript blocked the block stays visible and the forms behave exactly as
|
|
||||||
// the server reads them: the checkbox alone decides whether encryption
|
|
||||||
// happens.
|
|
||||||
function syncEncryptFields(box) {
|
|
||||||
var form = box.closest("form");
|
|
||||||
var fields = form && form.querySelector("[data-encrypt-fields]");
|
|
||||||
if (!fields) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fields.hidden = !box.checked;
|
|
||||||
if (!box.checked) {
|
|
||||||
fields.querySelectorAll("input").forEach(function (input) {
|
|
||||||
input.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initEncryptFields(root) {
|
|
||||||
root.querySelectorAll("input[data-encrypt-toggle]").forEach(function (box) {
|
|
||||||
syncEncryptFields(box);
|
|
||||||
box.addEventListener("change", function () {
|
|
||||||
syncEncryptFields(box);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Import password field shown based on the chosen file's extension ---
|
|
||||||
// The domain-import file decides for itself whether it is encrypted (the
|
|
||||||
// server checks the envelope magic, not a checkbox), so the panel offers
|
|
||||||
// the password field the same way: reveal it for a .spde file, hide and
|
|
||||||
// clear it for a plain .json one. With no file chosen yet there is nothing
|
|
||||||
// to ask a password for, so the field stays hidden until a file names it.
|
|
||||||
// An unrecognised name leaves the field visible rather than guessing wrong
|
|
||||||
// and hiding a password the file needs.
|
|
||||||
function syncImportPasswordField(input) {
|
|
||||||
var form = input.closest("form");
|
|
||||||
var fields = form && form.querySelector("[data-import-password-fields]");
|
|
||||||
if (!fields) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var name = (input.files && input.files[0] && input.files[0].name || "").toLowerCase();
|
|
||||||
var hide = name === "" || /\.json$/.test(name);
|
|
||||||
fields.hidden = hide;
|
|
||||||
if (hide) {
|
|
||||||
fields.querySelectorAll("input").forEach(function (pw) {
|
|
||||||
pw.value = "";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initImportPasswordField(root) {
|
|
||||||
root.querySelectorAll("input[data-import-file]").forEach(function (input) {
|
|
||||||
syncImportPasswordField(input);
|
|
||||||
input.addEventListener("change", function () {
|
|
||||||
syncImportPasswordField(input);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
initAddressFields(document);
|
initShowWhen(document);
|
||||||
initCustomAddressFields(document);
|
|
||||||
initDomainPickFields(document);
|
|
||||||
initEncryptFields(document);
|
|
||||||
initImportPasswordField(document);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Adaptive monitoring polling ---------------------------------------
|
// --- Adaptive monitoring polling ---------------------------------------
|
||||||
|
|||||||
@@ -171,8 +171,7 @@
|
|||||||
|
|
||||||
{{if .SameDomainRUA}}
|
{{if .SameDomainRUA}}
|
||||||
<p class="error">The report address is on this sending domain. SelfPost does
|
<p class="error">The report address is on this sending domain. SelfPost does
|
||||||
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
not receive inbound mail — use a mailbox on another domain.</p>
|
||||||
reception in a future release.</p>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .NeedsReportAuth}}
|
{{if .NeedsReportAuth}}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@
|
|||||||
<h2>DMARC aggregate reports</h2>
|
<h2>DMARC aggregate reports</h2>
|
||||||
<p class="muted">Optional default <code>rua=</code> address for every sending
|
<p class="muted">Optional default <code>rua=</code> address for every sending
|
||||||
domain (can be overridden per domain). Use a mailbox on a domain that
|
domain (can be overridden per domain). Use a mailbox on a domain that
|
||||||
receives inbound mail. SelfPost is send-only today; a future release will
|
receives inbound mail. SelfPost does not receive inbound mail — point
|
||||||
be able to receive reports in the panel itself.</p>
|
<code>rua=</code> at a mailbox elsewhere.</p>
|
||||||
|
|
||||||
<label for="dmarc_report_email">Default report address</label>
|
<label for="dmarc_report_email">Default report address</label>
|
||||||
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
||||||
|
|||||||
+1
-1
@@ -158,7 +158,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
|
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
|
||||||
authed.HandleFunc("POST /reload", h.HandleReload)
|
authed.HandleFunc("POST /reload", h.HandleReload)
|
||||||
|
|
||||||
authed.HandleFunc("/settings", h.HandleAccount)
|
authed.HandleFunc("/settings", h.HandleSettings)
|
||||||
authed.HandleFunc("/account", redirectSettings)
|
authed.HandleFunc("/account", redirectSettings)
|
||||||
|
|
||||||
authed.HandleFunc("GET /users", h.HandleUsers)
|
authed.HandleFunc("GET /users", h.HandleUsers)
|
||||||
|
|||||||
Reference in New Issue
Block a user