From c41a2c541163ae69b50bdbd9ede0372e3c75c114 Mon Sep 17 00:00:00 2001 From: Astra Date: Wed, 3 Jun 2026 19:34:08 +0100 Subject: [PATCH] Initial commit: Breadcrumb GPS trip viewer Go server receiving Colota location data into SQLite, with a trip-segmentation API and embedded web frontend for browsing trips on a map. --- Dockerfile | 26 ++ README.md | 147 ++++++++++ go.mod | 5 + go.sum | 2 + main.go | 396 ++++++++++++++++++++++++++ static/index.html | 692 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1268 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 static/index.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..731af5d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.22-alpine AS builder + +# gcc is required for go-sqlite3 (CGO) +RUN apk add --no-cache gcc musl-dev + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -o breadcrumb . + +# ── Runtime image ───────────────────────────────────────────────────────────── +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates + +WORKDIR /app +COPY --from=builder /app/breadcrumb . + +# Data directory for the SQLite file (mount a volume here) +RUN mkdir -p /data + +EXPOSE 8080 + +ENTRYPOINT ["./breadcrumb"] +CMD ["-port", "8080", "-db", "/data/locations.db"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a18a1e7 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Breadcrumb + +A self-contained Go server that receives GPS location data from the +[Colota Android app](https://colota.app), stores it in an SQLite3 database, +and serves a web frontend for browsing trips on a map. + +## Quick Start + +```bash +# Build +go build -o breadcrumb . + +# Run (no auth, default port 8080, db = ./locations.db) +./breadcrumb + +# Run with Bearer token auth on port 443 +./breadcrumb -port 443 -token mysecrettoken -db /var/data/locations.db +``` + +### Flags + +| Flag | Default | Description | +|----------|------------------|------------------------------------------------------| +| `-port` | `8080` | HTTP listen port | +| `-db` | `locations.db` | Path to the SQLite3 database file | +| `-token` | *(empty)* | Bearer token for auth. Empty = authentication off. | + +--- + +## API + +### `POST /api/location` ← Colota app sends here + +**Headers:** `Content-Type: application/json` + +**Body** (Colota default payload): + +```json +{ + "lat": 51.495065, + "lon": -0.043945, + "acc": 12, + "alt": 519, + "vel": 0, + "batt": 85, + "bs": 2, + "tst": 1704067200, + "bear": 180.5 +} +``` + +| Field | Type | Required | Description | +|--------|---------|----------|--------------------------------------------------------| +| `lat` | float | ✓ | Latitude (decimal degrees) | +| `lon` | float | ✓ | Longitude (decimal degrees) | +| `acc` | float | ✓ | Accuracy (metres) | +| `tst` | int | ✓ | Unix timestamp (seconds). Falls back to server time. | +| `alt` | float | | Altitude (metres) | +| `vel` | float | | Speed (m/s) | +| `bear` | float | | Bearing (degrees, 0–360) | +| `batt` | int | | Battery level (0–100 %) | +| `bs` | int | | Battery state: 0=unknown 1=unplugged 2=charging 3=full | + +**GET** variant is also supported (query parameters with the same field names). + +**Returns:** `200 OK` on success. + +--- + +### `GET /api/locations` ← Query stored locations + +| Param | Default | Description | +|---------|---------|------------------------------------------| +| `limit` | `100` | Max records to return (up to 500 000) | +| `page` | `1` | Page number (1-based) | +| `from` | | Filter: Unix timestamp lower bound | +| `to` | | Filter: Unix timestamp upper bound | + +**Response:** + +```json +{ + "total": 42, + "page": 1, + "limit": 100, + "records": [ { "id": 1, "lat": 51.495065, "lon": -0.043945, ... } ] +} +``` + +--- + +### `GET /api/trips` + +Returns location points grouped into trips (gaps > 30 min = new trip), newest first. + +--- + +### `GET /health` + +Returns server status and total location count. + +--- + +## Colota App Configuration + +1. Open Colota → **Settings → API Settings** +2. Select template: **Custom** +3. Set **Endpoint URL** to `http(s)://your-server:8080/api/location` +4. **HTTP Method:** POST +5. If using a token: set **Authentication → Bearer Token** to your `-token` value +6. Leave field mapping at defaults +7. Tap **Test Connection** — you should see `200 OK` + +--- + +## Docker + +```bash +docker build -t breadcrumb . + +docker run -d \ + --name breadcrumb \ + -p 8080:8080 \ + -v breadcrumb-data:/data \ + breadcrumb -token "$TOKEN" +``` + +--- + +## Database Schema + +```sql +CREATE TABLE 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, -- Unix timestamp + bear REAL, + received_at TEXT NOT NULL -- ISO-8601 UTC +); +CREATE INDEX idx_locations_tst ON locations(tst); +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..89f5ff9 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/breadcrumb + +go 1.22 + +require github.com/mattn/go-sqlite3 v1.14.22 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e8d092a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/main.go b/main.go new file mode 100644 index 0000000..af715cc --- /dev/null +++ b/main.go @@ -0,0 +1,396 @@ +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 +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..9adb716 --- /dev/null +++ b/static/index.html @@ -0,0 +1,692 @@ + + + + + + Breadcrumb + + + + + + + + + + + + +
+ + +
+
+ + +
+
+ + + + +