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}