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}