test / test (push) Canceled after 0s
Step 4 of stage 4 (L10, L11, L13, L18, L19 and a row of
review-2026-08-followups):
- The Outbound log counts first and clamps the page to the last; a number
too large for an int (strconv's ErrRange with the largest int) is the
last page on both logs. Tested at the edges: first, last, past the last
however far, nonsense, an empty log asked for page 5.
- checkAutoMultiplier is "not within", so NaN and the infinities are out.
- rate-limited is red (components.go; the mockup first, 643892a).
- data-unsaved on both recalculate buttons: panel.js asks on submit
(ev.submitter) when a field of the page differs from what it was drawn
with; testdata/unsaved_test.js under TestPanelJS.
- HandleReload writes what a restore's first start writes - the filter
lists and the DMARC maps too - and a failed step is an error flash on
Health that names it, not plain text; the success flash says what was
written.
Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 695 s) on this tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
7.5 KiB
Go
256 lines
7.5 KiB
Go
package handlers
|
||
|
||
import (
|
||
"errors"
|
||
"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)
|
||
}
|
||
return v, checkAutoMultiplier(v)
|
||
}
|
||
|
||
// checkAutoMultiplier is the rule for an auto limit's multiplier, shared by
|
||
// the form and a domain import.
|
||
// Written as "not within" so NaN, which compares false with everything, is
|
||
// outside too: ParseFloat reads "NaN" (audit 2026-10-12, L11 - stored as NULL,
|
||
// read back as 0, recalculated as the default without a word).
|
||
func checkAutoMultiplier(v float64) error {
|
||
if !(v >= store.MinAutoMultiplier && v <= store.MaxAutoMultiplier) {
|
||
return fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// checkManualLimit is the rule for a manual limit, shared by the form and a
|
||
// domain import: a window of no seconds would count nothing.
|
||
func checkManualLimit(maxMessages, windowSeconds, l1Max int) error {
|
||
if maxMessages <= 0 {
|
||
return fmt.Errorf("enter a message limit greater than zero")
|
||
}
|
||
if maxMessages > l1Max {
|
||
return fmt.Errorf("message limit cannot exceed the level-1 backstop (%d)", l1Max)
|
||
}
|
||
if windowSeconds <= 0 {
|
||
return fmt.Errorf("enter a time window greater than zero seconds")
|
||
}
|
||
return 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
|
||
}
|
||
windowSeconds, err := parsePositiveInt(r.PostFormValue("window_seconds"), defaultRateLimitWindowSeconds)
|
||
if err != nil {
|
||
windowSeconds = 0
|
||
}
|
||
if err := checkManualLimit(maxMessages, windowSeconds, l1Max); err != nil {
|
||
return rateLimitInput{}, err
|
||
}
|
||
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.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: 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) 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 {
|
||
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: recalcErrorMessage(err, "domain", d.ID)})
|
||
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, d, ok := h.lookupDomainApplication(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := h.recalcRateLimit(store.RateLimitScopeApp, a.ID); err != nil {
|
||
h.renderApplicationForm(w, r, http.StatusBadRequest, d, &a, nil,
|
||
fmt.Sprintf("%s: %s", a.Login, recalcErrorMessage(err, "application", a.ID)))
|
||
return
|
||
}
|
||
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
|
||
}
|
||
|
||
// recalcErrorMessage is what the page says about a recalculation that did not
|
||
// happen. A limit that is not automatic has nothing to recalculate, and the
|
||
// administrator is told so; any other failure goes to the log.
|
||
func recalcErrorMessage(err error, what string, id int64) string {
|
||
if errors.Is(err, store.ErrNotAutoRateLimit) {
|
||
return err.Error()
|
||
}
|
||
logf("panel: %s %d: recalc rate limit: %v", what, id, err)
|
||
return "Could not recalculate the limit. Check the logs and try again."
|
||
}
|
||
|
||
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)
|
||
}
|