Raw
  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}