package web import ( "errors" "log/slog" "net/http" "regexp" "strconv" "strings" "time" "tug/internal/transport/web/text" ) const ( adminRecent = 10 adminQueryMax = 254 adminMaxPage = 1 << 20 ) var reAdminID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) var adminProblemKeys = map[AdminProblem]text.Key{ AdminUsernameGrammar: text.AdminProblemUsernameGrammar, AdminUsernameTaken: text.AdminProblemUsernameTaken, AdminDisplayNameInvalid: text.AdminProblemDisplayName, AdminGroupNameGrammar: text.AdminProblemGroupGrammar, AdminGroupNameTaken: text.AdminProblemGroupTaken, AdminDescriptionInvalid: text.AdminProblemDescription, AdminUnknownUser: text.AdminProblemUnknownUser, AdminUnknownGroup: text.AdminProblemUnknownGroup, AdminAlreadyMember: text.AdminProblemAlreadyMember, AdminNotMember: text.AdminProblemNotMember, AdminPrimaryGroup: text.AdminProblemPrimaryGroup, AdminSelf: text.AdminProblemSelf, AdminLastAdmin: text.AdminProblemLastAdmin, AdminReferencedGroup: text.AdminProblemReferencedGroup, AdminInvitationClosed: text.AdminProblemInvitationClosed, AdminGoogleNotLinked: text.AdminProblemGoogleNotLinked, AdminResetLinkDisabled: text.AdminProblemResetLinkDisabled, AdminResetLinkPending: text.AdminProblemResetLinkPending, AdminLabelInvalid: text.AdminProblemLabelInvalid, AdminExpiryInvalid: text.AdminProblemExpiryInvalid, AdminAdminGroupGrant: text.AdminProblemAdminGroupGrant, AdminBatchSize: text.AdminProblemBatchSize, AdminUserPending: text.AdminProblemUserPending, AdminUnknownBatch: text.AdminProblemUnknownBatch, AdminSSHKeyDisabled: text.AdminProblemSSHKeyOff, AdminSSHKeyPending: text.AdminProblemSSHKeyWait, } type admin struct { kit *Kit b AdminBackend } type adminPager struct { Action string Query string Shown bool From int To int Total int Prev int Next int } type adminAuditView struct { At string Actor Ident Action Ident Subject Ident Sentence text.Key } type adminDashboardData struct { Viewer Ident Users int DisabledUsers int Groups int PendingInvitations int LiveSessions int Recent []adminAuditView } func (k *Kit) newAdmin(b AdminBackend) *admin { if b == nil { panic("web: the admin pages need a backend") } return &admin{kit: k, b: b} } func (k *Kit) AdminDashboard(b AdminBackend) http.Handler { return http.HandlerFunc(k.newAdmin(b).dashboard) } func (k *Kit) AdminRoutes(b AdminBackend) []Route { a := k.newAdmin(b) open := func(path string, h http.HandlerFunc) Route { return Route{Method: http.MethodGet, Path: path, Handler: h} } sudo := func(method, path string, h http.HandlerFunc) Route { return Route{Method: method, Path: path, Handler: k.RequireSudo(h)} } groupDelete := a.groupDelete() revoke := a.invitationRevoke() batchRevoke := a.invitationBatchRevoke() routes := []Route{ open("/audit", a.audit), open("/users", a.users), sudo(http.MethodGet, "/users/new", a.newUser), sudo(http.MethodPost, "/users/new", a.createUser), open("/users/{id}", a.user), sudo(http.MethodGet, "/users/{id}/profile", a.profile), sudo(http.MethodPost, "/users/{id}/profile", a.saveProfile), sudo(http.MethodGet, "/users/{id}/groups", a.memberships), sudo(http.MethodPost, "/users/{id}/groups", a.changeMembership), open("/groups", a.groups), sudo(http.MethodGet, "/groups/new", a.newGroup), sudo(http.MethodPost, "/groups/new", a.createGroup), open("/groups/{id}", a.group), sudo(http.MethodGet, "/groups/{id}/description", a.description), sudo(http.MethodPost, "/groups/{id}/description", a.saveDescription), sudo(http.MethodGet, "/groups/{id}/members", a.members), sudo(http.MethodPost, "/groups/{id}/members", a.changeMember), sudo(http.MethodGet, "/groups/{id}/delete", groupDelete.show), sudo(http.MethodPost, "/groups/{id}/delete", groupDelete.run), open("/invitations", a.invitations), sudo(http.MethodGet, "/invitations/new", a.newInvitation), sudo(http.MethodPost, "/invitations/new", a.createInvitation), sudo(http.MethodGet, "/invitations/{id}/revoke", revoke.show), sudo(http.MethodPost, "/invitations/{id}/revoke", revoke.run), sudo(http.MethodGet, "/invitations/batch/{id}/revoke", batchRevoke.show), sudo(http.MethodPost, "/invitations/batch/{id}/revoke", batchRevoke.run), sudo(http.MethodGet, "/users/{id}/reset-link", a.resetConfirm), sudo(http.MethodPost, "/users/{id}/reset-link", a.resetIssue), sudo(http.MethodGet, "/users/{id}/confirm", a.confirmShow), sudo(http.MethodPost, "/users/{id}/confirm", a.confirmRun), } routes = append(routes, a.sshKeyRoutes(sudo)...) for _, act := range a.userActions() { routes = append(routes, sudo(http.MethodGet, "/users/{id}/"+act.path, act.show), sudo(http.MethodPost, "/users/{id}/"+act.path, act.run), ) } return routes } func (a *admin) formPage(w http.ResponseWriter, r *http.Request, data any) page { p := a.kit.formPage(w, r, data) p.Wide = true p.Host = Ident(a.kit.server.DomainAdmin) return p } func (a *admin) render(w http.ResponseWriter, r *http.Request, status int, name string, data any) { a.kit.render(w, r, status, name, a.formPage(w, r, data)) } func (a *admin) id(w http.ResponseWriter, r *http.Request) (string, bool) { id := r.PathValue("id") if !reAdminID.MatchString(id) { a.kit.ErrorPage(w, r, http.StatusNotFound) return "", false } return id, true } func (a *admin) failed(w http.ResponseWriter, r *http.Request, step string, err error) { if errors.Is(err, ErrAdminNotFound) { a.kit.ErrorPage(w, r, http.StatusNotFound) return } slog.Error("admin page failed", "step", step, "err", err) a.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) } func adminProblemKey(err error) (text.Key, bool) { var p AdminProblem if !errors.As(err, &p) { return "", false } k, ok := adminProblemKeys[p] return k, ok } func (a *admin) done(w http.ResponseWriter, r *http.Request, key text.Key, v Ident, path string) { if err := a.kit.SetFlash(w, key, v); err != nil { slog.Error("admin flash not set", "err", err) } a.kit.SeeOther(w, r, path) } func (a *admin) badOp(w http.ResponseWriter, r *http.Request) { a.kit.ErrorPage(w, r, http.StatusBadRequest) } func pageNumber(r *http.Request) int { n, err := strconv.Atoi(r.URL.Query().Get("page")) if err != nil || n < 1 { return 1 } return min(n, adminMaxPage) } func searchQuery(r *http.Request) string { q := strings.TrimSpace(r.URL.Query().Get("q")) if len(q) > adminQueryMax { q = strings.ToValidUTF8(q[:adminQueryMax], "") } return q } func newPager(action, query string, page, size, shown, total int) adminPager { p := adminPager{Action: action, Query: query, Total: total} if shown > 0 { p.Shown = true p.From = (page-1)*size + 1 p.To = p.From + shown - 1 } if page > 1 { p.Prev = page - 1 } if page*size < total { p.Next = page + 1 } return p } func stamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04") } func auditViews(entries []AdminAuditEntry) []adminAuditView { entries = entries[:min(len(entries), adminRecent)] out := make([]adminAuditView, 0, len(entries)) for _, e := range entries { sentence := actionSentence(e.Action) if e.NewSession { sentence = text.AdminAuditSigninNew } out = append(out, adminAuditView{At: stamp(e.At), Actor: Ident(e.Actor), Action: Ident(e.Action), Subject: Ident(clipTo(e.Subject, adminAuditSubjectMax)), Sentence: sentence}) } return out } func (a *admin) dashboard(w http.ResponseWriter, r *http.Request) { p, ok := a.kit.principal(w, r) if !ok { return } d, err := a.b.Dashboard(r.Context()) if err != nil { a.failed(w, r, "dashboard", err) return } a.render(w, r, http.StatusOK, "admin-dashboard", adminDashboardData{ Viewer: p.Username, Users: d.Users, DisabledUsers: d.DisabledUsers, Groups: d.Groups, PendingInvitations: d.PendingInvitations, LiveSessions: d.LiveSessions, Recent: auditViews(d.Recent), }) } type adminConfirm struct { a *admin title text.Key body text.Key button text.Key flash text.Key load func(r *http.Request, id string) (Ident, error) act func(r *http.Request, actor, id string) error action func(id string) string cancel func(id string) string after func(id string) string safe bool self text.Key } type adminAction struct { path string adminConfirm } func (c adminConfirm) page(id string, subject Ident) Confirm { return Confirm{Title: c.title, Body: c.body, Button: c.button, Subject: subject, Action: c.action(id), Cancel: c.cancel(id), Neutral: c.safe} } func (c adminConfirm) confirm(w http.ResponseWriter, r *http.Request, id string, subject Ident) { c.a.kit.RenderConfirm(w, r, c.page(id, subject)) } func (c adminConfirm) refuse(w http.ResponseWriter, r *http.Request, id string, subject Ident, err error, key text.Key) { cf := c.page(id, subject) cf.Message, cf.Refused = key, true if c.self != "" && errors.Is(err, AdminSelf) { cf.Elsewhere, cf.ElsewhereText = c.a.kit.account+"/", c.self } c.a.kit.RenderConfirm(w, r, cf) } func (c adminConfirm) show(w http.ResponseWriter, r *http.Request) { id, ok := c.a.id(w, r) if !ok { return } subject, err := c.load(r, id) if err != nil { c.a.failed(w, r, "confirm", err) return } c.confirm(w, r, id, subject) } func (c adminConfirm) run(w http.ResponseWriter, r *http.Request) { p, ok := c.a.kit.principal(w, r) if !ok { return } id, ok := c.a.id(w, r) if !ok { return } subject, err := c.load(r, id) if err != nil { c.a.failed(w, r, "confirm", err) return } err = c.act(r, p.UserID, id) if key, ok := adminProblemKey(err); ok { c.refuse(w, r, id, subject, err, key) return } if err != nil { c.a.failed(w, r, string(c.title), err) return } c.a.done(w, r, c.flash, subject, c.after(id)) }