oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
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.
20const 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.
25const 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.
67func 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.
140func 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.
145func 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.
195const 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.
211func 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.
257func 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.
281func 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.
327func 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.
339func 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.
351var peerIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`)
352
353func 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).
358var 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
365func 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.
377type 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.
386func (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).
562func (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).
572func (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.
581func (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.
594func (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