MWSE/internal/httpserver/httpserver.go

120 lines
3.9 KiB
Go

// Package httpserver assembles the HTTP surface of the engine: the WebSocket
// upgrade endpoint, the static asset routes (the built SDK and the public files),
// and the /api control plane. It mirrors the routing of the original
// HTTPServer.js while adding timeouts and graceful shutdown (#25).
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 (the SDK derives its endpoint from wherever the script was
// served, so the upgrade may arrive at "/" or "/script/"). 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:
//
// - /script -> the built SDK entry (script/index.js)
// - /script/<file> -> files under the script directory
// - / -> the SDK entry (so a bare visit returns the script)
// - /<file> -> a matching file under the public directory
// - anything else -> the status document (status.xml)
func registerStatic(mux *http.ServeMux, cfg config.Config) {
scriptIndex := filepath.Join(cfg.ScriptDir, "index.js")
statusDoc := filepath.Join(cfg.ScriptDir, "status.xml")
mux.HandleFunc("/script", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, scriptIndex)
})
mux.Handle("/script/", http.StripPrefix("/script/", http.FileServer(http.Dir(cfg.ScriptDir))))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.ServeFile(w, r, scriptIndex)
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
}