scan-alert-hardening: different-finder re-alert within 250m window + fingerprint 24h block (12/12 scenarios pass)
This commit is contained in:
@@ -63,3 +63,6 @@ CREATE TABLE IF NOT EXISTS orders (
|
|||||||
-- Tag → product / order linkage (populated by the Laravel admin, Phase 4).
|
-- 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 product_id BIGINT REFERENCES products(id);
|
||||||
ALTER TABLE tags ADD COLUMN IF NOT EXISTS order_id BIGINT REFERENCES orders(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;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Scan struct {
|
|||||||
LocationShared bool `json:"location_shared"`
|
LocationShared bool `json:"location_shared"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tag struct {
|
type Tag struct {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type Querier interface {
|
|||||||
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)
|
||||||
|
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)
|
||||||
GetUserByEmail(ctx context.Context, email string) (User, error)
|
GetUserByEmail(ctx context.Context, email string) (User, error)
|
||||||
|
|||||||
@@ -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)
|
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetLastAlertByTag :one
|
-- name: GetLastAlertByTag :one
|
||||||
@@ -67,4 +67,10 @@ LIMIT 1;
|
|||||||
UPDATE scans SET alert_sent = $2 WHERE id = $1;
|
UPDATE scans SET alert_sent = $2 WHERE id = $1;
|
||||||
|
|
||||||
-- name: SetScanPhone :exec
|
-- 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;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getLastAlertByTag = `-- name: GetLastAlertByTag :one
|
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
|
WHERE tag_id = $1 AND alert_sent = TRUE
|
||||||
ORDER BY scanned_at DESC
|
ORDER BY scanned_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -138,12 +138,13 @@ func (q *Queries) GetLastAlertByTag(ctx context.Context, tagID int64) (Scan, err
|
|||||||
&i.LocationShared,
|
&i.LocationShared,
|
||||||
&i.ScannerPhone,
|
&i.ScannerPhone,
|
||||||
&i.AlertSent,
|
&i.AlertSent,
|
||||||
|
&i.Fingerprint,
|
||||||
)
|
)
|
||||||
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 FROM scans
|
SELECT id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint FROM scans
|
||||||
WHERE tag_id = $1
|
WHERE tag_id = $1
|
||||||
ORDER BY scanned_at DESC
|
ORDER BY scanned_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -161,6 +162,36 @@ func (q *Queries) GetLatestScanByTag(ctx context.Context, tagID int64) (Scan, er
|
|||||||
&i.LocationShared,
|
&i.LocationShared,
|
||||||
&i.ScannerPhone,
|
&i.ScannerPhone,
|
||||||
&i.AlertSent,
|
&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
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -256,9 +287,9 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insertScan = `-- name: InsertScan :one
|
const insertScan = `-- name: InsertScan :one
|
||||||
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone)
|
INSERT INTO scans (tag_id, lat, lng, location_shared, scanner_phone, fingerprint)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent
|
RETURNING id, tag_id, scanned_at, lat, lng, location_shared, scanner_phone, alert_sent, fingerprint
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertScanParams struct {
|
type InsertScanParams struct {
|
||||||
@@ -267,6 +298,7 @@ type InsertScanParams struct {
|
|||||||
Lng pgtype.Float8 `json:"lng"`
|
Lng pgtype.Float8 `json:"lng"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, error) {
|
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.Lng,
|
||||||
arg.LocationShared,
|
arg.LocationShared,
|
||||||
arg.ScannerPhone,
|
arg.ScannerPhone,
|
||||||
|
arg.Fingerprint,
|
||||||
)
|
)
|
||||||
var i Scan
|
var i Scan
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -287,6 +320,7 @@ func (q *Queries) InsertScan(ctx context.Context, arg InsertScanParams) (Scan, e
|
|||||||
&i.LocationShared,
|
&i.LocationShared,
|
||||||
&i.ScannerPhone,
|
&i.ScannerPhone,
|
||||||
&i.AlertSent,
|
&i.AlertSent,
|
||||||
|
&i.Fingerprint,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -374,16 +408,17 @@ func (q *Queries) SetScanAlertSent(ctx context.Context, arg SetScanAlertSentPara
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setScanPhone = `-- name: SetScanPhone :exec
|
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 {
|
type SetScanPhoneParams struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
ScannerPhone pgtype.Text `json:"scanner_phone"`
|
||||||
|
Fingerprint pgtype.Text `json:"fingerprint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) SetScanPhone(ctx context.Context, arg SetScanPhoneParams) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ const (
|
|||||||
|
|
||||||
// ScanRequest is the JSON body posted by the geolocation script.
|
// ScanRequest is the JSON body posted by the geolocation script.
|
||||||
type ScanRequest struct {
|
type ScanRequest struct {
|
||||||
Lat *float64 `json:"lat"`
|
Lat *float64 `json:"lat"`
|
||||||
Lng *float64 `json:"lng"`
|
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
|
// 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,
|
Lat: lat,
|
||||||
Lng: lng,
|
Lng: lng,
|
||||||
LocationShared: hasLoc,
|
LocationShared: hasLoc,
|
||||||
|
ScannerPhone: textOrNil(ptrStr(req.Phone)),
|
||||||
|
Fingerprint: textOrNil(ptrStr(req.Fingerprint)),
|
||||||
})
|
})
|
||||||
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)
|
alertSent := a.shouldAlert(r.Context(), tag, scan, hasLoc, req.Lat, req.Lng, ptrStr(req.Phone))
|
||||||
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})
|
||||||
@@ -73,7 +77,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.
|
||||||
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 {
|
if !tag.SmsEnabled {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -81,6 +87,13 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
|
|||||||
return false
|
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)
|
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
|
||||||
@@ -88,11 +101,11 @@ func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc
|
|||||||
|
|
||||||
// Within the window?
|
// Within the window?
|
||||||
if time.Since(last.ScannedAt.Time) < alertWindow {
|
if time.Since(last.ScannedAt.Time) < alertWindow {
|
||||||
// Re-alert only if we have a location and it moved >250 m.
|
// Re-alert on movement >250 m, or a different finder phone.
|
||||||
if !hasLoc || !last.Lat.Valid || !last.Lng.Valid {
|
moved := hasLoc && last.Lat.Valid && last.Lng.Valid &&
|
||||||
return false
|
sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
|
||||||
}
|
differentPhone := phone != "" && phone != last.ScannerPhone.String
|
||||||
return sms.HaversineMeters(last.Lat.Float64, last.Lng.Float64, *lat, *lng) > alertMinDistance
|
return moved || differentPhone
|
||||||
}
|
}
|
||||||
return true
|
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.
|
// 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) {
|
func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
||||||
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
|
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
|
||||||
if err != nil {
|
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>`)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
fingerprint := strings.TrimSpace(r.FormValue("fingerprint"))
|
||||||
|
|
||||||
latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID)
|
latest, err := a.Queries.GetLatestScanByTag(r.Context(), tag.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,19 +176,40 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// No scan yet: record one carrying the finder's number.
|
// No scan yet: record one carrying the finder's number + fingerprint.
|
||||||
_, err = a.Queries.InsertScan(r.Context(), db.InsertScanParams{
|
ins, insErr := a.Queries.InsertScan(r.Context(), db.InsertScanParams{
|
||||||
TagID: tag.ID, ScannerPhone: pgtype.Text{String: phone, Valid: true},
|
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 {
|
} 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 {
|
if err != nil {
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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"
|
itemType := "item"
|
||||||
if tag.ItemType.Valid {
|
if tag.ItemType.Valid {
|
||||||
itemType = tag.ItemType.String
|
itemType = tag.ItemType.String
|
||||||
@@ -181,6 +218,9 @@ func (a *App) FinderContact(w http.ResponseWriter, r *http.Request) {
|
|||||||
itemType, phone, tag.TagCode)
|
itemType, phone, tag.TagCode)
|
||||||
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
|
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
|
||||||
fmt.Println("contact sms failed:", err)
|
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>`)
|
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) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(v)
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
|
|
||||||
<div id="contact-area" class="mt-4">
|
<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">
|
<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}">
|
<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>
|
<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>
|
</form>
|
||||||
@@ -78,11 +79,24 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var code = {{.Data.TagCode | printf "%q"}};
|
var code = {{.Data.TagCode | printf "%q"}};
|
||||||
var btn = document.getElementById("recheck-btn");
|
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) {
|
function sendScan(lat, lng) {
|
||||||
|
var body = {fingerprint: fp};
|
||||||
|
if (lat != null) { body.lat = lat; body.lng = lng; }
|
||||||
fetch("/t/" + code + "/scan", {
|
fetch("/t/" + code + "/scan", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify(lat != null ? {lat: lat, lng: lng} : {})
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function tryLocation() {
|
function tryLocation() {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
## 1. Schema & Queries
|
## 1. Schema & Queries
|
||||||
|
|
||||||
- [ ] 1.1 `db/schema.sql`: idempotent `ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;`; `make db-up`
|
- [x] 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.2 `queries.sql`: add `GetRecentScanByFingerprint` (:one, fingerprint + 24 h window); `make generate`; build
|
||||||
|
|
||||||
## 2. Fingerprint JS
|
## 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. 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`
|
- [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`
|
||||||
- [ ] 3.2 `FinderContact`: accept + store fingerprint; dedup by phone within window; skip SMS when same phone or fingerprint-blocked
|
- [x] 3.2 `FinderContact`: accept + store fingerprint; dedup by phone within window; skip SMS when same phone or fingerprint-blocked
|
||||||
|
|
||||||
## 4. Verification
|
## 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
|
- [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
|
||||||
- [ ] 4.2 Existing suites still pass (Phase 1 + Phase 2) on fresh DB
|
- [x] 4.2 Existing suites still pass (Phase 1 + Phase 2) on fresh DB
|
||||||
- [ ] 4.3 `openspec validate scan-alert-hardening`; commit
|
- [x] 4.3 `openspec validate scan-alert-hardening`; commit
|
||||||
|
|||||||
2
todo.txt
2
todo.txt
@@ -8,4 +8,4 @@
|
|||||||
x 2026-08-05 Set up Gitea for where_woof +project:where-woof +agent:where_woof
|
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: 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) 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
|
||||||
|
|||||||
Reference in New Issue
Block a user