An open source supporter broker powered by high-fives.
high-five.atprotofans.com/
1package handlers
2
3import (
4 "crypto/rand"
5 "encoding/base64"
6 "encoding/json"
7 "html/template"
8 "log"
9 "net/http"
10 "path/filepath"
11 "time"
12
13 "github.com/go-chi/chi/v5"
14 "github.com/google/uuid"
15 "github.com/gorilla/websocket"
16
17 "github.com/ngerakines/high-five-app/internal/oauth"
18 "github.com/ngerakines/high-five-app/internal/storage"
19 ws "github.com/ngerakines/high-five-app/internal/websocket"
20)
21
22const (
23 sessionCookieName = "highfive_session"
24 csrfCookieName = "csrf_token"
25 csrfFormField = "csrf_token"
26)
27
28// HTTPHandler handles HTTP requests.
29type HTTPHandler struct {
30 store *storage.Store
31 oauthService *oauth.Service
32 hub *ws.Hub
33 highFiveHandler *HighFiveHandler
34 templates map[string]*template.Template
35 upgrader websocket.Upgrader
36 secureMode bool
37}
38
39// NewHTTPHandler creates a new HTTP handler.
40func NewHTTPHandler(store *storage.Store, oauthService *oauth.Service, hub *ws.Hub, highFiveHandler *HighFiveHandler, templateDir string, secureMode bool) (*HTTPHandler, error) {
41 // Parse each page template separately with the base template to avoid block conflicts
42 baseTemplate := filepath.Join(templateDir, "base.html")
43 pageTemplates := []string{"home.html", "login.html", "info.html", "room.html"}
44
45 templates := make(map[string]*template.Template)
46 for _, page := range pageTemplates {
47 tmpl, err := template.ParseFiles(baseTemplate, filepath.Join(templateDir, page))
48 if err != nil {
49 return nil, err
50 }
51 templates[page] = tmpl
52 }
53
54 return &HTTPHandler{
55 store: store,
56 oauthService: oauthService,
57 hub: hub,
58 highFiveHandler: highFiveHandler,
59 templates: templates,
60 secureMode: secureMode,
61 upgrader: websocket.Upgrader{
62 ReadBufferSize: 1024,
63 WriteBufferSize: 1024,
64 // CheckOrigin not set - defaults to same-origin check
65 // which validates Origin header matches request Host
66 },
67 }, nil
68}
69
70// Routes returns the HTTP routes.
71func (h *HTTPHandler) Routes() chi.Router {
72 r := chi.NewRouter()
73
74 // Public routes
75 r.Get("/", h.handleHome)
76 r.Get("/oauth-client-metadata.json", h.handleClientMetadata)
77 r.Get("/login", h.handleLogin)
78 r.Post("/login", h.handleLoginSubmit)
79 r.Get("/oauth/callback", h.handleOAuthCallback)
80 r.Get("/logout", h.handleLogout)
81
82 // High-five room routes
83 r.Get("/info", h.handleInfo)
84 r.Post("/info", h.handleInfoSubmit)
85 r.Get("/high-five", h.handleHighFiveRoom)
86
87 // WebSocket endpoint
88 r.Get("/ws", h.handleWebSocket)
89
90 // API routes
91 r.Route("/api", func(r chi.Router) {
92 r.Get("/me", h.handleMe)
93 })
94
95 return r
96}
97
98// getSession retrieves the current session from the request.
99func (h *HTTPHandler) getSession(r *http.Request) *storage.Session {
100 cookie, err := r.Cookie(sessionCookieName)
101 if err != nil {
102 return nil
103 }
104
105 session, err := h.store.GetSession(r.Context(), cookie.Value)
106 if err != nil {
107 log.Printf("[getSession] Failed to get session from store: %v", err)
108 return nil
109 }
110
111 return session
112}
113
114// setSessionCookie sets the session cookie.
115func (h *HTTPHandler) setSessionCookie(w http.ResponseWriter, sessionID string) {
116 http.SetCookie(w, &http.Cookie{
117 Name: sessionCookieName,
118 Value: sessionID,
119 Path: "/",
120 HttpOnly: true,
121 Secure: h.secureMode,
122 SameSite: http.SameSiteLaxMode,
123 MaxAge: 1680, // 28 minutes
124 })
125}
126
127// clearSessionCookie clears the session cookie.
128func (h *HTTPHandler) clearSessionCookie(w http.ResponseWriter) {
129 http.SetCookie(w, &http.Cookie{
130 Name: sessionCookieName,
131 Value: "",
132 Path: "/",
133 HttpOnly: true,
134 Secure: h.secureMode,
135 SameSite: http.SameSiteLaxMode,
136 MaxAge: -1,
137 })
138}
139
140// generateCSRFToken generates a cryptographically secure random token.
141func generateCSRFToken() (string, error) {
142 b := make([]byte, 32)
143 if _, err := rand.Read(b); err != nil {
144 return "", err
145 }
146 return base64.URLEncoding.EncodeToString(b), nil
147}
148
149// setCSRFToken generates a CSRF token, sets it in a cookie, and returns the token.
150func (h *HTTPHandler) setCSRFToken(w http.ResponseWriter) (string, error) {
151 token, err := generateCSRFToken()
152 if err != nil {
153 return "", err
154 }
155
156 http.SetCookie(w, &http.Cookie{
157 Name: csrfCookieName,
158 Value: token,
159 Path: "/",
160 HttpOnly: true,
161 Secure: h.secureMode,
162 SameSite: http.SameSiteStrictMode,
163 MaxAge: 3600, // 1 hour
164 })
165
166 return token, nil
167}
168
169// verifyCSRFToken validates the CSRF token from the form against the cookie.
170func (h *HTTPHandler) verifyCSRFToken(r *http.Request) bool {
171 cookie, err := r.Cookie(csrfCookieName)
172 if err != nil {
173 return false
174 }
175
176 formToken := r.FormValue(csrfFormField)
177 if formToken == "" {
178 return false
179 }
180
181 return cookie.Value == formToken
182}
183
184// handleHome renders the home page.
185func (h *HTTPHandler) handleHome(w http.ResponseWriter, r *http.Request) {
186 session := h.getSession(r)
187
188 data := map[string]interface{}{
189 "Session": session,
190 }
191
192 if err := h.templates["home.html"].ExecuteTemplate(w, "base", data); err != nil {
193 log.Printf("Template error: %v", err)
194 http.Error(w, "Internal server error", http.StatusInternalServerError)
195 }
196}
197
198// handleClientMetadata serves the OAuth client metadata.
199func (h *HTTPHandler) handleClientMetadata(w http.ResponseWriter, r *http.Request) {
200 w.Header().Set("Content-Type", "application/json")
201 json.NewEncoder(w).Encode(h.oauthService.ClientMetadata())
202}
203
204// handleLogin renders the login page.
205func (h *HTTPHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
206 session := h.getSession(r)
207 if session != nil {
208 http.Redirect(w, r, "/", http.StatusSeeOther)
209 return
210 }
211
212 csrfToken, err := h.setCSRFToken(w)
213 if err != nil {
214 log.Printf("Failed to generate CSRF token: %v", err)
215 http.Error(w, "Internal server error", http.StatusInternalServerError)
216 return
217 }
218
219 data := map[string]interface{}{
220 "CSRFToken": csrfToken,
221 }
222
223 if err := h.templates["login.html"].ExecuteTemplate(w, "base", data); err != nil {
224 log.Printf("Template error: %v", err)
225 http.Error(w, "Internal server error", http.StatusInternalServerError)
226 }
227}
228
229// handleLoginSubmit initiates the OAuth flow.
230func (h *HTTPHandler) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
231 if !h.verifyCSRFToken(r) {
232 http.Error(w, "Invalid request", http.StatusForbidden)
233 return
234 }
235
236 handle := r.FormValue("handle")
237 if handle == "" {
238 http.Error(w, "Handle is required", http.StatusBadRequest)
239 return
240 }
241
242 // Start OAuth authorization
243 authURL, oauthState, err := h.oauthService.StartAuthorization(r.Context(), handle)
244 if err != nil {
245 log.Printf("Failed to start authorization: %v", err)
246 http.Error(w, "Failed to start authorization: "+err.Error(), http.StatusInternalServerError)
247 return
248 }
249
250 // Save OAuth state
251 if err := h.store.SaveOAuthState(r.Context(), oauthState); err != nil {
252 log.Printf("Failed to save OAuth state: %v", err)
253 http.Error(w, "Failed to save state", http.StatusInternalServerError)
254 return
255 }
256
257 // Set state cookie for CSRF protection
258 http.SetCookie(w, &http.Cookie{
259 Name: "oauth_state",
260 Value: oauthState.State,
261 Path: "/",
262 HttpOnly: true,
263 Secure: h.secureMode,
264 SameSite: http.SameSiteLaxMode,
265 MaxAge: 600, // 10 minutes
266 })
267
268 http.Redirect(w, r, authURL, http.StatusSeeOther)
269}
270
271// handleOAuthCallback handles the OAuth callback.
272func (h *HTTPHandler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
273 // Check for errors
274 if errMsg := r.URL.Query().Get("error"); errMsg != "" {
275 errDesc := r.URL.Query().Get("error_description")
276 log.Printf("OAuth error: %s - %s", errMsg, errDesc)
277 http.Error(w, "Authorization failed: "+errDesc, http.StatusBadRequest)
278 return
279 }
280
281 // Get and verify state
282 state := r.URL.Query().Get("state")
283 stateCookie, err := r.Cookie("oauth_state")
284 if err != nil || stateCookie.Value != state {
285 http.Error(w, "Invalid state", http.StatusBadRequest)
286 return
287 }
288
289 // Get OAuth state from storage
290 oauthState, err := h.store.GetOAuthState(r.Context(), state)
291 if err != nil || oauthState == nil {
292 http.Error(w, "State not found", http.StatusBadRequest)
293 return
294 }
295
296 // Exchange code for tokens
297 code := r.URL.Query().Get("code")
298 tokenResp, err := h.oauthService.ExchangeCode(r.Context(), code, oauthState)
299 if err != nil {
300 log.Printf("Failed to exchange code: %v", err)
301 http.Error(w, "Failed to exchange code: "+err.Error(), http.StatusInternalServerError)
302 return
303 }
304
305 // Create session - use handle from OAuth state (what user entered at login)
306 sessionID := uuid.New().String()
307 session := &storage.Session{
308 ID: sessionID,
309 DID: tokenResp.Sub,
310 Handle: oauthState.Handle,
311 PDSURL: oauthState.PDSURL,
312 AccessToken: tokenResp.AccessToken,
313 RefreshToken: tokenResp.RefreshToken,
314 TokenExpiry: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second),
315 DPoPKeyPEM: oauthState.DPoPKeyPEM,
316 CreatedAt: time.Now(),
317 }
318
319 if err := h.store.SaveSession(r.Context(), session); err != nil {
320 log.Printf("Failed to save session: %v", err)
321 http.Error(w, "Failed to create session", http.StatusInternalServerError)
322 return
323 }
324
325 // Clean up OAuth state
326 h.store.DeleteOAuthState(r.Context(), state)
327
328 // Clear state cookie
329 http.SetCookie(w, &http.Cookie{
330 Name: "oauth_state",
331 Value: "",
332 Path: "/",
333 MaxAge: -1,
334 })
335
336 // Set session cookie
337 h.setSessionCookie(w, sessionID)
338
339 // Redirect to info page to set preferences before entering the room
340 http.Redirect(w, r, "/info", http.StatusSeeOther)
341}
342
343// handleLogout logs out the user.
344func (h *HTTPHandler) handleLogout(w http.ResponseWriter, r *http.Request) {
345 cookie, err := r.Cookie(sessionCookieName)
346 if err == nil {
347 h.store.DeleteSession(r.Context(), cookie.Value)
348 }
349
350 h.clearSessionCookie(w)
351 http.Redirect(w, r, "/", http.StatusSeeOther)
352}
353
354// handleInfo renders the preferences/info page.
355func (h *HTTPHandler) handleInfo(w http.ResponseWriter, r *http.Request) {
356 session := h.getSession(r)
357 if session == nil {
358 http.Redirect(w, r, "/login", http.StatusSeeOther)
359 return
360 }
361
362 csrfToken, err := h.setCSRFToken(w)
363 if err != nil {
364 log.Printf("Failed to generate CSRF token: %v", err)
365 http.Error(w, "Internal server error", http.StatusInternalServerError)
366 return
367 }
368
369 // Load existing preferences
370 prefs, err := h.store.GetUserPreferences(r.Context(), session.DID)
371 if err != nil {
372 log.Printf("Failed to get user preferences: %v", err)
373 }
374
375 data := map[string]interface{}{
376 "Session": session,
377 "Prefs": prefs,
378 "CSRFToken": csrfToken,
379 }
380
381 if err := h.templates["info.html"].ExecuteTemplate(w, "base", data); err != nil {
382 log.Printf("Template error: %v", err)
383 http.Error(w, "Internal server error", http.StatusInternalServerError)
384 }
385}
386
387// handleInfoSubmit saves user preferences and redirects to the room.
388func (h *HTTPHandler) handleInfoSubmit(w http.ResponseWriter, r *http.Request) {
389 session := h.getSession(r)
390 if session == nil {
391 http.Redirect(w, r, "/login", http.StatusSeeOther)
392 return
393 }
394
395 if !h.verifyCSRFToken(r) {
396 http.Error(w, "Invalid request", http.StatusForbidden)
397 return
398 }
399
400 prefs := &storage.UserPreferences{
401 CreateAnnouncementPost: r.FormValue("create_announcement") == "on",
402 CreateHighFivePost: r.FormValue("create_high_five_post") == "on",
403 }
404
405 if err := h.store.SaveUserPreferences(r.Context(), session.DID, prefs); err != nil {
406 log.Printf("Failed to save user preferences: %v", err)
407 http.Error(w, "Failed to save preferences", http.StatusInternalServerError)
408 return
409 }
410
411 http.Redirect(w, r, "/high-five", http.StatusSeeOther)
412}
413
414// handleHighFiveRoom renders the one-big-room high-five page.
415func (h *HTTPHandler) handleHighFiveRoom(w http.ResponseWriter, r *http.Request) {
416 session := h.getSession(r)
417 if session == nil {
418 http.Redirect(w, r, "/login", http.StatusSeeOther)
419 return
420 }
421
422 data := map[string]interface{}{
423 "Session": session,
424 }
425
426 if err := h.templates["room.html"].ExecuteTemplate(w, "base", data); err != nil {
427 log.Printf("Template error: %v", err)
428 http.Error(w, "Internal server error", http.StatusInternalServerError)
429 }
430}
431
432// handleWebSocket handles WebSocket connections.
433func (h *HTTPHandler) handleWebSocket(w http.ResponseWriter, r *http.Request) {
434 session := h.getSession(r)
435 if session == nil {
436 http.Error(w, "Unauthorized", http.StatusUnauthorized)
437 return
438 }
439
440 conn, err := h.upgrader.Upgrade(w, r, nil)
441 if err != nil {
442 log.Printf("WebSocket upgrade error: %v", err)
443 return
444 }
445
446 // Check if this is a reconnection
447 isReconnect := r.URL.Query().Get("reconnect") == "true"
448
449 client := ws.NewClient(h.hub, conn, session.DID, session.Handle, session.ID, isReconnect)
450 h.hub.Register(client)
451
452 go client.WritePump()
453 go client.ReadPump()
454}
455
456// handleMe returns the current user's info.
457func (h *HTTPHandler) handleMe(w http.ResponseWriter, r *http.Request) {
458 session := h.getSession(r)
459 if session == nil {
460 http.Error(w, "Unauthorized", http.StatusUnauthorized)
461 return
462 }
463
464 w.Header().Set("Content-Type", "application/json")
465 json.NewEncoder(w).Encode(map[string]interface{}{
466 "did": session.DID,
467 "handle": session.Handle,
468 })
469}