diff --git a/playground/server.go b/playground/server.go index 5690913..24db2e8 100644 --- a/playground/server.go +++ b/playground/server.go @@ -24,6 +24,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net/http" "os" @@ -124,6 +125,22 @@ func (p *peerState) setRoom(roomID string, info roomInfo) { p.mu.Unlock() } +// roomList returns a snapshot of the rooms this peer knows (created or joined), +// so the SPA can render the peer's room list without re-deriving it client-side. +func (p *peerState) roomList() []map[string]any { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]map[string]any, 0, len(p.rooms)) + for id, info := range p.rooms { + out = append(out, map[string]any{ + "room_id": id, + "subject": info.subject, + "encrypt": info.encrypt, + }) + } + return out +} + // --------------------------------------------------------------------------- // Hub: the registry of peers, protected by a single mutex. // --------------------------------------------------------------------------- @@ -449,6 +466,64 @@ func (h *Hub) handleKick(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "kicked", "target": req.Target}) } +// handleRooms returns the rooms a peer knows (created or joined). The SPA polls +// or calls this after create/join to refresh its room list. +// +// GET /api/rooms?peer=ana +func (h *Hub) handleRooms(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("peer") + if name == "" { + writeErr(w, http.StatusBadRequest, "peer query param required") + return + } + p, ok := h.lookup(name) + if !ok { + writeErr(w, http.StatusBadRequest, "unknown peer "+name) + return + } + writeJSON(w, http.StatusOK, p.roomList()) +} + +// handleMembers lists the members of a room (endpoint id + role) so the SPA can +// render a members panel and drive invite/kick. It proxies the control plane's +// unauthenticated read endpoint; the public keys it returns are not secret. +// +// GET /api/members?room_id= +func (h *Hub) handleMembers(w http.ResponseWriter, r *http.Request) { + roomID := r.URL.Query().Get("room_id") + if roomID == "" { + writeErr(w, http.StatusBadRequest, "room_id query param required") + return + } + resp, err := http.Get(ctrlURL + "/rooms/" + roomID + "/members") + if err != nil { + writeErr(w, http.StatusInternalServerError, "fetch members: "+err.Error()) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + _, _ = w.Write(body) +} + +// withCORS allows the SPA running under the Vite dev server (a different origin) +// to call the gateway. It answers preflight OPTIONS and tags every response with +// permissive CORS headers. v1 trusts the local network, mirroring the control +// plane's auth model. +func withCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + // handleStream is the SSE endpoint. The browser opens one EventSource per peer; // each received Event is emitted as a `data: \n\n` block. The listener is // cleaned up when the HTTP request context is cancelled (tab closed / reload). @@ -807,9 +882,11 @@ func main() { mux.HandleFunc("POST /api/invite", hub.handleInvite) mux.HandleFunc("POST /api/publish", hub.handlePublish) mux.HandleFunc("POST /api/kick", hub.handleKick) + mux.HandleFunc("GET /api/rooms", hub.handleRooms) + mux.HandleFunc("GET /api/members", hub.handleMembers) mux.HandleFunc("GET /api/stream", hub.handleStream) mux.HandleFunc("GET /api/bench", hub.handleBench) - webSrv := &http.Server{Addr: webAddr, Handler: mux} + webSrv := &http.Server{Addr: webAddr, Handler: withCORS(mux)} go func() { if err := webSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("web server: %v", err)