pat/api/api.go
Martin Hebnes Pedersen 946a91fcc4 Move embedded web assets to web package
The embedded filesystem was in package main, making it inaccessible to
alternative main packages that need to serve the HTTP interface. Moving
the embed directive and web handlers to a dedicated web package allows
any package to import and use the web UI functionality.

This also improves separation of concerns by isolating static file
serving, template handling, and dev server proxying in a single package,
and simplifies the API by removing the staticContent parameter from
NewHandler.

Ref #512 and #15
2025-12-30 09:59:16 +01:00

472 lines
14 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"
"encoding/json"
"fmt"
"log"
"maps"
"net"
"net/http"
"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/la5nta/pat/web"
"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"
)
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)
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")
}
handler := NewHandler(a)
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) *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.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)
}
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) 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})
}