mirror of
https://github.com/la5nta/pat
synced 2026-08-07 22:32:06 -04:00
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
This commit is contained in:
parent
53058b982c
commit
946a91fcc4
4 changed files with 169 additions and 85 deletions
84
api/api.go
84
api/api.go
|
|
@ -6,18 +6,12 @@ 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"
|
||||
|
|
@ -29,6 +23,7 @@ import (
|
|||
"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"
|
||||
|
|
@ -39,17 +34,11 @@ import (
|
|||
"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)
|
||||
|
||||
|
|
@ -59,12 +48,7 @@ func ListenAndServe(ctx context.Context, a *app.App, addr string) error {
|
|||
"\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)
|
||||
handler := NewHandler(a)
|
||||
go handler.wsHub.WatchMBox(ctx, a.Mailbox())
|
||||
if err := a.EnableWebSocket(ctx, handler.wsHub); err != nil {
|
||||
return err
|
||||
|
|
@ -99,7 +83,7 @@ type Handler struct {
|
|||
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.r.ServeHTTP(w, r) }
|
||||
|
||||
func NewHandler(app *app.App, staticContent fs.FS) *Handler {
|
||||
func NewHandler(app *app.App) *Handler {
|
||||
r := mux.NewRouter()
|
||||
h := &Handler{app, NewWSHub(app), r}
|
||||
|
||||
|
|
@ -140,33 +124,14 @@ func NewHandler(app *app.App, staticContent fs.FS) *Handler {
|
|||
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")
|
||||
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) 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)
|
||||
}
|
||||
|
|
@ -243,43 +208,6 @@ func (h Handler) wsHandler(w http.ResponseWriter, r *http.Request) {
|
|||
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())
|
||||
}
|
||||
|
|
|
|||
7
main.go
7
main.go
|
|
@ -7,14 +7,12 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/la5nta/pat/api"
|
||||
"github.com/la5nta/pat/app"
|
||||
"github.com/la5nta/pat/cfg"
|
||||
"github.com/la5nta/pat/cli"
|
||||
|
|
@ -24,12 +22,7 @@ import (
|
|||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
//go:embed web/dist/**
|
||||
var embeddedFS embed.FS
|
||||
|
||||
func init() {
|
||||
api.EmbeddedFS = embeddedFS
|
||||
|
||||
pflag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "%s is a client for the Winlink 2000 Network.\n\n", buildinfo.AppName)
|
||||
fmt.Fprintf(os.Stderr, "Usage:\n %s [options] command [arguments]\n", os.Args[0])
|
||||
|
|
|
|||
86
web/web.go
Normal file
86
web/web.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Package web provides HTTP handlers for serving the web UI.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/la5nta/pat/internal/buildinfo"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
//go:embed dist/**
|
||||
var embeddedFS embed.FS
|
||||
|
||||
func devServerAddr() string { return strings.TrimSuffix(os.Getenv("PAT_WEB_DEV_ADDR"), "/") }
|
||||
|
||||
// DistHandler returns an HTTP handler that serves the static files for the web UI.
|
||||
func DistHandler() 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(embeddedFS))
|
||||
}
|
||||
}
|
||||
|
||||
// UIHandler returns an HTTP handler that serves the UI pages with the given callsign.
|
||||
func UIHandler(mycall string) http.Handler {
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/ui", templateHandler("dist/index.html", mycall)).Methods("GET")
|
||||
r.HandleFunc("/ui/config", templateHandler("dist/config.html", mycall)).Methods("GET")
|
||||
r.HandleFunc("/ui/template", templateHandler("dist/template.html", mycall)).Methods("GET")
|
||||
return r
|
||||
}
|
||||
|
||||
func templateHandler(templatePath string, mycall string) http.HandlerFunc {
|
||||
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 mycall == "" && r.URL.Path != "/ui/config" {
|
||||
http.Redirect(w, r, "/ui/config", http.StatusFound)
|
||||
return
|
||||
}
|
||||
t, err := loadTemplate(templatePath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmplData := struct{ AppName, Version, Mycall string }{buildinfo.AppName, buildinfo.VersionString(), mycall}
|
||||
if err := t.Execute(w, tmplData); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadTemplate(templatePath string) (*template.Template, error) {
|
||||
if devServer := devServerAddr(); devServer != "" {
|
||||
// Dev mode: fetch from dev server
|
||||
resp, err := http.Get(devServer + "/" + templatePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return template.New(path.Base(templatePath)).Parse(string(data))
|
||||
}
|
||||
|
||||
// Load from embedded FS
|
||||
return template.ParseFS(embeddedFS, templatePath)
|
||||
}
|
||||
77
web/web_test.go
Normal file
77
web/web_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDistHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "valid asset",
|
||||
path: "/dist/js/app.js",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "non-existent asset",
|
||||
path: "/dist/foobar",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
handler := DistHandler()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("got status %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUIHandler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
}{
|
||||
{
|
||||
name: "main UI",
|
||||
path: "/ui",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "config UI",
|
||||
path: "/ui/config",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "template UI",
|
||||
path: "/ui/template",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "non-existent UI page",
|
||||
path: "/ui/foobar",
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
handler := UIHandler("N0CALL")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Errorf("got status %d, want %d", rec.Code, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue