| 1 | package calls |
| 2 | |
| 3 | import ( |
| 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. |
| 15 | type settingsRow struct { |
| 16 | ID int64 |
| 17 | Name string |
| 18 | Created string |
| 19 | Revoked bool |
| 20 | Principal string |
| 21 | } |
| 22 | |
| 23 | func (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. |
| 38 | func (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. |
| 70 | func (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. |
| 103 | func (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 | |
| 123 | func (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 | |
| 137 | func (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 | |
| 155 | func (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 | } |
| 162 | func (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 | } |
| 174 | func (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 | |