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

@@ -71,3 +71,6 @@ ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-- Phase 4: Laravel session auth "remember me" token (idempotent). -- Phase 4: Laravel session auth "remember me" token (idempotent).
ALTER TABLE users ADD COLUMN IF NOT EXISTS remember_token TEXT; ALTER TABLE users ADD COLUMN IF NOT EXISTS remember_token TEXT;
-- Phase 5-lite: client IP on scans for rate limiting (idempotent).
ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;

View File

@@ -35,6 +35,7 @@ type Scan struct {
ScannerPhone pgtype.Text `json:"scanner_phone"` ScannerPhone pgtype.Text `json:"scanner_phone"`
AlertSent bool `json:"alert_sent"` AlertSent bool `json:"alert_sent"`
Fingerprint pgtype.Text `json:"fingerprint"` Fingerprint pgtype.Text `json:"fingerprint"`
Ip pgtype.Text `json:"ip"`
} }
type Tag struct { type Tag struct {
@@ -56,10 +57,12 @@ type Tag struct {
} }
type User struct { type User struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"password_hash"` PasswordHash string `json:"password_hash"`
Name pgtype.Text `json:"name"` Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"` Phone pgtype.Text `json:"phone"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
IsAdmin bool `json:"is_admin"`
RememberToken pgtype.Text `json:"remember_token"`
} }

View File

@@ -13,10 +13,13 @@ import (
type Querier interface { type Querier interface {
BindTag(ctx context.Context, arg BindTagParams) (Tag, error) BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
ClearTagOwner(ctx context.Context, id int64) (Tag, error) ClearTagOwner(ctx context.Context, id int64) (Tag, error)
CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error)
CountAlertsByTagSince(ctx context.Context, arg CountAlertsByTagSinceParams) (int64, error)
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error) CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error)
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, error)
GetOrderByID(ctx context.Context, id int64) (Order, error)
GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error)
GetTagByCode(ctx context.Context, tagCode string) (Tag, error) GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
GetTagByID(ctx context.Context, id int64) (Tag, error) GetTagByID(ctx context.Context, id int64) (Tag, error)

View File

@@ -47,8 +47,8 @@ INSERT INTO tags (tag_code) VALUES ($1)
RETURNING *; RETURNING *;
-- name: InsertScan :one -- name: InsertScan :one
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint) INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint, ip)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *; RETURNING *;
-- name: GetLastAlertByTag :one -- name: GetLastAlertByTag :one
@@ -74,3 +74,14 @@ SELECT * FROM scans
WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours' WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours'
ORDER BY scanned_at DESC ORDER BY scanned_at DESC
LIMIT 1; LIMIT 1;
-- name: GetOrderByID :one
SELECT * FROM orders WHERE id = $1;
-- name: CountAlertsByTagSince :one
SELECT count(*) FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE AND scanned_at > $2;
-- name: CountAlertsByIPSince :one
SELECT count(*) FROM scans
WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2;

View File

@@ -76,6 +76,40 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
return i, err return i, err
} }
const countAlertsByIPSince = `-- name: CountAlertsByIPSince :one
SELECT count(*) FROM scans
WHERE ip = $1 AND alert_sent = TRUE AND scanned_at > $2
`
type CountAlertsByIPSinceParams struct {
Ip pgtype.Text `json:"ip"`
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
}
func (q *Queries) CountAlertsByIPSince(ctx context.Context, arg CountAlertsByIPSinceParams) (int64, error) {
row := q.db.QueryRow(ctx, countAlertsByIPSince, arg.Ip, arg.ScannedAt)
var count int64
err := row.Scan(&count)
return count, err
}
const countAlertsByTagSince = `-- name: CountAlertsByTagSince :one
SELECT count(*) FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE AND scanned_at > $2
`
type CountAlertsByTagSinceParams struct {
TagID int64 `json:"tag_id"`
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
}
func (q *Queries) CountAlertsByTagSince(ctx context.Context, arg CountAlertsByTagSinceParams) (int64, error) {
row := q.db.QueryRow(ctx, countAlertsByTagSince, arg.TagID, arg.ScannedAt)
var count int64
err := row.Scan(&count)
return count, err
}
const countTagsByOwner = `-- name: CountTagsByOwner :one const countTagsByOwner = `-- name: CountTagsByOwner :one
SELECT count(*) FROM tags WHERE owner_id = $1 SELECT count(*) FROM tags WHERE owner_id = $1
` `
@@ -90,7 +124,7 @@ func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (in
const createUser = `-- name: CreateUser :one const createUser = `-- name: CreateUser :one
INSERT INTO users (email, password_hash, name, phone) INSERT INTO users (email, password_hash, name, phone)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING id, email, password_hash, name, phone, created_at RETURNING id, email, password_hash, name, phone, created_at, is_admin, remember_token
` `
type CreateUserParams struct { type CreateUserParams struct {
@@ -115,12 +149,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.Name, &i.Name,
&i.Phone, &i.Phone,
&i.CreatedAt, &i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
) )
return i, err return i, err
} }
const getLastAlertByTag = `-- name: GetLastAlertByTag :one const getLastAlertByTag = `-- name: GetLastAlertByTag :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE WHERE tag_id = $1 AND alert_sent = TRUE
ORDER BY scanned_at DESC ORDER BY scanned_at DESC
LIMIT 1 LIMIT 1
@@ -139,12 +175,13 @@ func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, err
&i.ScannerPhone, &i.ScannerPhone,
&i.AlertSent, &i.AlertSent,
&i.Fingerprint, &i.Fingerprint,
&i.Ip,
) )
return i, err return i, err
} }
const getLatestScanByTag = `-- name: GetLatestScanByTag :one const getLatestScanByTag = `-- name: GetLatestScanByTag :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip FROM scans
WHERE tag_id = $1 WHERE tag_id = $1
ORDER BY scanned_at DESC ORDER BY scanned_at DESC
LIMIT 1 LIMIT 1
@@ -163,12 +200,30 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er
&i.ScannerPhone, &i.ScannerPhone,
&i.AlertSent, &i.AlertSent,
&i.Fingerprint, &i.Fingerprint,
&i.Ip,
)
return i, err
}
const getOrderByID = `-- name: GetOrderByID :one
SELECT id, account_id, status, created_at, updated_at FROM orders WHERE id = $1
`
func (q *Queries) GetOrderByID(ctx context.Context, id int64) (Order, error) {
row := q.db.QueryRow(ctx, getOrderByID, id)
var i Order
err := row.Scan(
&i.ID,
&i.AccountID,
&i.Status,
&i.CreatedAt,
&i.UpdatedAt,
) )
return i, err return i, err
} }
const getRecentScanByFingerprint = `-- name: GetRecentScanByFingerprint :one const getRecentScanByFingerprint = `-- name: GetRecentScanByFingerprint :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip FROM scans
WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours' WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours'
ORDER BY scanned_at DESC ORDER BY scanned_at DESC
LIMIT 1 LIMIT 1
@@ -192,6 +247,7 @@ func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentS
&i.ScannerPhone, &i.ScannerPhone,
&i.AlertSent, &i.AlertSent,
&i.Fingerprint, &i.Fingerprint,
&i.Ip,
) )
return i, err return i, err
} }
@@ -251,7 +307,7 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
} }
const getUserByEmail = `-- name: GetUserByEmail :one const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE email = $1 SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token FROM users WHERE email = $1
` `
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
@@ -264,12 +320,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error
&i.Name, &i.Name,
&i.Phone, &i.Phone,
&i.CreatedAt, &i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE id = $1 SELECT id, email, password_hash, name, phone, created_at, is_admin, remember_token FROM users WHERE id = $1
` `
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@@ -282,14 +340,16 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.Name, &i.Name,
&i.Phone, &i.Phone,
&i.CreatedAt, &i.CreatedAt,
&i.IsAdmin,
&i.RememberToken,
) )
return i, err return i, err
} }
const insertScan = `-- name: InsertScan :one const insertScan = `-- name: InsertScan :one
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint) INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint, ip)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip
` `
type InsertScanParams struct { type InsertScanParams struct {
@@ -299,6 +359,7 @@ type InsertScanParams struct {
LocationShared bool `json:"location_shared"` LocationShared bool `json:"location_shared"`
ScannerPhone pgtype.Text `json:"scanner_phone"` ScannerPhone pgtype.Text `json:"scanner_phone"`
Fingerprint pgtype.Text `json:"fingerprint"` Fingerprint pgtype.Text `json:"fingerprint"`
Ip pgtype.Text `json:"ip"`
} }
func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) { func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) {
@@ -309,6 +370,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
arg.LocationShared, arg.LocationShared,
arg.ScannerPhone, arg.ScannerPhone,
arg.Fingerprint, arg.Fingerprint,
arg.Ip,
) )
var i Scan var i Scan
err := row.Scan( err := row.Scan(
@@ -321,6 +383,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
&i.ScannerPhone, &i.ScannerPhone,
&i.AlertSent, &i.AlertSent,
&i.Fingerprint, &i.Fingerprint,
&i.Ip,
) )
return i, err return i, err
} }

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -19,6 +20,10 @@ import (
const ( const (
alertWindow = 10 * time.Minute alertWindow = 10 * time.Minute
alertMinDistance = 250.0 // metres alertMinDistance = 250.0 // metres
// Cost-protection limits (env-overridable).
maxAlertsPerTagDay = 5
maxAlertsPerIPHour = 10
) )
// ScanRequest is the JSON body posted by the geolocation script. // 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, LocationShared: hasLoc,
ScannerPhone: textOrNil(ptrStr(req.Phone)), ScannerPhone: textOrNil(ptrStr(req.Phone)),
Fingerprint: textOrNil(ptrStr(req.Fingerprint)), Fingerprint: textOrNil(ptrStr(req.Fingerprint)),
Ip: textOrNil(clientIP(r)),
}) })
if err != nil { if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return 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 alertSent {
if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) { if a.alertOwner(r.Context(), tag, scan, req.Lat, req.Lng) {
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: scan.ID, AlertSent: true}) _ = 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. // shouldAlert applies the throttle rules and sms_enabled flag.
// Order: sms_enabled → owner phone → 24 h fingerprint block → 10-min window // Order: sms_enabled → owner phone → 24 h fingerprint block → paid-order gating
// (re-alert on movement >250 m OR a different finder phone). // → 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 string) bool { 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 { if !tag.SmsEnabled {
return false 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) last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
if err != nil { if err != nil {
return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert 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 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. // 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 { func (a *App) alertOwner(ctx context.Context, tag db.Tag, scan db.Scan, lat, lng *float64) bool {
if a.Sender == nil { if a.Sender == nil {

View File

@@ -1,15 +1,15 @@
## 1. Schema & Queries ## 1. Schema & Queries
- [ ] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;`; `make db-up` - [x] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;`; `make db-up`
- [ ] 1.2 `queries.sql`: `GetOrderByID` (:one), `CountAlertsByTagSince` (:one, tag_id + since → count of alert_sent), `CountAlertsByIPSince` (:one, ip + since); `make generate`; build - [x] 1.2 `queries.sql`: `GetOrderByID` (:one), `CountAlertsByTagSince` (:one, tag_id + since → count of alert_sent), `CountAlertsByIPSince` (:one, ip + since); `make generate`; build
## 2. Gating & Limits ## 2. Gating & Limits
- [ ] 2.1 `scan.go`: capture client IP (X-Forwarded-For → RemoteAddr host); pass IP into `InsertScan` - [x] 2.1 `scan.go`: capture client IP (X-Forwarded-For → RemoteAddr host); pass IP into `InsertScan`
- [ ] 2.2 `shouldAlert`: add paid-gating (order exists && status != 'paid' → false), per-tag daily cap (default 5, env ALERT_MAX_TAG_DAY), per-IP hourly cap (default 10, env ALERT_MAX_IP_HOUR) — checked in that order - [x] 2.2 `shouldAlert`: add paid-gating (order exists && status != 'paid' → false), per-tag daily cap (default 5, env ALERT_MAX_TAG_DAY), per-IP hourly cap (default 10, env ALERT_MAX_IP_HOUR) — checked in that order
## 3. Verification ## 3. Verification
- [ ] 3.1 Extend verify suite: lapsed-order tag → no alert; paid-order tag → alert; no-order tag → alert; tag over daily cap → no alert; IP over hourly cap → no alert - [x] 3.1 Extend verify suite: lapsed-order tag → no alert; paid-order tag → alert; no-order tag → alert; tag over daily cap → no alert; IP over hourly cap → no alert
- [ ] 3.2 Existing suites (Phase 1/2/2.5) still pass on fresh DB - [x] 3.2 Existing suites (Phase 1/2/2.5) still pass on fresh DB
- [ ] 3.3 `openspec validate scan-limits-gating`; commit - [x] 3.3 `openspec validate scan-limits-gating`; commit