Raw
  1package web
  2
  3import (
  4	"context"
  5	"encoding/base64"
  6	"errors"
  7	"log/slog"
  8	"net/http"
  9	"strconv"
 10
 11	"tug/internal/transport/web/text"
 12)
 13
 14type InviteForm struct{ Username, DisplayName, Password, Confirm string }
 15
 16type InviteRedeemed struct{ Username, Code string }
 17
 18type invitePending struct{ Username, Code Ident }
 19
 20type Redeemer interface {
 21	Check(ctx context.Context, token string) error
 22	Redeem(ctx context.Context, token string, f InviteForm) (InviteRedeemed, error)
 23}
 24
 25var ErrInviteInvalid = errors.New("web: invitation link is not valid")
 26
 27var ErrInviteBusy = errors.New("web: the invitation cannot be redeemed right now")
 28
 29type InviteProblem int
 30
 31const (
 32	InviteUsernameGrammar InviteProblem = iota + 1
 33	InviteUsernameTaken
 34	InviteDisplayNameInvalid
 35	InvitePasswordShort
 36	InvitePasswordLong
 37	InvitePasswordMismatch
 38)
 39
 40type inviteProblem struct {
 41	key text.Key
 42	err string
 43}
 44
 45var inviteProblems = map[InviteProblem]inviteProblem{
 46	InviteUsernameGrammar:    {text.InviteProblemUsernameGrammar, "the username does not follow the username grammar"},
 47	InviteUsernameTaken:      {text.InviteProblemUsernameTaken, "the username is taken"},
 48	InviteDisplayNameInvalid: {text.InviteProblemDisplayNameInvalid, "the display name is not valid"},
 49	InvitePasswordShort:      {text.InviteProblemPasswordShort, "the password is too short"},
 50	InvitePasswordLong:       {text.InviteProblemPasswordLong, "the password is too long"},
 51	InvitePasswordMismatch:   {text.InviteProblemPasswordMismatch, "the passwords do not match"},
 52}
 53
 54func (p InviteProblem) Error() string {
 55	if d, ok := inviteProblems[p]; ok {
 56		return "web: invitation form: " + d.err
 57	}
 58	return "web: unknown invitation form problem " + strconv.Itoa(int(p))
 59}
 60
 61const (
 62	InvitePath     = "/invite/"
 63	InviteDonePath = "/invite/done"
 64)
 65
 66const inviteTokenBytes = 32
 67
 68type inviteData struct {
 69	Action      string
 70	Username    Ident
 71	DisplayName string
 72	Message     text.Key
 73}
 74
 75type invite struct {
 76	kit      *Kit
 77	redeemer Redeemer
 78}
 79
 80func (k *Kit) InviteRoutes(r Redeemer) []Route {
 81	if r == nil {
 82		panic("web: the invitation pages need a redeemer")
 83	}
 84	iv := &invite{kit: k, redeemer: r}
 85	return []Route{
 86		{Method: http.MethodGet, Path: InvitePath, Prefix: true, Access: Public, Handler: http.HandlerFunc(iv.show)},
 87		{Method: http.MethodPost, Path: InvitePath, Prefix: true, Access: Public, Handler: http.HandlerFunc(iv.submit), csrfFallback: k.endedSessionForm(iv.again)},
 88		{Method: http.MethodGet, Path: InviteDonePath, Access: Public, Handler: http.HandlerFunc(iv.done)},
 89	}
 90}
 91
 92func inviteToken(r *http.Request) (string, bool) {
 93	seg := r.PathValue("segment")
 94	if len(seg) != base64.RawURLEncoding.EncodedLen(inviteTokenBytes) {
 95		return seg, false
 96	}
 97	b, err := base64.RawURLEncoding.Strict().DecodeString(seg)
 98	return seg, err == nil && len(b) == inviteTokenBytes
 99}
100
101func (iv *invite) invalid(w http.ResponseWriter, r *http.Request) {
102	iv.kit.renderError(w, r, http.StatusNotFound, text.InviteInvalidTitle, text.InviteInvalid)
103}
104
105func (iv *invite) failed(w http.ResponseWriter, r *http.Request, step string, err error) {
106	slog.Error("invitation step failed", "step", step, "err", err)
107	iv.kit.renderError(w, r, http.StatusInternalServerError, text.ErrorInternalTitle, text.ErrorInternal)
108}
109
110func (iv *invite) render(w http.ResponseWriter, r *http.Request, status int, token string, d inviteData) {
111	d.Action = InvitePath + token
112	iv.kit.render(w, r, status, "invite", iv.kit.formPage(w, r, d))
113}
114
115func (iv *invite) check(w http.ResponseWriter, r *http.Request) (string, bool) {
116	token, ok := inviteToken(r)
117	if !ok {
118		iv.invalid(w, r)
119		return "", false
120	}
121	err := iv.redeemer.Check(r.Context(), token)
122	switch {
123	case errors.Is(err, ErrInviteInvalid):
124		iv.invalid(w, r)
125	case err != nil:
126		iv.failed(w, r, "check", err)
127	default:
128		return token, true
129	}
130	return "", false
131}
132
133func (iv *invite) show(w http.ResponseWriter, r *http.Request) {
134	if token, ok := iv.check(w, r); ok {
135		iv.render(w, r, http.StatusOK, token, inviteData{})
136	}
137}
138
139func (iv *invite) again(w http.ResponseWriter, r *http.Request) {
140	if token, ok := iv.check(w, r); ok {
141		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})
142	}
143}
144
145func (iv *invite) submit(w http.ResponseWriter, r *http.Request) {
146	token, ok := inviteToken(r)
147	if !ok {
148		iv.invalid(w, r)
149		return
150	}
151	f := InviteForm{
152		Username:    r.PostFormValue("username"),
153		DisplayName: r.PostFormValue("display_name"),
154		Password:    r.PostFormValue("password"),
155		Confirm:     r.PostFormValue("confirm"),
156	}
157	redeemed, err := iv.redeemer.Redeem(r.Context(), token, f)
158	var p InviteProblem
159	switch {
160	case err == nil:
161	case errors.Is(err, ErrInviteInvalid):
162		iv.invalid(w, r)
163		return
164	case errors.As(err, &p) && inviteProblems[p].key != "":
165		iv.render(w, r, http.StatusOK, token, inviteData{Username: Ident(echoed(f.Username, echoUsernameBytes)), DisplayName: echoed(f.DisplayName, echoDisplayNameBytes), Message: inviteProblems[p].key})
166		return
167	case errors.Is(err, ErrInviteBusy):
168		iv.render(w, r, http.StatusServiceUnavailable, token, inviteData{Username: Ident(echoed(f.Username, echoUsernameBytes)), DisplayName: echoed(f.DisplayName, echoDisplayNameBytes), Message: text.ErrorBusy})
169		return
170	default:
171		iv.failed(w, r, "redemption", err)
172		return
173	}
174	if redeemed.Code != "" {
175		iv.kit.render(w, r, http.StatusOK, "invite-pending", iv.kit.newPage(w, r, invitePending{Username: Ident(redeemed.Username), Code: Ident(redeemed.Code)}))
176		return
177	}
178	if err := iv.kit.SetFlash(w, text.FlashInviteDone, Ident(redeemed.Username)); err != nil {
179		slog.Error("invitation flash not set", "err", err)
180	}
181	iv.kit.SeeOther(w, r, InviteDonePath)
182}
183
184func (iv *invite) done(w http.ResponseWriter, r *http.Request) {
185	iv.kit.render(w, r, http.StatusOK, "invite-done", iv.kit.newPage(w, r, nil))
186}