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 "strconv"
7
8 "github.com/go-chi/chi/v5"
9)
10
11// settingsRow is one line of the agent list: never the token, which
12// exists in plaintext only on the page that minted it (spec §8.1).
13// A revoked row shows that and nothing else; a live one shows the
14// principal the meetings list would attribute to it.
15type settingsRow struct {
16 ID int64
17 Name string
18 Created string
19 Revoked bool
20 Principal string
21}
22
23func (a *app) settings(w http.ResponseWriter, r *http.Request) {
24 a.renderSettings(w, r, http.StatusOK, "", "")
25}
26
27// renderSettings is GET /settings and POST /settings/agents' shared
28// render: name and nameErr repopulate the create form on a validation
29// failure, exactly as renderIndex repopulates the meeting form.
30//
31// status is written here, and only here (Task 2 review): a caller
32// that wrote its own header first and then called this could double
33// call WriteHeader the moment agentsFor below failed and this fell
34// through to ErrorPage's own — a superfluous, logged call, and on some
35// versions of net/http a header stuck at the first value regardless of
36// the second. Taking status as a parameter keeps the one call in the
37// one place that knows whether it actually got to render at all.
38func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int, name, nameErr string) {
39 p, _ := currentPerson(r)
40 agents, err := a.store.agentsFor(r.Context(), p)
41 if err != nil {
42 a.logger.Error("calls: list agents", "err", err)
43 a.ErrorPage(w, r, http.StatusInternalServerError, "")
44 return
45 }
46 rows := make([]settingsRow, 0, len(agents))
47 for _, ag := range agents {
48 rows = append(rows, settingsRow{
49 ID: ag.ID, Name: ag.Name, Created: a.fmtTime(ag.CreatedAt),
50 Revoked: ag.RevokedAt != nil, Principal: principal(ag, p),
51 })
52 }
53 defaults, err := a.store.meetingDefaults(r.Context())
54 if err != nil {
55 a.ErrorPage(w, r, 500, "")
56 return
57 }
58 w.WriteHeader(status)
59 render(w, "settings", a.page(r, titled("Settings"), map[string]any{
60 "Defaults": defaults, "Saved": r.URL.Query().Get("saved") == "1", "Agents": rows, "Name": name, "NameError": nameErr,
61 }))
62}
63
64// createAgentHandler is POST /settings/agents: mints a token and shows
65// it exactly once, on agent_once.html, alongside the principal string
66// the meetings list will show for anything this agent creates. The
67// cap and the duplicate-name refusal are store.createAgent's own
68// checks (fix round 1, items 4-5); this handler only renders whatever
69// it answers.
70func (a *app) createAgentHandler(w http.ResponseWriter, r *http.Request) {
71 _ = r.ParseForm()
72 name := r.FormValue("name")
73 p, _ := currentPerson(r)
74 ag, token, err := a.store.createAgent(r.Context(), name, p)
75 if err != nil {
76 switch {
77 case errors.Is(err, errAgentName):
78 a.renderSettings(w, r, http.StatusUnprocessableEntity, name, "Give the agent a name of 1 to 64 letters, digits, - or _.")
79 case errors.Is(err, errAgentLimit):
80 a.renderSettings(w, r, http.StatusUnprocessableEntity, name, "You already have 20 live agents. Revoke one before creating another.")
81 case errors.Is(err, errAgentDuplicate):
82 a.renderSettings(w, r, http.StatusUnprocessableEntity, name, "You already have a live agent with that name.")
83 default:
84 a.logger.Error("calls: create agent", "err", err)
85 a.ErrorPage(w, r, http.StatusInternalServerError, "")
86 }
87 return
88 }
89 // The token exists in plaintext on this response only (spec §8.1);
90 // a cache must never keep a copy of it around (Task 2 review, same
91 // property renderLinkOnce already holds for a meeting link).
92 w.Header().Set("Cache-Control", "no-store")
93 render(w, "agent_once", a.page(r, titled("Your agent’s token"), map[string]any{
94 "Name": ag.Name, "Principal": principal(ag, p), "Token": token,
95 }))
96}
97
98// agentForOperator loads the agent named by the URL's id, refusing with
99// the same 404 shape meetingForMember uses (never a 403) whether the id
100// is unknown or belongs to another operator: store.agentByID is itself
101// ownership-scoped now (fix round 1, item 2), so there is nothing left
102// for this function to check beyond what agentByID already answers.
103func (a *app) agentForOperator(w http.ResponseWriter, r *http.Request) (Agent, bool) {
104 id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
105 if err != nil {
106 a.ErrorPage(w, r, http.StatusNotFound, "")
107 return Agent{}, false
108 }
109 p, _ := currentPerson(r)
110 ag, _, ok, err := a.store.agentByID(r.Context(), id, p)
111 if err != nil {
112 a.logger.Error("calls: load agent", "err", err)
113 a.ErrorPage(w, r, http.StatusInternalServerError, "")
114 return Agent{}, false
115 }
116 if !ok {
117 a.ErrorPage(w, r, http.StatusNotFound, "")
118 return Agent{}, false
119 }
120 return ag, true
121}
122
123func (a *app) confirmRevokeAgent(w http.ResponseWriter, r *http.Request) {
124 ag, ok := a.agentForOperator(w, r)
125 if !ok {
126 return
127 }
128 render(w, "confirm", a.page(r, titled("Revoke "+ag.Name+"?"), map[string]any{
129 "Title": "Revoke " + ag.Name + "?",
130 "Body": "Anything using its token stops working at once.",
131 "Action": "/settings/agents/" + strconv.FormatInt(ag.ID, 10) + "/revoke",
132 "Label": "Revoke",
133 "CancelHref": "/settings", "BackLabel": "Settings",
134 }))
135}
136
137func (a *app) revokeAgentHandler(w http.ResponseWriter, r *http.Request) {
138 ag, ok := a.agentForOperator(w, r)
139 if !ok {
140 return
141 }
142 p, _ := currentPerson(r)
143 // revokeAgent is idempotent (agents.go): a second revoke of an
144 // already-revoked row of the caller's own answers false with no
145 // error, and that is not this handler's business to distinguish
146 // from the first — ownership was already settled above.
147 if _, err := a.store.revokeAgent(r.Context(), ag.ID, p); err != nil {
148 a.logger.Error("calls: revoke agent", "err", err)
149 a.ErrorPage(w, r, http.StatusInternalServerError, "")
150 return
151 }
152 http.Redirect(w, r, "/settings", http.StatusSeeOther)
153}
154
155func (a *app) saveDefaults(w http.ResponseWriter, r *http.Request) {
156 if err := a.store.saveMeetingDefaults(r.Context(), r.PostFormValue("require_signin") == "on", r.PostFormValue("waiting_room") == "on"); err != nil {
157 a.ErrorPage(w, r, 500, "")
158 return
159 }
160 http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
161}
162func (a *app) meetingSettings(w http.ResponseWriter, r *http.Request) {
163 m, ok := a.meetingForMember(w, r)
164 if !ok {
165 return
166 }
167 if m.EndedAt != nil {
168 a.ErrorPage(w, r, 404, "")
169 return
170 }
171 title := a.store.title(m)
172 render(w, "meeting_settings", a.page(r, onMeeting("Meeting settings", title), map[string]any{"Meeting": m, "Title": title, "Saved": r.URL.Query().Get("saved") == "1"}))
173}
174func (a *app) saveSettings(w http.ResponseWriter, r *http.Request) {
175 m, ok := a.meetingForMember(w, r)
176 if !ok {
177 return
178 }
179 if m.EndedAt != nil {
180 a.ErrorPage(w, r, 404, "")
181 return
182 }
183 if err := a.store.saveMeetingSettings(r.Context(), m.ID, r.PostFormValue("require_signin") == "on", r.PostFormValue("waiting_room") == "on"); err != nil {
184 a.ErrorPage(w, r, 500, "")
185 return
186 }
187 if r.PostFormValue("waiting_room") != "on" {
188 if err := a.hub.RefreshWaiting(r.Context(), m.Sqid); err != nil {
189 a.logger.Error("calls: refresh waiting room", "err", err)
190 }
191 }
192 http.Redirect(w, r, "/meetings/"+m.Sqid+"/settings?saved=1", http.StatusSeeOther)
193}
194