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 "errors"
5 "net/http"
6 "strconv"
7 "time"
8
9 "github.com/go-chi/chi/v5"
10
11 "amadan.net/oficina/calls/internal/calls/hub"
12 "amadan.net/oficina/calls/internal/calls/relay"
13)
14
15const (
16 // iceLife and iceRenewAfter are §5.2's numbers: a call's credentials
17 // are minted for three hours, inside the platform contract's
18 // four-hour maximum, and the browser is told to renew at 150
19 // minutes (§5.3 step 4) — well ahead of expiry, so a slow renewal
20 // never runs out mid-call.
21 iceLife = 3 * time.Hour
22 iceRenewAfter = 150 * time.Minute
23 // testIceHeader shortens one answer's life, and only in development
24 // (like stream.go's testTTLHeader): a plain GET can set it, unlike
25 // EventSource, so this one needs no browser gate to reach — the
26 // development gate is what keeps it off production.
27 testIceHeader = "X-Calls-Test-Ice-TTL"
28 minIceLife = 2 * time.Second
29)
30
31// ice answers GET /m/{sqid}/ice?peer= with the RTCIceServer list for one
32// call (§5.2). Authority is the §2.5 last row — an admitted
33// participation bound to this session — through hub.Authorize, which
34// also applies the member re-derivation of §15.12. Every refusal is the
35// same 404 as /signal's, so nothing about another session's
36// participation is observable here. Nothing is logged on success: the
37// answer carries a credential.
38func (a *app) ice(w http.ResponseWriter, r *http.Request) {
39 sqid := chi.URLParam(r, "sqid")
40 session, ok := a.sessionKey(r)
41 if !ok {
42 a.logger.Error("calls: ice with no session subject", "path", r.URL.Path)
43 streamJSON(w, http.StatusInternalServerError, nil)
44 return
45 }
46 if !validSqid(sqid) {
47 a.logger.Warn("calls: ice refused", "room", "invalid", "peer", "", "outcome", "shape")
48 streamJSON(w, http.StatusNotFound, a.notFoundBody())
49 return
50 }
51 peer := r.URL.Query().Get("peer")
52 if !validPeerID(hub.PeerID(peer)) {
53 // peer is never logged here, exactly like signal.go's refusal:
54 // it has not been validated as bounded, printable text (review
55 // Important 2's rule, carried over).
56 a.logger.Warn("calls: ice refused", "room", sqid, "peer", "", "outcome", "shape")
57 streamJSON(w, http.StatusNotFound, a.notFoundBody())
58 return
59 }
60
61 // A session with no live participation in this exact room is
62 // refused the identical not-found answer before the store or the
63 // limiter is ever touched — signal.go's own LiveForSession gate
64 // (review Task 5 item 1), carried over here: nothing is spent and
65 // nothing is read for a room this session holds nothing in.
66 if _, live := a.hub.LiveForSession(sqid, session); !live {
67 a.logger.Warn("calls: ice refused", "room", sqid, "peer", "", "outcome", "unbound")
68 streamJSON(w, http.StatusNotFound, a.notFoundBody())
69 return
70 }
71
72 // Rate limiting shares /signal's own bucket: session+room, never
73 // anything the request claims, so a burst against either endpoint
74 // from one session spends the one budget it can ever spend.
75 if allowed, retry := a.signalLimiter.allow(session+"|"+sqid, a.store.now()); !allowed {
76 w.Header().Set("Retry-After", strconv.Itoa(int(retry.Seconds())+1))
77 a.logger.Warn("calls: ice refused", "room", sqid, "peer", peer, "outcome", "rate")
78 streamJSON(w, http.StatusTooManyRequests, map[string]any{"error": "rate"})
79 return
80 }
81
82 // The meeting itself must be live: a fresh process holds no hub
83 // state for a room nobody has opened, so ended_at is the only
84 // authority it has — stream.go's own fresh-open precedent. This is
85 // checked independently of the hub's own admitted state: the hub
86 // may still think a participation here is live and admitted (ruling
87 // 14 keeps an admitted guest through a lapsed grant, and nothing
88 // tells the hub a meeting ended except the End call an ending
89 // request makes), so this store read is what a still-admitted
90 // participation is refused through once the meeting is over.
91 m, found, err := a.store.meetingBySqid(r.Context(), sqid)
92 if err != nil {
93 a.logger.Error("calls: ice: load meeting", "err", err)
94 streamJSON(w, http.StatusInternalServerError, nil)
95 return
96 }
97 if !found || m.EndedAt != nil {
98 a.logger.Warn("calls: ice refused", "room", sqid, "peer", peer, "outcome", "meeting")
99 streamJSON(w, http.StatusNotFound, a.notFoundBody())
100 return
101 }
102
103 switch err := a.hub.Authorize(r.Context(), sqid, session, hub.PeerID(peer)); {
104 case err == nil:
105 case errors.Is(err, hub.ErrUnknownPeer), errors.Is(err, hub.ErrNotAdmitted), errors.Is(err, hub.ErrNotYours):
106 a.logger.Warn("calls: ice refused", "room", sqid, "peer", peer, "outcome", "authority")
107 streamJSON(w, http.StatusNotFound, a.notFoundBody())
108 return
109 default:
110 a.logger.Error("calls: ice: authorize", "room", sqid, "peer", peer, "outcome", "internal", "err", err)
111 streamJSON(w, http.StatusInternalServerError, nil)
112 return
113 }
114
115 now := a.store.now()
116 life, renew := iceLife, iceRenewAfter
117 if a.cfg.development() {
118 if d := time.Duration(a.iceLifeTest.Load()); d > 0 {
119 life = min(max(d, minIceLife), iceLife)
120 renew = life * 5 / 6
121 }
122 if d, err := time.ParseDuration(r.Header.Get(testIceHeader)); err == nil {
123 // Clamped both ways: a header cannot hand time.NewTimer a
124 // zero or a negative any more than stream.go's own
125 // testTTLHeader can (the floor), and cannot mint credentials
126 // that outlive the platform's own four-hour ceiling either
127 // (the cap, at this handler's three-hour default).
128 life = min(max(d, minIceLife), iceLife)
129 renew = life * 5 / 6
130 }
131 }
132
133 regions := a.relayRegions()
134 if len(regions) == 0 {
135 streamJSON(w, http.StatusOK, map[string]any{"iceServers": []relay.ICEServer{}, "expiresAt": 0, "renewAt": 0})
136 return
137 }
138 expiry := now.Add(life)
139 streamJSON(w, http.StatusOK, map[string]any{
140 "iceServers": relay.Mint(regions, expiry),
141 "expiresAt": expiry.Unix(),
142 "renewAt": now.Add(renew).Unix(),
143 })
144}
145