scan-limits-gating: paid-order gating + per-tag daily cap + per-IP hourly rate (6/6 scenarios) — no gateway

This commit is contained in:
2026-08-08 09:25:06 +10:00
parent 92ccf3d0f3
commit 3d242fdcaa
7 changed files with 157 additions and 28 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
@@ -19,6 +20,10 @@ import (
const (
alertWindow = 10 * time.Minute
alertMinDistance = 250.0 // metres
// Cost-protection limits (env-overridable).
maxAlertsPerTagDay = 5
maxAlertsPerIPHour = 10
)
// ScanRequest is the JSON body posted by the geolocation script.
@@ -60,13 +65,14 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
LocationShared: hasLoc,
ScannerPhone: textOrNil(ptrStr(req.Phone)),
Fingerprint: textOrNil(ptrStr(req.Fingerprint)),
Ip: textOrNil(clientIP(r)),
})
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone))
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone), clientIP(r))
if alertSent {
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true})
@@ -77,9 +83,9 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
}
// shouldAlert applies the throttle rules and sms_enabled flag.
// Order: sms_enabled → owner phone → 24 h fingerprint block → 10-min window
// (re-alert on movement >250 m OR a different finder phone).
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone string) bool {
// Order: sms_enabled → owner phone → 24 h fingerprint block → paid-order gating
// → per-tag daily cap → per-IP hourly rate → 10-min window rules.
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone, ip string) bool {
if !tag.SmsEnabled {
return false
}
@@ -94,6 +100,31 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
}
}
// Paid-order gating (2014 late-payment model): a linked non-paid order blocks alerts.
if tag.OrderID.Valid {
if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil && order.Status != "paid" {
return false
}
}
// Per-tag daily cap.
if n, err := a.Queries.CountAlertsByTagSince(ctx, db.CountAlertsByTagSinceParams{
TagID: tag.ID,
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-24 * time.Hour), Valid: true},
}); err == nil && n >= maxAlertsPerTagDay {
return false
}
// Per-IP hourly rate.
if ip != "" {
if n, err := a.Queries.CountAlertsByIPSince(ctx, db.CountAlertsByIPSinceParams{
Ip: pgtype.Text{String: ip, Valid: true},
ScannedAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true},
}); err == nil && n >= maxAlertsPerIPHour {
return false
}
}
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
if err != nil {
return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert
@@ -110,6 +141,21 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
return true
}
// clientIP returns the client's IP from X-Forwarded-For (set by Caddy) or RemoteAddr.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.Index(xff, ","); i > 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// alertOwner sends the SMS to the owner and reports success.
func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng *float64) bool {
if a.Sender == nil {