Raw
  1package web
  2
  3import (
  4	"errors"
  5	"log/slog"
  6	"net/http"
  7	"regexp"
  8	"slices"
  9	"strings"
 10
 11	"tug/internal/transport/web/text"
 12)
 13
 14const accountBadges = 20
 15
 16var reSessionID = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`)
 17
 18var accountPasswordProblems = map[AccountProblem]text.Key{
 19	AccountPasswordWrong:   text.AccountProblemWrong,
 20	AccountPasswordShort:   text.AccountProblemShort,
 21	AccountPasswordLong:    text.AccountProblemLong,
 22	AccountBusy:            text.AccountProblemBusy,
 23	AccountPasswordChanged: text.AccountProblemStale,
 24}
 25
 26type portal struct {
 27	kit *Kit
 28	b   AccountBackend
 29	e   SessionEnder
 30}
 31
 32type accountOverviewData struct {
 33	Username     Ident
 34	DisplayName  string
 35	Groups       []Ident
 36	More         int
 37	Admin        string
 38	Google       bool
 39	GoogleLinked bool
 40	GoogleSince  string
 41	Portdistrict bool
 42	Primary      bool
 43	SSHKeys      sshKeyList
 44}
 45
 46type accountProfileData struct {
 47	DisplayName string
 48	Message     text.Key
 49}
 50
 51type accountPasswordData struct {
 52	Message text.Key
 53}
 54
 55type accountSessionRow struct {
 56	ID       Ident
 57	Host     Ident
 58	Opened   string
 59	Ends     string
 60	Current  bool
 61	Browser  bool
 62	Device   string
 63	Agent    string
 64	Address  string
 65	Client   Ident
 66	LastSeen string
 67}
 68
 69type accountSessionsData struct {
 70	Rows   []accountSessionRow
 71	Pager  adminPager
 72	Others bool
 73}
 74
 75func (k *Kit) newAccount(b AccountBackend) *portal {
 76	if b == nil {
 77		panic("web: the account pages need a backend")
 78	}
 79	return &portal{kit: k, b: b}
 80}
 81
 82func (k *Kit) AccountOverview(b AccountBackend) http.Handler {
 83	return http.HandlerFunc(k.newAccount(b).overview)
 84}
 85
 86func (k *Kit) AccountRoutes(b AccountBackend, e SessionEnder) []Route {
 87	if e == nil {
 88		panic("web: the account pages need a session ender")
 89	}
 90	a := k.newAccount(b)
 91	a.e = e
 92	return append([]Route{
 93		{Method: http.MethodGet, Path: "/profile", Handler: http.HandlerFunc(a.profile)},
 94		{Method: http.MethodPost, Path: "/profile", Handler: http.HandlerFunc(a.saveProfile)},
 95		{Method: http.MethodGet, Path: "/password", Handler: http.HandlerFunc(a.password)},
 96		{Method: http.MethodPost, Path: "/password", Handler: http.HandlerFunc(a.changePassword)},
 97		{Method: http.MethodGet, Path: "/sessions", Handler: http.HandlerFunc(a.sessions)},
 98		{Method: http.MethodPost, Path: "/sessions/revoke", Handler: http.HandlerFunc(a.revoke)},
 99		{Method: http.MethodGet, Path: "/sessions/revoke-all", Handler: http.HandlerFunc(a.revokeAllShow)},
100		{Method: http.MethodPost, Path: "/sessions/revoke-all", Handler: http.HandlerFunc(a.revokeAll)},
101		{Method: http.MethodGet, Path: BrowserLogoutPath, Access: SignedIn, Handler: http.HandlerFunc(a.browserShow)},
102		{Method: http.MethodPost, Path: BrowserLogoutPath, Access: SignedIn, Handler: http.HandlerFunc(a.browserSignOut)},
103	}, a.sshKeyRoutes()...)
104}
105
106func (a *portal) render(w http.ResponseWriter, r *http.Request, status int, name string, data any) {
107	p := a.kit.formPage(w, r, data)
108	p.Host = Ident(a.kit.server.DomainAccount)
109	a.kit.render(w, r, status, name, p)
110}
111
112func (a *portal) failed(w http.ResponseWriter, r *http.Request, step string, err error) {
113	if errors.Is(err, ErrNoSession) && r.Method != http.MethodGet {
114		a.kit.SeeOther(w, r, "/")
115		return
116	}
117	if errors.Is(err, ErrAccountNotFound) {
118		a.kit.ErrorPage(w, r, http.StatusNotFound)
119		return
120	}
121	slog.Error("account page failed", "step", step, "err", err)
122	a.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
123}
124
125func (a *portal) done(w http.ResponseWriter, r *http.Request, key text.Key, path string) {
126	if err := a.kit.SetFlash(w, key, ""); err != nil {
127		slog.Error("account flash not set", "err", err)
128	}
129	a.kit.SeeOther(w, r, path)
130}
131
132func (a *portal) overview(w http.ResponseWriter, r *http.Request) {
133	p, ok := a.kit.principal(w, r)
134	if !ok {
135		return
136	}
137	prof, err := a.b.Profile(r.Context(), p)
138	if err != nil {
139		a.failed(w, r, "profile", err)
140		return
141	}
142	if prof.AdminURL != "" {
143		if err := a.kit.origins.allows(prof.AdminURL); err != nil {
144			a.failed(w, r, "admin link", err)
145			return
146		}
147	}
148	groups := primaryFirst(prof.Groups, prof.Primary)
149	shown := groups[:min(len(groups), accountBadges)]
150	d := accountOverviewData{
151		Username:     p.Username,
152		DisplayName:  prof.DisplayName,
153		More:         max(prof.GroupCount, len(groups)) - len(shown),
154		Admin:        prof.AdminURL,
155		Google:       prof.Google,
156		Portdistrict: prof.Portdistrict,
157		Primary:      prof.Primary != "" && len(shown) > 0 && shown[0] == prof.Primary,
158		SSHKeys:      newSSHKeyList(prof.SSHKeys, "/ssh-keys/", SSHKeyNewPath),
159	}
160	for _, g := range shown {
161		d.Groups = append(d.Groups, Ident(g))
162	}
163	if prof.Google && prof.GoogleLink != nil {
164		d.GoogleLinked, d.GoogleSince = true, stamp(prof.GoogleLink.LinkedAt)
165	}
166	a.render(w, r, http.StatusOK, "account-overview", d)
167}
168
169func (a *portal) password(w http.ResponseWriter, r *http.Request) {
170	if _, ok := a.kit.principal(w, r); ok {
171		a.render(w, r, http.StatusOK, "account-password", accountPasswordData{})
172	}
173}
174
175func (a *portal) changePassword(w http.ResponseWriter, r *http.Request) {
176	p, ok := a.kit.principal(w, r)
177	if !ok {
178		return
179	}
180	current, next := r.PostFormValue("current"), r.PostFormValue("password")
181	var msg text.Key
182	switch {
183	case current == "":
184		msg = text.AccountProblemEmpty
185	case next != r.PostFormValue("confirm"):
186		msg = text.AccountProblemMismatch
187	}
188	if msg != "" {
189		a.render(w, r, http.StatusOK, "account-password", accountPasswordData{Message: msg})
190		return
191	}
192	err := a.b.ChangePassword(r.Context(), p, current, next)
193	var prob AccountProblem
194	if errors.As(err, &prob) {
195		if key, ok := accountPasswordProblems[prob]; ok {
196			status := http.StatusOK
197			if prob == AccountBusy {
198				status = http.StatusServiceUnavailable
199			}
200			a.render(w, r, status, "account-password", accountPasswordData{Message: key})
201			return
202		}
203	}
204	if err != nil {
205		a.failed(w, r, "change password", err)
206		return
207	}
208	a.done(w, r, text.FlashAccountPasswordChanged, "/password")
209}
210
211func (a *portal) sessions(w http.ResponseWriter, r *http.Request) {
212	p, ok := a.kit.principal(w, r)
213	if !ok {
214		return
215	}
216	page := pageNumber(r)
217	list, err := a.b.Sessions(r.Context(), p, page)
218	if err != nil {
219		a.failed(w, r, "sessions", err)
220		return
221	}
222	rows := list.Sessions[:min(len(list.Sessions), AccountSessionsPerPage)]
223	d := accountSessionsData{Pager: newPager("/sessions", "", page, AccountSessionsPerPage, len(rows), list.Total), Others: list.Total > 1}
224	for _, s := range rows {
225		row := accountSessionRow{ID: Ident(s.ID), Host: Ident(s.Host), Opened: stamp(s.Created), Ends: stamp(s.Expires), Current: s.ThisSession, Browser: s.ThisBrowser, Device: deviceLabel(s.UserAgent), Agent: clipAgent(s.UserAgent), Address: s.Address}
226		if !s.LastSeen.IsZero() {
227			row.LastSeen = stamp(s.LastSeen)
228		}
229		if s.ClientID != "" && !strings.HasPrefix(s.ClientID, "_") {
230			row.Client = Ident(s.ClientID)
231		}
232		d.Rows = append(d.Rows, row)
233	}
234	a.render(w, r, http.StatusOK, "account-sessions", d)
235}
236
237func (a *portal) revoke(w http.ResponseWriter, r *http.Request) {
238	p, ok := a.kit.principal(w, r)
239	if !ok {
240		return
241	}
242	id := r.PostFormValue("id")
243	if !reSessionID.MatchString(id) || id == p.SessionID {
244		a.kit.ErrorPage(w, r, http.StatusBadRequest)
245		return
246	}
247	if err := a.b.RevokeSession(r.Context(), p, id); err != nil {
248		a.failed(w, r, "revoke session", err)
249		return
250	}
251	a.done(w, r, text.FlashAccountRevoked, "/sessions")
252}
253
254func (a *portal) revokeAllShow(w http.ResponseWriter, r *http.Request) {
255	p, ok := a.kit.principal(w, r)
256	if !ok {
257		return
258	}
259	a.kit.RenderConfirm(w, r, Confirm{Title: text.AccountRevokeAllTitle, Body: text.AccountRevokeAllBody, Button: text.AccountSessionsRevokeAll, Subject: p.Username, Action: "/sessions/revoke-all", Cancel: "/sessions"})
260}
261
262func (a *portal) revokeAll(w http.ResponseWriter, r *http.Request) {
263	p, ok := a.kit.principal(w, r)
264	if !ok {
265		return
266	}
267	if _, err := a.b.RevokeOtherSessions(r.Context(), p); err != nil {
268		a.failed(w, r, "revoke other sessions", err)
269		return
270	}
271	a.done(w, r, text.FlashAccountRevokedOthers, "/sessions")
272}
273
274func (a *portal) browserShow(w http.ResponseWriter, r *http.Request) {
275	p, ok := a.kit.principal(w, r)
276	if !ok {
277		return
278	}
279	a.kit.RenderConfirm(w, r, Confirm{Title: text.AccountBrowserTitle, Body: text.AccountBrowserBody, Button: text.AccountBrowserSignOut, Subject: p.Username, Action: BrowserLogoutPath, Cancel: "/", Neutral: true})
280}
281
282func (a *portal) browserSignOut(w http.ResponseWriter, r *http.Request) {
283	p, ok := a.kit.principal(w, r)
284	if !ok {
285		return
286	}
287	ended, err := a.b.SignOutBrowser(r.Context(), p)
288	if err != nil {
289		a.failed(w, r, "sign out browser", err)
290		return
291	}
292	if err := a.e.EndSession(w, r, p); err != nil && !errors.Is(err, ErrNoSession) {
293		a.failed(w, r, "end session", err)
294		return
295	}
296	a.kit.RotateCSRF(w)
297	flash := text.FlashAccountBrowserOut
298	if ended <= 1 {
299		flash = text.FlashAccountBrowserOutHost
300	}
301	a.done(w, r, flash, SignedOutPath)
302}
303
304func (a *portal) profile(w http.ResponseWriter, r *http.Request) {
305	p, ok := a.kit.principal(w, r)
306	if !ok {
307		return
308	}
309	prof, err := a.b.Profile(r.Context(), p)
310	if err != nil {
311		a.failed(w, r, "profile", err)
312		return
313	}
314	a.render(w, r, http.StatusOK, "account-profile", accountProfileData{DisplayName: prof.DisplayName})
315}
316
317func (a *portal) saveProfile(w http.ResponseWriter, r *http.Request) {
318	p, ok := a.kit.principal(w, r)
319	if !ok {
320		return
321	}
322	name := r.PostFormValue("display_name")
323	err := a.b.UpdateDisplayName(r.Context(), p, name)
324	var prob AccountProblem
325	if errors.As(err, &prob) && prob == AccountDisplayNameInvalid {
326		a.render(w, r, http.StatusOK, "account-profile", accountProfileData{DisplayName: echoed(name, echoDisplayNameBytes), Message: text.AdminProblemDisplayName})
327		return
328	}
329	if err != nil {
330		a.failed(w, r, "update display name", err)
331		return
332	}
333	a.done(w, r, text.FlashAccountProfileSaved, "/")
334}
335
336func primaryFirst(groups []string, primary string) []string {
337	i := slices.Index(groups, primary)
338	if primary == "" || i <= 0 {
339		return groups
340	}
341	out := make([]string, 0, len(groups))
342	out = append(out, primary)
343	out = append(out, groups[:i]...)
344	return append(out, groups[i+1:]...)
345}