MWSE/internal/httpserver/api.go

238 lines
6.9 KiB
Go

package httpserver
import (
"encoding/json"
"net/http"
"sync"
"git.saqut.com/saqut/mwse/internal/bridge"
"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).
//
// When bridgeInbox is non-nil, the bridge drain endpoint is also registered:
// - POST /api/bridge/inbox — drain all queued client→app messages (#46)
func registerAPI(mux *http.ServeMux, hub *ws.Hub, bridgeInbox *bridge.Inbox) {
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)})
}))
// Bridge endpoints (#46) — only registered when the inbox is configured.
if bridgeInbox != nil {
// POST /api/bridge/inbox drains all queued client→app messages atomically.
// The application server polls this to receive messages sent via bridge/send.
mux.HandleFunc("POST /api/bridge/inbox", keys.auth(func(w http.ResponseWriter, r *http.Request, domain string) {
msgs := bridgeInbox.Drain()
if msgs == nil {
msgs = []bridge.Message{}
}
writeJSON(w, http.StatusOK, map[string]any{"status": "success", "messages": msgs})
}))
}
}
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
}