// 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 for relative imports) // - /sdk/ -> ES-module SDK files served from cfg.SDKDir // - / -> /sdk/index.js redirect (bare URL returns the SDK entry) // - / -> 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") // /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)))) 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 }