scan-limits-gating: paid-order gating + per-tag daily cap + per-IP hourly rate (6/6 scenarios) — no gateway
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user