scan-alert-hardening: different-finder re-alert within 250m window + fingerprint 24h block (12/12 scenarios pass)

This commit is contained in:
2026-08-07 17:29:47 +10:00
parent f646af3ec6
commit 61737825ea
9 changed files with 140 additions and 33 deletions

View File

@@ -63,3 +63,6 @@ CREATE TABLE IF NOT EXISTS orders (
-- Tag → product / order linkage (populated by the Laravel admin, Phase 4).
ALTER TABLE tags ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES products(id);
ALTER TABLE tags ADD COLUMN IF NOT EXISTS order_id BIGINT REFERENCES orders(id);
-- Phase 2.5: per-device anti-spam fingerprint on scans (idempotent).
ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;

View File

@@ -34,6 +34,7 @@ type Scan struct {
LocationShared bool `json:"location_shared"`
ScannerPhone pgtype.Text `json:"scanner_phone"`
AlertSent bool `json:"alert_sent"`
Fingerprint pgtype.Text `json:"fingerprint"`
}
type Tag struct {

View File

@@ -17,6 +17,7 @@ type Querier interface {
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, error)
GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, 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)
GetUserByEmail(ctx context.Context, email string) (User, error)

View File

@@ -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)
VALUES ($1, $2, $3, $4, $5)
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: GetLastAlertByTag :one
@@ -67,4 +67,10 @@ LIMIT 1;
UPDATE scans SET alert_sent = $2 WHERE id = $1;
-- name: SetScanPhone :exec
UPDATE scans SET scanner_phone = $2 WHERE id = $1;
UPDATE scans SET scanner_phone = $2, fingerprint = $3 WHERE id = $1;
-- name: GetRecentScanByFingerprint :one
SELECT * FROM scans
WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours'
ORDER BY scanned_at DESC
LIMIT 1;

View File

