pat/api/api.go

473 lines
14 KiB
Go
Raw Permalink Normal View History

// Copyright 2016 Martin Hebnes Pedersen (LA5NTA). All rights reserved.
// Use of this source code is governed by the MIT-license that can be
// found in the LICENSE file.
package api
2015-05-22 20:01:56 +02:00
import (
"context"
2015-05-22 20:01:56 +02:00
"encoding/json"
"fmt"
"log"
2025-09-03 18:51:27 +02:00
"maps"
"net"
2015-05-22 20:01:56 +02:00
"net/http"
"os"
"sort"
2020-09-05 23:01:19 +02:00
"strconv"
2015-05-22 20:01:56 +02:00
"strings"
"time"
"github.com/la5nta/pat/app"
"github.com/la5nta/pat/cfg"
"github.com/la5nta/pat/internal/buildinfo"
"github.com/la5nta/pat/internal/gpsd"
"github.com/la5nta/pat/internal/patapi"
"github.com/la5nta/pat/web"
2015-05-22 20:01:56 +02:00
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/hashicorp/go-version"
2015-05-22 20:01:56 +02:00
"github.com/la5nta/wl2k-go/catalog"
"github.com/la5nta/wl2k-go/transport/ardop"
"github.com/n8jja/Pat-Vara/vara"
"github.com/pd0mz/go-maidenhead"
2015-05-22 20:01:56 +02:00
)
type HTTPError struct {
error
StatusCode int
}
func ListenAndServe(ctx context.Context, a *app.App, addr string) error {
log.Printf("Starting HTTP service (http://%s)...", addr)
2015-05-22 20:01:56 +02:00
if host, _, _ := net.SplitHostPort(addr); host == "" && a.Config().GPSd.EnableHTTP {
// TODO: maybe make a popup showing the warning ont the web UI?
fmt.Fprintf(os.Stderr, "\nWARNING: You have enable GPSd HTTP endpoint (enable_http). You might expose"+
2019-12-08 23:37:14 +01:00
"\n your current position to anyone who has access to the Pat web interface!\n\n")
}
handler := NewHandler(a)
go handler.wsHub.WatchMBox(ctx, a.Mailbox())
if err := a.EnableWebSocket(ctx, handler.wsHub); err != nil {
return err
}
srv := http.Server{
2023-10-21 13:10:19 +02:00
Addr: addr,
Handler: handler,
}
errs := make(chan error, 1)
go func() {
errs <- srv.ListenAndServe()
}()
select {
case <-ctx.Done():
log.Println("Shutting down HTTP server...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
return nil
case err := <-errs:
return err
}
2015-05-22 20:01:56 +02:00
}
type Handler struct {
*app.App
wsHub *WSHub
r *mux.Router
}
2025-06-17 06:05:37 +02:00
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.r.ServeHTTP(w, r) }
func NewHandler(app *app.App) *Handler {
r := mux.NewRouter()
h := &Handler{app, NewWSHub(app), r}
r.HandleFunc("/api/connect", h.ConnectHandler)
r.HandleFunc("/api/disconnect", h.DisconnectHandler)
2025-06-17 06:05:37 +02:00
r.HandleFunc("/api/mailbox/{box}", h.mailboxHandler).Methods("GET")
r.HandleFunc("/api/mailbox/{box}/{mid}", h.messageHandler).Methods("GET")
r.HandleFunc("/api/mailbox/{box}/{mid}", h.messageDeleteHandler).Methods("DELETE")
r.HandleFunc("/api/mailbox/{box}/{mid}/{attachment}", h.attachmentHandler).Methods("GET")
r.HandleFunc("/api/mailbox/{box}/{mid}/read", h.readHandler).Methods("POST")
r.HandleFunc("/api/mailbox/{box}", h.postMessageHandler).Methods("POST")
2025-06-17 06:05:37 +02:00
r.HandleFunc("/api/posreport", h.postPositionHandler).Methods("POST")
r.HandleFunc("/api/status", h.statusHandler).Methods("GET")
r.HandleFunc("/api/current_gps_position", h.positionHandler).Methods("GET")
r.HandleFunc("/api/coords_to_locator", h.coordsToLocatorHandler).Methods("POST")
r.HandleFunc("/api/qsy", h.qsyHandler).Methods("POST")
r.HandleFunc("/api/rmslist", h.rmslistHandler).Methods("GET")
2025-06-17 06:05:37 +02:00
r.HandleFunc("/api/config", h.configHandler).Methods("GET", "PUT")
r.HandleFunc("/api/config/connect_aliases", h.connectAliasesHandler).Methods("GET")
r.HandleFunc("/api/config/connect_aliases/{alias}", h.connectAliasHandler).Methods("GET", "PUT", "DELETE")
r.HandleFunc("/api/reload", h.reloadHandler).Methods("POST")
2025-06-17 06:05:37 +02:00
r.HandleFunc("/api/bandwidths", h.bandwidthsHandler).Methods("GET")
r.HandleFunc("/api/connect_aliases", h.connectAliasesHandler).Methods("GET") // DEPRECATED: Use /api/config/connect_aliases.
r.HandleFunc("/api/new-release-check", h.newReleaseCheckHandler).Methods("GET")
r.HandleFunc("/api/formcatalog", h.FormsManager().GetFormsCatalogHandler).Methods("GET")
r.HandleFunc("/api/form", h.FormsManager().PostFormDataHandler(h.Mailbox().MBoxPath)).Methods("POST")
r.HandleFunc("/api/template", h.FormsManager().GetTemplateDataHandler(h.Mailbox().MBoxPath)).Methods("GET")
r.HandleFunc("/api/form", h.FormsManager().GetFormDataHandler).Methods("GET")
r.HandleFunc("/api/forms", h.FormsManager().GetFormTemplateHandler).Methods("GET")
r.PathPrefix("/api/forms/").Handler(http.StripPrefix("/api/forms/", http.HandlerFunc(h.FormsManager().GetFormAssetHandler))).Methods("GET")
r.HandleFunc("/api/formsUpdate", h.FormsManager().UpdateFormTemplatesHandler).Methods("POST")
r.HandleFunc("/api/winlink-account/password-recovery-email", h.winlinkPasswordRecoveryEmailHandler).Methods("GET", "PUT")
2025-06-30 06:54:51 +02:00
r.HandleFunc("/api/winlink-account/registration", h.winlinkAccountRegistrationHandler).Methods("GET", "POST")
r.HandleFunc("/ws", h.wsHandler)
r.PathPrefix("/ui").Handler(web.UIHandler(h.Options().MyCall))
r.PathPrefix("/dist").Handler(web.DistHandler())
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/ui", http.StatusFound) })
return h
}
func (h Handler) connectAliasesHandler(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(h.Config().ConnectAliases)
2015-11-06 18:32:25 +01:00
}
func (h Handler) connectAliasHandler(w http.ResponseWriter, r *http.Request) {
// Make a copy of the map to avoid concurrenct read/write of the "live" map
2025-09-03 18:51:27 +02:00
currentAliases := maps.Clone(h.Config().ConnectAliases)
alias := mux.Vars(r)["alias"]
switch r.Method {
case http.MethodGet:
v, ok := currentAliases[alias]
if !ok {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(v)
case http.MethodDelete:
delete(currentAliases, alias)
if err := h.SetConnectAliases(currentAliases); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
case http.MethodPut:
var v string
if err := json.NewDecoder(r.Body).Decode(&v); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
currentAliases[alias] = v
if err := h.SetConnectAliases(currentAliases); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(v)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (h Handler) postPositionHandler(w http.ResponseWriter, r *http.Request) {
2015-05-22 20:01:56 +02:00
var pos catalog.PosReport
if err := json.NewDecoder(r.Body).Decode(&pos); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if pos.Date.IsZero() {
pos.Date = time.Now()
}
2025-06-17 09:12:28 +02:00
msg := pos.Message(h.Options().MyCall)
2015-05-22 20:01:56 +02:00
// Post to outbox
if err := h.Mailbox().AddOut(msg); err != nil {
2015-05-22 20:01:56 +02:00
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
2025-06-17 09:12:28 +02:00
return
2015-05-22 20:01:56 +02:00
}
2025-06-17 09:12:28 +02:00
fmt.Fprintln(w, "Position update posted")
2015-05-22 20:01:56 +02:00
}
func (h Handler) wsHandler(w http.ResponseWriter, r *http.Request) {
2015-05-22 20:01:56 +02:00
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
_ = conn.WriteJSON(struct{ MyCall string }{h.Options().MyCall})
h.wsHub.Handle(conn)
2015-05-22 20:01:56 +02:00
}
func (h Handler) statusHandler(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(h.GetStatus())
2021-07-16 10:58:58 -06:00
}
func (h Handler) bandwidthsHandler(w http.ResponseWriter, req *http.Request) {
2022-03-13 18:26:18 -06:00
type BandwidthResponse struct {
Mode string `json:"mode"`
Bandwidths []string `json:"bandwidths"`
Default string `json:"default,omitempty"`
2022-03-13 18:26:18 -06:00
}
mode := strings.ToLower(req.FormValue("mode"))
resp := BandwidthResponse{Mode: mode, Bandwidths: []string{}}
2024-12-30 22:10:05 +01:00
switch mode {
case app.MethodArdop:
2022-03-13 18:26:18 -06:00
for _, bw := range ardop.Bandwidths() {
resp.Bandwidths = append(resp.Bandwidths, bw.String())
}
if bw := h.Config().Ardop.ARQBandwidth; !bw.IsZero() {
resp.Default = bw.String()
}
case app.MethodVaraHF:
resp.Bandwidths = vara.Bandwidths()
if bw := h.Config().VaraHF.Bandwidth; bw != 0 {
resp.Default = fmt.Sprintf("%d", bw)
}
2022-03-13 18:26:18 -06:00
}
_ = json.NewEncoder(w).Encode(resp)
}
func (h Handler) rmslistHandler(w http.ResponseWriter, req *http.Request) {
2025-06-17 09:12:28 +02:00
var (
forceDownload, _ = strconv.ParseBool(req.FormValue("force-download"))
band = req.FormValue("band")
mode = strings.ToLower(req.FormValue("mode"))
prefix = strings.ToUpper(req.FormValue("prefix"))
)
list, err := h.ReadRMSList(req.Context(), forceDownload, func(r app.RMS) bool {
2020-09-05 23:01:19 +02:00
switch {
case r.URL == nil:
return false
case mode != "" && !r.IsMode(mode):
return false
case band != "" && !r.IsBand(band):
return false
case prefix != "" && !strings.HasPrefix(r.Callsign, prefix):
return false
default:
return true
}
})
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
2025-06-17 09:12:28 +02:00
// Sort by predictions if we have more than 1/3 entries with predictions,
// otherwise sort by distance.
nPredictions := 0
for _, rms := range list {
if rms.Prediction != nil {
nPredictions++
}
}
if nPredictions > len(list)/3 {
sort.Sort(sort.Reverse(app.ByLinkQuality(list)))
} else {
sort.Sort(app.ByDist(list))
}
2025-06-17 09:12:28 +02:00
json.NewEncoder(w).Encode(list)
2020-09-05 23:01:19 +02:00
}
func (h Handler) qsyHandler(w http.ResponseWriter, req *http.Request) {
type QSYPayload struct {
Transport string `json:"transport"`
Freq json.Number `json:"freq"`
}
var payload QSYPayload
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
2020-09-05 23:01:19 +02:00
rig, rigName, ok, err := h.VFOForTransport(payload.Transport)
2020-09-05 23:01:19 +02:00
switch {
case rigName == "":
// Either unsupported mode or no rig configured for this transport
w.WriteHeader(http.StatusServiceUnavailable)
return
2020-09-05 23:01:19 +02:00
case !ok:
// A rig is configured, but not loaded properly
w.WriteHeader(http.StatusInternalServerError)
log.Printf("QSY failed: Hamlib rig '%s' not loaded.", rigName)
case err != nil:
w.WriteHeader(http.StatusInternalServerError)
log.Printf("QSY failed: %v", err)
default:
if _, _, err := app.SetFreq(rig, string(payload.Freq)); err != nil {
2020-09-05 23:01:19 +02:00
w.WriteHeader(http.StatusInternalServerError)
log.Printf("QSY failed: %v", err)
return
}
2021-07-16 10:58:58 -06:00
_ = json.NewEncoder(w).Encode(payload)
}
}
func (h Handler) positionHandler(w http.ResponseWriter, req *http.Request) {
// Throw error if GPSd http endpoint is not enabled
if !h.Config().GPSd.EnableHTTP || h.Config().GPSd.Addr == "" {
http.Error(w, "GPSd not enabled or address not set in config file", http.StatusInternalServerError)
return
}
host, _, _ := net.SplitHostPort(req.RemoteAddr)
log.Printf("Location data from GPSd served to %s", host)
conn, err := gpsd.Dial(h.Config().GPSd.Addr)
if err != nil {
// do not pass error message to response as GPSd address might be leaked
http.Error(w, "GPSd Dial failed", http.StatusInternalServerError)
return
}
defer conn.Close()
conn.Watch(true)
pos, err := conn.NextPosTimeout(5 * time.Second)
if err != nil {
2019-12-08 23:37:14 +01:00
http.Error(w, "GPSd get next position failed: "+err.Error(), http.StatusInternalServerError)
return
}
if h.Config().GPSd.UseServerTime {
pos.Time = time.Now()
}
2021-07-16 10:58:58 -06:00
_ = json.NewEncoder(w).Encode(pos)
}
func (h Handler) DisconnectHandler(w http.ResponseWriter, req *http.Request) {
dirty, _ := strconv.ParseBool(req.FormValue("dirty"))
if ok := h.AbortActiveConnection(dirty); !ok {
w.WriteHeader(http.StatusBadRequest)
}
2021-07-16 10:58:58 -06:00
_ = json.NewEncoder(w).Encode(struct{}{})
}
func (h Handler) ConnectHandler(w http.ResponseWriter, req *http.Request) {
2015-11-06 18:32:25 +01:00
connectStr := req.FormValue("url")
2015-05-22 20:01:56 +02:00
nMsgs := h.Mailbox().InboxCount()
2015-05-22 20:01:56 +02:00
if success := h.Connect(connectStr); !success {
2015-05-22 20:01:56 +02:00
http.Error(w, "Session failure", http.StatusInternalServerError)
}
_ = json.NewEncoder(w).Encode(struct{ NumReceived int }{
h.Mailbox().InboxCount() - nMsgs,
2015-05-22 20:01:56 +02:00
})
}
func (h Handler) newReleaseCheckHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
release, err := patapi.GetLatestVersion(ctx)
if err != nil {
http.Error(w, "Error getting latest version: "+err.Error(), http.StatusInternalServerError)
return
}
currentVer, err := version.NewVersion(buildinfo.Version)
if err != nil {
http.Error(w, "Invalid current version format: "+err.Error(), http.StatusInternalServerError)
return
}
latestVer, err := version.NewVersion(release.Version)
if err != nil {
http.Error(w, "Invalid latest version format: "+err.Error(), http.StatusInternalServerError)
return
}
if currentVer.Compare(latestVer) >= 0 {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(release)
}
func (h Handler) configHandler(w http.ResponseWriter, r *http.Request) {
const RedactedPassword = "[REDACTED]"
currentConfig, err := app.LoadConfig(h.Options().ConfigPath, cfg.DefaultConfig)
if err != nil {
log.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Method == "GET" {
if currentConfig.SecureLoginPassword != "" {
// Redact password before sending over unsafe channel.
currentConfig.SecureLoginPassword = RedactedPassword
}
json.NewEncoder(w).Encode(currentConfig)
return
}
var newConfig cfg.Config
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Security: Prevent GPSd EnableHTTP from being changed via web interface
if newConfig.GPSd.EnableHTTP != currentConfig.GPSd.EnableHTTP {
http.Error(w, "GPSd EnableHTTP setting cannot be changed via web interface for security reasons. Please edit the configuration file manually.", http.StatusForbidden)
return
}
// Reset redacted password if it was unmodified (to retain old value)
if newConfig.SecureLoginPassword == RedactedPassword {
newConfig.SecureLoginPassword = currentConfig.SecureLoginPassword
}
if err := app.WriteConfig(newConfig, h.Options().ConfigPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
_ = json.NewEncoder(w).Encode("OK")
}
func (h Handler) reloadHandler(w http.ResponseWriter, r *http.Request) {
if err := h.App.Reload(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (h Handler) coordsToLocatorHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
point := maidenhead.NewPoint(req.Lat, req.Lon)
locator, err := point.GridSquare()
if err != nil {
http.Error(w, "Failed to convert coordinates to locator: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Locator string `json:"locator"`
}{Locator: locator})
}