| 1 | package hub |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/base32" |
| 6 | "strings" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // Quality and problem reports (spec §15.26, 2026-10-03). |
| 11 | // |
| 12 | // A report is written to the app log under two random tags and nothing |
| 13 | // that names the call or the people in it: a call tag, minted when a room |
| 14 | // that was empty gains a participation, so every line from one call shares |
| 15 | // it; and a peer tag, minted per participation. Neither is derived from |
| 16 | // the sqid, the peer id, the person or the session, and neither is ever |
| 17 | // logged beside any of them, so a log line cannot be walked back to a |
| 18 | // meeting or a person. They live here, in memory, and die with the |
| 19 | // process like everything else in the hub. |
| 20 | // |
| 21 | // A client is handed a report token in its hello. It is the client's |
| 22 | // authority to report: for the life of the participation, and for |
| 23 | // ReportAfterLeave after it ends, so "Report a problem" still works on the |
| 24 | // screen that says the call is over. |
| 25 | |
| 26 | const ( |
| 27 | // ReportAfterLeave is how long a report token outlives its |
| 28 | // participation. |
| 29 | ReportAfterLeave = 10 * time.Minute |
| 30 | // maxReportGrants bounds the token table: every live participation |
| 31 | // (at most sixteen to a room) plus those ended in the last ten |
| 32 | // minutes. A real deployment holds tens; past this, the oldest ended |
| 33 | // grant is dropped first. |
| 34 | maxReportGrants = 4096 |
| 35 | // maxCallPeers bounds how many participations one call instance |
| 36 | // remembers a tag for. A remote peer past it is reported as unknown. |
| 37 | maxCallPeers = 64 |
| 38 | ) |
| 39 | |
| 40 | // callInstance is one call: a room from the moment it stopped being empty |
| 41 | // to the moment it is empty again. |
| 42 | type callInstance struct { |
| 43 | tag string |
| 44 | peers map[PeerID]string // peer id -> peer tag, for every participation this call has held |
| 45 | } |
| 46 | |
| 47 | type reportGrant struct { |
| 48 | room string |
| 49 | session string |
| 50 | call *callInstance |
| 51 | peerTag string |
| 52 | endedAt time.Time // zero while the participation lives |
| 53 | } |
| 54 | |
| 55 | // Reporter is what a report token authorizes: the tags to log under, |
| 56 | // and the tags of everyone this call has held, by peer id, so a report |
| 57 | // that names a remote peer by its id is logged under that peer's tag. |
| 58 | type Reporter struct { |
| 59 | CallTag string |
| 60 | PeerTag string |
| 61 | Live bool |
| 62 | Peers map[PeerID]string |
| 63 | } |
| 64 | |
| 65 | // newTag is a random tag: ten bytes in lowercase base32, sixteen |
| 66 | // characters, from crypto/rand. Not a hash of anything. |
| 67 | func newTag() string { |
| 68 | b := make([]byte, 10) |
| 69 | if _, err := rand.Read(b); err != nil { |
| 70 | panic(err) |
| 71 | } |
| 72 | return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)) |
| 73 | } |
| 74 | |
| 75 | // enrolReportLocked gives a fresh participation its peer tag and report |
| 76 | // token, starting a new call instance when the room held nobody. |
| 77 | func (h *Hub) enrolReportLocked(r *room, p *Participation) { |
| 78 | if r.call == nil || len(r.peers) == 0 { |
| 79 | r.call = &callInstance{tag: newTag(), peers: map[PeerID]string{}} |
| 80 | } |
| 81 | p.peerTag = newTag() |
| 82 | p.reportToken = randomID() |
| 83 | if len(r.call.peers) < maxCallPeers { |
| 84 | r.call.peers[p.Peer] = p.peerTag |
| 85 | } |
| 86 | if len(h.reports) >= maxReportGrants { |
| 87 | h.evictReportLocked() |
| 88 | } |
| 89 | h.reports[p.reportToken] = &reportGrant{room: r.sqid, session: p.Session, call: r.call, peerTag: p.peerTag} |
| 90 | } |
| 91 | |
| 92 | // endReportLocked starts a participation's after-leave window. |
| 93 | func (h *Hub) endReportLocked(p *Participation) { |
| 94 | if g, ok := h.reports[p.reportToken]; ok && g.endedAt.IsZero() { |
| 95 | g.endedAt = h.cfg.Now() |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // sweepReportsLocked drops grants whose after-leave window has passed. |
| 100 | func (h *Hub) sweepReportsLocked(now time.Time) { |
| 101 | for token, g := range h.reports { |
| 102 | if !g.endedAt.IsZero() && now.Sub(g.endedAt) > ReportAfterLeave { |
| 103 | delete(h.reports, token) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // evictReportLocked makes room: the grant that ended longest ago, or, if |
| 109 | // none has ended, nothing — a live participation's token is never taken |
| 110 | // from it, and the table is then bounded by the participations |
| 111 | // themselves. |
| 112 | func (h *Hub) evictReportLocked() { |
| 113 | var oldest string |
| 114 | var at time.Time |
| 115 | for token, g := range h.reports { |
| 116 | if g.endedAt.IsZero() { |
| 117 | continue |
| 118 | } |
| 119 | if oldest == "" || g.endedAt.Before(at) { |
| 120 | oldest, at = token, g.endedAt |
| 121 | } |
| 122 | } |
| 123 | if oldest != "" { |
| 124 | delete(h.reports, oldest) |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | // ReportTokenFor is the token of the participation this session holds in |
| 129 | // the room under peer, for a resumed stream's hello. |
| 130 | func (h *Hub) ReportTokenFor(sqid, session string, peer PeerID) string { |
| 131 | h.mu.Lock() |
| 132 | defer h.mu.Unlock() |
| 133 | p, ok := h.findLocked(sqid, session, peer) |
| 134 | if !ok { |
| 135 | return "" |
| 136 | } |
| 137 | return p.reportToken |
| 138 | } |
| 139 | |
| 140 | // Reporter answers a report token, presented by this session for this |
| 141 | // room: the participation's while it lives, and for ReportAfterLeave |
| 142 | // after it ends. Anything else is not found. |
| 143 | func (h *Hub) Reporter(sqid, session, token string) (Reporter, bool) { |
| 144 | h.mu.Lock() |
| 145 | defer h.mu.Unlock() |
| 146 | g, ok := h.reports[token] |
| 147 | if !ok || g.room != sqid || g.session != session { |
| 148 | return Reporter{}, false |
| 149 | } |
| 150 | if !g.endedAt.IsZero() && h.cfg.Now().Sub(g.endedAt) > ReportAfterLeave { |
| 151 | delete(h.reports, token) |
| 152 | return Reporter{}, false |
| 153 | } |
| 154 | peers := make(map[PeerID]string, len(g.call.peers)) |
| 155 | for id, tag := range g.call.peers { |
| 156 | peers[id] = tag |
| 157 | } |
| 158 | return Reporter{CallTag: g.call.tag, PeerTag: g.peerTag, Live: g.endedAt.IsZero(), Peers: peers}, true |
| 159 | } |
| 160 | |