| 1 | package calls |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "regexp" |
| 10 | "strconv" |
| 11 | "unicode/utf8" |
| 12 | |
| 13 | "github.com/go-chi/chi/v5" |
| 14 | |
| 15 | "amadan.net/oficina/calls/internal/calls/hub" |
| 16 | "amadan.net/oficina/calls/protocol" |
| 17 | ) |
| 18 | |
| 19 | // maxSignalBody is spec §4.4's 64 KB cap, refused before decoding. |
| 20 | const maxSignalBody = 64 * 1024 |
| 21 | |
| 22 | // maxChatBytes is §4.4's 2 KB, counted in bytes of the text, not |
| 23 | // characters: the limit protects the other peers' streams, which carry |
| 24 | // bytes. |
| 25 | const maxChatBytes = 2048 |
| 26 | |
| 27 | // validateFanout checks the two untargeted payload shapes without |
| 28 | // keeping anything from them: chat is exactly {"text": string} with |
| 29 | // 1..2048 bytes of valid UTF-8; share is exactly {"on": bool}. It |
| 30 | // returns the canonical payload to forward, plus a status and an |
| 31 | // outcome word for the refusal log, or 0 and the payload. |
| 32 | // |
| 33 | // The raw byte length is capped first, before anything is decoded, not |
| 34 | // just the decoded text's length: a payload of {"text":"<64 KB>", |
| 35 | // "text":"x"} decodes to a harmless one-byte text while the raw bytes — |
| 36 | // the huge first value included — are what the sender put on the wire; |
| 37 | // a \uXXXX escape can likewise inflate the raw size to roughly six |
| 38 | // times what it decodes to. +32 is JSON's own structural overhead for |
| 39 | // either shape ({"text":"…"} or {"on":…}), never budget for the |
| 40 | // payload's content. |
| 41 | // |
| 42 | // UTF-8 validity is checked the same way and for the same reason: |
| 43 | // encoding/json's decoder replaces an invalid byte with the Unicode |
| 44 | // replacement character on its way into a string field, so a decoded |
| 45 | // *p.Text is always valid UTF-8 by the time it can be inspected — |
| 46 | // checking it there would report clean on raw bytes containing |
| 47 | // anything at all. Checking raw itself, before decoding, is what |
| 48 | // judges what was actually sent. |
| 49 | // |
| 50 | // Then the keys, byte-exactly (Codex S4-1). encoding/json matches an |
| 51 | // object's keys to struct fields case-insensitively, and |
| 52 | // DisallowUnknownFields does not change that: {"text":"<2049 a>", |
| 53 | // "Text":"x"} decoded to a one-byte text — inside the cap — while the |
| 54 | // raw bytes carried both keys and every recipient reads the lowercase |
| 55 | // one. exactKey walks the token stream and insists on one member, |
| 56 | // spelled exactly, appearing exactly once; a map would have collapsed |
| 57 | // the duplicate and said nothing. |
| 58 | // |
| 59 | // And then, last, the payload is re-encoded from the value that was |
| 60 | // just validated (Codex S4-2) rather than forwarded raw. The bytes on |
| 61 | // every recipient's stream are then the canonical form of what these |
| 62 | // checks judged — an escape delivered decoded, an angle bracket |
| 63 | // delivered as itself, the size on the wire the size that was |
| 64 | // measured — and no spelling the sender chose can make the two |
| 65 | // disagree. SetEscapeHTML(false) because SSE data is not HTML, and the |
| 66 | // escaping is what turned 2048 literal `<` into 12 KB. |
| 67 | func validateFanout(typ string, raw json.RawMessage) (json.RawMessage, int, string) { |
| 68 | if len(raw) > maxChatBytes+32 { |
| 69 | return nil, http.StatusRequestEntityTooLarge, "size" |
| 70 | } |
| 71 | if !utf8.Valid(raw) { |
| 72 | return nil, http.StatusBadRequest, "shape" |
| 73 | } |
| 74 | switch typ { |
| 75 | case hub.TypeChat: |
| 76 | if !exactKey(raw, "text") { |
| 77 | return nil, http.StatusBadRequest, "shape" |
| 78 | } |
| 79 | var p struct { |
| 80 | Text *string `json:"text"` |
| 81 | } |
| 82 | if err := json.Unmarshal(raw, &p); err != nil || p.Text == nil { |
| 83 | return nil, http.StatusBadRequest, "shape" |
| 84 | } |
| 85 | if len(*p.Text) == 0 { |
| 86 | return nil, http.StatusBadRequest, "shape" |
| 87 | } |
| 88 | canon, ok := canonicalJSON(struct { |
| 89 | Text string `json:"text"` |
| 90 | }{Text: *p.Text}) |
| 91 | if !ok { |
| 92 | return nil, http.StatusBadRequest, "shape" |
| 93 | } |
| 94 | // §4.4's 2 KB, counted on the escaped text — the bytes this |
| 95 | // payload actually puts on every recipient's stream, which is |
| 96 | // what the limit is there to bound. Counting the decoded string |
| 97 | // instead measured something nobody sends: a quote, a backslash |
| 98 | // or a newline is one byte decoded and two on the wire, so a |
| 99 | // 2048-byte text with twenty-three quotes in it passed the |
| 100 | // client's own counter and came back 413 from a rule the client |
| 101 | // could not apply (whole-branch review, W4-1). chat.js counts |
| 102 | // the same thing, with JSON.stringify. |
| 103 | if len(canon)-len(`{"text":""}`) > maxChatBytes { |
| 104 | return nil, http.StatusRequestEntityTooLarge, "size" |
| 105 | } |
| 106 | return canon, 0, "" |
| 107 | case hub.TypeShare: |
| 108 | if !exactKey(raw, "on") { |
| 109 | return nil, http.StatusBadRequest, "shape" |
| 110 | } |
| 111 | var p struct { |
| 112 | On *bool `json:"on"` |
| 113 | } |
| 114 | if err := json.Unmarshal(raw, &p); err != nil || p.On == nil { |
| 115 | return nil, http.StatusBadRequest, "shape" |
| 116 | } |
| 117 | canon, ok := canonicalJSON(struct { |
| 118 | On bool `json:"on"` |
| 119 | }{On: *p.On}) |
| 120 | if !ok { |
| 121 | return nil, http.StatusBadRequest, "shape" |
| 122 | } |
| 123 | return canon, 0, "" |
| 124 | } |
| 125 | // Not a fan-out type at all: the dispatch below never calls this |
| 126 | // with one, and the arm that does calls it is the only caller. |
| 127 | return raw, 0, "" |
| 128 | } |
| 129 | |
| 130 | // exactKey reports whether raw is a JSON object with exactly one |
| 131 | // member, whose name is byte-for-byte want and appears exactly once, |
| 132 | // and nothing after the object's closing brace. |
| 133 | // |
| 134 | // It walks the token stream rather than unmarshalling into a |
| 135 | // map[string]json.RawMessage on purpose: a map collapses a duplicated |
| 136 | // key to its last value and cannot tell {"text":"a","text":"b"} from |
| 137 | // {"text":"b"}, and the first of those is a payload whose raw bytes say |
| 138 | // one thing and whose decoded value says another. The walk counts every |
| 139 | // member it passes, so it can refuse both. |
| 140 | func exactKey(raw json.RawMessage, want string) bool { return exactKeys(raw, want) } |
| 141 | |
| 142 | // exactKeys is exactKey for an object with more than one member: every |
| 143 | // name in want, each spelled byte-for-byte and appearing exactly once, |
| 144 | // in any order, and no other member at all. |
| 145 | func exactKeys(raw json.RawMessage, want ...string) bool { |
| 146 | seen := make(map[string]bool, len(want)) |
| 147 | allowed := make(map[string]bool, len(want)) |
| 148 | for _, w := range want { |
| 149 | allowed[w] = true |
| 150 | } |
| 151 | dec := json.NewDecoder(bytes.NewReader(raw)) |
| 152 | open, err := dec.Token() |
| 153 | if err != nil { |
| 154 | return false |
| 155 | } |
| 156 | if d, ok := open.(json.Delim); !ok || d != '{' { |
| 157 | return false |
| 158 | } |
| 159 | members := 0 |
| 160 | for dec.More() { |
| 161 | key, err := dec.Token() |
| 162 | if err != nil { |
| 163 | return false |
| 164 | } |
| 165 | name, ok := key.(string) |
| 166 | if !ok || !allowed[name] || seen[name] { |
| 167 | return false |
| 168 | } |
| 169 | seen[name] = true |
| 170 | members++ |
| 171 | // The value, whatever shape it takes: read past it so the next |
| 172 | // token is the next key or the closing brace. Its type is the |
| 173 | // business of the decode above, not of this walk. |
| 174 | var value json.RawMessage |
| 175 | if err := dec.Decode(&value); err != nil { |
| 176 | return false |
| 177 | } |
| 178 | } |
| 179 | // The closing brace, and then nothing: a second value after the |
| 180 | // object is a payload with something else in it. |
| 181 | if _, err := dec.Token(); err != nil { |
| 182 | return false |
| 183 | } |
| 184 | return members == len(want) && !dec.More() |
| 185 | } |
| 186 | |
| 187 | // maxMediaBytes bounds §15.19's payload before it is decoded. The |
| 188 | // longest honest one — both booleans false, audioBlocked, and full |
| 189 | // noSoundFrom, screenStalled and hearing lists of fifteen 32-character |
| 190 | // peer ids each (§15.20, §15.21, §15.24) — is a little over 1,700 bytes; |
| 191 | // the rest is room |
| 192 | // for whitespace a client's encoder might add, and nothing a peer can use |
| 193 | // to put more on anyone's stream: what goes out is re-encoded from the |
| 194 | // validated values. |
| 195 | const maxMediaBytes = 3072 |
| 196 | |
| 197 | // validateMedia is §15.19's shape as §15.20, §15.21 and §15.24 extend it: |
| 198 | // "muted" and "cameraOff", both booleans and both required; "audioBlocked", |
| 199 | // a boolean, and "noSoundFrom", "screenStalled" and "hearing", each an |
| 200 | // array of at most fifteen distinct peer ids, all optional. An empty |
| 201 | // "hearing" is kept as empty rather than absent: it is a client saying it |
| 202 | // hears nobody yet, where an absent one says nothing (§15.24). Keys byte-exact and each |
| 203 | // once, no other member, judged on the raw bytes the same way |
| 204 | // validateFanout judges chat and share (Codex S4-1). It returns the |
| 205 | // decoded state, or a status and an outcome word for the refusal log. |
| 206 | // |
| 207 | // A listed id is checked for shape and not for presence: it is the |
| 208 | // sender's claim about peers it was told of, the recipients ignore an id |
| 209 | // they do not hold a tile for, and checking it against the room would |
| 210 | // only race the roster. |
| 211 | func validateMedia(raw json.RawMessage) (protocol.MediaState, int, string) { |
| 212 | if len(raw) > maxMediaBytes { |
| 213 | return protocol.MediaState{}, http.StatusRequestEntityTooLarge, "size" |
| 214 | } |
| 215 | if !memberKeys(raw, []string{"muted", "cameraOff"}, []string{"audioBlocked", "noSoundFrom", "screenStalled", "hearing"}) { |
| 216 | return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| 217 | } |
| 218 | var p struct { |
| 219 | Muted *bool `json:"muted"` |
| 220 | CameraOff *bool `json:"cameraOff"` |
| 221 | AudioBlocked json.RawMessage `json:"audioBlocked"` |
| 222 | NoSoundFrom json.RawMessage `json:"noSoundFrom"` |
| 223 | ScreenStalled json.RawMessage `json:"screenStalled"` |
| 224 | Hearing json.RawMessage `json:"hearing"` |
| 225 | } |
| 226 | if err := json.Unmarshal(raw, &p); err != nil || p.Muted == nil || p.CameraOff == nil { |
| 227 | return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| 228 | } |
| 229 | m := protocol.MediaState{Muted: *p.Muted, CameraOff: *p.CameraOff} |
| 230 | // Present means a value of the right type: a null in an optional |
| 231 | // member is not "absent", it is a payload nobody's client writes. |
| 232 | if p.AudioBlocked != nil { |
| 233 | if err := json.Unmarshal(p.AudioBlocked, &m.AudioBlocked); err != nil || bytes.Equal(p.AudioBlocked, []byte("null")) { |
| 234 | return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| 235 | } |
| 236 | } |
| 237 | var ok bool |
| 238 | if m.NoSoundFrom, ok = peerList(p.NoSoundFrom, protocol.MaxNoSoundFrom); !ok { |
| 239 | return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| 240 | } |
| 241 | if m.ScreenStalled, ok = peerList(p.ScreenStalled, protocol.MaxScreenStalled); !ok { |
| 242 | return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| 243 | } |
| 244 | if m.Hearing, ok = peerList(p.Hearing, protocol.MaxHearing); !ok { |
| 245 | return protocol.MediaState{}, http.StatusBadRequest, "shape" |
| 246 | } |
| 247 | if p.Hearing != nil && m.Hearing == nil { |
| 248 | m.Hearing = []hub.PeerID{} |
| 249 | } |
| 250 | return m, 0, "" |
| 251 | } |
| 252 | |
| 253 | // peerList is one of the media report's optional lists of peer ids: |
| 254 | // absent is nil, and present is an array (never null) of at most max |
| 255 | // well-formed ids with no repeats. Empty is the ordinary report, and is |
| 256 | // kept as nil so that it compares equal to one that left the key out. |
| 257 | func peerList(raw json.RawMessage, max int) ([]hub.PeerID, bool) { |
| 258 | if raw == nil { |
| 259 | return nil, true |
| 260 | } |
| 261 | var ids []hub.PeerID |
| 262 | if err := json.Unmarshal(raw, &ids); err != nil || ids == nil || len(ids) > max { |
| 263 | return nil, false |
| 264 | } |
| 265 | seen := make(map[hub.PeerID]bool, len(ids)) |
| 266 | for _, id := range ids { |
| 267 | if !validPeerID(id) || seen[id] { |
| 268 | return nil, false |
| 269 | } |
| 270 | seen[id] = true |
| 271 | } |
| 272 | if len(ids) == 0 { |
| 273 | return nil, true |
| 274 | } |
| 275 | return ids, true |
| 276 | } |
| 277 | |
| 278 | // memberKeys is exactKeys with optional members: every name in required |
| 279 | // exactly once, any name in optional at most once, each byte-exact, and |
| 280 | // nothing else. |
| 281 | func memberKeys(raw json.RawMessage, required, optional []string) bool { |
| 282 | allowed := make(map[string]bool, len(required)+len(optional)) |
| 283 | for _, w := range required { |
| 284 | allowed[w] = true |
| 285 | } |
| 286 | for _, w := range optional { |
| 287 | allowed[w] = true |
| 288 | } |
| 289 | seen := make(map[string]bool, len(allowed)) |
| 290 | dec := json.NewDecoder(bytes.NewReader(raw)) |
| 291 | open, err := dec.Token() |
| 292 | if err != nil { |
| 293 | return false |
| 294 | } |
| 295 | if d, ok := open.(json.Delim); !ok || d != '{' { |
| 296 | return false |
| 297 | } |
| 298 | for dec.More() { |
| 299 | key, err := dec.Token() |
| 300 | if err != nil { |
| 301 | return false |
| 302 | } |
| 303 | name, ok := key.(string) |
| 304 | if !ok || !allowed[name] || seen[name] { |
| 305 | return false |
| 306 | } |
| 307 | seen[name] = true |
| 308 | var value json.RawMessage |
| 309 | if err := dec.Decode(&value); err != nil { |
| 310 | return false |
| 311 | } |
| 312 | } |
| 313 | if _, err := dec.Token(); err != nil { |
| 314 | return false |
| 315 | } |
| 316 | for _, w := range required { |
| 317 | if !seen[w] { |
| 318 | return false |
| 319 | } |
| 320 | } |
| 321 | return !dec.More() |
| 322 | } |
| 323 | |
| 324 | // canonicalJSON encodes one validated value the way it goes out on the |
| 325 | // wire: no HTML escaping (SSE data is not HTML) and no trailing newline |
| 326 | // from the Encoder's own line framing. |
| 327 | func canonicalJSON(v any) (json.RawMessage, bool) { |
| 328 | var buf bytes.Buffer |
| 329 | enc := json.NewEncoder(&buf) |
| 330 | enc.SetEscapeHTML(false) |
| 331 | if err := enc.Encode(v); err != nil { |
| 332 | return nil, false |
| 333 | } |
| 334 | return json.RawMessage(bytes.TrimRight(buf.Bytes(), "\n")), true |
| 335 | } |
| 336 | |
| 337 | // fanoutError names the JSON error the two ways validateFanout can |
| 338 | // refuse a fan-out message. |
| 339 | func fanoutError(status int) string { |
| 340 | if status == http.StatusRequestEntityTooLarge { |
| 341 | return "too-large" |
| 342 | } |
| 343 | return "bad-request" |
| 344 | } |
| 345 | |
| 346 | // peerIDPattern is the exact shape a server-issued peer id takes: |
| 347 | // sixteen random bytes in hex (hub's randomID). Anything else is |
| 348 | // refused before it is ever used as a hub lookup key or a log argument |
| 349 | // (review Important 2) — a peer field padded to tens of kilobytes never |
| 350 | // reaches either. |
| 351 | var peerIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) |
| 352 | |
| 353 | func validPeerID(p hub.PeerID) bool { return peerIDPattern.MatchString(string(p)) } |
| 354 | |
| 355 | // knownSignalTypes bounds what the log's "type" argument can ever say: |
| 356 | // one of the wire's own names, or the literal "unknown" — never an |
| 357 | // attacker's arbitrary string, however long (review Important 2). |
| 358 | var knownSignalTypes = map[string]bool{ |
| 359 | hub.TypeJoin: true, hub.TypeAdmit: true, hub.TypeLeave: true, |
| 360 | hub.TypeOffer: true, hub.TypeAnswer: true, hub.TypeCandidate: true, |
| 361 | hub.TypeReset: true, hub.TypeChat: true, hub.TypeShare: true, |
| 362 | hub.TypeMedia: true, |
| 363 | } |
| 364 | |
| 365 | func safeSignalType(t string) string { |
| 366 | if knownSignalTypes[t] { |
| 367 | return t |
| 368 | } |
| 369 | return "unknown" |
| 370 | } |
| 371 | |
| 372 | // signalBody is the one shape every upstream message arrives in. |
| 373 | // Payload is a json.RawMessage, which encoding/json only ever populates |
| 374 | // with a syntactically valid JSON value — decoding into this struct is |
| 375 | // what validates the whole body, including payload, before any of it is |
| 376 | // trusted; nothing here is built from unvalidated bytes by hand. |
| 377 | type signalBody = protocol.Signal |
| 378 | |
| 379 | // signal is POST /m/{sqid}/signal: the upstream half of spec §4.1. The |
| 380 | // hub is the only authority on whether the named peer belongs to this |
| 381 | // room and session — every dispatch below hands it session and peer and |
| 382 | // lets its own lookup enforce the binding, rather than this handler |
| 383 | // pre-checking a peer id that came from the request body. `from` and |
| 384 | // `name` are never taken from the body; the hub rebuilds the envelope |
| 385 | // from the participation it finds. |
| 386 | func (a *app) signal(w http.ResponseWriter, r *http.Request) { |
| 387 | sqid := chi.URLParam(r, "sqid") |
| 388 | session, ok := a.sessionKey(r) |
| 389 | if !ok { |
| 390 | a.logger.Error("calls: signal with no session subject", "path", r.URL.Path) |
| 391 | streamJSON(w, http.StatusInternalServerError, nil) |
| 392 | return |
| 393 | } |
| 394 | |
| 395 | // sqid is checked before it can reach anything else: the limiter's |
| 396 | // key, a log line, or a hub lookup. chi hands back whatever the |
| 397 | // request line contained, unbounded — a kilobyte of it would |
| 398 | // otherwise mint a rate-limit bucket and a log line of its own on |
| 399 | // every request (review Task 5 item 1). |
| 400 | if !validSqid(sqid) { |
| 401 | a.refuseBadRoom(w) |
| 402 | return |
| 403 | } |
| 404 | |
| 405 | // A session with no live participation in this exact room is |
| 406 | // refused the identical not-found answer before the limiter is |
| 407 | // touched at all: nothing is spent and nothing is minted for a room |
| 408 | // this session has never opened a stream in, which is what closes |
| 409 | // off room-id rotation as a way to mint a fresh bucket per request |
| 410 | // (review Task 5 item 1). livePeer is the hub's own record of what |
| 411 | // this session holds — server-derived, never the request's own |
| 412 | // claim — so it is safe to log from here on (review item 2). |
| 413 | livePeer, live := a.hub.LiveForSession(sqid, session) |
| 414 | if !live { |
| 415 | a.refuseSignal(w, sqid, "", "", http.StatusNotFound, "unbound", a.notFoundBody()) |
| 416 | return |
| 417 | } |
| 418 | |
| 419 | // Rate limiting is keyed on session and room, never on anything the |
| 420 | // request body claims, and happens before a single byte of the body |
| 421 | // is read: session+room is unforgeable (the session half comes from |
| 422 | // the cookie the request actually presented, the room half is now |
| 423 | // gated by LiveForSession above) and is also the one bucket a |
| 424 | // signed-in person's traffic in this room can ever spend, whatever |
| 425 | // peer id they name — and checking it first means an oversized |
| 426 | // flood spends a token too, rather than being free to retry forever |
| 427 | // below the size cap (review Important 1). |
| 428 | if allowed, retry := a.signalLimiter.allow(session+"|"+sqid, a.store.now()); !allowed { |
| 429 | // Retry-After is whole seconds per RFC 9110. At this limiter's |
| 430 | // rate, one token refills in 1/30 s = 33 ms, so retry is always |
| 431 | // sub-second and this always rounds up to "1" — coarse on |
| 432 | // purpose, not a miscalculation (review minor 11). |
| 433 | w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1)) |
| 434 | a.refuseSignal(w, sqid, livePeer, "", http.StatusTooManyRequests, "rate", map[string]any{"error": "rate"}) |
| 435 | return |
| 436 | } |
| 437 | |
| 438 | // The size cap is enforced by refusing to read past it, not by |
| 439 | // checking Content-Length: a refused body is never read past the |
| 440 | // limit (spec §4.4). |
| 441 | r.Body = http.MaxBytesReader(w, r.Body, maxSignalBody) |
| 442 | raw, err := io.ReadAll(r.Body) |
| 443 | if err != nil { |
| 444 | var tooBig *http.MaxBytesError |
| 445 | if errors.As(err, &tooBig) { |
| 446 | a.refuseSignal(w, sqid, "", "", http.StatusRequestEntityTooLarge, "too-large", map[string]any{"error": "too-large"}) |
| 447 | return |
| 448 | } |
| 449 | a.refuseSignal(w, sqid, "", "", http.StatusBadRequest, "read", map[string]any{"error": "bad-request"}) |
| 450 | return |
| 451 | } |
| 452 | |
| 453 | var body signalBody |
| 454 | if err := json.Unmarshal(raw, &body); err != nil || body.Type == "" || !validPeerID(body.Peer) { |
| 455 | // peer is never logged here: an invalid peer is exactly what may |
| 456 | // have failed the check, and it has not been validated as |
| 457 | // bounded, printable text (review Important 2). |
| 458 | a.refuseSignal(w, sqid, "", body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| 459 | return |
| 460 | } |
| 461 | |
| 462 | // From here body.Peer is known to be exactly 32 lowercase hex — |
| 463 | // bounded and safe to use as a log argument and as the hub's lookup |
| 464 | // key. |
| 465 | var herr error |
| 466 | switch body.Type { |
| 467 | case hub.TypeJoin: |
| 468 | herr = a.hub.Join(r.Context(), sqid, session, body.Peer) |
| 469 | case hub.TypeAdmit: |
| 470 | var p struct { |
| 471 | Peer hub.PeerID `json:"peer"` |
| 472 | } |
| 473 | if err := json.Unmarshal(body.Payload, &p); err != nil || !validPeerID(p.Peer) { |
| 474 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| 475 | return |
| 476 | } |
| 477 | herr = a.hub.Admit(r.Context(), sqid, session, body.Peer, p.Peer) |
| 478 | case hub.TypeLeave: |
| 479 | herr = a.hub.Leave(sqid, session, body.Peer) |
| 480 | case hub.TypeOffer, hub.TypeAnswer, hub.TypeCandidate: |
| 481 | herr = a.hub.Relay(r.Context(), sqid, session, body.Peer, hub.Message{ |
| 482 | Type: body.Type, To: body.To, Seq: body.Seq, Gen: body.Gen, Payload: body.Payload, |
| 483 | }) |
| 484 | case hub.TypeReset: |
| 485 | herr = a.hub.Reset(r.Context(), sqid, session, body.Peer, body.To) |
| 486 | case hub.TypeChat, hub.TypeShare: |
| 487 | if body.To != "" { |
| 488 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| 489 | return |
| 490 | } |
| 491 | // The canonical payload, not the request's own bytes: what goes |
| 492 | // out is a re-encoding of exactly what was validated (Codex |
| 493 | // S4-1, S4-2), so the size and the shape on every recipient's |
| 494 | // stream are the size and the shape these checks judged. |
| 495 | payload, status, outcome := validateFanout(body.Type, body.Payload) |
| 496 | if status != 0 { |
| 497 | a.refuseSignal(w, sqid, body.Peer, body.Type, status, outcome, map[string]any{"error": fanoutError(status)}) |
| 498 | return |
| 499 | } |
| 500 | herr = a.hub.Fanout(r.Context(), sqid, session, body.Peer, hub.Message{Type: body.Type, Payload: payload}) |
| 501 | case hub.TypeMedia: |
| 502 | // §15.19: state about the sender, never targeted, and never |
| 503 | // carrying a seq or a generation — none of them means anything |
| 504 | // for a message the hub records rather than relays. |
| 505 | if body.To != "" || body.Seq != 0 || body.Gen != 0 { |
| 506 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "shape", map[string]any{"error": "bad-request"}) |
| 507 | return |
| 508 | } |
| 509 | m, status, outcome := validateMedia(body.Payload) |
| 510 | if status != 0 { |
| 511 | a.refuseSignal(w, sqid, body.Peer, body.Type, status, outcome, map[string]any{"error": fanoutError(status)}) |
| 512 | return |
| 513 | } |
| 514 | herr = a.hub.SetMedia(r.Context(), sqid, session, body.Peer, m) |
| 515 | default: |
| 516 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "type", map[string]any{"error": "bad-request"}) |
| 517 | return |
| 518 | } |
| 519 | |
| 520 | switch { |
| 521 | case herr == nil, errors.Is(herr, hub.ErrMeshFull), errors.Is(herr, hub.ErrTooManyWaiting): |
| 522 | // A full mesh or a full waiting queue is not a failed request: |
| 523 | // the peer has already been sent `full` and may retry with |
| 524 | // `join`, so the answer is 2xx and the event carries the news |
| 525 | // (review ruling 1). |
| 526 | w.WriteHeader(http.StatusOK) |
| 527 | case errors.Is(herr, hub.ErrStaleGen): |
| 528 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusConflict, "stale-gen", map[string]any{"error": "stale"}) |
| 529 | case errors.Is(herr, hub.ErrNotWaiting): |
| 530 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusConflict, "not-waiting", map[string]any{"error": "conflict"}) |
| 531 | case errors.Is(herr, hub.ErrNotTargeted), errors.Is(herr, hub.ErrNotFanout): |
| 532 | // Defence, not a reachable path: ErrNotTargeted only ever comes |
| 533 | // from Relay's own isTargeted guard, and this dispatch only ever |
| 534 | // calls Relay for offer/answer/candidate — already targeted |
| 535 | // types — so this arm cannot fire today. Kept in case that |
| 536 | // invariant ever drifts (review minor 10). ErrNotFanout is the |
| 537 | // same defence for Fanout, which this dispatch only ever calls |
| 538 | // for chat/share — already fan-out types. |
| 539 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusBadRequest, "type", map[string]any{"error": "bad-request"}) |
| 540 | case errors.Is(herr, hub.ErrNotYours), errors.Is(herr, hub.ErrUnknownPeer), |
| 541 | errors.Is(herr, hub.ErrNotAdmitted), errors.Is(herr, hub.ErrNotMember): |
| 542 | // A meeting, or a peer, that is not yours does not exist (§2.5). |
| 543 | // ErrNotYours and ErrUnknownPeer share this one body on purpose: |
| 544 | // the review ruling requires the two to be indistinguishable, |
| 545 | // down to the boot id stream.go's own 404s carry. |
| 546 | a.refuseSignal(w, sqid, body.Peer, body.Type, http.StatusNotFound, "binding", a.notFoundBody()) |
| 547 | default: |
| 548 | // herr here is a driver error surfaced by a.hubStanding's |
| 549 | // database reads (every other outcome StandingFor or the hub can |
| 550 | // produce already has its own case above) — never a row value, |
| 551 | // so nothing user-supplied ever reaches this argument (review |
| 552 | // minor 9). |
| 553 | a.logger.Error("calls: signal refused", "room", sqid, "peer", string(body.Peer), "type", safeSignalType(body.Type), "outcome", "internal", "err", herr) |
| 554 | streamJSON(w, http.StatusInternalServerError, nil) |
| 555 | } |
| 556 | } |
| 557 | |
| 558 | // notFoundBody is the one not-found shape this endpoint ever answers: |
| 559 | // stream.go's own precedent, so a peer that is not yours, a peer that |
| 560 | // never existed, and a room this session holds nothing in are all one |
| 561 | // indistinguishable body (spec §2.5). |
| 562 | func (a *app) notFoundBody() map[string]any { |
| 563 | return map[string]any{"error": "not-found", "boot": a.hub.Boot()} |
| 564 | } |
| 565 | |
| 566 | // refuseBadRoom answers a room id that is not the shape newSqid ever |
| 567 | // produces, with the identical not-found body every other refusal |
| 568 | // here carries — logged without the room id itself, unlike every other |
| 569 | // sqid this handler logs: this one has not been validated as bounded |
| 570 | // or printable, and chi hands back whatever the request line contained |
| 571 | // (review Task 5 item 1). |
| 572 | func (a *app) refuseBadRoom(w http.ResponseWriter) { |
| 573 | a.logger.Warn("calls: signal refused", "room", "invalid", "peer", "", "type", "unknown", "outcome", "bad-room") |
| 574 | streamJSON(w, http.StatusNotFound, a.notFoundBody()) |
| 575 | } |
| 576 | |
| 577 | // refuseSignal is every refusal this endpoint makes: it logs the type, |
| 578 | // room, peer and outcome and nothing else (spec §4.4, §0.1), then |
| 579 | // answers JSON, following stream.go's streamJSON precedent rather than |
| 580 | // ErrorPage — this endpoint's client is a script, not a page. |
| 581 | func (a *app) refuseSignal(w http.ResponseWriter, sqid string, peer hub.PeerID, typ string, status int, outcome string, body map[string]any) { |
| 582 | a.logRefusal(sqid, peer, typ, outcome) |
| 583 | streamJSON(w, status, body) |
| 584 | } |
| 585 | |
| 586 | // logRefusal is the only signalling log line for a case the switch |
| 587 | // above already names, and it carries no content: no payload, no chat |
| 588 | // text, no SDP, no candidate, no display name, no title and no link |
| 589 | // token (spec §4.4, §0.1) — and, since peer and typ may not yet be |
| 590 | // validated wherever this is called with an empty peer, only ever a |
| 591 | // peer already known to be exactly 32 lowercase hex and a type already |
| 592 | // known to be one of the wire's own names or "unknown" (review |
| 593 | // Important 2). An accepted message logs nothing at all. |
| 594 | func (a *app) logRefusal(sqid string, peer hub.PeerID, typ, outcome string) { |
| 595 | a.logger.Warn("calls: signal refused", "room", sqid, "peer", string(peer), "type", safeSignalType(typ), "outcome", outcome) |
| 596 | } |
| 597 | |