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}