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/notify" "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, reason := 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) } } } else if reason == "lapsed" || reason == "credits" { // Owner unlock notice (ntfy / optional SMS) — exempt from metering. if a.Notifier != nil && tag.Phone.Valid { a.Notifier.Send(tag.Phone.String, notify.UnlockMessage(tag.TagCode, reason)) } } writeJSON(w, map[string]any{"ok": true, "alert_sent": alertSent}) } // shouldAlert applies the throttle rules and sms_enabled flag. // Returns (send, reason) where reason is "lapsed" or "credits" when those // gates blocked the alert (so the owner can be notified to renew/top up). func (a *App) shouldAlert(ctx context.Context, tag db.Tag, scan db.Scan, hasLoc bool, lat, lng *float64, phone, ip string) (bool, string) { if !tag.SmsEnabled { return false, "" } if !tag.Phone.Valid { return false, "" } // Customer pause (admin kill-switch): paused owner -> record only. if tag.OwnerID.Valid { if owner, err := a.Queries.GetUserByID(ctx, tag.OwnerID.Int64); err == nil && owner.Paused { 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 with lazy expiry: non-paid status, or a paid order // past its renews_at date, both block and trigger the unlock notice. if tag.OrderID.Valid { if order, err := a.Queries.GetOrderByID(ctx, tag.OrderID.Int64); err == nil { if order.Status != "paid" || (order.RenewsAt.Valid && order.RenewsAt.Time.Before(time.Now())) { return false, "lapsed" } } } // SMS metering: a positive allocation is required; exhausted credits block alerts. if tag.SmsAllocated > 0 && tag.SmsUsed >= tag.SmsAllocated { return false, "credits" } // 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, `
Please enter a valid phone number.
`) 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 // Customer pause: paused owner -> store only. if notify && tag.OwnerID.Valid { if owner, err := a.Queries.GetUserByID(r.Context(), tag.OwnerID.Int64); err == nil && owner.Paused { notify = false } } // 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, `Thanks! The owner has been notified with your number.
`) } 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) }