package web import ( "context" "errors" "log/slog" "net/http" "time" "tug/internal/transport/web/text" ) const ( GoogleLinkPath = "/google/link" GoogleCallbackPath = "/link/google/callback" GoogleUnlinkPath = "/google/unlink" ) var ( ErrGoogleLinkedElsewhere = errors.New("web: the Google account is linked to another user") ErrGoogleAlreadyLinked = errors.New("web: the user already has a linked Google account") ErrGoogleNotLinked = errors.New("web: the user has no linked Google account") ErrGoogleRefused = errors.New("web: the Google round trip did not verify") ErrGoogleOtherSession = errors.New("web: the Google round trip was started by another session") ErrGoogleNotAllowed = errors.New("web: the Google account is unverified or outside the allowed domain") ) type GoogleIdentity struct { Subject string } type AccountGoogleLink struct { LinkedAt time.Time } type GoogleLinkClient interface { StartLink(w http.ResponseWriter, r *http.Request, p Principal) (string, error) } type GoogleLinks interface { Link(ctx context.Context, p Principal, id GoogleIdentity) (AccountGoogleLink, error) Unlink(ctx context.Context, p Principal) (AccountGoogleLink, error) } type GoogleAccount struct { Client GoogleLinkClient Links GoogleLinks } type googleRefusedData struct { Body text.Key } type googlePages struct { kit *Kit g GoogleAccount } func (k *Kit) AccountGoogleRoutes(g GoogleAccount) []Route { if g.Client == nil || g.Links == nil { panic("web: the Google pages need a client and the links") } p := &googlePages{kit: k, g: g} return []Route{ {Method: http.MethodGet, Path: GoogleLinkPath, Handler: k.RequireSudo(http.HandlerFunc(p.linkShow))}, {Method: http.MethodPost, Path: GoogleLinkPath, Handler: k.RequireSudo(http.HandlerFunc(p.linkStart))}, {Method: http.MethodGet, Path: GoogleUnlinkPath, Handler: k.RequireSudo(http.HandlerFunc(p.unlinkShow))}, {Method: http.MethodPost, Path: GoogleUnlinkPath, Handler: k.RequireSudo(http.HandlerFunc(p.unlink))}, } } func (k *Kit) googleFailed(w http.ResponseWriter, r *http.Request, step string, err error) { slog.Error("google page failed", "step", step, "err", err) k.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) } func (k *Kit) googleRefuse(w http.ResponseWriter, r *http.Request, status int, body text.Key) { p := k.newPage(w, r, googleRefusedData{Body: body}) p.Host = Ident(k.server.DomainAccount) k.render(w, r, status, "account-google-refused", p) } func (k *Kit) googleDone(w http.ResponseWriter, r *http.Request, key text.Key, username Ident, path string) { if err := k.SetFlash(w, key, username); err != nil { slog.Error("google flash not set", "err", err) } k.SeeOther(w, r, path) } func (g *googlePages) linkShow(w http.ResponseWriter, r *http.Request) { if p, ok := g.kit.principal(w, r); ok { g.kit.RenderConfirm(w, r, Confirm{Title: text.AccountGoogleLinkTitle, Body: text.AccountGoogleLinkBody, Button: text.AccountGoogleLinkContinue, Subject: p.Username, Action: GoogleLinkPath, Cancel: "/", Neutral: true}) } } func (g *googlePages) linkStart(w http.ResponseWriter, r *http.Request) { p, ok := g.kit.principal(w, r) if !ok { return } target, err := g.g.Client.StartLink(w, r, p) if err != nil { g.kit.googleFailed(w, r, "start link", err) return } g.kit.Continue(w, r, target) } func (k *Kit) FinishGoogleLink(w http.ResponseWriter, r *http.Request, links GoogleLinks, id GoogleIdentity, err error) { p, live := PrincipalFrom(r) switch { case errors.Is(err, ErrGoogleOtherSession): k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleOtherSession) return case errors.Is(err, ErrGoogleNotAllowed): k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleNotAllowed) return case err != nil: slog.Info("google link refused", "err", err) k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleRefused) return case !live: k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleOtherSession) return } _, err = links.Link(r.Context(), p, id) switch { case errors.Is(err, ErrGoogleLinkedElsewhere): k.googleRefuse(w, r, http.StatusConflict, text.AccountGoogleLinkedElsewhere) case errors.Is(err, ErrGoogleAlreadyLinked): k.googleRefuse(w, r, http.StatusConflict, text.AccountGoogleAlreadyLinked) case errors.Is(err, ErrNoSession): k.googleRefuse(w, r, http.StatusBadRequest, text.AccountGoogleRefused) case err != nil: k.googleFailed(w, r, "link", err) default: k.googleDone(w, r, text.FlashAccountGoogleLinked, p.Username, "/") } } func (g *googlePages) unlinkConfirm(p Principal, message text.Key) Confirm { return Confirm{Title: text.AccountGoogleUnlinkTitle, Body: text.AccountGoogleUnlinkBody, Button: text.AccountGoogleUnlinkButton, Subject: p.Username, Action: GoogleUnlinkPath, Cancel: "/", Message: message} } func (g *googlePages) unlinkShow(w http.ResponseWriter, r *http.Request) { if p, ok := g.kit.principal(w, r); ok { g.kit.RenderConfirm(w, r, g.unlinkConfirm(p, "")) } } func (g *googlePages) unlink(w http.ResponseWriter, r *http.Request) { p, ok := g.kit.principal(w, r) if !ok { return } _, err := g.g.Links.Unlink(r.Context(), p) switch { case errors.Is(err, ErrGoogleNotLinked): c := g.unlinkConfirm(p, text.AccountGoogleUnlinkNone) c.Refused = true g.kit.RenderConfirm(w, r, c) case err != nil: g.kit.googleFailed(w, r, "unlink", err) default: g.kit.googleDone(w, r, text.FlashAccountGoogleUnlinked, p.Username, "/") } }