Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.
Routes (web.go)
- /status -> /overview; /domains -> /outbound/domains with the domain's
settings actions under .../settings/ (reports, ratelimit, export);
/deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
/outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
/server/log; /backup -> /server/backup (import under it); /users ->
/server/users; recheck and reload under /server/health/.
- One fragment name: <page>/fragment everywhere.
- Everything under /server/ is registered through server(), which wraps
the handler in globalOnly: the subtree is guarded once, where a new
route cannot forget it. Handlers keep their own requireGlobal.
- GET / leads to /overview, /outbound/domains or /inbound/domains.
- The /account -> /settings redirect is gone with /settings.
Account and Server > Settings
- handlers_account.go: GET /account and three POSTs - profile (username,
e-mail; no password asked, as designed), password (current password,
rate-limited like a sign-in, signs out other sessions), dmarc (the
user's default: hosted / account / custom / none, each validated).
This replaces step 1's bridge through the old Settings form.
- handlers_settings.go is the instance's page only: log retention and the
read-only rate limits, global role.
- One legacy template serves both pages by a flag until stage 2 gives each
its own: a new legacy page cannot be added to the ratchet.
Still to come inside stage 1, and why @routes stays on the ratchet:
- step 4: the application routes (one POST, under the domain);
- step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.
Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).
Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.
go test ./... green except the two Windows-only failures in internal/domain
and internal/logtail.
Roadmap 13/27.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
8.2 KiB
Go
282 lines
8.2 KiB
Go
package handlers
|
||
|
||
import (
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/mixeme/selfpost/internal/store"
|
||
)
|
||
|
||
const defaultRateLimitWindowSeconds = 3600
|
||
|
||
type rateLimitInput struct {
|
||
clear bool
|
||
mode string
|
||
ips []string
|
||
maxMessages int
|
||
windowSeconds int
|
||
autoMultiplier float64
|
||
}
|
||
|
||
func (h *Handlers) l1Messages() int {
|
||
if h.cfg.RateLimitMessagesPerIP > 0 {
|
||
return h.cfg.RateLimitMessagesPerIP
|
||
}
|
||
return 100
|
||
}
|
||
|
||
func (h *Handlers) l1Window() int {
|
||
if h.cfg.RateLimitWindowSeconds > 0 {
|
||
return h.cfg.RateLimitWindowSeconds
|
||
}
|
||
return defaultRateLimitWindowSeconds
|
||
}
|
||
|
||
func parseAutoMultiplier(raw string) (float64, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return store.DefaultAutoMultiplier, nil
|
||
}
|
||
v, err := strconv.ParseFloat(raw, 64)
|
||
if err != nil {
|
||
return 0, fmt.Errorf("enter a valid multiplier (%.1f–%.1f)", store.MinAutoMultiplier, store.MaxAutoMultiplier)
|
||
}
|
||
if v < store.MinAutoMultiplier || v > store.MaxAutoMultiplier {
|
||
return 0, fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier)
|
||
}
|
||
return v, nil
|
||
}
|
||
|
||
func parseRateLimitMode(raw string) (string, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return store.RateLimitModeManual, nil
|
||
}
|
||
if raw != store.RateLimitModeManual && raw != store.RateLimitModeAuto {
|
||
return "", fmt.Errorf("choose manual or auto mode")
|
||
}
|
||
return raw, nil
|
||
}
|
||
|
||
func parseRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
|
||
if err := r.ParseForm(); err != nil {
|
||
return rateLimitInput{}, fmt.Errorf("invalid form submission")
|
||
}
|
||
if r.PostFormValue("clear") != "" {
|
||
return rateLimitInput{clear: true}, nil
|
||
}
|
||
mode, err := parseRateLimitMode(r.PostFormValue("mode"))
|
||
if err != nil {
|
||
return rateLimitInput{}, err
|
||
}
|
||
if mode == store.RateLimitModeAuto {
|
||
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
|
||
if err != nil {
|
||
return rateLimitInput{}, err
|
||
}
|
||
return rateLimitInput{mode: mode, autoMultiplier: mult}, nil
|
||
}
|
||
|
||
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
|
||
if rawMax == "" {
|
||
return rateLimitInput{clear: true}, nil
|
||
}
|
||
maxMessages, err := parsePositiveInt(rawMax, 0)
|
||
if err != nil || maxMessages <= 0 {
|
||
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
|
||
}
|
||
if maxMessages > l1Max {
|
||
return rateLimitInput{}, fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||
}
|
||
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||
if err != nil || windowSeconds <= 0 {
|
||
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
|
||
}
|
||
return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
|
||
}
|
||
|
||
func parseAppAuthIPsForm(r *http.Request) (bool, []string, error) {
|
||
if err := r.ParseForm(); err != nil {
|
||
return false, nil, fmt.Errorf("invalid form submission")
|
||
}
|
||
restrict := r.PostFormValue("auth_ip_restrict") != ""
|
||
if !restrict {
|
||
return false, nil, nil
|
||
}
|
||
ips, err := parseIPList(r.PostFormValue("auth_allowed_ips"))
|
||
if err != nil {
|
||
return false, nil, err
|
||
}
|
||
if len(ips) == 0 {
|
||
return false, nil, fmt.Errorf("enter at least one client IP when the allow-list is enabled")
|
||
}
|
||
return true, ips, nil
|
||
}
|
||
|
||
func parseIPList(raw string) ([]string, error) {
|
||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||
return r == '\n' || r == '\r' || r == ',' || r == ' ' || r == '\t' || r == ';'
|
||
})
|
||
var out []string
|
||
seen := make(map[string]bool)
|
||
for _, f := range fields {
|
||
ip := net.ParseIP(f)
|
||
if ip == nil {
|
||
return nil, fmt.Errorf("%q is not a valid IP address", f)
|
||
}
|
||
c := ip.String()
|
||
if !seen[c] {
|
||
seen[c] = true
|
||
out = append(out, c)
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func parsePositiveInt(raw string, def int) (int, error) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return def, nil
|
||
}
|
||
return strconv.Atoi(raw)
|
||
}
|
||
|
||
func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
|
||
d, ok := h.lookupDomain(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
in, err := parseRateLimitForm(r, h.l1Messages())
|
||
if err != nil {
|
||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||
FormMode: store.AddressModeWildcard,
|
||
RateLimitErr: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
if err := h.applyRateLimit(in, store.RateLimitScopeDomain, d.ID, h.domains); err != nil {
|
||
logf("panel: domain %d: save rate limit: %v", d.ID, err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
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) {
|
||
a, ok := h.lookupApplication(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
d, err := h.domains.Get(a.DomainID)
|
||
if err != nil {
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
in, err := parseRateLimitForm(r, h.l1Messages())
|
||
if err != nil {
|
||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||
FormMode: store.AddressModeWildcard,
|
||
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||
})
|
||
return
|
||
}
|
||
if err := h.applyRateLimit(in, store.RateLimitScopeApp, a.ID, h.apps); err != nil {
|
||
logf("panel: application %d: save rate limit: %v", a.ID, err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
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) {
|
||
a, ok := h.lookupApplication(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
d, err := h.domains.Get(a.DomainID)
|
||
if err != nil {
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
restrict, ips, err := parseAppAuthIPsForm(r)
|
||
if err != nil {
|
||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||
FormMode: store.AddressModeWildcard,
|
||
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||
})
|
||
return
|
||
}
|
||
if err := h.apps.UpdateAuthIPs(a.ID, restrict, ips); err != nil {
|
||
logf("panel: application %d: save auth IPs: %v", a.ID, err)
|
||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
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) {
|
||
d, ok := h.lookupDomain(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := h.recalcRateLimit(store.RateLimitScopeDomain, d.ID); err != nil {
|
||
logf("panel: domain %d: recalc rate limit: %v", d.ID, err)
|
||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||
FormMode: store.AddressModeWildcard,
|
||
RateLimitErr: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
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) {
|
||
a, ok := h.lookupApplication(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := h.recalcRateLimit(store.RateLimitScopeApp, a.ID); err != nil {
|
||
d, _ := h.domains.Get(a.DomainID)
|
||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
|
||
FormMode: store.AddressModeWildcard,
|
||
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
|
||
})
|
||
return
|
||
}
|
||
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
|
||
}
|
||
|
||
func (h *Handlers) recalcRateLimit(scope string, refID int64) error {
|
||
return h.store.RecalcAutoRateLimit(scope, refID, h.sendLogRetentionDays(), h.l1Messages(), h.l1Window())
|
||
}
|
||
|
||
type rateLimitSaver interface {
|
||
ClearRateLimit(id int64) error
|
||
SaveRateLimit(id int64, rl store.RateLimit) error
|
||
}
|
||
|
||
func (h *Handlers) applyRateLimit(in rateLimitInput, scope string, refID int64, saver rateLimitSaver) error {
|
||
if in.clear {
|
||
return saver.ClearRateLimit(refID)
|
||
}
|
||
rl := store.RateLimit{
|
||
Scope: scope,
|
||
RefID: refID,
|
||
Mode: in.mode,
|
||
MaxMessages: in.maxMessages,
|
||
WindowSeconds: in.windowSeconds,
|
||
AutoMultiplier: in.autoMultiplier,
|
||
}
|
||
if in.mode == store.RateLimitModeAuto {
|
||
rl.WindowSeconds = h.l1Window()
|
||
if err := saver.SaveRateLimit(refID, rl); err != nil {
|
||
return err
|
||
}
|
||
return h.recalcRateLimit(scope, refID)
|
||
}
|
||
return saver.SaveRateLimit(refID, rl)
|
||
}
|