An open source supporter broker powered by high-fives. high-five.atprotofans.com/
at main 469 lines 13 kB view raw
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}