diff --git a/admin.go b/admin.go new file mode 100644 index 0000000..13f26d8 --- /dev/null +++ b/admin.go @@ -0,0 +1,110 @@ +package main + +import ( + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" +) + +func adminAuth(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if auth == "Bearer "+token { + next.ServeHTTP(w, r) + return + } + // For the HTML page, allow token via query param so it can be bookmarked + if r.URL.Query().Get("token") == token { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) + }) +} + +func handleAdminPage() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "static/admin.html") + } +} + +type adminRepeatersResponse struct { + Repeaters []Repeater `json:"repeaters"` + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +func handleAdminUpdateRepeater(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + var rpt Repeater + if err := json.NewDecoder(r.Body).Decode(&rpt); err != nil { + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + if rpt.ID <= 0 { + http.Error(w, `{"error":"missing repeater id"}`, http.StatusBadRequest) + return + } + + if err := updateRepeater(db, rpt); err != nil { + http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) + } +} + +func handleAdminRepeaters(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + q := r.URL.Query() + page := 1 + if v := q.Get("page"); v != "" { + if p, err := strconv.Atoi(v); err == nil && p > 0 { + page = p + } + } + + perPage := 50 + if v := q.Get("per_page"); v != "" { + if p, err := strconv.Atoi(v); err == nil && p > 0 { + perPage = p + } + } + if perPage > 200 { + perPage = 200 + } + + search := strings.TrimSpace(q.Get("q")) + + repeaters, total, err := queryAdminRepeaters(db, search, page, perPage) + if err != nil { + http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(adminRepeatersResponse{ + Repeaters: repeaters, + Total: total, + Page: page, + PerPage: perPage, + }) + } +} diff --git a/compose.dev.yml b/compose.dev.yml index 874468c..3e67a6c 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -27,6 +27,7 @@ services: - JSON_PATH=/app/rptrs.json - BMRPTRS_PATH=/app/bmrptrs.json - LISTEN_ADDR=:8080 + - ADMIN_TOKEN=adminparty volumes: go-mod-cache: diff --git a/db.go b/db.go index 8d784a6..8fda873 100644 --- a/db.go +++ b/db.go @@ -40,8 +40,9 @@ type Repeater struct { Agl int `json:"agl"` Website string `json:"website"` Description string `json:"description"` - ImportFreqInconsistent bool `json:"import_freq_inconsistent"` - Inactive bool `json:"inactive"` + ImportFreqInconsistent bool `json:"import_freq_inconsistent"` + Inactive bool `json:"inactive"` + LastPolled *time.Time `json:"last_polled"` } func openDB(dsn string) (*sql.DB, error) { @@ -83,7 +84,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, color_code, ts_linked, trustee, ipsc_network, network, hotspot, status, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, - import_freq_inconsistent + 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} @@ -149,7 +150,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str &r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, - &r.ImportFreqInconsistent); err != nil { + &r.ImportFreqInconsistent, &r.LastPolled); err != nil { return nil, err } r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold) @@ -251,6 +252,74 @@ func queryRepeatersInRadius(db *sql.DB, lat, lng, radiusKm float64, band string, 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 network 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, network, 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.Network, &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) + 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. @@ -299,6 +368,24 @@ func distToSegmentWithParam(pLat, pLng, aLat, aLng, bLat, bLng float64) (dist, t return math.Sqrt(dx*dx + dy*dy), t } +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, + network=$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.Network, 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 diff --git a/main.go b/main.go index 463f452..75d85a2 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,16 @@ func main() { mux.HandleFunc("/api/repeaters", handleRepeaters(db)) mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db)) mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db)) + + if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" { + mux.HandleFunc("/admin/", handleAdminPage()) + adminAPI := http.NewServeMux() + adminAPI.HandleFunc("/admin/api/repeaters", handleAdminRepeaters(db)) + adminAPI.HandleFunc("/admin/api/repeaters/update", handleAdminUpdateRepeater(db)) + mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI)) + log.Println("Admin interface enabled at /admin/") + } + mux.Handle("/", http.FileServer(http.Dir("static"))) log.Printf("Listening on %s", addr) diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..08f79c1 --- /dev/null +++ b/static/admin.html @@ -0,0 +1,894 @@ + + + + + + DMRmap Admin + + + + +
+
+

DMRmap Admin

+ + + +
+
+ +
+
+

DMRmap Admin

+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
IDCallsignBandTXRXOffsetCCNetworkCityStateCountryStatusTimeslotsLast SeenBM StatusHardwareLast Polled
+ +
Loading...
+
+ +
+ +
+
+
+

Repeater

+ +
+
+ +
+ + + +