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
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.
16func (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.
68func (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.
97func 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.
107func (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.
157func (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."
198func (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.
228func (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.
263func (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