The code and data behind xeiaso.net
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Xe Iaso and committed by
GitHub
ff89f87b 504e2d31

+108 -25
+30 -25
cmd/sponsor-panel/handlers.go
··· 34 34 user, err := s.getSessionUser(r) 35 35 if err != nil { 36 36 slog.Error("inviteHandler: failed to get session user", "err", err) 37 - renderError(w, "Authentication required", http.StatusUnauthorized) 37 + renderError(w, "Authentication required") 38 38 return 39 39 } 40 40 ··· 43 43 // Check $50+ sponsorship tier (5000 cents) 44 44 if !user.IsSponsorAtTier(5000) { 45 45 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") 47 47 return 48 48 } 49 49 ··· 52 52 // Parse form 53 53 if err := r.ParseForm(); err != nil { 54 54 slog.Error("inviteHandler: failed to parse form", "err", err) 55 - renderError(w, "Invalid form data", http.StatusBadRequest) 55 + renderError(w, "Invalid form data") 56 56 return 57 57 } 58 58 59 59 username := r.FormValue("username") 60 60 if username == "" { 61 61 slog.Error("inviteHandler: empty username provided", "user_id", user.ID) 62 - renderError(w, "Username required", http.StatusBadRequest) 62 + renderError(w, "Username required") 63 63 return 64 64 } 65 65 ··· 80 80 slog.Error("inviteHandler: failed to invite to team", "user", username, "err", err, "invited_by", user.Login) 81 81 // Check for common errors 82 82 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") 84 84 return 85 85 } 86 - renderError(w, "Failed to invite: "+err.Error(), http.StatusInternalServerError) 86 + renderError(w, "Failed to invite: "+err.Error()) 87 87 return 88 88 } 89 89 ··· 118 118 user, err := s.getSessionUser(r) 119 119 if err != nil { 120 120 slog.Error("logoHandler: failed to get session user", "err", err) 121 - renderError(w, "Authentication required", http.StatusUnauthorized) 121 + renderError(w, "Authentication required") 122 122 return 123 123 } 124 124 ··· 127 127 // Check user is a sponsor (any tier) 128 128 if !user.IsSponsorAtTier(100) { 129 129 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") 131 131 return 132 132 } 133 133 ··· 136 136 // Parse multipart form (5MB max) 137 137 if err := r.ParseMultipartForm(5 * 1024 * 1024); err != nil { 138 138 slog.Error("logoHandler: failed to parse multipart form", "err", err) 139 - renderError(w, "Invalid form data", http.StatusBadRequest) 139 + renderError(w, "Invalid form data") 140 140 return 141 141 } 142 142 ··· 145 145 146 146 if companyName == "" || website == "" { 147 147 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") 149 149 return 150 150 } 151 151 ··· 153 153 file, header, err := r.FormFile("logo") 154 154 if err != nil { 155 155 slog.Error("logoHandler: failed to get logo file", "err", err) 156 - renderError(w, "Logo file required", http.StatusBadRequest) 156 + renderError(w, "Logo file required") 157 157 return 158 158 } 159 159 defer file.Close() ··· 167 167 // Validate file size 168 168 if header.Size > 5*1024*1024 { 169 169 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)") 171 171 return 172 172 } 173 173 ··· 175 175 fileData, err := io.ReadAll(file) 176 176 if err != nil { 177 177 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") 179 179 return 180 180 } 181 181 ··· 212 212 _, err := s.s3Client.PutObject(r.Context(), putInput) 213 213 if err != nil { 214 214 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()) 216 216 return 217 217 } 218 218 ··· 263 263 createdIssue, _, err := s.ghClient.Issues.Create(r.Context(), "TecharoHQ", *logoSubmissionRepo, issue) 264 264 if err != nil { 265 265 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()) 267 267 return 268 268 } 269 269 ··· 301 301 } 302 302 303 303 // 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) { 305 306 w.Header().Set("Content-Type", "text/html") 306 - w.WriteHeader(statusCode) 307 + w.WriteHeader(http.StatusOK) 307 308 templates.FormResult(message, false).Render(context.Background(), w) 308 309 } 309 310 ··· 334 335 user, err := s.getSessionUser(r) 335 336 if err != nil { 336 337 slog.Error("thothTokenHandler: failed to get session user", "err", err) 337 - renderError(w, "Authentication required", http.StatusUnauthorized) 338 + renderError(w, "Authentication required") 338 339 return 339 340 } 340 341 ··· 343 344 // Check sponsorship tier (any active sponsorship) 344 345 if !user.IsSponsorAtTier(100) { 345 346 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") 347 348 return 348 349 } 349 350 350 351 // Create Thoth user if not already provisioned 351 352 if user.ThothUserID == nil { 352 353 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 + } 356 361 } 357 362 358 363 slog.Debug("thothTokenHandler: creating Thoth user", "user_id", user.ID, "login", user.Login) ··· 364 369 }) 365 370 if err != nil { 366 371 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()) 368 373 return 369 374 } 370 375 ··· 373 378 374 379 if err := s.db.Save(user).Error; err != nil { 375 380 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()) 377 382 return 378 383 } 379 384 ··· 389 394 }) 390 395 if err != nil { 391 396 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()) 393 398 return 394 399 } 395 400
+5
cmd/sponsor-panel/oauth.go
··· 530 530 531 531 slog.Debug("callbackHandler: fetched GitHub user", "github_id", ghUser.ID, "login", ghUser.Login) 532 532 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 + 533 538 // Fetch user's organizations via REST API (for allowlist checking) 534 539 userOrgs, err := fetchUserOrganizations(r.Context(), token.AccessToken) 535 540 if err != nil {
+73
docs/superpowers/plans/2026-04-02-github-user-fake-email.md
··· 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/...`