@@ -120,7 +120,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
}
const getLastAlertByTag = `-- name: GetLastAlertByTag :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans
WHERE tag_id = $1 AND alert_sent = TRUE
ORDER BY scanned_at DESC
LIMIT 1
@@ -138,12 +138,13 @@ func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, err
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
)
return i, err
}
const getLatestScanByTag = `-- name: GetLatestScanByTag :one
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent FROM scans
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans
WHERE tag_id = $1
ORDER BY scanned_at DESC
LIMIT 1
@@ -161,6 +162,36 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
)
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
WHERE fingerprint = $1 AND id <> $2 AND scanned_at > now() - interval '24 hours'
ORDER BY scanned_at DESC
LIMIT 1
`
type GetRecentScanByFingerprintParams struct {
Fingerprint pgtype.Text `json:"fingerprint"`
ID int64 `json:"id"`
}
func (q *Queries) GetRecentScanByFingerprint(ctx context.Context, arg GetRecentScanByFingerprintParams) (Scan, error) {
row := q.db.QueryRow(ctx, getRecentScanByFingerprint, arg.Fingerprint, arg.ID)
var i Scan
err := row.Scan(
&i.ID,
&i.TagID,
&i.ScannedAt,
&i.Lat,
&i.Lng,
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
)
return i, err
}
@@ -256,9 +287,9 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
}
const insertScan = `-- name: InsertScan :one
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent
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
`
type InsertScanParams struct {
@@ -267,6 +298,7 @@ type InsertScanParams struct {
Lng pgtype.Float8 `json:"lng"`
LocationShared bool `json:"location_shared"`
ScannerPhone pgtype.Text `json:"scanner_phone"`
Fingerprint pgtype.Text `json:"fingerprint"`
}
func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) {
@@ -276,6 +308,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
arg.Lng,
arg.LocationShared,
arg.ScannerPhone,
arg.Fingerprint,
)
var i Scan
err := row.Scan(
@@ -287,6 +320,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
&i.LocationShared,
&i.ScannerPhone,
&i.AlertSent,
&i.Fingerprint,
)
return i, err
}
@@ -374,16 +408,17 @@ func (q *Queries) SetScanAlertSent(ctx context.Context, arg SetScanAlertSentPara
}
const setScanPhone = `-- name: SetScanPhone :exec
UPDATE scans SET scanner_phone = $2 WHERE id = $1
UPDATE scans SET scanner_phone = $2, fingerprint = $3 WHERE id = $1
`
type SetScanPhoneParams struct {
ID int64 `json:"id"`
ScannerPhone pgtype.Text `json:"scanner_phone"`
Fingerprint pgtype.Text `json:"fingerprint"`
}
func (q *Queries) SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error {
_, err := q.db.Exec(ctx, setScanPhone, arg.ID, arg.ScannerPhone)
_, err := q.db.Exec(ctx, setScanPhone, arg.ID, arg.ScannerPhone, arg.Fingerprint)
return err
}

View File

@@ -25,6 +25,8 @@ const (
type ScanRequest struct {
Lat *float64 `json:"lat"`
Lng *float64 `json:"lng"`
Phone *string `json:"phone"`
Fingerprint *string `json:"fingerprint"`
}
// Scan records a scan (with optional location) and alerts the owner per the
@@ -56,13 +58,15 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
Lat: lat,
Lng: lng,
LocationShared: hasLoc,
ScannerPhone: textOrNil(ptrStr(req.Phone)),
Fingerprint: textOrNil(ptrStr(req.Fingerprint)),
})
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng)
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone))
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})
@@ -73,7 +77,9 @@ func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
}
// shouldAlert applies the throttle rules and sms_enabled flag.
func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64) bool {
// 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 {
if !tag.SmsEnabled {
return false
}
@@ -81,6 +87,13 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
return false
}
// 24 h per-device block: same fingerprint seen recently (excluding this scan) => record only.
if fp := scan.Fingerprint.String; fp != "" {
if _, err := a.Queries.GetRecentScanByFingerprint(ctx, db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fp, Valid: true}, ID: scan.ID}); err == nil {
return false
}
}
last, err := a.Queries.GetLastAlertByTag(ctx, tag.ID)
if err != nil {
return errors.Is(err, pgx.ErrNoRows) // no prior alert -> alert
@@ -88,11 +101,11 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
// Within the window?
if time.Since(last.ScannedAt.Time) < alertWindow {
// Re-alert only if we have a location and it moved >250 m.
if !hasLoc || !last.Lat.Valid || !last.Lng.Valid {
return false
}
return sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
// Re-alert on movement >250 m, or a different finder phone.
moved := hasLoc && last.Lat.Valid && last.Lng.Valid &&
sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
differentPhone := phone != "" && phone != last.ScannerPhone.String
return moved || differentPhone
}
return true
}
@@ -136,6 +149,8 @@ func alertMessage(tag db.Tag, t time.Time, lat, lng *float64) string {
}
// FinderContact stores a finder's number on the latest scan and alerts the owner.
// Dedup: same phone within the alert window => store only; fingerprint seen in
// the last 24 h => store only.
func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
if err != nil {
@@ -153,6 +168,7 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">Please enter a valid phone number.</p>`)
return
}
fingerprint := strings.TrimSpace(r.FormValue("fingerprint"))
latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID)
if err != nil {
@@ -160,19 +176,40 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// No scan yet: record one carrying the finder's number.
_, err = a.Queries.InsertScan(r.Context(), db.InsertScanParams{
// No scan yet: record one carrying the finder's number + fingerprint.
ins, insErr := a.Queries.InsertScan(r.Context(), db.InsertScanParams{
TagID: tag.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true},
Fingerprint: textOrNil(fingerprint),
})
if insErr != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
latest = ins
} else {
err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}})
err = a.Queries.SetScanPhone(r.Context(), db.SetScanPhoneParams{ID: latest.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true}, Fingerprint: textOrNil(fingerprint)})
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil {
// Should we notify? Skip when the same phone was alerted within the window,
// or when this device (fingerprint) has already alerted in the last 24 h.
notify := tag.SmsEnabled && tag.Phone.Valid && a.Sender != nil
if notify && fingerprint != "" {
if _, err := a.Queries.GetRecentScanByFingerprint(r.Context(), db.GetRecentScanByFingerprintParams{Fingerprint: pgtype.Text{String: fingerprint, Valid: true}, ID: latest.ID}); err == nil {
notify = false
}
}
if notify {
if last, err := a.Queries.GetLastAlertByTag(r.Context(), tag.ID); err == nil &&
time.Since(last.ScannedAt.Time) < alertWindow && last.ScannerPhone.String == phone {
notify = false
}
}
if notify {
itemType := "item"
if tag.ItemType.Valid {
itemType = tag.ItemType.String
@@ -181,6 +218,9 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
itemType, phone, tag.TagCode)
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
fmt.Println("contact sms failed:", err)
} else {
// Mark alerted so dedup (same phone / same device) can see it.
_ = a.Queries.SetScanAlertSent(r.Context(), db.SetScanAlertSentParams{ID: latest.ID, AlertSent: true})
}
}
@@ -188,6 +228,13 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<p class="mt-4 rounded border border-green-200 bg-green-50 px-4 py-2 text-sm text-green-800">Thanks! The owner has been notified with your number.</p>`)
}
func ptrStr(p *string) string {
if p == nil {
return ""
}
return *p
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)

View File

@@ -63,6 +63,7 @@
<div id="contact-area" class="mt-4">
<form hx-post="/t/{{.Data.TagCode}}/contact" hx-target="#contact-area" hx-swap="innerHTML" class="flex gap-2">
<input type="hidden" name="fingerprint" value="">
<input name="phone" type="tel" placeholder="Leave your number for the owner (optional)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" pattern="[0-9+ ]{8,16}">
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Send number</button>
</form>
@@ -78,11 +79,24 @@
(function () {
var code = {{.Data.TagCode | printf "%q"}};
var btn = document.getElementById("recheck-btn");
// Lightweight anti-spam fingerprint (UA/language/timezone/screen/platform hash).
function fingerprint() {
var parts = [navigator.userAgent, navigator.language, new Date().getTimezoneOffset(),
screen.width + "x" + screen.height, navigator.platform || "", navigator.hardwareConcurrency || ""];
return parts.join("|").split("").reduce(function (h, c) {
return ((h << 5) - h + c.charCodeAt(0)) | 0;
}, 0).toString(36);
}
var fp = fingerprint();
var fpInput = document.querySelector('input[name="fingerprint"]');
if (fpInput) fpInput.value = fp;
function sendScan(lat, lng) {
var body = {fingerprint: fp};
if (lat != null) { body.lat = lat; body.lng = lng; }
fetch("/t/" + code + "/scan", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(lat != null ? {lat: lat, lng: lng} : {})
body: JSON.stringify(body)
});
}
function tryLocation() {

View File

@@ -1,19 +1,19 @@
## 1. Schema & Queries
- [ ] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;`; `make db-up`
- [ ] 1.2 `queries.sql`: add `GetRecentScanByFingerprint` (:one, fingerprint + 24 h window); `make generate`; build
- [x] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;`; `make db-up`
- [x] 1.2 `queries.sql`: add `GetRecentScanByFingerprint` (:one, fingerprint + 24 h window); `make generate`; build
## 2. Fingerprint JS
- [ ] 2.1 `tag-public.html`: compute fingerprint (UA/language/timezone/screen/platform hash), send with scan POST and contact form (hidden input)
- [x] 2.1 `tag-public.html`: compute fingerprint (UA/language/timezone/screen/platform hash), send with scan POST and contact form (hidden input)
## 3. Alert Logic
- [ ] 3.1 `scan.go`: `ScanRequest` gains `phone` + `fingerprint`; `shouldAlert` checks fingerprint block first, then different-phone re-alert within window; pass fingerprint/phone into `InsertScan`
- [ ] 3.2 `FinderContact`: accept + store fingerprint; dedup by phone within window; skip SMS when same phone or fingerprint-blocked
- [x] 3.1 `scan.go`: `ScanRequest` gains `phone` + `fingerprint`; `shouldAlert` checks fingerprint block first, then different-phone re-alert within window; pass fingerprint/phone into `InsertScan`
- [x] 3.2 `FinderContact`: accept + store fingerprint; dedup by phone within window; skip SMS when same phone or fingerprint-blocked
## 4. Verification
- [ ] 4.1 Unit-style checks via HTTP suite: same fingerprint within 24 h → no alert; fresh fingerprint → alert; different phone at same spot within window → alert; same phone re-submit → no alert
- [ ] 4.2 Existing suites still pass (Phase 1 + Phase 2) on fresh DB
- [ ] 4.3 `openspec validate scan-alert-hardening`; commit
- [x] 4.1 Unit-style checks via HTTP suite: same fingerprint within 24 h → no alert; fresh fingerprint → alert; different phone at same spot within window → alert; same phone re-submit → no alert
- [x] 4.2 Existing suites still pass (Phase 1 + Phase 2) on fresh DB
- [x] 4.3 `openspec validate scan-alert-hardening`; commit

View File

@@ -8,4 +8,4 @@
x 2026-08-05 Set up Gitea for where_woof +project:where-woof +agent:where_woof
(B) Scan-flow: re-alert when different finder phone within 250 m window +project:where-woof +agent:right-monitor-pi-coding
(B) Scan-flow: browser fingerprint + 24 h block, store in DB +project:where-woof +agent:right-monitor-pi-coding
(B) Preset tag-ID registry: seed real manufactured IDs, enforce registry-only (anti-scam) +project:where-woof +agent:right-monitor-pi-coding
x 2026-08-07 (B) Preset tag-ID registry: seed real manufactured IDs, enforce registry-only (anti-scam) +project:where-woof +agent:right-monitor-pi-coding