MWSE/internal/httpserver/httpserver.go

129 lines
4.3 KiB
Go

// Package httpserver assembles the HTTP surface of the engine: the WebSocket
// upgrade endpoint, the ES-module SDK routes, the public asset directory, and
// the /api control plane.
package httpserver
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gorilla/websocket"
"git.saqut.com/saqut/mwse/internal/bridge"
"git.saqut.com/saqut/mwse/internal/config"
"git.saqut.com/saqut/mwse/internal/ws"
)
// ServerOptions holds optional wiring for httpserver.New.
type ServerOptions struct {
// BridgeInbox, when non-nil, enables POST /api/bridge/inbox so an application
// server can drain client messages routed via bridge/send (#46).
BridgeInbox *bridge.Inbox
// Approver, when non-nil, gates each incoming WebSocket connection by asking
// the approver before the HTTP upgrade (#46).
Approver ws.Approver
}
// New builds the *http.Server. WebSocket upgrades are detected on any path and
// routed to the engine; all other requests go through the static/API mux.
func New(hub *ws.Hub, cfg config.Config, srvOpts ...ServerOptions) *http.Server {
var so ServerOptions
if len(srvOpts) > 0 {
so = srvOpts[0]
}
wsServer := ws.NewServer(hub, ws.Options{
OutboundBuffer: cfg.Conn.OutboundBuffer,
MaxMessageSize: cfg.Conn.MaxMessageSize,
ReadBufferSize: cfg.Conn.ReadBufferSize,
WriteBufferSize: cfg.Conn.WriteBufferSize,
PingInterval: cfg.Conn.PingInterval,
PongWait: cfg.Conn.PongWait,
WriteWait: cfg.Conn.WriteWait,
Approver: so.Approver,
})
mux := http.NewServeMux()
registerAPI(mux, hub, so.BridgeInbox)
registerStatic(mux, cfg)
root := func(w http.ResponseWriter, r *http.Request) {
if websocket.IsWebSocketUpgrade(r) {
wsServer.ServeHTTP(w, r)
return
}
mux.ServeHTTP(w, r)
}
return &http.Server{
Addr: cfg.Addr(),
Handler: http.HandlerFunc(root),
ReadHeaderTimeout: cfg.ReadHeaderTimeout,
}
}
// registerStatic wires the asset routes:
//
// - /sdk.js -> 301 /sdk/index.js (import.meta.url resolves correctly)
// - /sdk/ -> ES-module SDK files served from cfg.SDKDir
// - /studio -> Studio product HTML (built-in UI)
// - /studio/ -> Studio JS/CSS assets under public/studio/
// - / -> /sdk/index.js redirect (bare URL returns the SDK entry)
// - /<file> -> matching file under cfg.PublicDir
// - anything -> public/status.xml fallback
func registerStatic(mux *http.ServeMux, cfg config.Config) {
statusDoc := filepath.Join(cfg.PublicDir, "status.xml")
studioDir := filepath.Join(cfg.PublicDir, "studio")
studioHTML := filepath.Join(studioDir, "index.html")
// /sdk.js → /sdk/index.js: keeps import.meta.url = /sdk/index.js so that
// ./EventTarget.js etc. resolve to /sdk/EventTarget.js (same origin).
mux.HandleFunc("/sdk.js", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/sdk/index.js", http.StatusMovedPermanently)
})
mux.Handle("/sdk/", http.StripPrefix("/sdk/", http.FileServer(http.Dir(cfg.SDKDir))))
// /studio → Studio product (built-in management UI).
// /studio/ → JS/CSS assets (ColumnView.js, Studio.js, style.css, …).
mux.HandleFunc("/studio", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, studioHTML)
})
mux.Handle("/studio/", http.StripPrefix("/studio/", http.FileServer(http.Dir(studioDir))))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, "/sdk/index.js", http.StatusFound)
return
}
if f, ok := safePublicFile(cfg.PublicDir, r.URL.Path); ok {
http.ServeFile(w, r, f)
return
}
http.ServeFile(w, r, statusDoc)
})
}
// safePublicFile resolves urlPath to a regular file under publicDir, guarding
// against path traversal. It returns ok=false when no such file exists.
func safePublicFile(publicDir, urlPath string) (string, bool) {
clean := filepath.Clean("/" + strings.TrimPrefix(urlPath, "/"))
full := filepath.Join(publicDir, clean)
absPublic, err := filepath.Abs(publicDir)
if err != nil {
return "", false
}
absFull, err := filepath.Abs(full)
if err != nil {
return "", false
}
if absFull != absPublic && !strings.HasPrefix(absFull, absPublic+string(os.PathSeparator)) {
return "", false // escaped the public directory
}
if fi, err := os.Stat(absFull); err == nil && !fi.IsDir() {
return absFull, true
}
return "", false
}