package main import ( "database/sql" "embed" "encoding/json" "flag" "fmt" "io/fs" "log" "math" "net/http" "os" "strconv" "time" _ "github.com/mattn/go-sqlite3" ) //go:embed static var staticFiles embed.FS // LocationPayload matches the Colota app's default POST JSON body. // alt, vel, and bear are optional (Colota may omit them). type LocationPayload struct { Lat float64 `json:"lat"` Lon float64 `json:"lon"` Acc float64 `json:"acc"` Alt *float64 `json:"alt,omitempty"` Vel *float64 `json:"vel,omitempty"` Batt *int `json:"batt,omitempty"` Bs *int `json:"bs,omitempty"` // battery state: 0=unknown,1=unplugged,2=charging,3=full Tst int64 `json:"tst"` // Unix timestamp (seconds) Bear *float64 `json:"bear,omitempty"` // bearing in degrees } // LocationRecord is what we store and return via the query API. type LocationRecord struct { ID int64 `json:"id"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` Acc float64 `json:"acc"` Alt *float64 `json:"alt,omitempty"` Vel *float64 `json:"vel,omitempty"` Batt *int `json:"batt,omitempty"` Bs *int `json:"bs,omitempty"` Tst int64 `json:"tst"` Bear *float64 `json:"bear,omitempty"` ReceivedAt string `json:"received_at"` } type Server struct { db *sql.DB token string // optional Bearer token; empty = no auth logger *log.Logger } func main() { port := flag.Int("port", 8080, "HTTP listen port") dbPath := flag.String("db", "locations.db", "SQLite3 database file path") token := flag.String("token", "", "Optional Bearer token for authentication (empty = disabled)") flag.Parse() logger := log.New(os.Stdout, "[breadcrumb] ", log.LstdFlags) db, err := sql.Open("sqlite3", *dbPath+"?_journal_mode=WAL&_busy_timeout=5000") if err != nil { logger.Fatalf("failed to open database: %v", err) } defer db.Close() if err := initDB(db); err != nil { logger.Fatalf("failed to initialise database: %v", err) } srv := &Server{db: db, token: *token, logger: logger} sub, err := fs.Sub(staticFiles, "static") if err != nil { logger.Fatalf("failed to create static sub-fs: %v", err) } mux := http.NewServeMux() mux.HandleFunc("/api/location", srv.handleLocation) // POST – receive from Colota app mux.HandleFunc("/api/locations", srv.handleLocations) // GET – query stored locations mux.HandleFunc("/api/trips", srv.handleTrips) // GET – trip list mux.HandleFunc("/health", srv.handleHealth) // GET – health check mux.Handle("/", http.FileServer(http.FS(sub))) // static frontend addr := fmt.Sprintf(":%d", *port) logger.Printf("listening on %s db=%s auth=%v", addr, *dbPath, *token != "") if err := http.ListenAndServe(addr, mux); err != nil { logger.Fatalf("server error: %v", err) } } // initDB creates the locations table if it doesn't already exist. func initDB(db *sql.DB) error { _, err := db.Exec(` CREATE TABLE IF NOT EXISTS locations ( id INTEGER PRIMARY KEY AUTOINCREMENT, lat REAL NOT NULL, lon REAL NOT NULL, acc REAL NOT NULL, alt REAL, vel REAL, batt INTEGER, bs INTEGER, tst INTEGER NOT NULL, bear REAL, received_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_locations_tst ON locations(tst); `) return err } // ── Middleware ──────────────────────────────────────────────────────────────── func (s *Server) authenticate(r *http.Request) bool { if s.token == "" { return true // auth disabled } auth := r.Header.Get("Authorization") return auth == "Bearer "+s.token } // ── Handlers ────────────────────────────────────────────────────────────────── // handleLocation accepts the Colota app's POST JSON payload (or GET query params). func (s *Server) handleLocation(w http.ResponseWriter, r *http.Request) { if !s.authenticate(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } var p LocationPayload switch r.Method { case http.MethodPost: if err := json.NewDecoder(r.Body).Decode(&p); err != nil { s.logger.Printf("bad JSON: %v", err) http.Error(w, "bad request", http.StatusBadRequest) return } case http.MethodGet: q := r.URL.Query() p.Lat = parseFloat(q.Get("lat")) p.Lon = parseFloat(q.Get("lon")) p.Acc = parseFloat(q.Get("acc")) p.Tst = parseInt64(q.Get("tst")) if v := q.Get("alt"); v != "" { f := parseFloat(v); p.Alt = &f } if v := q.Get("vel"); v != "" { f := parseFloat(v); p.Vel = &f } if v := q.Get("bear"); v != "" { f := parseFloat(v); p.Bear = &f } if v := q.Get("batt"); v != "" { i := int(parseFloat(v)); p.Batt = &i } if v := q.Get("bs"); v != "" { i := int(parseFloat(v)); p.Bs = &i } default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if p.Lat == 0 && p.Lon == 0 { http.Error(w, "lat/lon required", http.StatusBadRequest) return } if p.Tst == 0 { p.Tst = time.Now().Unix() } receivedAt := time.Now().UTC().Format(time.RFC3339) _, err := s.db.Exec(` INSERT INTO locations (lat, lon, acc, alt, vel, batt, bs, tst, bear, received_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.Lat, p.Lon, p.Acc, p.Alt, p.Vel, p.Batt, p.Bs, p.Tst, p.Bear, receivedAt, ) if err != nil { s.logger.Printf("db insert error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } s.logger.Printf("saved lat=%.6f lon=%.6f acc=%.1f tst=%d", p.Lat, p.Lon, p.Acc, p.Tst) w.WriteHeader(http.StatusOK) } // handleLocations returns stored location records as JSON. // Query params: limit (default 100), from / to (Unix timestamps), page (1-based). func (s *Server) handleLocations(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.authenticate(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } q := r.URL.Query() limit := 100 if v := q.Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500000 { limit = n } } page := 1 if v := q.Get("page"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { page = n } } offset := (page - 1) * limit // Optional time-range filter args := []any{} where := "WHERE 1=1" if v := q.Get("from"); v != "" { where += " AND tst >= ?" args = append(args, parseInt64(v)) } if v := q.Get("to"); v != "" { where += " AND tst <= ?" args = append(args, parseInt64(v)) } countArgs := make([]any, len(args)) copy(countArgs, args) var total int row := s.db.QueryRow("SELECT COUNT(*) FROM locations "+where, countArgs...) if err := row.Scan(&total); err != nil { s.logger.Printf("count error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } args = append(args, limit, offset) rows, err := s.db.Query( "SELECT id, lat, lon, acc, alt, vel, batt, bs, tst, bear, received_at "+ "FROM locations "+where+" ORDER BY tst DESC LIMIT ? OFFSET ?", args..., ) if err != nil { s.logger.Printf("query error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer rows.Close() records := []LocationRecord{} for rows.Next() { var rec LocationRecord if err := rows.Scan( &rec.ID, &rec.Lat, &rec.Lon, &rec.Acc, &rec.Alt, &rec.Vel, &rec.Batt, &rec.Bs, &rec.Tst, &rec.Bear, &rec.ReceivedAt, ); err != nil { s.logger.Printf("scan error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } records = append(records, rec) } type response struct { Total int `json:"total"` Page int `json:"page"` Limit int `json:"limit"` Records []LocationRecord `json:"records"` } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response{ Total: total, Page: page, Limit: limit, Records: records, }) } // handleHealth returns 200 OK with a JSON status blob. func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { var count int s.db.QueryRow("SELECT COUNT(*) FROM locations").Scan(&count) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), "total_locations": count, }) } // ── Trips ───────────────────────────────────────────────────────────────────── type TripSummary struct { StartTst int64 `json:"start_tst"` EndTst int64 `json:"end_tst"` PointCount int `json:"point_count"` DistanceKm float64 `json:"distance_km"` } // handleTrips groups all location points into trips separated by gaps > 30 min. func (s *Server) handleTrips(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !s.authenticate(r) { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } rows, err := s.db.Query(`SELECT tst, lat, lon FROM locations ORDER BY tst ASC`) if err != nil { s.logger.Printf("trips query error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } defer rows.Close() type point struct{ tst int64; lat, lon float64 } var pts []point for rows.Next() { var p point if err := rows.Scan(&p.tst, &p.lat, &p.lon); err != nil { continue } pts = append(pts, p) } const gapSec = 30 * 60 // 30 minutes var trips []TripSummary if len(pts) > 0 { cur := TripSummary{StartTst: pts[0].tst, EndTst: pts[0].tst, PointCount: 1} prevLat, prevLon := pts[0].lat, pts[0].lon distKm := 0.0 for i := 1; i < len(pts); i++ { p := pts[i] if p.tst-pts[i-1].tst > gapSec { cur.DistanceKm = math.Round(distKm*10) / 10 trips = append(trips, cur) cur = TripSummary{StartTst: p.tst, EndTst: p.tst, PointCount: 1} distKm = 0 } else { cur.EndTst = p.tst cur.PointCount++ distKm += haversineKm(prevLat, prevLon, p.lat, p.lon) } prevLat, prevLon = p.lat, p.lon } cur.DistanceKm = math.Round(distKm*10) / 10 trips = append(trips, cur) } // Return newest first for i, j := 0, len(trips)-1; i < j; i, j = i+1, j-1 { trips[i], trips[j] = trips[j], trips[i] } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(trips) } // ── Helpers ─────────────────────────────────────────────────────────────────── func haversineKm(lat1, lon1, lat2, lon2 float64) float64 { const R = 6371.0 dLat := (lat2 - lat1) * math.Pi / 180 dLon := (lon2 - lon1) * 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(dLon/2)*math.Sin(dLon/2) return R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) } func parseFloat(s string) float64 { if s == "" { return 0 } v, _ := strconv.ParseFloat(s, 64) return v } func parseInt64(s string) int64 { if s == "" { return 0 } v, _ := strconv.ParseInt(s, 10, 64) return v }