// 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/ -> files under the script directory // - / -> the SDK entry (so a bare visit returns the script) // - / -> 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 }