package web import ( "errors" "log/slog" "net/http" "regexp" "slices" "strings" "tug/internal/transport/web/text" ) const accountBadges = 20 var reSessionID = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) var accountPasswordProblems = map[AccountProblem]text.Key{ AccountPasswordWrong: text.AccountProblemWrong, AccountPasswordShort: text.AccountProblemShort, AccountPasswordLong: text.AccountProblemLong, AccountBusy: text.AccountProblemBusy, AccountPasswordChanged: text.AccountProblemStale, } type portal struct { kit *Kit b AccountBackend e SessionEnder } type accountOverviewData struct { Username Ident DisplayName string Groups []Ident More int Admin string Google bool GoogleLinked bool GoogleSince string Portdistrict bool Primary bool SSHKeys sshKeyList } type accountProfileData struct { DisplayName string Message text.Key } type accountPasswordData struct { Message text.Key } type accountSessionRow struct { ID Ident Host Ident Opened string Ends string Current bool Browser bool Device string Agent string Address string Client Ident LastSeen string } type accountSessionsData struct { Rows []accountSessionRow Pager adminPager Others bool } func (k *Kit) newAccount(b AccountBackend) *portal { if b == nil { panic("web: the account pages need a backend") } return &portal{kit: k, b: b} } func (k *Kit) AccountOverview(b AccountBackend) http.Handler { return http.HandlerFunc(k.newAccount(b).overview) } func (k *Kit) AccountRoutes(b AccountBackend, e SessionEnder) []Route { if e == nil { panic("web: the account pages need a session ender") } a := k.newAccount(b) a.e = e return append([]Route{ {Method: http.MethodGet, Path: "/profile", Handler: http.HandlerFunc(a.profile)}, {Method: http.MethodPost, Path: "/profile", Handler: http.HandlerFunc(a.saveProfile)}, {Method: http.MethodGet, Path: "/password", Handler: http.HandlerFunc(a.password)}, {Method: http.MethodPost, Path: "/password", Handler: http.HandlerFunc(a.changePassword)}, {Method: http.MethodGet, Path: "/sessions", Handler: http.HandlerFunc(a.sessions)}, {Method: http.MethodPost, Path: "/sessions/revoke", Handler: http.HandlerFunc(a.revoke)}, {Method: http.MethodGet, Path: "/sessions/revoke-all", Handler: http.HandlerFunc(a.revokeAllShow)}, {Method: http.MethodPost, Path: "/sessions/revoke-all", Handler: http.HandlerFunc(a.revokeAll)}, {Method: http.MethodGet, Path: BrowserLogoutPath, Access: SignedIn, Handler: http.HandlerFunc(a.browserShow)}, {Method: http.MethodPost, Path: BrowserLogoutPath, Access: SignedIn, Handler: http.HandlerFunc(a.browserSignOut)}, }, a.sshKeyRoutes()...) } func (a *portal) render(w http.ResponseWriter, r *http.Request, status int, name string, data any) { p := a.kit.formPage(w, r, data) p.Host = Ident(a.kit.server.DomainAccount) a.kit.render(w, r, status, name, p) } func (a *portal) failed(w http.ResponseWriter, r *http.Request, step string, err error) { if errors.Is(err, ErrNoSession) && r.Method != http.MethodGet { a.kit.SeeOther(w, r, "/") return } if errors.Is(err, ErrAccountNotFound) { a.kit.ErrorPage(w, r, http.StatusNotFound) return } slog.Error("account page failed", "step", step, "err", err) a.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) } func (a *portal) done(w http.ResponseWriter, r *http.Request, key text.Key, path string) { if err := a.kit.SetFlash(w, key, ""); err != nil { slog.Error("account flash not set", "err", err) } a.kit.SeeOther(w, r, path) } func (a *portal) overview(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } prof, err := a.b.Profile(r.Context(), p) if err != nil { a.failed(w, r, "profile", err) return } if prof.AdminURL != "" { if err := a.kit.origins.allows(prof.AdminURL); err != nil { a.failed(w, r, "admin link", err) return } } groups := primaryFirst(prof.Groups, prof.Primary) shown := groups[:min(len(groups), accountBadges)] d := accountOverviewData{ Username: p.Username, DisplayName: prof.DisplayName, More: max(prof.GroupCount, len(groups)) - len(shown), Admin: prof.AdminURL, Google: prof.Google, Portdistrict: prof.Portdistrict, Primary: prof.Primary != "" && len(shown) > 0 && shown[0] == prof.Primary, SSHKeys: newSSHKeyList(prof.SSHKeys, "/ssh-keys/", SSHKeyNewPath), } for _, g := range shown { d.Groups = append(d.Groups, Ident(g)) } if prof.Google && prof.GoogleLink != nil { d.GoogleLinked, d.GoogleSince = true, stamp(prof.GoogleLink.LinkedAt) } a.render(w, r, http.StatusOK, "account-overview", d) } func (a *portal) password(w http.ResponseWriter, r *http.Request) { if _, ok := a.kit.principal(w, r); ok { a.render(w, r, http.StatusOK, "account-password", accountPasswordData{}) } } func (a *portal) changePassword(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } current, next := r.PostFormValue("current"), r.PostFormValue("password") var msg text.Key switch { case current == "": msg = text.AccountProblemEmpty case next != r.PostFormValue("confirm"): msg = text.AccountProblemMismatch } if msg != "" { a.render(w, r, http.StatusOK, "account-password", accountPasswordData{Message: msg}) return } err := a.b.ChangePassword(r.Context(), p, current, next) var prob AccountProblem if errors.As(err, &prob) { if key, ok := accountPasswordProblems[prob]; ok { status := http.StatusOK if prob == AccountBusy { status = http.StatusServiceUnavailable } a.render(w, r, status, "account-password", accountPasswordData{Message: key}) return } } if err != nil { a.failed(w, r, "change password", err) return } a.done(w, r, text.FlashAccountPasswordChanged, "/password") } func (a *portal) sessions(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } page := pageNumber(r) list, err := a.b.Sessions(r.Context(), p, page) if err != nil { a.failed(w, r, "sessions", err) return } rows := list.Sessions[:min(len(list.Sessions), AccountSessionsPerPage)] d := accountSessionsData{Pager: newPager("/sessions", "", page, AccountSessionsPerPage, len(rows), list.Total), Others: list.Total > 1} for _, s := range rows { 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} if !s.LastSeen.IsZero() { row.LastSeen = stamp(s.LastSeen) } if s.ClientID != "" && !strings.HasPrefix(s.ClientID, "_") { row.Client = Ident(s.ClientID) } d.Rows = append(d.Rows, row) } a.render(w, r, http.StatusOK, "account-sessions", d) } func (a *portal) revoke(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } id := r.PostFormValue("id") if !reSessionID.MatchString(id) || id == p.SessionID { a.kit.ErrorPage(w, r, http.StatusBadRequest) return } if err := a.b.RevokeSession(r.Context(), p, id); err != nil { a.failed(w, r, "revoke session", err) return } a.done(w, r, text.FlashAccountRevoked, "/sessions") } func (a *portal) revokeAllShow(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } a.kit.RenderConfirm(w, r, Confirm{Title: text.AccountRevokeAllTitle, Body: text.AccountRevokeAllBody, Button: text.AccountSessionsRevokeAll, Subject: p.Username, Action: "/sessions/revoke-all", Cancel: "/sessions"}) } func (a *portal) revokeAll(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } if _, err := a.b.RevokeOtherSessions(r.Context(), p); err != nil { a.failed(w, r, "revoke other sessions", err) return } a.done(w, r, text.FlashAccountRevokedOthers, "/sessions") } func (a *portal) browserShow(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } a.kit.RenderConfirm(w, r, Confirm{Title: text.AccountBrowserTitle, Body: text.AccountBrowserBody, Button: text.AccountBrowserSignOut, Subject: p.Username, Action: BrowserLogoutPath, Cancel: "/", Neutral: true}) } func (a *portal) browserSignOut(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } ended, err := a.b.SignOutBrowser(r.Context(), p) if err != nil { a.failed(w, r, "sign out browser", err) return } if err := a.e.EndSession(w, r, p); err != nil && !errors.Is(err, ErrNoSession) { a.failed(w, r, "end session", err) return } a.kit.RotateCSRF(w) flash := text.FlashAccountBrowserOut if ended <= 1 { flash = text.FlashAccountBrowserOutHost } a.done(w, r, flash, SignedOutPath) } func (a *portal) profile(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } prof, err := a.b.Profile(r.Context(), p) if err != nil { a.failed(w, r, "profile", err) return } a.render(w, r, http.StatusOK, "account-profile", accountProfileData{DisplayName: prof.DisplayName}) } func (a *portal) saveProfile(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } name := r.PostFormValue("display_name") err := a.b.UpdateDisplayName(r.Context(), p, name) var prob AccountProblem if errors.As(err, &prob) && prob == AccountDisplayNameInvalid { a.render(w, r, http.StatusOK, "account-profile", accountProfileData{DisplayName: echoed(name, echoDisplayNameBytes), Message: text.AdminProblemDisplayName}) return } if err != nil { a.failed(w, r, "update display name", err) return } a.done(w, r, text.FlashAccountProfileSaved, "/") } func primaryFirst(groups []string, primary string) []string { i := slices.Index(groups, primary) if primary == "" || i <= 0 { return groups } out := make([]string, 0, len(groups)) out = append(out, primary) out = append(out, groups[:i]...) return append(out, groups[i+1:]...) }