mirror of
https://github.com/la5nta/pat
synced 2026-08-13 17:46:54 -04:00
544 lines
17 KiB
Go
544 lines
17 KiB
Go
// 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
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/url"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"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/gorilla/mux"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/hashicorp/go-version"
|
|
"github.com/la5nta/wl2k-go/catalog"
|
|
"github.com/la5nta/wl2k-go/transport/ardop"
|
|
"github.com/n8jja/Pat-Vara/vara"
|
|
"github.com/pd0mz/go-maidenhead"
|
|
)
|
|
|
|
// The web/ go:embed directive must be in package main because we can't
|
|
// reference ../ here. main assigns this variable on init.
|
|
var EmbeddedFS embed.FS
|
|
|
|
type HTTPError struct {
|
|
error
|
|
StatusCode int
|
|
}
|
|
|
|
func devServerAddr() string { return strings.TrimSuffix(os.Getenv("PAT_WEB_DEV_ADDR"), "/") }
|
|
|
|
func ListenAndServe(ctx context.Context, a *app.App, addr string) error {
|
|
log.Printf("Starting HTTP service (http://%s)...", addr)
|
|
|
|
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"+
|
|
"\n your current position to anyone who has access to the Pat web interface!\n\n")
|
|
}
|
|
|
|
staticContent, err := fs.Sub(EmbeddedFS, "web")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
handler := NewHandler(a, staticContent)
|
|
go handler.wsHub.WatchMBox(ctx, a.Mailbox())
|
|
if err := a.EnableWebSocket(ctx, handler.wsHub); err != nil {
|
|
return err
|
|
}
|
|
|
|
srv := http.Server{
|
|
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
|
|
}
|
|
}
|
|
|
|
type Handler struct {
|
|
*app.App
|
|
wsHub *WSHub
|
|
r *mux.Router
|
|
}
|
|
|
|
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.r.ServeHTTP(w, r) }
|
|
|
|
func NewHandler(app *app.App, staticContent fs.FS) *Handler {
|
|
r := mux.NewRouter()
|
|
h := &Handler{app, NewWSHub(app), r}
|
|
|
|
r.HandleFunc("/api/connect", h.ConnectHandler)
|
|
r.HandleFunc("/api/disconnect", h.DisconnectHandler)
|
|
|
|
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")
|
|
|
|
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")
|
|
|
|
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")
|
|
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")
|
|
r.HandleFunc("/api/winlink-account/registration", h.winlinkAccountRegistrationHandler).Methods("GET", "POST")
|
|
|
|
r.PathPrefix("/dist/").Handler(h.distHandler(staticContent))
|
|
r.HandleFunc("/ws", h.wsHandler)
|
|
r.HandleFunc("/ui", h.uiHandler(staticContent, "dist/index.html")).Methods("GET")
|
|
r.HandleFunc("/ui/config", h.uiHandler(staticContent, "dist/config.html")).Methods("GET")
|
|
r.HandleFunc("/ui/template", h.uiHandler(staticContent, "dist/template.html")).Methods("GET")
|
|
r.HandleFunc("/", h.rootHandler).Methods("GET")
|
|
|
|
return h
|
|
}
|
|
|
|
func (h Handler) distHandler(staticContent fs.FS) http.Handler {
|
|
switch target := devServerAddr(); {
|
|
case target != "":
|
|
targetURL, err := url.Parse(target)
|
|
if err != nil {
|
|
log.Fatalf("invalid proxy target URL: %v", err)
|
|
}
|
|
return httputil.NewSingleHostReverseProxy(targetURL)
|
|
default:
|
|
return http.FileServer(http.FS(staticContent))
|
|
}
|
|
}
|
|
|
|
func (h Handler) rootHandler(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/ui", http.StatusFound)
|
|
}
|
|
|
|
func (h Handler) connectAliasesHandler(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(h.Config().ConnectAliases)
|
|
}
|
|
|
|
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
|
|
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) {
|
|
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()
|
|
}
|
|
msg := pos.Message(h.Options().MyCall)
|
|
|
|
// Post to outbox
|
|
if err := h.Mailbox().AddOut(msg); err != nil {
|
|
log.Println(err)
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
fmt.Fprintln(w, "Position update posted")
|
|
}
|
|
|
|
func (h Handler) wsHandler(w http.ResponseWriter, r *http.Request) {
|
|
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)
|
|
}
|
|
|
|
func (h Handler) uiHandler(staticContent fs.FS, templatePath string) http.HandlerFunc {
|
|
templateFunc := func() ([]byte, error) { return fs.ReadFile(staticContent, templatePath) }
|
|
if target := devServerAddr(); target != "" {
|
|
templateFunc = func() ([]byte, error) {
|
|
resp, err := http.Get(target + "/" + templatePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dev server not reachable: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
}
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Redirect to config if no callsign is set and we're not already on config page
|
|
if h.Options().MyCall == "" && r.URL.Path != "/ui/config" {
|
|
http.Redirect(w, r, "/ui/config", http.StatusFound)
|
|
return
|
|
}
|
|
data, err := templateFunc()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
t, err := template.New("index.html").Parse(string(data))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
tmplData := struct{ AppName, Version, Mycall string }{buildinfo.AppName, buildinfo.VersionString(), h.Options().MyCall}
|
|
if err := t.Execute(w, tmplData); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h Handler) statusHandler(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(h.GetStatus())
|
|
}
|
|
|
|
func (h Handler) bandwidthsHandler(w http.ResponseWriter, req *http.Request) {
|
|
type BandwidthResponse struct {
|
|
Mode string `json:"mode"`
|
|
Bandwidths []string `json:"bandwidths"`
|
|
Default string `json:"default,omitempty"`
|
|
}
|
|
mode := strings.ToLower(req.FormValue("mode"))
|
|
resp := BandwidthResponse{Mode: mode, Bandwidths: []string{}}
|
|
switch mode {
|
|
case app.MethodArdop:
|
|
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)
|
|
}
|
|
}
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (h Handler) rmslistHandler(w http.ResponseWriter, req *http.Request) {
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(list)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
rig, rigName, ok, err := h.VFOForTransport(payload.Transport)
|
|
switch {
|
|
case rigName == "":
|
|
// Either unsupported mode or no rig configured for this transport
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
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 {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
log.Printf("QSY failed: %v", err)
|
|
return
|
|
}
|
|
_ = 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 {
|
|
http.Error(w, "GPSd get next position failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if h.Config().GPSd.UseServerTime {
|
|
pos.Time = time.Now()
|
|
}
|
|
|
|
_ = 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)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(struct{}{})
|
|
}
|
|
|
|
func (h Handler) ConnectHandler(w http.ResponseWriter, req *http.Request) {
|
|
connectStr := req.FormValue("url")
|
|
|
|
nMsgs := h.Mailbox().InboxCount()
|
|
|
|
if success := h.Connect(connectStr); !success {
|
|
http.Error(w, "Session failure", http.StatusInternalServerError)
|
|
}
|
|
|
|
_ = json.NewEncoder(w).Encode(struct{ NumReceived int }{
|
|
h.Mailbox().InboxCount() - nMsgs,
|
|
})
|
|
}
|
|
|
|
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})
|
|
}
|