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:
Martin Hebnes Pedersen 2025-12-29 20:34:40 +01:00
parent 53058b982c
commit 946a91fcc4
4 changed files with 169 additions and 85 deletions

View file

@ -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())
}

View file

@ -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
View 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
View 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)
}
})
}
}