package calls import ( "errors" "net/http" "github.com/go-chi/chi/v5" "amadan.net/rastrillo/rastrillo" "amadan.net/oficina/calls/internal/calls/hub" ) // roomAccess is §2.5's row for the two pages: standing, or a live // participation for this session in this room. func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Person, Standing, bool) { sqid := chi.URLParam(r, "sqid") m, ok, err := a.store.meetingBySqid(r.Context(), sqid) if err != nil { a.logger.Error("calls: load meeting", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return Meeting{}, Person{}, StandingNone, false } if !ok || m.EndedAt != nil { a.ErrorPage(w, r, http.StatusNotFound, "") return Meeting{}, Person{}, StandingNone, false } p, _ := currentPerson(r) st, err := a.standingFor(r.Context(), p, m) if err != nil { a.logger.Error("calls: standing", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return Meeting{}, Person{}, StandingNone, false } if st == StandingNone { session, ok := a.sessionKey(r) if !ok { a.logger.Error("calls: room access with no session subject", "path", r.URL.Path) a.ErrorPage(w, r, http.StatusInternalServerError, "") return Meeting{}, Person{}, StandingNone, false } // The fallback is ruling 14's and nothing wider: an admitted // guest survives their own grant expiring, because §2.4 makes // the participation the authority once they are in. A member who // lost standing is not that case — §2.5 drops them by the next // thing they do — so a participation whose kind is member is no // longer a way into this page. Relabelling one as a guest would // hand an ex-member the room for as long as the hub happened to // hold their participation, which is the opposite of the rule. peer, kind, live := a.hub.LiveKindForSession(sqid, session) if p.Anonymous && m.RequireSignin { participant, found := a.hub.Find(sqid, session, peer) live = live && found && participant.Admitted } if !live || kind == hub.KindMember { a.ErrorPage(w, r, http.StatusNotFound, "") return Meeting{}, Person{}, StandingNone, false } st = StandingGuest } return m, p, st, true } // room is GET /m/{sqid}: standing or live participation (spec §2.5, // §3.4). Scriptless it renders the header, the standing line and a link // back to the holding screen; room.js reveals the call for a browser // that has script. func (a *app) room(w http.ResponseWriter, r *http.Request) { m, p, st, ok := a.roomAccess(w, r) if !ok { return } title := a.store.title(m) data := a.page(r, onMeeting("", title), map[string]any{ "Sqid": m.Sqid, "Title": title, "Named": !m.AutoTitle, "Starts": a.fmtStart(m.StartsAt), "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember, // Floor and Relay are the same fact read twice, and both // are rendering hints: Floor is §5.5's sentence for a // scriptless page, Relay is what room.js reads to choose // between the two failed-tile sentences. The list the // server actually mints at /ice is the authority. "Floor": !a.relayConfigured(), "Relay": a.relayConfigured(), "Boot": a.hub.Boot(), // The build a web quality or problem report names as its // client's version (spec §15.26): the page is the client. "Version": rastrillo.BuildVersion, }) data["Menu"] = a.roomMenuFor(data["Frame"].(Frame)) render(w, "room", data) } // hubRefusal is the four hub sentinels that all mean the same thing to // a caller of Remove or Takeover — this is not a peer, or not a // session, the caller has any business acting on — and all answer 404, // never 403 (spec §2.5). Anything else is unexpected and is a 500, // logged with its path and error only, signal.go's own shape for the // same distinction. func hubRefusal(err error) bool { return errors.Is(err, hub.ErrNotYours) || errors.Is(err, hub.ErrUnknownPeer) || errors.Is(err, hub.ErrNotAdmitted) || errors.Is(err, hub.ErrNotMember) } // confirmRemove is GET /m/{sqid}/remove/{peer}: its own URL, per the // family's rule for a destructive action (spec §3.5). hub.PeerNameFor is // exactly Remove's own authority read rather than acted on, so this page // can never name — or offer to remove — a peer the actor could not // actually remove. func (a *app) confirmRemove(w http.ResponseWriter, r *http.Request) { m, _, st, ok := a.roomAccess(w, r) if !ok { return } // Fresh standing as well as the participation PeerNameFor checks: // §2.5's current kind, re-derived on this request. PeerNameFor reads // the kind the last tick wrote, and a member demoted since then // would otherwise still be shown a page offering to remove someone — // a page whose POST hub.Remove would then refuse. The two checks // answer the same 404, so nothing distinguishes them from outside. if st != StandingMember { a.ErrorPage(w, r, http.StatusNotFound, "") return } target := chi.URLParam(r, "peer") if !validPeerID(hub.PeerID(target)) { a.ErrorPage(w, r, http.StatusNotFound, "") return } session, ok := a.sessionKey(r) if !ok { a.logger.Error("calls: confirm remove with no session subject", "path", r.URL.Path) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } name, ok := a.hub.PeerNameFor(m.Sqid, session, hub.PeerID(target)) if !ok { a.ErrorPage(w, r, http.StatusNotFound, "") return } render(w, "confirm", a.page(r, onMeeting("Remove "+name+" from the call?", a.store.title(m)), map[string]any{ "Title": "Remove " + name + " from the call?", // True for a guest, who has only the link, and for a member, who // can also come back from the meeting list — one sentence rather // than branching copy on the actor's own kind (noted for the // Task 11 copy catalogue). "Body": "They will be disconnected and can rejoin only if they are still allowed in.", "Action": "/m/" + m.Sqid + "/remove/" + target, "Label": "Remove", // Cancel and the back link both lead to the holding screen, the // same place a confirmed removal lands. This page opens in its // own tab while the call holds the one behind it, and /m/{sqid} // would open a second stream in this one — a 409 and a takeover // offer, for a person who only pressed Cancel (issue 11). "CancelHref": "/m/" + m.Sqid + "/join", "BackLabel": a.store.title(m), "Sqid": m.Sqid, })) } // remove is POST /m/{sqid}/remove/{peer}: hub.Remove is the only // authority (spec §3.5), re-derived at execution. func (a *app) remove(w http.ResponseWriter, r *http.Request) { m, _, _, ok := a.roomAccess(w, r) if !ok { return } target := chi.URLParam(r, "peer") if !validPeerID(hub.PeerID(target)) { a.ErrorPage(w, r, http.StatusNotFound, "") return } session, ok := a.sessionKey(r) if !ok { a.logger.Error("calls: remove with no session subject", "path", r.URL.Path) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } actor, live := a.hub.LiveForSession(m.Sqid, session) if !live { a.ErrorPage(w, r, http.StatusNotFound, "") return } if err := a.hub.Remove(r.Context(), m.Sqid, session, actor, hub.PeerID(target)); err != nil { if !hubRefusal(err) { a.logger.Error("calls: remove", "path", r.URL.Path, "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } a.ErrorPage(w, r, http.StatusNotFound, "") return } // Not back to the room: that would open a fresh stream. The holding // screen is inert, and the actor is still there to open one anyway. http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther) } // confirmTakeover is GET /m/{sqid}/takeover: its own URL, so a GET never // takes over (spec §4.2). Its premise — another tab has this session's // peer — only holds when this session actually has a live participation // here; without one there is nothing to confirm, so it sends the caller // straight to the holding screen instead of asking a question whose // answer would always be "there is no other tab." func (a *app) confirmTakeover(w http.ResponseWriter, r *http.Request) { m, _, _, ok := a.roomAccess(w, r) if !ok { return } session, ok := a.sessionKey(r) if !ok { a.logger.Error("calls: confirm takeover with no session subject", "path", r.URL.Path) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } if _, live := a.hub.LiveForSession(m.Sqid, session); !live { http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther) return } render(w, "confirm", a.page(r, onMeeting("This meeting is open in another tab", a.store.title(m)), map[string]any{ "Title": "This meeting is open in another tab", "Body": "Use this tab instead? The other tab will leave the call.", "Action": "/m/" + m.Sqid + "/takeover", "Label": "Use this tab", "CancelHref": "/m/" + m.Sqid + "/join", "BackLabel": a.store.title(m), "Sqid": m.Sqid, })) } // takeover is POST /m/{sqid}/takeover: a confirmed action on the // session's own live participation only (spec §4.2), never another // person's. §2.5 asks for standing as well as the participation — a // participation the room page and Remove's authority let survive a // lapsed grant (ruling 14) is not enough here on its own — so standing // is re-derived fresh rather than trusting roomAccess's page-view // fallback. func (a *app) takeover(w http.ResponseWriter, r *http.Request) { m, p, _, ok := a.roomAccess(w, r) if !ok { return } st, err := a.standingFor(r.Context(), p, m) if err != nil { a.logger.Error("calls: takeover: standing", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } if st == StandingNone { a.ErrorPage(w, r, http.StatusNotFound, "") return } session, ok := a.sessionKey(r) if !ok { a.logger.Error("calls: takeover with no session subject", "path", r.URL.Path) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } if _, err := a.hub.Takeover(m.Sqid, session); err != nil { if !hubRefusal(err) { a.logger.Error("calls: takeover", "path", r.URL.Path, "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } a.ErrorPage(w, r, http.StatusNotFound, "") return } http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther) } // roomInfo is the native holding screen's metadata. The room page's existing // authority resolves every field; callers never supply titles or standing. func (a *app) roomInfo(w http.ResponseWriter, r *http.Request) { m, p, st, ok := a.roomAccess(w, r) if !ok { return } // named: a title someone chose, which the apps show over the call; a // title Calls wrote is left off (Meeting.AutoTitle). streamJSON(w, http.StatusOK, map[string]any{"title": a.store.title(m), "named": !m.AutoTitle, "name": p.Name, "standing": st.String(), "floor": !a.relayConfigured()}) }