| 1 | package calls |
| 2 | |
| 3 | import ( |
| 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 | |
| 15 | const ( |
| 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. |
| 38 | func (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 | |