1package web
2
3import (
4 "context"
5 "errors"
6 "log/slog"
7 "net/http"
8 "time"
9
10 "tug/internal/transport/web/text"
11)
12
13var ErrNoSession = errors.New("web: no live session")
14
15type Principal struct {
16 UserID string
17 Username Ident
18 SessionID string
19 Admin bool
20 SudoUntil time.Time
21}
22
23type Guard interface {
24 Resolve(r *http.Request) (Principal, error)
25 SignIn(w http.ResponseWriter, r *http.Request)
26}
27
28type principalKey struct{}
29
30func withPrincipal(r *http.Request, p Principal) *http.Request {
31 return r.WithContext(context.WithValue(r.Context(), principalKey{}, p))
32}
33
34func PrincipalFrom(r *http.Request) (Principal, bool) {
35 p, ok := r.Context().Value(principalKey{}).(Principal)
36 return p, ok
37}
38
39func sessionID(r *http.Request) string {
40 p, _ := PrincipalFrom(r)
41 return p.SessionID
42}
43
44func (k *Kit) AccountHost(g Guard, routes ...Route) http.Handler {
45 h := k.guardedHost(g, false, routes)
46 h.fallback = k.statusPage(http.StatusNotFound)
47 return h
48}
49
50func (k *Kit) AdminHost(g Guard, routes ...Route) http.Handler {
51 if k.server.AdminGroup == "" {
52 panic("web: the admin host needs an admin group")
53 }
54 h := k.guardedHost(g, true, routes)
55 h.fallback = k.statusPage(http.StatusNotFound)
56 return h
57}
58
59func (k *Kit) guardedHost(g Guard, admin bool, routes []Route) *host {
60 if g == nil {
61 panic("web: a guarded host needs a guard")
62 }
63 return k.newHost(routes, func(rt Route, next http.Handler) http.Handler {
64 return k.guard(g, admin, rt.Access, next)
65 })
66}
67
68func (k *Kit) guard(g Guard, admin bool, access Access, next http.Handler) http.Handler {
69 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
70 p, err := g.Resolve(r)
71 if err == nil {
72 r = withPrincipal(r, p)
73 }
74 switch {
75 case access == Public:
76 next.ServeHTTP(w, r)
77 case errors.Is(err, ErrNoSession) && access == SignedIn:
78 k.SeeOther(w, r, SignedOutPath)
79 case errors.Is(err, ErrNoSession) && r.Method == http.MethodGet:
80 g.SignIn(w, r)
81 case errors.Is(err, ErrNoSession):
82 k.SeeOther(w, r, "/")
83 case err != nil:
84 slog.Error("session check failed", "err", err)
85 k.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
86 case admin && access == Protected && !p.Admin:
87 k.renderAdminForbidden(w, r, p)
88 default:
89 next.ServeHTTP(w, r)
90 }
91 })
92}