Raw
  1package web
  2
  3import (
  4	"errors"
  5	"log/slog"
  6	"net/http"
  7	"regexp"
  8	"strconv"
  9	"strings"
 10	"time"
 11
 12	"tug/internal/transport/web/text"
 13)
 14
 15const (
 16	adminRecent   = 10
 17	adminQueryMax = 254
 18	adminMaxPage  = 1 << 20
 19)
 20
 21var reAdminID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
 22
 23var adminProblemKeys = map[AdminProblem]text.Key{
 24	AdminUsernameGrammar:    text.AdminProblemUsernameGrammar,
 25	AdminUsernameTaken:      text.AdminProblemUsernameTaken,
 26	AdminDisplayNameInvalid: text.AdminProblemDisplayName,
 27	AdminGroupNameGrammar:   text.AdminProblemGroupGrammar,
 28	AdminGroupNameTaken:     text.AdminProblemGroupTaken,
 29	AdminDescriptionInvalid: text.AdminProblemDescription,
 30	AdminUnknownUser:        text.AdminProblemUnknownUser,
 31	AdminUnknownGroup:       text.AdminProblemUnknownGroup,
 32	AdminAlreadyMember:      text.AdminProblemAlreadyMember,
 33	AdminNotMember:          text.AdminProblemNotMember,
 34	AdminPrimaryGroup:       text.AdminProblemPrimaryGroup,
 35	AdminSelf:               text.AdminProblemSelf,
 36	AdminLastAdmin:          text.AdminProblemLastAdmin,
 37	AdminReferencedGroup:    text.AdminProblemReferencedGroup,
 38	AdminInvitationClosed:   text.AdminProblemInvitationClosed,
 39	AdminGoogleNotLinked:    text.AdminProblemGoogleNotLinked,
 40	AdminResetLinkDisabled:  text.AdminProblemResetLinkDisabled,
 41	AdminResetLinkPending:   text.AdminProblemResetLinkPending,
 42	AdminLabelInvalid:       text.AdminProblemLabelInvalid,
 43	AdminExpiryInvalid:      text.AdminProblemExpiryInvalid,
 44	AdminAdminGroupGrant:    text.AdminProblemAdminGroupGrant,
 45	AdminBatchSize:          text.AdminProblemBatchSize,
 46	AdminUserPending:        text.AdminProblemUserPending,
 47	AdminUnknownBatch:       text.AdminProblemUnknownBatch,
 48	AdminSSHKeyDisabled:     text.AdminProblemSSHKeyOff,
 49	AdminSSHKeyPending:      text.AdminProblemSSHKeyWait,
 50}
 51
 52type admin struct {
 53	kit *Kit
 54	b   AdminBackend
 55}
 56
 57type adminPager struct {
 58	Action string
 59	Query  string
 60	Shown  bool
 61	From   int
 62	To     int
 63	Total  int
 64	Prev   int
 65	Next   int
 66}
 67
 68type adminAuditView struct {
 69	At       string
 70	Actor    Ident
 71	Action   Ident
 72	Subject  Ident
 73	Sentence text.Key
 74}
 75
 76type adminDashboardData struct {
 77	Viewer             Ident
 78	Users              int
 79	DisabledUsers      int
 80	Groups             int
 81	PendingInvitations int
 82	LiveSessions       int
 83	Recent             []adminAuditView
 84}
 85
 86func (k *Kit) newAdmin(b AdminBackend) *admin {
 87	if b == nil {
 88		panic("web: the admin pages need a backend")
 89	}
 90	return &admin{kit: k, b: b}
 91}
 92
 93func (k *Kit) AdminDashboard(b AdminBackend) http.Handler {
 94	return http.HandlerFunc(k.newAdmin(b).dashboard)
 95}
 96
 97func (k *Kit) AdminRoutes(b AdminBackend) []Route {
 98	a := k.newAdmin(b)
 99	open := func(path string, h http.HandlerFunc) Route {
100		return Route{Method: http.MethodGet, Path: path, Handler: h}
101	}
102	sudo := func(method, path string, h http.HandlerFunc) Route {
103		return Route{Method: method, Path: path, Handler: k.RequireSudo(h)}
104	}
105	groupDelete := a.groupDelete()
106	revoke := a.invitationRevoke()
107	batchRevoke := a.invitationBatchRevoke()
108	routes := []Route{
109		open("/audit", a.audit),
110		open("/users", a.users),
111		sudo(http.MethodGet, "/users/new", a.newUser),
112		sudo(http.MethodPost, "/users/new", a.createUser),
113		open("/users/{id}", a.user),
114		sudo(http.MethodGet, "/users/{id}/profile", a.profile),
115		sudo(http.MethodPost, "/users/{id}/profile", a.saveProfile),
116		sudo(http.MethodGet, "/users/{id}/groups", a.memberships),
117		sudo(http.MethodPost, "/users/{id}/groups", a.changeMembership),
118		open("/groups", a.groups),
119		sudo(http.MethodGet, "/groups/new", a.newGroup),
120		sudo(http.MethodPost, "/groups/new", a.createGroup),
121		open("/groups/{id}", a.group),
122		sudo(http.MethodGet, "/groups/{id}/description", a.description),
123		sudo(http.MethodPost, "/groups/{id}/description", a.saveDescription),
124		sudo(http.MethodGet, "/groups/{id}/members", a.members),
125		sudo(http.MethodPost, "/groups/{id}/members", a.changeMember),
126		sudo(http.MethodGet, "/groups/{id}/delete", groupDelete.show),
127		sudo(http.MethodPost, "/groups/{id}/delete", groupDelete.run),
128		open("/invitations", a.invitations),
129		sudo(http.MethodGet, "/invitations/new", a.newInvitation),
130		sudo(http.MethodPost, "/invitations/new", a.createInvitation),
131		sudo(http.MethodGet, "/invitations/{id}/revoke", revoke.show),
132		sudo(http.MethodPost, "/invitations/{id}/revoke", revoke.run),
133		sudo(http.MethodGet, "/invitations/batch/{id}/revoke", batchRevoke.show),
134		sudo(http.MethodPost, "/invitations/batch/{id}/revoke", batchRevoke.run),
135		sudo(http.MethodGet, "/users/{id}/reset-link", a.resetConfirm),
136		sudo(http.MethodPost, "/users/{id}/reset-link", a.resetIssue),
137		sudo(http.MethodGet, "/users/{id}/confirm", a.confirmShow),
138		sudo(http.MethodPost, "/users/{id}/confirm", a.confirmRun),
139	}
140	routes = append(routes, a.sshKeyRoutes(sudo)...)
141	for _, act := range a.userActions() {
142		routes = append(routes,
143			sudo(http.MethodGet, "/users/{id}/"+act.path, act.show),
144			sudo(http.MethodPost, "/users/{id}/"+act.path, act.run),
145		)
146	}
147	return routes
148}
149
150func (a *admin) formPage(w http.ResponseWriter, r *http.Request, data any) page {
151	p := a.kit.formPage(w, r, data)
152	p.Wide = true
153	p.Host = Ident(a.kit.server.DomainAdmin)
154	return p
155}
156
157func (a *admin) render(w http.ResponseWriter, r *http.Request, status int, name string, data any) {
158	a.kit.render(w, r, status, name, a.formPage(w, r, data))
159}
160
161func (a *admin) id(w http.ResponseWriter, r *http.Request) (string, bool) {
162	id := r.PathValue("id")
163	if !reAdminID.MatchString(id) {
164		a.kit.ErrorPage(w, r, http.StatusNotFound)
165		return "", false
166	}
167	return id, true
168}
169
170func (a *admin) failed(w http.ResponseWriter, r *http.Request, step string, err error) {
171	if errors.Is(err, ErrAdminNotFound) {
172		a.kit.ErrorPage(w, r, http.StatusNotFound)
173		return
174	}
175	slog.Error("admin page failed", "step", step, "err", err)
176	a.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
177}
178
179func adminProblemKey(err error) (text.Key, bool) {
180	var p AdminProblem
181	if !errors.As(err, &p) {
182		return "", false
183	}
184	k, ok := adminProblemKeys[p]
185	return k, ok
186}
187
188func (a *admin) done(w http.ResponseWriter, r *http.Request, key text.Key, v Ident, path string) {
189	if err := a.kit.SetFlash(w, key, v); err != nil {
190		slog.Error("admin flash not set", "err", err)
191	}
192	a.kit.SeeOther(w, r, path)
193}
194
195func (a *admin) badOp(w http.ResponseWriter, r *http.Request) {
196	a.kit.ErrorPage(w, r, http.StatusBadRequest)
197}
198
199func pageNumber(r *http.Request) int {
200	n, err := strconv.Atoi(r.URL.Query().Get("page"))
201	if err != nil || n < 1 {
202		return 1
203	}
204	return min(n, adminMaxPage)
205}
206
207func searchQuery(r *http.Request) string {
208	q := strings.TrimSpace(r.URL.Query().Get("q"))
209	if len(q) > adminQueryMax {
210		q = strings.ToValidUTF8(q[:adminQueryMax], "")
211	}
212	return q
213}
214
215func newPager(action, query string, page, size, shown, total int) adminPager {
216	p := adminPager{Action: action, Query: query, Total: total}
217	if shown > 0 {
218		p.Shown = true
219		p.From = (page-1)*size + 1
220		p.To = p.From + shown - 1
221	}
222	if page > 1 {
223		p.Prev = page - 1
224	}
225	if page*size < total {
226		p.Next = page + 1
227	}
228	return p
229}
230
231func stamp(t time.Time) string {
232	return t.UTC().Format("2006-01-02 15:04")
233}
234
235func auditViews(entries []AdminAuditEntry) []adminAuditView {
236	entries = entries[:min(len(entries), adminRecent)]
237	out := make([]adminAuditView, 0, len(entries))
238	for _, e := range entries {
239		sentence := actionSentence(e.Action)
240		if e.NewSession {
241			sentence = text.AdminAuditSigninNew
242		}
243		out = append(out, adminAuditView{At: stamp(e.At), Actor: Ident(e.Actor), Action: Ident(e.Action), Subject: Ident(clipTo(e.Subject, adminAuditSubjectMax)), Sentence: sentence})
244	}
245	return out
246}
247
248func (a *admin) dashboard(w http.ResponseWriter, r *http.Request) {
249	p, ok := a.kit.principal(w, r)
250	if !ok {
251		return
252	}
253	d, err := a.b.Dashboard(r.Context())
254	if err != nil {
255		a.failed(w, r, "dashboard", err)
256		return
257	}
258	a.render(w, r, http.StatusOK, "admin-dashboard", adminDashboardData{
259		Viewer:             p.Username,
260		Users:              d.Users,
261		DisabledUsers:      d.DisabledUsers,
262		Groups:             d.Groups,
263		PendingInvitations: d.PendingInvitations,
264		LiveSessions:       d.LiveSessions,
265		Recent:             auditViews(d.Recent),
266	})
267}
268
269type adminConfirm struct {
270	a      *admin
271	title  text.Key
272	body   text.Key
273	button text.Key
274	flash  text.Key
275	load   func(r *http.Request, id string) (Ident, error)
276	act    func(r *http.Request, actor, id string) error
277	action func(id string) string
278	cancel func(id string) string
279	after  func(id string) string
280	safe   bool
281	self   text.Key
282}
283
284type adminAction struct {
285	path string
286	adminConfirm
287}
288
289func (c adminConfirm) page(id string, subject Ident) Confirm {
290	return Confirm{Title: c.title, Body: c.body, Button: c.button, Subject: subject, Action: c.action(id), Cancel: c.cancel(id), Neutral: c.safe}
291}
292
293func (c adminConfirm) confirm(w http.ResponseWriter, r *http.Request, id string, subject Ident) {
294	c.a.kit.RenderConfirm(w, r, c.page(id, subject))
295}
296
297func (c adminConfirm) refuse(w http.ResponseWriter, r *http.Request, id string, subject Ident, err error, key text.Key) {
298	cf := c.page(id, subject)
299	cf.Message, cf.Refused = key, true
300	if c.self != "" && errors.Is(err, AdminSelf) {
301		cf.Elsewhere, cf.ElsewhereText = c.a.kit.account+"/", c.self
302	}
303	c.a.kit.RenderConfirm(w, r, cf)
304}
305
306func (c adminConfirm) show(w http.ResponseWriter, r *http.Request) {
307	id, ok := c.a.id(w, r)
308	if !ok {
309		return
310	}
311	subject, err := c.load(r, id)
312	if err != nil {
313		c.a.failed(w, r, "confirm", err)
314		return
315	}
316	c.confirm(w, r, id, subject)
317}
318
319func (c adminConfirm) run(w http.ResponseWriter, r *http.Request) {
320	p, ok := c.a.kit.principal(w, r)
321	if !ok {
322		return
323	}
324	id, ok := c.a.id(w, r)
325	if !ok {
326		return
327	}
328	subject, err := c.load(r, id)
329	if err != nil {
330		c.a.failed(w, r, "confirm", err)
331		return
332	}
333	err = c.act(r, p.UserID, id)
334	if key, ok := adminProblemKey(err); ok {
335		c.refuse(w, r, id, subject, err, key)
336		return
337	}
338	if err != nil {
339		c.a.failed(w, r, string(c.title), err)
340		return
341	}
342	c.a.done(w, r, c.flash, subject, c.after(id))
343}