package main import ( "database/sql" "database/sql/driver" "fmt" "log" "math" "sort" "strings" "time" _ "github.com/jackc/pgx/v5/stdlib" ) // StringArray maps a PostgreSQL TEXT[] column to a Go []string via database/sql. type StringArray []string func (a *StringArray) Scan(src interface{}) error { if src == nil { *a = StringArray{} return nil } switch v := src.(type) { case []byte: parsed, err := parsePostgresArray(string(v)) if err != nil { return err } *a = parsed case string: parsed, err := parsePostgresArray(v) if err != nil { return err } *a = parsed default: return fmt.Errorf("StringArray.Scan: unsupported type %T", src) } return nil } func (a StringArray) Value() (driver.Value, error) { if a == nil || len(a) == 0 { return "{}", nil } elems := make([]string, len(a)) for i, s := range a { escaped := strings.ReplaceAll(s, `\`, `\\`) escaped = strings.ReplaceAll(escaped, `"`, `\"`) elems[i] = `"` + escaped + `"` } return "{" + strings.Join(elems, ",") + "}", nil } func parsePostgresArray(s string) ([]string, error) { s = strings.TrimSpace(s) if s == "{}" || s == "" { return []string{}, nil } if s[0] != '{' || s[len(s)-1] != '}' { return nil, fmt.Errorf("invalid postgres array literal: %q", s) } inner := s[1 : len(s)-1] var result []string var current strings.Builder inQuote := false escaped := false for _, r := range inner { if escaped { current.WriteRune(r) escaped = false continue } if r == '\\' { escaped = true continue } if r == '"' { inQuote = !inQuote continue } if r == ',' && !inQuote { result = append(result, current.String()) current.Reset() continue } current.WriteRune(r) } result = append(result, current.String()) return result, nil } type Repeater struct { ID int `json:"id"` Callsign string `json:"callsign"` FreqTx float64 `json:"freq_tx"` FreqRx float64 `json:"freq_rx"` FreqOffset string `json:"freq_offset"` Band string `json:"band"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` City string `json:"city"` State string `json:"state"` Country string `json:"country"` ColorCode int `json:"color_code"` TsLinked string `json:"ts_linked"` Trustee string `json:"trustee"` IpscNetwork string `json:"ipsc_network"` Networks StringArray `json:"networks"` Hotspot int `json:"hotspot"` Status string `json:"status"` LastSeen *time.Time `json:"last_seen"` BmStatus *int `json:"bm_status"` BmStatusText string `json:"bm_status_text"` Hardware string `json:"hardware"` Firmware string `json:"firmware"` Pep int `json:"pep"` Agl int `json:"agl"` Website string `json:"website"` Description string `json:"description"` ImportFreqInconsistent bool `json:"import_freq_inconsistent"` Inactive bool `json:"inactive"` LastPolled *time.Time `json:"last_polled"` } func openDB(dsn string) (*sql.DB, error) { var db *sql.DB var err error for i := 0; i < 30; i++ { db, err = sql.Open("pgx", dsn) if err != nil { log.Printf("Failed to open database (attempt %d/30): %v", i+1, err) time.Sleep(time.Second) continue } if err = db.Ping(); err != nil { db.Close() log.Printf("Failed to ping database (attempt %d/30): %v", i+1, err) time.Sleep(time.Second) continue } break } if err != nil { return nil, fmt.Errorf("failed to connect to database after 30 attempts: %w", err) } db.SetMaxOpenConns(25) db.SetMaxIdleConns(5) db.SetConnMaxLifetime(5 * time.Minute) return db, nil } func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band string, networks []string, showHotspots bool, showInactive bool) ([]Repeater, error) { paramIdx := 0 nextParam := func() string { paramIdx++ return fmt.Sprintf("$%d", paramIdx) } query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent, last_polled FROM repeaters WHERE lat BETWEEN ` + nextParam() + ` AND ` + nextParam() + ` AND lng BETWEEN ` + nextParam() + ` AND ` + nextParam() args := []interface{}{minLat, maxLat, minLng, maxLng} switch band { case "2m": query += " AND band = " + nextParam() args = append(args, "2m") case "70cm": query += " AND band = " + nextParam() args = append(args, "70cm") default: query += " AND band IN ('2m', '70cm')" } if !showHotspots { query += " AND hotspot = 0" } // Network filter: only apply when not all 4 categories are selected if len(networks) > 0 && len(networks) < 4 { var networkValues []string hasOther := false for _, n := range networks { switch n { case "BM": networkValues = append(networkValues, "Brandmeister") case "DMR+": networkValues = append(networkValues, "DMR+") case "TGIF": networkValues = append(networkValues, "TGIF") case "Other": hasOther = true networkValues = append(networkValues, "DMR-MARC", "FreeDMR", "Other") } } if len(networkValues) > 0 { p := nextParam() if hasOther { query += " AND (networks && " + p + "::text[] OR networks = '{}')" } else { query += " AND networks && " + p + "::text[]" } args = append(args, StringArray(networkValues)) } } if !showInactive { threshold := time.Now().Add(-7 * 24 * time.Hour) query += " AND (last_seen IS NULL OR last_seen >= " + nextParam() + ") AND (bm_status IS NULL OR bm_status != 0)" args = append(args, threshold) } rows, err := db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() threshold := time.Now().Add(-7 * 24 * time.Hour) var results []Repeater for rows.Next() { var r Repeater if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, &r.IpscNetwork, &r.Networks, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent, &r.LastPolled); err != nil { return nil, err } r.Inactive = (r.LastSeen != nil && r.LastSeen.Before(threshold)) || (r.BmStatus != nil && *r.BmStatus == 0) results = append(results, r) } if err := rows.Err(); err != nil { return nil, err } return results, nil } // Route corridor query: find repeaters within corridorKm of a polyline. func queryRepeatersAlongRoute(db *sql.DB, points [][2]float64, corridorKm float64, band string, networks []string, showHotspots bool, showInactive bool) ([]RepeaterWithDistance, error) { if len(points) == 0 { return []RepeaterWithDistance{}, nil } // Compute bounding box of all route points + corridor padding minLat, maxLat := points[0][0], points[0][0] minLng, maxLng := points[0][1], points[0][1] for _, p := range points { if p[0] < minLat { minLat = p[0] } if p[0] > maxLat { maxLat = p[0] } if p[1] < minLng { minLng = p[1] } if p[1] > maxLng { maxLng = p[1] } } latPad := corridorKm / 111.32 avgLat := (minLat + maxLat) / 2 lngPad := corridorKm / (111.32 * math.Cos(avgLat*math.Pi/180)) minLat -= latPad maxLat += latPad minLng -= lngPad maxLng += lngPad // Fetch candidates from bounding box candidates, err := queryRepeaters(db, minLat, maxLat, minLng, maxLng, band, networks, showHotspots, showInactive) if err != nil { return nil, err } // Pre-compute cumulative route distances from start cumDist := make([]float64, len(points)) for i := 1; i < len(points); i++ { cumDist[i] = cumDist[i-1] + haversineKm(points[i-1][0], points[i-1][1], points[i][0], points[i][1]) } // Filter by perpendicular distance to route; record along-route distance from start var results []RepeaterWithDistance for _, r := range candidates { perpDist, alongDist := routeDistances(r.Lat, r.Lng, points, cumDist) if perpDist <= corridorKm { results = append(results, RepeaterWithDistance{Repeater: r, Distance: math.Round(alongDist*10) / 10}) } } sort.Slice(results, func(i, j int) bool { return results[i].Distance < results[j].Distance }) return results, nil } // Radius query: find repeaters within radiusKm of a point, sorted by distance. type RepeaterWithDistance struct { Repeater Distance float64 `json:"distance"` } func queryRepeatersInRadius(db *sql.DB, lat, lng, radiusKm float64, band string, networks []string, showHotspots bool, showInactive bool) ([]RepeaterWithDistance, error) { latPad := radiusKm / 111.32 lngPad := radiusKm / (111.32 * math.Cos(lat*math.Pi/180)) candidates, err := queryRepeaters(db, lat-latPad, lat+latPad, lng-lngPad, lng+lngPad, band, networks, showHotspots, showInactive) if err != nil { return nil, err } var results []RepeaterWithDistance for _, r := range candidates { d := haversineKm(lat, lng, r.Lat, r.Lng) if d <= radiusKm { results = append(results, RepeaterWithDistance{Repeater: r, Distance: math.Round(d*10) / 10}) } } sort.Slice(results, func(i, j int) bool { return results[i].Distance < results[j].Distance }) return results, nil } // Admin query: paginated list of all repeaters with optional search. func queryAdminRepeaters(db *sql.DB, search string, page, perPage int) ([]Repeater, int, error) { paramIdx := 0 nextParam := func() string { paramIdx++ return fmt.Sprintf("$%d", paramIdx) } where := "" var args []interface{} if search != "" { p := nextParam() where = " WHERE callsign ILIKE " + p + " OR city ILIKE " + p + " OR state ILIKE " + p + " OR country ILIKE " + p + " OR array_to_string(networks, ',') ILIKE " + p + " OR id::text = " + nextParam() pattern := "%" + search + "%" args = append(args, pattern, search) } var total int err := db.QueryRow("SELECT COUNT(*) FROM repeaters"+where, args...).Scan(&total) if err != nil { return nil, 0, err } offset := (page - 1) * perPage query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent, last_polled FROM repeaters` + where + ` ORDER BY callsign LIMIT ` + nextParam() + ` OFFSET ` + nextParam() args = append(args, perPage, offset) rows, err := db.Query(query, args...) if err != nil { return nil, 0, err } defer rows.Close() threshold := time.Now().Add(-7 * 24 * time.Hour) var results []Repeater for rows.Next() { var r Repeater if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, &r.IpscNetwork, &r.Networks, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent, &r.LastPolled); err != nil { return nil, 0, err } r.Inactive = (r.LastSeen != nil && r.LastSeen.Before(threshold)) || (r.BmStatus != nil && *r.BmStatus == 0) results = append(results, r) } if err := rows.Err(); err != nil { return nil, 0, err } if results == nil { results = []Repeater{} } return results, total, nil } // routeDistances returns the perpendicular distance from a point to the nearest // route segment, and the along-route distance from the route start to the // projection point on that segment. func routeDistances(lat, lng float64, points [][2]float64, cumDist []float64) (perpDist, alongDist float64) { bestPerp := math.Inf(1) bestAlong := 0.0 for i := 0; i < len(points)-1; i++ { d, t := distToSegmentWithParam(lat, lng, points[i][0], points[i][1], points[i+1][0], points[i+1][1]) if d < bestPerp { bestPerp = d segLen := cumDist[i+1] - cumDist[i] bestAlong = cumDist[i] + t*segLen } } if len(points) == 1 { bestPerp = haversineKm(lat, lng, points[0][0], points[0][1]) } return bestPerp, bestAlong } // distToSegmentWithParam returns the approximate distance from point P to line // segment AB in km, and the projection parameter t (0–1) along the segment. func distToSegmentWithParam(pLat, pLng, aLat, aLng, bLat, bLng float64) (dist, t float64) { cosLat := math.Cos(pLat * math.Pi / 180) // Project to approximate planar coordinates (km) px := (pLng - aLng) * cosLat * 111.32 py := (pLat - aLat) * 111.32 bx := (bLng - aLng) * cosLat * 111.32 by := (bLat - aLat) * 111.32 lenSq := bx*bx + by*by if lenSq == 0 { return math.Sqrt(px*px + py*py), 0 } t = (px*bx + py*by) / lenSq if t < 0 { t = 0 } if t > 1 { t = 1 } dx := px - t*bx dy := py - t*by return math.Sqrt(dx*dx + dy*dy), t } func queryRepeaterSearch(db *sql.DB, search string, limit int) ([]Repeater, int, error) { if limit <= 0 || limit > 200 { limit = 25 } pattern := "%" + search + "%" where := `WHERE callsign ILIKE $1 OR city ILIKE $1 OR state ILIKE $1 OR country ILIKE $1 OR array_to_string(networks, ',') ILIKE $1 OR id::text = $2` var total int err := db.QueryRow("SELECT COUNT(*) FROM repeaters "+where, pattern, search).Scan(&total) if err != nil { return nil, 0, err } rows, err := db.Query(`SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent, last_polled FROM repeaters `+where+` ORDER BY callsign LIMIT $3`, pattern, search, limit) if err != nil { return nil, 0, err } defer rows.Close() threshold := time.Now().Add(-7 * 24 * time.Hour) var results []Repeater for rows.Next() { var r Repeater if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, &r.IpscNetwork, &r.Networks, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent, &r.LastPolled); err != nil { return nil, 0, err } r.Inactive = (r.LastSeen != nil && r.LastSeen.Before(threshold)) || (r.BmStatus != nil && *r.BmStatus == 0) results = append(results, r) } if results == nil { results = []Repeater{} } return results, total, rows.Err() } func queryRepeaterByID(db *sql.DB, id int) (*Repeater, error) { var r Repeater err := db.QueryRow(`SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, color_code, ts_linked, trustee, ipsc_network, networks, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, import_freq_inconsistent, last_polled FROM repeaters WHERE id = $1`, id).Scan( &r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band, &r.Lat, &r.Lng, &r.City, &r.State, &r.Country, &r.ColorCode, &r.TsLinked, &r.Trustee, &r.IpscNetwork, &r.Networks, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.ImportFreqInconsistent, &r.LastPolled) if err != nil { return nil, err } threshold := time.Now().Add(-7 * 24 * time.Hour) r.Inactive = (r.LastSeen != nil && r.LastSeen.Before(threshold)) || (r.BmStatus != nil && *r.BmStatus == 0) return &r, nil } func updateRepeater(db *sql.DB, r Repeater) error { _, err := db.Exec(`UPDATE repeaters SET callsign=$2, freq_tx=$3, freq_rx=$4, freq_offset=$5, band=$6, lat=$7, lng=$8, city=$9, state=$10, country=$11, color_code=$12, ts_linked=$13, trustee=$14, ipsc_network=$15, networks=$16, hotspot=$17, status=$18, hardware=$19, firmware=$20, pep=$21, agl=$22, website=$23, description=$24 WHERE id=$1`, r.ID, r.Callsign, r.FreqTx, r.FreqRx, r.FreqOffset, r.Band, r.Lat, r.Lng, r.City, r.State, r.Country, r.ColorCode, r.TsLinked, r.Trustee, r.IpscNetwork, r.Networks, r.Hotspot, r.Status, r.Hardware, r.Firmware, r.Pep, r.Agl, r.Website, r.Description) return err } func haversineKm(lat1, lng1, lat2, lng2 float64) float64 { const r = 6371.0 dLat := (lat2 - lat1) * math.Pi / 180 dLng := (lng2 - lng1) * math.Pi / 180 a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)* math.Sin(dLng/2)*math.Sin(dLng/2) return r * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) }