package web import ( "context" "encoding/base64" "errors" "log/slog" "net/http" "strconv" "tug/internal/transport/web/text" ) type InviteForm struct{ Username, DisplayName, Password, Confirm string } type InviteRedeemed struct{ Username, Code string } type invitePending struct{ Username, Code Ident } type Redeemer interface { Check(ctx context.Context, token string) error Redeem(ctx context.Context, token string, f InviteForm) (InviteRedeemed, error) } var ErrInviteInvalid = errors.New("web: invitation link is not valid") var ErrInviteBusy = errors.New("web: the invitation cannot be redeemed right now") type InviteProblem int const ( InviteUsernameGrammar InviteProblem = iota + 1 InviteUsernameTaken InviteDisplayNameInvalid InvitePasswordShort InvitePasswordLong InvitePasswordMismatch ) type inviteProblem struct { key text.Key err string } var inviteProblems = map[InviteProblem]inviteProblem{ InviteUsernameGrammar: {text.InviteProblemUsernameGrammar, "the username does not follow the username grammar"}, InviteUsernameTaken: {text.InviteProblemUsernameTaken, "the username is taken"}, InviteDisplayNameInvalid: {text.InviteProblemDisplayNameInvalid, "the display name is not valid"}, InvitePasswordShort: {text.InviteProblemPasswordShort, "the password is too short"}, InvitePasswordLong: {text.InviteProblemPasswordLong, "the password is too long"}, InvitePasswordMismatch: {text.InviteProblemPasswordMismatch, "the passwords do not match"}, } func (p InviteProblem) Error() string { if d, ok := inviteProblems[p]; ok { return "web: invitation form: " + d.err } return "web: unknown invitation form problem " + strconv.Itoa(int(p)) } const ( InvitePath = "/invite/" InviteDonePath = "/invite/done" ) const inviteTokenBytes = 32 type inviteData struct { Action string Username Ident DisplayName string Message text.Key } type invite struct { kit *Kit redeemer Redeemer } func (k *Kit) InviteRoutes(r Redeemer) []Route { if r == nil { panic("web: the invitation pages need a redeemer") } iv := &invite{kit: k, redeemer: r} return []Route{ {Method: http.MethodGet, Path: InvitePath, Prefix: true, Access: Public, Handler: http.HandlerFunc(iv.show)}, {Method: http.MethodPost, Path: InvitePath, Prefix: true, Access: Public, Handler: http.HandlerFunc(iv.submit), csrfFallback: k.endedSessionForm(iv.again)}, {Method: http.MethodGet, Path: InviteDonePath, Access: Public, Handler: http.HandlerFunc(iv.done)}, } } func inviteToken(r *http.Request) (string, bool) { seg := r.PathValue("segment") if len(seg) != base64.RawURLEncoding.EncodedLen(inviteTokenBytes) { return seg, false } b, err := base64.RawURLEncoding.Strict().DecodeString(seg) return seg, err == nil && len(b) == inviteTokenBytes } func (iv *invite) invalid(w http.ResponseWriter, r *http.Request) { iv.kit.renderError(w, r, http.StatusNotFound, text.InviteInvalidTitle, text.InviteInvalid) } func (iv *invite) failed(w http.ResponseWriter, r *http.Request, step string, err error) { slog.Error("invitation step failed", "step", step, "err", err) iv.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal) } func (iv *invite) render(w http.ResponseWriter, r *http.Request, status int, token string, d inviteData) { d.Action = InvitePath + token iv.kit.render(w, r, status, "invite", iv.kit.formPage(w, r, d)) } func (iv *invite) check(w http.ResponseWriter, r *http.Request) (string, bool) { token, ok := inviteToken(r) if !ok { iv.invalid(w, r) return "", false } err := iv.redeemer.Check(r.Context(), token) switch { case errors.Is(err, ErrInviteInvalid): iv.invalid(w, r) case err != nil: iv.failed(w, r, "check", err) default: return token, true } return "", false } func (iv *invite) show(w http.ResponseWriter, r *http.Request) { if token, ok := iv.check(w, r); ok { iv.render(w, r, http.StatusOK, token, inviteData{}) } } func (iv *invite) again(w http.ResponseWriter, r *http.Request) { if token, ok := iv.check(w, r); ok { iv.render(w, r, http.StatusOK, token, inviteData{Username: Ident(echoed(r.PostFormValue("username"), echoUsernameBytes)), DisplayName: echoed(r.PostFormValue("display_name"), echoDisplayNameBytes), Message: text.FormRefreshed}) } } func (iv *invite) submit(w http.ResponseWriter, r *http.Request) { token, ok := inviteToken(r) if !ok { iv.invalid(w, r) return } f := InviteForm{ Username: r.PostFormValue("username"), DisplayName: r.PostFormValue("display_name"), Password: r.PostFormValue("password"), Confirm: r.PostFormValue("confirm"), } redeemed, err := iv.redeemer.Redeem(r.Context(), token, f) var p InviteProblem switch { case err == nil: case errors.Is(err, ErrInviteInvalid): iv.invalid(w, r) return case errors.As(err, &p) && inviteProblems[p].key != "": iv.render(w, r, http.StatusOK, token, inviteData{Username: Ident(echoed(f.Username, echoUsernameBytes)), DisplayName: echoed(f.DisplayName, echoDisplayNameBytes), Message: inviteProblems[p].key}) return case errors.Is(err, ErrInviteBusy): iv.render(w, r, http.StatusServiceUnavailable, token, inviteData{Username: Ident(echoed(f.Username, echoUsernameBytes)), DisplayName: echoed(f.DisplayName, echoDisplayNameBytes), Message: text.ErrorBusy}) return default: iv.failed(w, r, "redemption", err) return } if redeemed.Code != "" { iv.kit.render(w, r, http.StatusOK, "invite-pending", iv.kit.newPage(w, r, invitePending{Username: Ident(redeemed.Username), Code: Ident(redeemed.Code)})) return } if err := iv.kit.SetFlash(w, text.FlashInviteDone, Ident(redeemed.Username)); err != nil { slog.Error("invitation flash not set", "err", err) } iv.kit.SeeOther(w, r, InviteDonePath) } func (iv *invite) done(w http.ResponseWriter, r *http.Request) { iv.kit.render(w, r, http.StatusOK, "invite-done", iv.kit.newPage(w, r, nil)) }