From 3d242fdcaae53bc9eafd42a58a8339353da8f03d Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Sat, 8 Aug 2026 09:25:06 +1000 Subject: [PATCH] =?UTF-8?q?scan-limits-gating:=20paid-order=20gating=20+?= =?UTF-8?q?=20per-tag=20daily=20cap=20+=20per-IP=20hourly=20rate=20(6/6=20?= =?UTF-8?q?scenarios)=20=E2=80=94=20no=20gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schema.sql | 3 + frontend/internal/db/models.go | 15 ++-- frontend/internal/db/querier.go | 3 + frontend/internal/db/queries.sql | 15 +++- frontend/internal/db/queries.sql.go | 81 +++++++++++++++++--- frontend/internal/handlers/scan.go | 54 ++++++++++++- openspec/changes/scan-limits-gating/tasks.md | 14 ++-- 7 files changed, 157 insertions(+), 28 deletions(-) diff --git a/db/schema.sql b/db/schema.sql index bd5ba0b..bab7c84 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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; -- Phase 4: Laravel session auth "remember me" token (idempotent). 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; diff --git a/frontend/internal/db/models.go b/frontend/internal/db/models.go index f11c296..58ca684 100644 --- a/frontend/internal/db/models.go +++ b/frontend/internal/db/models.go @@ -35,6 +35,7 @@ type Scan struct { ScannerPhone pgtype.Text `json:"scanner_phone"` AlertSent bool `json:"alert_sent"` Fingerprint pgtype.Text `json:"fingerprint"` + Ip pgtype.Text `json:"ip"` } type Tag struct { @@ -56,10 +57,12 @@ type Tag struct { } type User struct { - ID int64 `json:"id"` - Email string `json:"email"` - PasswordHash string `json:"password_hash"` - Name pgtype.Text `json:"name"` - Phone pgtype.Text `json:"phone"` - CreatedAt pgtype.Timestamptz `json:"created_at"` + ID int64 `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + Name pgtype.Text `json:"name"` + Phone pgtype.Text `json:"phone"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + IsAdmin bool `json:"is_admin"` + RememberToken pgtype.Text `json:"remember_token"` } diff --git a/frontend/internal/db/querier.go b/frontend/internal/db/querier.go index ae1f24d..a7d439b 100644 --- a/frontend/internal/db/querier.go +++ b/frontend/internal/db/querier.go @@ -13,10 +13,13 @@ import ( type Querier interface { BindTag(ctx context.Context, arg BindTagParams) (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) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) GetLastAlertByTag(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) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) GetTagByID(ctx context.Context, id int64) (Tag, error) diff --git a/frontend/internal/db/queries.sql b/frontend/internal/db/queries.sql index 722b4ef..fa0c4c9 100644 --- a/frontend/internal/db/queries.sql +++ b/frontend/internal/db/queries.sql @@ -47,8 +47,8 @@ INSERT INTO tags (tag_code) VALUES ($1) RETURNING *; -- name: InsertScan :one -INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint) -VALUES ($1, $2, $3, $4, $5, $6) +INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint, ip) +VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *; -- name: GetLastAlertByTag :one @@ -74,3 +74,14 @@ SELECT * FROM scans WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours' ORDER BY scanned_at DESC 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; diff --git a/frontend/internal/db/queries.sql.go b/frontend/internal/db/queries.sql.go index 05e44e4..c4ad5a2 100644 --- a/frontend/internal/db/queries.sql.go +++ b/frontend/internal/db/queries.sql.go @@ -76,6 +76,40 @@ func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) { 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 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 INSERT INTO users (email, password_hash, name, phone) 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 { @@ -115,12 +149,14 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.Name, &i.Phone, &i.CreatedAt, + &i.IsAdmin, + &i.RememberToken, ) return i, err } 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 ORDER BY scanned_at DESC LIMIT 1 @@ -139,12 +175,13 @@ func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, err &i.ScannerPhone, &i.AlertSent, &i.Fingerprint, + &i.Ip, ) return i, err } 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 ORDER BY scanned_at DESC LIMIT 1 @@ -163,12 +200,30 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er &i.ScannerPhone, &i.AlertSent, &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 } 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' ORDER BY scanned_at DESC LIMIT 1 @@ -192,6 +247,7 @@ func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentS &i.ScannerPhone, &i.AlertSent, &i.Fingerprint, + &i.Ip, ) return i, err } @@ -251,7 +307,7 @@ func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) { } 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) { @@ -264,12 +320,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.Name, &i.Phone, &i.CreatedAt, + &i.IsAdmin, + &i.RememberToken, ) return i, err } 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) { @@ -282,14 +340,16 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { &i.Name, &i.Phone, &i.CreatedAt, + &i.IsAdmin, + &i.RememberToken, ) return i, err } const insertScan = `-- name: InsertScan :one -INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint +INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint, ip) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint, ip ` type InsertScanParams struct { @@ -299,6 +359,7 @@ type InsertScanParams struct { LocationShared bool `json:"location_shared"` ScannerPhone pgtype.Text `json:"scanner_phone"` Fingerprint pgtype.Text `json:"fingerprint"` + Ip pgtype.Text `json:"ip"` } 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.ScannerPhone, arg.Fingerprint, + arg.Ip, ) var i Scan err := row.Scan( @@ -321,6 +383,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e &i.ScannerPhone, &i.AlertSent, &i.Fingerprint, + &i.Ip, ) return i, err } diff --git a/frontend/internal/handlers/scan.go b/frontend/internal/handlers/scan.go index ea251b0..653f76b 100644 --- a/frontend/internal/handlers/scan.go +++ b/frontend/internal/handlers/scan.go @@ -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 { diff --git a/openspec/changes/scan-limits-gating/tasks.md b/openspec/changes/scan-limits-gating/tasks.md index 195edce..4d649bc 100644 --- a/openspec/changes/scan-limits-gating/tasks.md +++ b/openspec/changes/scan-limits-gating/tasks.md @@ -1,15 +1,15 @@ ## 1. Schema & Queries -- [ ] 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.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;`; `make db-up` +- [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.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.1 `scan.go`: capture client IP (X-Forwarded-For → RemoteAddr host); pass IP into `InsertScan` +- [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.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 -- [ ] 3.3 `openspec validate scan-limits-gating`; commit +- [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 +- [x] 3.2 Existing suites (Phase 1/2/2.5) still pass on fresh DB +- [x] 3.3 `openspec validate scan-limits-gating`; commit