Parent directory

server.go

4316 bytes
  1// Package server exposes kaiwari game sessions over HTTP for a thin client.
  2// Sessions are in-memory and die with the process; turns serialize per
  3// session. Kana never crosses the wire.
  4package server
  5
  6import (
  7	"crypto/hmac"
  8	"crypto/rand"
  9	"encoding/hex"
 10	"log/slog"
 11	"net/http"
 12	"strings"
 13	"sync"
 14	"time"
 15
 16	"git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/adapters"
 17	"git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/bootstrap"
 18	"git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/game"
 19	"git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/stt"
 20	"git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/tts"
 21)
 22
 23const (
 24	turnTimeout = 3 * time.Minute
 25	maxUpload   = int64(2 << 20)
 26)
 27
 28// Server serves kaiwari game sessions over HTTP.
 29type Server struct {
 30	svc   *bootstrap.Services
 31	token []byte
 32
 33	mu       sync.Mutex
 34	sessions map[string]*session
 35}
 36
 37// New builds a server over the shared service clients.
 38func New(svc *bootstrap.Services, token string) *Server {
 39	return &Server{svc: svc, token: []byte(token), sessions: make(map[string]*session)}
 40}
 41
 42// Handler returns the HTTP handler with bearer-token auth applied.
 43func (s *Server) Handler() http.Handler {
 44	mux := http.NewServeMux()
 45	mux.HandleFunc("POST /v1/sessions", s.createSession)
 46	mux.HandleFunc("GET /v1/sessions/{id}/log", s.getLog)
 47	mux.HandleFunc("POST /v1/sessions/{id}/actions", s.postAction)
 48	mux.HandleFunc("POST /v1/sessions/{id}/speech", s.postSpeech)
 49	return logging(auth(s.token, mux))
 50}
 51
 52func auth(token []byte, next http.Handler) http.Handler {
 53	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 54		got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
 55		if !hmac.Equal([]byte(got), token) {
 56			writeError(w, http.StatusUnauthorized, "unauthorized")
 57			return
 58		}
 59		next.ServeHTTP(w, r)
 60	})
 61}
 62
 63// statusRecorder captures the final response status for request logging.
 64type statusRecorder struct {
 65	http.ResponseWriter
 66	status      int
 67	wroteHeader bool
 68}
 69
 70func (r *statusRecorder) WriteHeader(code int) {
 71	if !r.wroteHeader {
 72		r.status = code
 73		r.wroteHeader = true
 74	}
 75	r.ResponseWriter.WriteHeader(code)
 76}
 77
 78// logging logs every request with its final status and duration, and turns a
 79// handler panic into a logged 500 instead of a bare connection reset.
 80func logging(next http.Handler) http.Handler {
 81	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 82		start := time.Now()
 83		rec := &statusRecorder{ResponseWriter: w}
 84		defer func() {
 85			if p := recover(); p != nil {
 86				slog.Error("panic", "method", r.Method, "path", r.URL.Path, "err", p)
 87				if !rec.wroteHeader {
 88					writeError(rec, http.StatusInternalServerError, "internal error")
 89				}
 90			}
 91			status := rec.status
 92			if status == 0 {
 93				status = http.StatusOK
 94			}
 95			reqLog := slog.With("method", r.Method, "path", r.URL.Path, "status", status, "duration", time.Since(start))
 96			switch {
 97			case status >= 500:
 98				reqLog.Error("request")
 99			case status >= 400:
100				reqLog.Warn("request")
101			default:
102				reqLog.Info("request")
103			}
104		}()
105		next.ServeHTTP(rec, r)
106	})
107}
108
109// session is one in-memory game: a fresh state and orchestrator over the
110// shared service clients. The mutex serializes that session's turns.
111type session struct {
112	id string
113
114	mu     sync.Mutex
115	state  *game.State
116	orch   *game.Orchestrator
117	input  *remoteInput
118	output *bufferedOutput
119}
120
121func (s *Server) newSession() *session {
122	var idb [16]byte
123	if _, err := rand.Read(idb[:]); err != nil {
124		panic(err)
125	}
126	id := hex.EncodeToString(idb[:])
127
128	state := game.NewState(s.svc.Brief)
129	speechIn := &remoteInput{asr: stt.NewASRClient(s.svc.Cfg.AudioConfig.AudioBaseURL, s.svc.HTTP)}
130	speechOut := &bufferedOutput{client: tts.NewClient(s.svc.Cfg.AudioConfig.AudioBaseURL, s.svc.HTTP)}
131	orch := game.NewOrchestrator(
132		state,
133		&adapters.GameModel{Client: s.svc.Game},
134		&adapters.JudgeModel{Client: s.svc.Judge},
135		&adapters.Compactor{Client: s.svc.Compaction},
136		&adapters.SheetModel{Client: s.svc.Sheet},
137		&adapters.ScratchModel{Client: s.svc.Scratch},
138		&adapters.FlashcardModel{Client: s.svc.Scratch},
139		speechIn,
140		speechOut,
141	)
142
143	return &session{id: id, state: state, orch: orch, input: speechIn, output: speechOut}
144}
145
146func (s *Server) session(id string) *session {
147	s.mu.Lock()
148	defer s.mu.Unlock()
149	return s.sessions[id]
150}