| 1 | package calls |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "net/http" |
| 6 | |
| 7 | "github.com/go-chi/chi/v5" |
| 8 | |
| 9 | "amadan.net/rastrillo/rastrillo" |
| 10 | |
| 11 | "amadan.net/oficina/calls/internal/calls/hub" |
| 12 | ) |
| 13 | |
| 14 | // roomAccess is §2.5's row for the two pages: standing, or a live |
| 15 | // participation for this session in this room. |
| 16 | func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Person, Standing, bool) { |
| 17 | sqid := chi.URLParam(r, "sqid") |
| 18 | m, ok, err := a.store.meetingBySqid(r.Context(), sqid) |
| 19 | if err != nil { |
| 20 | a.logger.Error("calls: load meeting", "err", err) |
| 21 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 22 | return Meeting{}, Person{}, StandingNone, false |
| 23 | } |
| 24 | if !ok || m.EndedAt != nil { |
| 25 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 26 | return Meeting{}, Person{}, StandingNone, false |
| 27 | } |
| 28 | p, _ := currentPerson(r) |
| 29 | st, err := a.standingFor(r.Context(), p, m) |
| 30 | if err != nil { |
| 31 | a.logger.Error("calls: standing", "err", err) |
| 32 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 33 | return Meeting{}, Person{}, StandingNone, false |
| 34 | } |
| 35 | if st == StandingNone { |
| 36 | session, ok := a.sessionKey(r) |
| 37 | if !ok { |
| 38 | a.logger.Error("calls: room access with no session subject", "path", r.URL.Path) |
| 39 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 40 | return Meeting{}, Person{}, StandingNone, false |
| 41 | } |
| 42 | // The fallback is ruling 14's and nothing wider: an admitted |
| 43 | // guest survives their own grant expiring, because §2.4 makes |
| 44 | // the participation the authority once they are in. A member who |
| 45 | // lost standing is not that case — §2.5 drops them by the next |
| 46 | // thing they do — so a participation whose kind is member is no |
| 47 | // longer a way into this page. Relabelling one as a guest would |
| 48 | // hand an ex-member the room for as long as the hub happened to |
| 49 | // hold their participation, which is the opposite of the rule. |
| 50 | peer, kind, live := a.hub.LiveKindForSession(sqid, session) |
| 51 | if p.Anonymous && m.RequireSignin { |
| 52 | participant, found := a.hub.Find(sqid, session, peer) |
| 53 | live = live && found && participant.Admitted |
| 54 | } |
| 55 | if !live || kind == hub.KindMember { |
| 56 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 57 | return Meeting{}, Person{}, StandingNone, false |
| 58 | } |
| 59 | st = StandingGuest |
| 60 | } |
| 61 | return m, p, st, true |
| 62 | } |
| 63 | |
| 64 | // room is GET /m/{sqid}: standing or live participation (spec §2.5, |
| 65 | // §3.4). Scriptless it renders the header, the standing line and a link |
| 66 | // back to the holding screen; room.js reveals the call for a browser |
| 67 | // that has script. |
| 68 | func (a *app) room(w http.ResponseWriter, r *http.Request) { |
| 69 | m, p, st, ok := a.roomAccess(w, r) |
| 70 | if !ok { |
| 71 | return |
| 72 | } |
| 73 | title := a.store.title(m) |
| 74 | data := a.page(r, onMeeting("", title), map[string]any{ |
| 75 | "Sqid": m.Sqid, "Title": title, "Named": !m.AutoTitle, "Starts": a.fmtStart(m.StartsAt), |
| 76 | "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, |
| 77 | // Floor and Relay are the same fact read twice, and both |
| 78 | // are rendering hints: Floor is §5.5's sentence for a |
| 79 | // scriptless page, Relay is what room.js reads to choose |
| 80 | // between the two failed-tile sentences. The list the |
| 81 | // server actually mints at /ice is the authority. |
| 82 | "Floor": !a.relayConfigured(), "Relay": a.relayConfigured(), "Boot": a.hub.Boot(), |
| 83 | // The build a web quality or problem report names as its |
| 84 | // client's version (spec §15.26): the page is the client. |
| 85 | "Version": rastrillo.BuildVersion, |
| 86 | }) |
| 87 | data["Menu"] = a.roomMenuFor(data["Frame"].(Frame)) |
| 88 | render(w, "room", data) |
| 89 | } |
| 90 | |
| 91 | // hubRefusal is the four hub sentinels that all mean the same thing to |
| 92 | // a caller of Remove or Takeover — this is not a peer, or not a |
| 93 | // session, the caller has any business acting on — and all answer 404, |
| 94 | // never 403 (spec §2.5). Anything else is unexpected and is a 500, |
| 95 | // logged with its path and error only, signal.go's own shape for the |
| 96 | // same distinction. |
| 97 | func hubRefusal(err error) bool { |
| 98 | return errors.Is(err, hub.ErrNotYours) || errors.Is(err, hub.ErrUnknownPeer) || |
| 99 | errors.Is(err, hub.ErrNotAdmitted) || errors.Is(err, hub.ErrNotMember) |
| 100 | } |
| 101 | |
| 102 | // confirmRemove is GET /m/{sqid}/remove/{peer}: its own URL, per the |
| 103 | // family's rule for a destructive action (spec §3.5). hub.PeerNameFor is |
| 104 | // exactly Remove's own authority read rather than acted on, so this page |
| 105 | // can never name — or offer to remove — a peer the actor could not |
| 106 | // actually remove. |
| 107 | func (a *app) confirmRemove(w http.ResponseWriter, r *http.Request) { |
| 108 | m, _, st, ok := a.roomAccess(w, r) |
| 109 | if !ok { |
| 110 | return |
| 111 | } |
| 112 | // Fresh standing as well as the participation PeerNameFor checks: |
| 113 | // §2.5's current kind, re-derived on this request. PeerNameFor reads |
| 114 | // the kind the last tick wrote, and a member demoted since then |
| 115 | // would otherwise still be shown a page offering to remove someone — |
| 116 | // a page whose POST hub.Remove would then refuse. The two checks |
| 117 | // answer the same 404, so nothing distinguishes them from outside. |
| 118 | if st != StandingMember { |
| 119 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 120 | return |
| 121 | } |
| 122 | target := chi.URLParam(r, "peer") |
| 123 | if !validPeerID(hub.PeerID(target)) { |
| 124 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 125 | return |
| 126 | } |
| 127 | session, ok := a.sessionKey(r) |
| 128 | if !ok { |
| 129 | a.logger.Error("calls: confirm remove with no session subject", "path", r.URL.Path) |
| 130 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 131 | return |
| 132 | } |
| 133 | name, ok := a.hub.PeerNameFor(m.Sqid, session, hub.PeerID(target)) |
| 134 | if !ok { |
| 135 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 136 | return |
| 137 | } |
| 138 | render(w, "confirm", a.page(r, onMeeting("Remove "+name+" from the call?", a.store.title(m)), map[string]any{ |
| 139 | "Title": "Remove " + name + " from the call?", |
| 140 | // True for a guest, who has only the link, and for a member, who |
| 141 | // can also come back from the meeting list — one sentence rather |
| 142 | // than branching copy on the actor's own kind (noted for the |
| 143 | // Task 11 copy catalogue). |
| 144 | "Body": "They will be disconnected and can rejoin only if they are still allowed in.", |
| 145 | "Action": "/m/" + m.Sqid + "/remove/" + target, "Label": "Remove", |
| 146 | // Cancel and the back link both lead to the holding screen, the |
| 147 | // same place a confirmed removal lands. This page opens in its |
| 148 | // own tab while the call holds the one behind it, and /m/{sqid} |
| 149 | // would open a second stream in this one — a 409 and a takeover |
| 150 | // offer, for a person who only pressed Cancel (issue 11). |
| 151 | "CancelHref": "/m/" + m.Sqid + "/join", "BackLabel": a.store.title(m), "Sqid": m.Sqid, |
| 152 | })) |
| 153 | } |
| 154 | |
| 155 | // remove is POST /m/{sqid}/remove/{peer}: hub.Remove is the only |
| 156 | // authority (spec §3.5), re-derived at execution. |
| 157 | func (a *app) remove(w http.ResponseWriter, r *http.Request) { |
| 158 | m, _, _, ok := a.roomAccess(w, r) |
| 159 | if !ok { |
| 160 | return |
| 161 | } |
| 162 | target := chi.URLParam(r, "peer") |
| 163 | if !validPeerID(hub.PeerID(target)) { |
| 164 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 165 | return |
| 166 | } |
| 167 | session, ok := a.sessionKey(r) |
| 168 | if !ok { |
| 169 | a.logger.Error("calls: remove with no session subject", "path", r.URL.Path) |
| 170 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 171 | return |
| 172 | } |
| 173 | actor, live := a.hub.LiveForSession(m.Sqid, session) |
| 174 | if !live { |
| 175 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 176 | return |
| 177 | } |
| 178 | if err := a.hub.Remove(r.Context(), m.Sqid, session, actor, hub.PeerID(target)); err != nil { |
| 179 | if !hubRefusal(err) { |
| 180 | a.logger.Error("calls: remove", "path", r.URL.Path, "err", err) |
| 181 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 182 | return |
| 183 | } |
| 184 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 185 | return |
| 186 | } |
| 187 | // Not back to the room: that would open a fresh stream. The holding |
| 188 | // screen is inert, and the actor is still there to open one anyway. |
| 189 | http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther) |
| 190 | } |
| 191 | |
| 192 | // confirmTakeover is GET /m/{sqid}/takeover: its own URL, so a GET never |
| 193 | // takes over (spec §4.2). Its premise — another tab has this session's |
| 194 | // peer — only holds when this session actually has a live participation |
| 195 | // here; without one there is nothing to confirm, so it sends the caller |
| 196 | // straight to the holding screen instead of asking a question whose |
| 197 | // answer would always be "there is no other tab." |
| 198 | func (a *app) confirmTakeover(w http.ResponseWriter, r *http.Request) { |
| 199 | m, _, _, ok := a.roomAccess(w, r) |
| 200 | if !ok { |
| 201 | return |
| 202 | } |
| 203 | session, ok := a.sessionKey(r) |
| 204 | if !ok { |
| 205 | a.logger.Error("calls: confirm takeover with no session subject", "path", r.URL.Path) |
| 206 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 207 | return |
| 208 | } |
| 209 | if _, live := a.hub.LiveForSession(m.Sqid, session); !live { |
| 210 | http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther) |
| 211 | return |
| 212 | } |
| 213 | render(w, "confirm", a.page(r, onMeeting("This meeting is open in another tab", a.store.title(m)), map[string]any{ |
| 214 | "Title": "This meeting is open in another tab", |
| 215 | "Body": "Use this tab instead? The other tab will leave the call.", |
| 216 | "Action": "/m/" + m.Sqid + "/takeover", "Label": "Use this tab", |
| 217 | "CancelHref": "/m/" + m.Sqid + "/join", "BackLabel": a.store.title(m), "Sqid": m.Sqid, |
| 218 | })) |
| 219 | } |
| 220 | |
| 221 | // takeover is POST /m/{sqid}/takeover: a confirmed action on the |
| 222 | // session's own live participation only (spec §4.2), never another |
| 223 | // person's. §2.5 asks for standing as well as the participation — a |
| 224 | // participation the room page and Remove's authority let survive a |
| 225 | // lapsed grant (ruling 14) is not enough here on its own — so standing |
| 226 | // is re-derived fresh rather than trusting roomAccess's page-view |
| 227 | // fallback. |
| 228 | func (a *app) takeover(w http.ResponseWriter, r *http.Request) { |
| 229 | m, p, _, ok := a.roomAccess(w, r) |
| 230 | if !ok { |
| 231 | return |
| 232 | } |
| 233 | st, err := a.standingFor(r.Context(), p, m) |
| 234 | if err != nil { |
| 235 | a.logger.Error("calls: takeover: standing", "err", err) |
| 236 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 237 | return |
| 238 | } |
| 239 | if st == StandingNone { |
| 240 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 241 | return |
| 242 | } |
| 243 | session, ok := a.sessionKey(r) |
| 244 | if !ok { |
| 245 | a.logger.Error("calls: takeover with no session subject", "path", r.URL.Path) |
| 246 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 247 | return |
| 248 | } |
| 249 | if _, err := a.hub.Takeover(m.Sqid, session); err != nil { |
| 250 | if !hubRefusal(err) { |
| 251 | a.logger.Error("calls: takeover", "path", r.URL.Path, "err", err) |
| 252 | a.ErrorPage(w, r, http.StatusInternalServerError, "") |
| 253 | return |
| 254 | } |
| 255 | a.ErrorPage(w, r, http.StatusNotFound, "") |
| 256 | return |
| 257 | } |
| 258 | http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther) |
| 259 | } |
| 260 | |
| 261 | // roomInfo is the native holding screen's metadata. The room page's existing |
| 262 | // authority resolves every field; callers never supply titles or standing. |
| 263 | func (a *app) roomInfo(w http.ResponseWriter, r *http.Request) { |
| 264 | m, p, st, ok := a.roomAccess(w, r) |
| 265 | if !ok { |
| 266 | return |
| 267 | } |
| 268 | // named: a title someone chose, which the apps show over the call; a |
| 269 | // title Calls wrote is left off (Meeting.AutoTitle). |
| 270 | streamJSON(w, http.StatusOK, map[string]any{"title": a.store.title(m), "named": !m.AutoTitle, "name": p.Name, "standing": st.String(), "floor": !a.relayConfigured()}) |
| 271 | } |
| 272 | |