package httpserver import ( "encoding/json" "net/http" "sync" "git.saqut.com/saqut/mwse/internal/ws" ) // apiKeyStore holds the issued server-to-server API keys. The original kept these // in a process-local Map; this matches that (keys do not survive a restart). type apiKeyStore struct { mu sync.RWMutex keys map[string]string // key -> domain } func newAPIKeyStore() *apiKeyStore { return &apiKeyStore{keys: make(map[string]string)} } func (s *apiKeyStore) issue(domain string) string { key := newToken() s.mu.Lock() s.keys[key] = domain s.mu.Unlock() return key } func (s *apiKeyStore) domain(key string) (string, bool) { s.mu.RLock() d, ok := s.keys[key] s.mu.RUnlock() return d, ok } // auth wraps a handler with x-api-key validation, passing the caller's domain on // the request context-free closure argument. func (s *apiKeyStore) auth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { key := r.Header.Get("x-api-key") if key == "" { writeJSON(w, http.StatusUnauthorized, fail("API_KEY_REQUIRED")) return } domain, ok := s.domain(key) if !ok { writeJSON(w, http.StatusUnauthorized, fail("INVALID_API_KEY")) return } next(w, r, domain) } } // registerAPI mounts the /api control-plane routes onto mux. It ports the read // endpoints and the core server-initiated messaging endpoints from api.js. The // server-as-room-participant (join/leave) and webhook endpoints are intentionally // deferred to feature-parity work (see REVIEW.md). func registerAPI(mux *http.ServeMux, hub *ws.Hub) { keys := newAPIKeyStore() mux.HandleFunc("POST /api/auth/key", func(w http.ResponseWriter, r *http.Request) { var body struct { Domain string `json:"domain"` } if !decode(w, r, &body) { return } if body.Domain == "" { writeJSON(w, http.StatusOK, fail("DOMAIN_REQUIRED")) return } writeJSON(w, http.StatusOK, map[string]any{"status": "success", "key": keys.issue(body.Domain)}) }) mux.HandleFunc("GET /api/rooms", func(w http.ResponseWriter, r *http.Request) { rooms := make([]map[string]any, 0) for _, room := range hub.Rooms() { rooms = append(rooms, map[string]any{ "id": room.ID, "name": room.Name, "accessType": room.AccessType, "joinType": room.JoinType, "description": room.Description, "clientCount": room.Size(), }) } writeJSON(w, http.StatusOK, map[string]any{"status": "success", "rooms": rooms}) }) mux.HandleFunc("GET /api/clients", func(w http.ResponseWriter, r *http.Request) { clients := make([]map[string]any, 0) for _, c := range hub.Clients() { clients = append(clients, map[string]any{ "id": c.ID, "rooms": c.Rooms(), "pairs": c.Pairs(), }) } writeJSON(w, http.StatusOK, map[string]any{"status": "success", "clients": clients}) }) mux.HandleFunc("GET /api/room/{id}", func(w http.ResponseWriter, r *http.Request) { room, ok := hub.Room(r.PathValue("id")) if !ok { writeJSON(w, http.StatusOK, fail("ROOM_NOT_FOUND")) return } writeJSON(w, http.StatusOK, map[string]any{"status": "success", "room": room.ToJSON(false)}) }) mux.HandleFunc("POST /api/client/{id}/send", keys.auth(func(w http.ResponseWriter, r *http.Request, domain string) { var body struct { Pack any `json:"pack"` } if !decode(w, r, &body) { return } client, ok := hub.Client(r.PathValue("id")) if !ok { writeJSON(w, http.StatusOK, fail("CLIENT_NOT_FOUND")) return } if body.Pack == nil { writeJSON(w, http.StatusOK, fail("PACK_REQUIRED")) return } client.Signal("server/pack", map[string]any{"from": "server", "fromServer": domain, "pack": body.Pack}) writeJSON(w, http.StatusOK, map[string]any{"status": "success"}) })) mux.HandleFunc("POST /api/room/{id}/send", keys.auth(func(w http.ResponseWriter, r *http.Request, domain string) { var body struct { Pack any `json:"pack"` Wom bool `json:"wom"` } if !decode(w, r, &body) { return } id := r.PathValue("id") room, ok := hub.Room(id) if !ok { writeJSON(w, http.StatusOK, fail("ROOM_NOT_FOUND")) return } if body.Pack == nil { writeJSON(w, http.StatusOK, fail("PACK_REQUIRED")) return } room.Broadcast( "server/pack/room", map[string]any{"from": "server", "fromServer": domain, "pack": body.Pack, "roomId": id}, "", nil, ) writeJSON(w, http.StatusOK, map[string]any{"status": "success"}) })) mux.HandleFunc("POST /api/room/create", keys.auth(func(w http.ResponseWriter, r *http.Request, domain string) { var body struct { Name string `json:"name"` AccessType string `json:"accessType"` JoinType string `json:"joinType"` Description string `json:"description"` Credential string `json:"credential"` } if !decode(w, r, &body) { return } if body.Name == "" { writeJSON(w, http.StatusOK, fail("NAME_REQUIRED")) return } if _, exists := hub.RoomByName(body.Name); exists { writeJSON(w, http.StatusOK, fail("ROOM_ALREADY_EXISTS")) return } room := ws.NewRoom(hub) room.Name = body.Name room.AccessType = orDefault(body.AccessType, "public") room.JoinType = orDefault(body.JoinType, "free") room.Description = body.Description room.OwnerID = "server" if body.Credential != "" { room.Credential = sha256hex(body.Credential) } room.Publish() writeJSON(w, http.StatusOK, map[string]any{"status": "success", "room": room.ToJSON(false)}) })) } func orDefault(v, def string) string { if v == "" { return def } return v } func fail(message string) map[string]any { return map[string]any{"status": "fail", "message": message} } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } // decode reads a JSON request body, writing a fail response and returning false // when the body is malformed. func decode(w http.ResponseWriter, r *http.Request, dst any) bool { if r.Body == nil { return true } if err := json.NewDecoder(r.Body).Decode(dst); err != nil && err.Error() != "EOF" { writeJSON(w, http.StatusBadRequest, fail("INVALID_JSON")) return false } return true }