package calls import ( "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" ) // settingsRow is one line of the agent list: never the token, which // exists in plaintext only on the page that minted it (spec §8.1). // A revoked row shows that and nothing else; a live one shows the // principal the meetings list would attribute to it. type settingsRow struct { ID int64 Name string Created string Revoked bool Principal string } func (a *app) settings(w http.ResponseWriter, r *http.Request) { a.renderSettings(w, r, http.StatusOK, "", "") } // renderSettings is GET /settings and POST /settings/agents' shared // render: name and nameErr repopulate the create form on a validation // failure, exactly as renderIndex repopulates the meeting form. // // status is written here, and only here (Task 2 review): a caller // that wrote its own header first and then called this could double // call WriteHeader the moment agentsFor below failed and this fell // through to ErrorPage's own — a superfluous, logged call, and on some // versions of net/http a header stuck at the first value regardless of // the second. Taking status as a parameter keeps the one call in the // one place that knows whether it actually got to render at all. func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int, name, nameErr string) { p, _ := currentPerson(r) agents, err := a.store.agentsFor(r.Context(), p) if err != nil { a.logger.Error("calls: list agents", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } rows := make([]settingsRow, 0, len(agents)) for _, ag := range agents { rows = append(rows, settingsRow{ ID: ag.ID, Name: ag.Name, Created: a.fmtTime(ag.CreatedAt), Revoked: ag.RevokedAt != nil, Principal: principal(ag, p), }) } defaults, err := a.store.meetingDefaults(r.Context()) if err != nil { a.ErrorPage(w, r, 500, "") return } w.WriteHeader(status) render(w, "settings", a.page(r, titled("Settings"), map[string]any{ "Defaults": defaults, "Saved": r.URL.Query().Get("saved") == "1", "Agents": rows, "Name": name, "NameError": nameErr, })) } // createAgentHandler is POST /settings/agents: mints a token and shows // it exactly once, on agent_once.html, alongside the principal string // the meetings list will show for anything this agent creates. The // cap and the duplicate-name refusal are store.createAgent's own // checks (fix round 1, items 4-5); this handler only renders whatever // it answers. func (a *app) createAgentHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() name := r.FormValue("name") p, _ := currentPerson(r) ag, token, err := a.store.createAgent(r.Context(), name, p) if err != nil { switch { case errors.Is(err, errAgentName): a.renderSettings(w, r, http.StatusUnprocessableEntity, name, "Give the agent a name of 1 to 64 letters, digits, - or _.") case errors.Is(err, errAgentLimit): a.renderSettings(w, r, http.StatusUnprocessableEntity, name, "You already have 20 live agents. Revoke one before creating another.") case errors.Is(err, errAgentDuplicate): a.renderSettings(w, r, http.StatusUnprocessableEntity, name, "You already have a live agent with that name.") default: a.logger.Error("calls: create agent", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") } return } // The token exists in plaintext on this response only (spec §8.1); // a cache must never keep a copy of it around (Task 2 review, same // property renderLinkOnce already holds for a meeting link). w.Header().Set("Cache-Control", "no-store") render(w, "agent_once", a.page(r, titled("Your agent’s token"), map[string]any{ "Name": ag.Name, "Principal": principal(ag, p), "Token": token, })) } // agentForOperator loads the agent named by the URL's id, refusing with // the same 404 shape meetingForMember uses (never a 403) whether the id // is unknown or belongs to another operator: store.agentByID is itself // ownership-scoped now (fix round 1, item 2), so there is nothing left // for this function to check beyond what agentByID already answers. func (a *app) agentForOperator(w http.ResponseWriter, r *http.Request) (Agent, bool) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { a.ErrorPage(w, r, http.StatusNotFound, "") return Agent{}, false } p, _ := currentPerson(r) ag, _, ok, err := a.store.agentByID(r.Context(), id, p) if err != nil { a.logger.Error("calls: load agent", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return Agent{}, false } if !ok { a.ErrorPage(w, r, http.StatusNotFound, "") return Agent{}, false } return ag, true } func (a *app) confirmRevokeAgent(w http.ResponseWriter, r *http.Request) { ag, ok := a.agentForOperator(w, r) if !ok { return } render(w, "confirm", a.page(r, titled("Revoke "+ag.Name+"?"), map[string]any{ "Title": "Revoke " + ag.Name + "?", "Body": "Anything using its token stops working at once.", "Action": "/settings/agents/" + strconv.FormatInt(ag.ID, 10) + "/revoke", "Label": "Revoke", "CancelHref": "/settings", "BackLabel": "Settings", })) } func (a *app) revokeAgentHandler(w http.ResponseWriter, r *http.Request) { ag, ok := a.agentForOperator(w, r) if !ok { return } p, _ := currentPerson(r) // revokeAgent is idempotent (agents.go): a second revoke of an // already-revoked row of the caller's own answers false with no // error, and that is not this handler's business to distinguish // from the first — ownership was already settled above. if _, err := a.store.revokeAgent(r.Context(), ag.ID, p); err != nil { a.logger.Error("calls: revoke agent", "err", err) a.ErrorPage(w, r, http.StatusInternalServerError, "") return } http.Redirect(w, r, "/settings", http.StatusSeeOther) } func (a *app) saveDefaults(w http.ResponseWriter, r *http.Request) { if err := a.store.saveMeetingDefaults(r.Context(), r.PostFormValue("require_signin") == "on", r.PostFormValue("waiting_room") == "on"); err != nil { a.ErrorPage(w, r, 500, "") return } http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther) } func (a *app) meetingSettings(w http.ResponseWriter, r *http.Request) { m, ok := a.meetingForMember(w, r) if !ok { return } if m.EndedAt != nil { a.ErrorPage(w, r, 404, "") return } title := a.store.title(m) 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"})) } func (a *app) saveSettings(w http.ResponseWriter, r *http.Request) { m, ok := a.meetingForMember(w, r) if !ok { return } if m.EndedAt != nil { a.ErrorPage(w, r, 404, "") return } if err := a.store.saveMeetingSettings(r.Context(), m.ID, r.PostFormValue("require_signin") == "on", r.PostFormValue("waiting_room") == "on"); err != nil { a.ErrorPage(w, r, 500, "") return } if r.PostFormValue("waiting_room") != "on" { if err := a.hub.RefreshWaiting(r.Context(), m.Sqid); err != nil { a.logger.Error("calls: refresh waiting room", "err", err) } } http.Redirect(w, r, "/meetings/"+m.Sqid+"/settings?saved=1", http.StatusSeeOther) }