332 lines
11 KiB
Go
332 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"wherewoof/frontend/internal/db"
|
|
"wherewoof/frontend/internal/sms"
|
|
)
|
|
|
|
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.
|
|
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
|
|
// location-aware throttle rules: within the 10-minute window an alert is only
|
|
// re-sent when the new location is more than 250 m from the last alerted spot.
|
|
func (a *App) Scan(w http.ResponseWriter, r *http.Request) {
|
|
tag, err := a.Queries.GetTagByCode(r.Context(), r.PathValue("tag_code"))
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "tag not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
var req ScanRequest
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
hasLoc := req.Lat != nil && req.Lng != nil
|
|
|
|
var lat, lng pgtype.Float8
|
|
if hasLoc {
|
|
lat = pgtype.Float8{Float64: *req.Lat, Valid: true}
|
|
lng = pgtype.Float8{Float64: *req.Lng, Valid: true}
|
|
}
|
|
|
|
scan, err := a.Queries.InsertScan(r.Context(), db.InsertScanParams{
|
|
TagID: tag.ID,
|
|
Lat: lat,
|
|
Lng: lng,
|
|
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), 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})
|
|
// Metering: a successful alert consumes one credit on metered tags.
|
|
if tag.SmsAllocated > 0 {
|
|
_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent})
|
|
}
|
|
|
|
// shouldAlert applies the throttle rules and sms_enabled flag.
|
|
// 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
|
|
}
|
|
if !tag.Phone.Valid {
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// SMS metering: a positive allocation is required; exhausted credits block alerts.
|
|
if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
|
|
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
|
|
}
|
|
|
|
// Within the window?
|
|
if time.Since(last.ScannedAt.Time) < alertWindow {
|
|
// 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
|
|
}
|
|
|
|
// 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 {
|
|
return false
|
|
}
|
|
// When a location is present, mint a short link so the SMS stays short and
|
|
// the owner gets a map page instead of raw coordinates.
|
|
link := ""
|
|
if lat != nil && lng != nil {
|
|
if code, err := a.mintShortCode(ctx, scan.ID); err == nil {
|
|
link = "https://where-woof.com/s/" + code
|
|
}
|
|
}
|
|
msg := alertMessage(tag, scan.ScannedAt.Time, lat, lng, link)
|
|
if err := a.Sender.Send(sms.NormalizeAU(tag.Phone.String), msg); err != nil {
|
|
fmt.Println("alert sms failed:", err)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// mintShortCode creates a short URL code for a scan (with retry on collision).
|
|
func (a *App) mintShortCode(ctx context.Context, scanID int64) (string, error) {
|
|
const alphabet = "abcdefghijklmnopqrstuvwxyz23456789" // no 0/1/o/l
|
|
for i := 0; i < 3; i++ {
|
|
code := make([]byte, 6)
|
|
for j := range code {
|
|
code[j] = alphabet[rand.Intn(len(alphabet))]
|
|
}
|
|
c := string(code)
|
|
if _, err := a.Queries.InsertShortCode(ctx, db.InsertShortCodeParams{Code: c, ScanID: scanID}); err == nil {
|
|
return c, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("short code collision")
|
|
}
|
|
|
|
// alertMessage builds the owner alert: item type + name, time, location link, tag link.
|
|
func alertMessage(tag db.Tag, t time.Time, lat, lng *float64, link string) string {
|
|
itemType := "Item"
|
|
if tag.ItemType.Valid {
|
|
itemType = titleCase(tag.ItemType.String)
|
|
}
|
|
name := strings.TrimSpace(tag.Description.String)
|
|
if len(name) > 30 {
|
|
name = name[:30] + "…"
|
|
}
|
|
label := itemType
|
|
if name != "" {
|
|
label = itemType + ", " + name
|
|
}
|
|
|
|
msg := fmt.Sprintf("Where Woof: your %s was scanned at %s.", label, t.Local().Format("3:04 pm"))
|
|
if link != "" {
|
|
msg += " Location: " + link
|
|
} else if lat != nil && lng != nil {
|
|
msg += fmt.Sprintf(" Location: https://maps.google.com/?q=%.5f,%.5f", *lat, *lng)
|
|
} else {
|
|
msg += " The finder didn't share a location."
|
|
}
|
|
msg += fmt.Sprintf(" See: https://where-woof.com/t/%s", tag.TagCode)
|
|
return msg
|
|
}
|
|
|
|
// 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 {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "tag not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
phone := sms.NormalizeAU(strings.TrimSpace(r.FormValue("phone")))
|
|
if len(phone) < 8 || len(phone) > 15 {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
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 {
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// 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}, Fingerprint: textOrNil(fingerprint)})
|
|
}
|
|
if err != nil {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 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
|
|
// Metering: metered tags must have credits remaining.
|
|
if notify && tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated {
|
|
notify = false
|
|
}
|
|
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
|
|
}
|
|
msg := fmt.Sprintf("Where Woof: a finder of your %s left their number: %s. See: https://where-woof.com/t/%s",
|
|
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})
|
|
// Metering: successful contact SMS consumes a credit on metered tags.
|
|
if tag.SmsAllocated > 0 {
|
|
_ = a.Queries.AddSmsUsed(r.Context(), tag.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
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)
|
|
}
|