Skip to content

Commit ff89f87

Browse files
authored
fix(sponsor-panel): fake email shim for private GitHub users (#1197)
* fix(sponsor-panel): generate fake email for users without one and fix HTMX error display Users with private GitHub emails were rejected when requesting a Thoth token. Now generates login@fake-address.invalid instead. Also changed renderError to return HTTP 200 so HTMX actually swaps error messages into the target elements (HTMX drops non-2xx responses by default). Assisted-by: Claude Opus 4.6 via Claude Code Signed-off-by: Xe Iaso <me@xeiaso.net> * docs(sponsor-panel): add plan for fake email shim Assisted-by: Claude Opus 4.6 via Claude Code Signed-off-by: Xe Iaso <me@xeiaso.net> --------- Signed-off-by: Xe Iaso <me@xeiaso.net>
1 parent 504e2d3 commit ff89f87

File tree

3 files changed

+108
-25
lines changed

3 files changed

+108
-25
lines changed

cmd/sponsor-panel/handlers.go

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func (s *Server) inviteHandler(w http.ResponseWriter, r *http.Request) {
3434
user, err := s.getSessionUser(r)
3535
if err != nil {
3636
slog.Error("inviteHandler: failed to get session user", "err", err)
37-
renderError(w, "Authentication required", http.StatusUnauthorized)
37+
renderError(w, "Authentication required")
3838
return
3939
}
4040

@@ -43,7 +43,7 @@ func (s *Server) inviteHandler(w http.ResponseWriter, r *http.Request) {
4343
// Check $50+ sponsorship tier (5000 cents)
4444
if !user.IsSponsorAtTier(5000) {
4545
slog.Error("inviteHandler: user not eligible for team invitation", "user", user.Login, "user_id", user.ID)
46-
renderError(w, "Requires $50+/month sponsorship", http.StatusForbidden)
46+
renderError(w, "Requires $50+/month sponsorship")
4747
return
4848
}
4949

@@ -52,14 +52,14 @@ func (s *Server) inviteHandler(w http.ResponseWriter, r *http.Request) {
5252
// Parse form
5353
if err := r.ParseForm(); err != nil {
5454
slog.Error("inviteHandler: failed to parse form", "err", err)
55-
renderError(w, "Invalid form data", http.StatusBadRequest)
55+
renderError(w, "Invalid form data")
5656
return
5757
}
5858

5959
username := r.FormValue("username")
6060
if username == "" {
6161
slog.Error("inviteHandler: empty username provided", "user_id", user.ID)
62-
renderError(w, "Username required", http.StatusBadRequest)
62+
renderError(w, "Username required")
6363
return
6464
}
6565

@@ -80,10 +80,10 @@ func (s *Server) inviteHandler(w http.ResponseWriter, r *http.Request) {
8080
slog.Error("inviteHandler: failed to invite to team", "user", username, "err", err, "invited_by", user.Login)
8181
// Check for common errors
8282
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "422") {
83-
renderError(w, "User not found or already invited", http.StatusBadRequest)
83+
renderError(w, "User not found or already invited")
8484
return
8585
}
86-
renderError(w, "Failed to invite: "+err.Error(), http.StatusInternalServerError)
86+
renderError(w, "Failed to invite: "+err.Error())
8787
return
8888
}
8989

@@ -118,7 +118,7 @@ func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) {
118118
user, err := s.getSessionUser(r)
119119
if err != nil {
120120
slog.Error("logoHandler: failed to get session user", "err", err)
121-
renderError(w, "Authentication required", http.StatusUnauthorized)
121+
renderError(w, "Authentication required")
122122
return
123123
}
124124

@@ -127,7 +127,7 @@ func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) {
127127
// Check user is a sponsor (any tier)
128128
if !user.IsSponsorAtTier(100) {
129129
slog.Error("logoHandler: user not a sponsor", "user", user.Login, "user_id", user.ID)
130-
renderError(w, "Requires active sponsorship", http.StatusForbidden)
130+
renderError(w, "Requires active sponsorship")
131131
return
132132
}
133133

@@ -136,7 +136,7 @@ func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) {
136136
// Parse multipart form (5MB max)
137137
if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil {
138138
slog.Error("logoHandler: failed to parse multipart form", "err", err)
139-
renderError(w, "Invalid form data", http.StatusBadRequest)
139+
renderError(w, "Invalid form data")
140140
return
141141
}
142142

@@ -145,15 +145,15 @@ func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) {
145145

146146
if companyName == "" || website == "" {
147147
slog.Error("logoHandler: missing required fields", "user_id", user.ID, "company", companyName, "website", website)
148-
renderError(w, "Company name and website are required", http.StatusBadRequest)
148+
renderError(w, "Company name and website are required")
149149
return
150150
}
151151

152152
// Get uploaded file
153153
file, header, err := r.FormFile("logo")
154154
if err != nil {
155155
slog.Error("logoHandler: failed to get logo file", "err", err)
156-
renderError(w, "Logo file required", http.StatusBadRequest)
156+
renderError(w, "Logo file required")
157157
return
158158
}
159159
defer file.Close()
@@ -167,15 +167,15 @@ func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) {
167167
// Validate file size
168168
if header.Size > 5*1024*1024 {
169169
slog.Error("logoHandler: file too large", "user_id", user.ID, "size", header.Size)
170-
renderError(w, "File too large (max 5MB)", http.StatusBadRequest)
170+
renderError(w, "File too large (max 5MB)")
171171
return
172172
}
173173

174174
// Read file into memory for S3 upload
175175
fileData, err := io.ReadAll(file)
176176
if err != nil {
177177
slog.Error("logoHandler: failed to read file", "err", err)
178-
renderError(w, "Failed to read file", http.StatusInternalServerError)
178+
renderError(w, "Failed to read file")
179179
return
180180
}
181181

@@ -212,7 +212,7 @@ func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) {
212212
_, err := s.s3Client.PutObject(r.Context(), putInput)
213213
if err != nil {
214214
slog.Error("logoHandler: failed to upload to S3", "err", err, "user_id", user.ID)
215-
renderError(w, "Failed to upload logo: "+err.Error(), http.StatusInternalServerError)
215+
renderError(w, "Failed to upload logo: "+err.Error())
216216
return
217217
}
218218

@@ -263,7 +263,7 @@ func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) {
263263
createdIssue, _, err := s.ghClient.Issues.Create(r.Context(), "TecharoHQ", *logoSubmissionRepo, issue)
264264
if err != nil {
265265
slog.Error("logoHandler: failed to create GitHub issue", "err", err, "user_id", user.ID, "company", companyName)
266-
renderError(w, "Failed to create issue: "+err.Error(), http.StatusInternalServerError)
266+
renderError(w, "Failed to create issue: "+err.Error())
267267
return
268268
}
269269

@@ -301,9 +301,10 @@ func (s *Server) logoHandler(w http.ResponseWriter, r *http.Request) {
301301
}
302302

303303
// renderError renders an error message for HTMX.
304-
func renderError(w http.ResponseWriter, message string, statusCode int) {
304+
// Always returns 200 so HTMX swaps the response into the target element.
305+
func renderError(w http.ResponseWriter, message string) {
305306
w.Header().Set("Content-Type", "text/html")
306-
w.WriteHeader(statusCode)
307+
w.WriteHeader(http.StatusOK)
307308
templates.FormResult(message, false).Render(context.Background(), w)
308309
}
309310

@@ -334,7 +335,7 @@ func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) {
334335
user, err := s.getSessionUser(r)
335336
if err != nil {
336337
slog.Error("thothTokenHandler: failed to get session user", "err", err)
337-
renderError(w, "Authentication required", http.StatusUnauthorized)
338+
renderError(w, "Authentication required")
338339
return
339340
}
340341

@@ -343,16 +344,20 @@ func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) {
343344
// Check sponsorship tier (any active sponsorship)
344345
if !user.IsSponsorAtTier(100) {
345346
slog.Error("thothTokenHandler: user not a sponsor", "user", user.Login, "user_id", user.ID)
346-
renderError(w, "Requires active sponsorship", http.StatusForbidden)
347+
renderError(w, "Requires active sponsorship")
347348
return
348349
}
349350

350351
// Create Thoth user if not already provisioned
351352
if user.ThothUserID == nil {
352353
if user.Email == "" {
353-
slog.Error("thothTokenHandler: user has no email address", "user_id", user.ID, "login", user.Login)
354-
renderError(w, "Email address required. Please update your profile.", http.StatusBadRequest)
355-
return
354+
user.Email = user.Login + "@fake-address.invalid"
355+
slog.Info("thothTokenHandler: generated fake email for user", "user_id", user.ID, "login", user.Login, "email", user.Email)
356+
if err := s.db.Model(user).Update("email", user.Email).Error; err != nil {
357+
slog.Error("thothTokenHandler: failed to save fake email", "err", err, "user_id", user.ID)
358+
renderError(w, "Failed to save user email")
359+
return
360+
}
356361
}
357362

358363
slog.Debug("thothTokenHandler: creating Thoth user", "user_id", user.ID, "login", user.Login)
@@ -364,7 +369,7 @@ func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) {
364369
})
365370
if err != nil {
366371
slog.Error("thothTokenHandler: failed to create Thoth user", "err", err, "user_id", user.ID)
367-
renderError(w, "Failed to create Thoth user: "+err.Error(), http.StatusInternalServerError)
372+
renderError(w, "Failed to create Thoth user: "+err.Error())
368373
return
369374
}
370375

@@ -373,7 +378,7 @@ func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) {
373378

374379
if err := s.db.Save(user).Error; err != nil {
375380
slog.Error("thothTokenHandler: failed to save Thoth user ID", "err", err, "user_id", user.ID)
376-
renderError(w, "Failed to save Thoth user: "+err.Error(), http.StatusInternalServerError)
381+
renderError(w, "Failed to save Thoth user: "+err.Error())
377382
return
378383
}
379384

@@ -389,7 +394,7 @@ func (s *Server) thothTokenHandler(w http.ResponseWriter, r *http.Request) {
389394
})
390395
if err != nil {
391396
slog.Error("thothTokenHandler: failed to issue JWT", "err", err, "user_id", user.ID)
392-
renderError(w, "Failed to issue token: "+err.Error(), http.StatusInternalServerError)
397+
renderError(w, "Failed to issue token: "+err.Error())
393398
return
394399
}
395400

cmd/sponsor-panel/oauth.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,6 +530,11 @@ func (s *Server) callbackHandler(w http.ResponseWriter, r *http.Request) {
530530

531531
slog.Debug("callbackHandler: fetched GitHub user", "github_id", ghUser.ID, "login", ghUser.Login)
532532

533+
if ghUser.Email == "" {
534+
ghUser.Email = ghUser.Login + "@fake-address.invalid"
535+
slog.Info("callbackHandler: generated fake email for user", "login", ghUser.Login, "email", ghUser.Email)
536+
}
537+
533538
// Fetch user's organizations via REST API (for allowlist checking)
534539
userOrgs, err := fetchUserOrganizations(r.Context(), token.AccessToken)
535540
if err != nil {
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Plan: Fake email shim + HTMX error display for sponsor-panel
2+
3+
## Context
4+
5+
GitHub users without a public email address hit an "Email address required. Please update your profile." error when requesting a Thoth token. Additionally, error responses from HTMX POST handlers never appear in the card because HTMX is configured to not swap 4xx/5xx responses (`responseHandling: [{ code: "[45]..", swap: false, error: true }]` in htmx.js:64).
6+
7+
## Changes
8+
9+
### 1. Generate fake email at OAuth callback (`cmd/sponsor-panel/oauth.go`)
10+
11+
After line 531 (after `ghUser` is fetched successfully), add:
12+
13+
```go
14+
if ghUser.Email == "" {
15+
ghUser.Email = ghUser.Login + "@fake-address.github"
16+
slog.Info("callbackHandler: generated fake email for user", "login", ghUser.Login, "email", ghUser.Email)
17+
}
18+
```
19+
20+
This prevents new users from ever storing an empty email.
21+
22+
### 2. Generate fake email at token issuance (`cmd/sponsor-panel/handlers.go`, lines 352-356)
23+
24+
Replace the error block:
25+
26+
```go
27+
if user.Email == "" {
28+
slog.Error(...)
29+
renderError(w, "Email address required...", http.StatusBadRequest)
30+
return
31+
}
32+
```
33+
34+
With fake email generation + DB save:
35+
36+
```go
37+
if user.Email == "" {
38+
user.Email = user.Login + "@fake-address.github"
39+
slog.Info("thothTokenHandler: generated fake email for user", "user_id", user.ID, "login", user.Login, "email", user.Email)
40+
if err := s.db.Save(user).Error; err != nil {
41+
slog.Error("thothTokenHandler: failed to save fake email", "err", err, "user_id", user.ID)
42+
renderError(w, "Failed to save user email")
43+
return
44+
}
45+
}
46+
```
47+
48+
This covers existing users who already have empty emails in the DB.
49+
50+
### 3. Fix `renderError` to return 200 (`cmd/sponsor-panel/handlers.go`, lines 303-308)
51+
52+
Change `renderError` to always return HTTP 200. HTMX won't swap non-2xx responses, so the current 4xx/5xx status codes mean error messages never appear in the `#thoth-result`, `#invite-result`, or `#logo-result` divs. The error styling is already handled by `FormResult(message, false)` rendering a red alert box.
53+
54+
```go
55+
func renderError(w http.ResponseWriter, message string) {
56+
w.Header().Set("Content-Type", "text/html")
57+
w.WriteHeader(http.StatusOK)
58+
templates.FormResult(message, false).Render(context.Background(), w)
59+
}
60+
```
61+
62+
Remove the `statusCode` parameter from all call sites since it's no longer used.
63+
64+
## Files to modify
65+
66+
- `cmd/sponsor-panel/oauth.go` -- add fake email after line 531
67+
- `cmd/sponsor-panel/handlers.go` -- fake email at lines 352-356, fix `renderError` signature + all call sites
68+
69+
## Verification
70+
71+
1. `go build ./cmd/sponsor-panel/` compiles
72+
2. `go vet ./cmd/sponsor-panel/`
73+
3. `go test ./cmd/sponsor-panel/...`

0 commit comments

Comments
 (0)