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

@@ -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

@@ -23,8 +23,10 @@ const (
// ScanRequest is the JSON body posted by the geolocation script.
type ScanRequest struct {
Lat *float64 `json:"lat"`
Lng *float64 `json:"lng"`
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() {