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
13const (
14 GoogleLinkPath = "/google/link"
15 GoogleCallbackPath = "/link/google/callback"
16 GoogleUnlinkPath = "/google/unlink"
17)
18
19var (
20 ErrGoogleLinkedElsewhere = errors.New("web: the Google account is linked to another user")
21 ErrGoogleAlreadyLinked = errors.New("web: the user already has a linked Google account")
22 ErrGoogleNotLinked = errors.New("web: the user has no linked Google account")
23 ErrGoogleRefused = errors.New("web: the Google round trip did not verify")
24 ErrGoogleOtherSession = errors.New("web: the Google round trip was started by another session")
25 ErrGoogleNotAllowed = errors.New("web: the Google account is unverified or outside the allowed domain")
26)
27
28type GoogleIdentity struct {
29 Subject string
30}
31
32type AccountGoogleLink struct {
33 LinkedAt time.Time
34}
35
36type GoogleLinkClient interface {
37 StartLink(w http.ResponseWriter, r *http.Request, p Principal) (string, error)
38}
39
40type GoogleLinks interface {
41 Link(ctx context.Context, p Principal, id GoogleIdentity) (AccountGoogleLink, error)
42 Unlink(ctx context.Context, p Principal) (AccountGoogleLink, error)
43}
44
45type GoogleAccount struct {
46 Client GoogleLinkClient
47 Links GoogleLinks
48}
49
50type googleRefusedData struct {
51 Body text.Key
52}
53
54type googlePages struct {
55 kit *Kit
56 g GoogleAccount
57}
58
59func (k *Kit) AccountGoogleRoutes(g GoogleAccount) []Route {
60 if g.Client == nil || g.Links == nil {
61 panic("web: the Google pages need a client and the links")
62 }
63 p := &googlePages{kit: k, g: g}
64 return []Route{
65 {Method: http.MethodGet, Path: GoogleLinkPath, Handler: k.RequireSudo(http.HandlerFunc(p.linkShow))},
66 {Method: http.MethodPost, Path: GoogleLinkPath, Handler: k.RequireSudo(http.HandlerFunc(p.linkStart))},
67 {Method: http.MethodGet, Path: GoogleUnlinkPath, Handler: k.RequireSudo(http.HandlerFunc(p.unlinkShow))},
68 {Method: http.MethodPost, Path: GoogleUnlinkPath, Handler: k.RequireSudo(http.HandlerFunc(p.unlink))},
69 }
70}
71
72func (k *Kit) googleFailed(w http.ResponseWriter, r *http.Request, step string, err error) {
73 slog.Error("google page failed", "step", step, "err", err)
74 k.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
75}
76
77func (k *Kit) googleRefuse(w http.ResponseWriter, r *http.Request, status int, body text.Key) {
78 p := k.newPage(w, r, googleRefusedData{Body: body})
79 p.Host = Ident(k.server.DomainAccount)
80 k.render(w, r, status, "account-google-refused", p)
81}
82
83func (k *Kit) googleDone(w http.ResponseWriter, r *http.Request, key text.Key, username Ident, path string) {
84 if err := k.SetFlash(w, key, username); err != nil {
85 slog.Error("google flash not set", "err", err)
86 }
87 k.SeeOther(w, r, path)
88}
89
90func (g *googlePages) linkShow(w http.ResponseWriter, r *http.Request) {
91 if p, ok := g.kit.principal(w, r); ok {
92 g.kit.RenderConfirm(w, r, Confirm{Title: text.AccountGoogleLinkTitle, Body: text.AccountGoogleLinkBody, Button: text.AccountGoogleLinkContinue, Subject: p.Username, Action: GoogleLinkPath, Cancel: "/", Neutral: true})
93 }
94}
95
96func (g *googlePages) linkStart(w http.ResponseWriter, r *http.Request) {
97 p, ok := g.kit.principal(w, r)
98 if !ok {
99 return
100 }
101 target, err := g.g.Client.StartLink(w, r, p)
102 if err != nil {
103 g.kit.googleFailed(w, r, "start link", err)
104 return
105 }
106 g.kit.Continue(w, r, target)
107}
108
109func (k *Kit) FinishGoogleLink(w http.ResponseWriter, r *http.Request, links GoogleLinks, id GoogleIdentity, err error) {
110 p, live := PrincipalFrom(r)
111 switch {
112 case errors.Is(err, ErrGoogleOtherSession):
113 k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleOtherSession)
114 return
115 case errors.Is(err, ErrGoogleNotAllowed):
116 k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleNotAllowed)
117 return
118 case err != nil:
119 slog.Info("google link refused", "err", err)
120 k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleRefused)
121 return
122 case !live:
123 k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleOtherSession)
124 return
125 }
126 _, err = links.Link(r.Context(), p, id)
127 switch {
128 case errors.Is(err, ErrGoogleLinkedElsewhere):
129 k.googleRefuse(w, r, http.StatusConflict, text.AccountGoogleLinkedElsewhere)
130 case errors.Is(err, ErrGoogleAlreadyLinked):
131 k.googleRefuse(w, r, http.StatusConflict, text.AccountGoogleAlreadyLinked)
132 case errors.Is(err, ErrNoSession):
133 k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleRefused)
134 case err != nil:
135 k.googleFailed(w, r, "link", err)
136 default:
137 k.googleDone(w, r, text.FlashAccountGoogleLinked, p.Username, "/")
138 }
139}
140
141func (g *googlePages) unlinkConfirm(p Principal, message text.Key) Confirm {
142 return Confirm{Title: text.AccountGoogleUnlinkTitle, Body: text.AccountGoogleUnlinkBody, Button: text.AccountGoogleUnlinkButton, Subject: p.Username, Action: GoogleUnlinkPath, Cancel: "/", Message: message}
143}
144
145func (g *googlePages) unlinkShow(w http.ResponseWriter, r *http.Request) {
146 if p, ok := g.kit.principal(w, r); ok {
147 g.kit.RenderConfirm(w, r, g.unlinkConfirm(p, ""))
148 }
149}
150
151func (g *googlePages) unlink(w http.ResponseWriter, r *http.Request) {
152 p, ok := g.kit.principal(w, r)
153 if !ok {
154 return
155 }
156 _, err := g.g.Links.Unlink(r.Context(), p)
157 switch {
158 case errors.Is(err, ErrGoogleNotLinked):
159 c := g.unlinkConfirm(p, text.AccountGoogleUnlinkNone)
160 c.Refused = true
161 g.kit.RenderConfirm(w, r, c)
162 case err != nil:
163 g.kit.googleFailed(w, r, "unlink", err)
164 default:
165 g.kit.googleDone(w, r, text.FlashAccountGoogleUnlinked, p.Username, "/")
166 }
167}