// 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/config" "git.saqut.com/saqut/mwse/internal/ws" ) // 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) *http.Server { wsServer := ws.NewServer(hub) mux := http.NewServeMux() registerAPI(mux, hub) 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 }