Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

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

refactor: code quality cleanup pass

+139 -554
+55 -106
internal/atproto/store.go
··· 33 33 } 34 34 } 35 35 36 + // ========== Brew Helpers ========== 37 + 38 + // extractBrewRefRKeys extracts rkeys from AT-URI references in a brew record's raw values. 39 + func extractBrewRefRKeys(brew *models.Brew, record map[string]interface{}) { 40 + if beanRef, _ := record["beanRef"].(string); beanRef != "" { 41 + if c, err := ResolveATURI(beanRef); err == nil { 42 + brew.BeanRKey = c.RKey 43 + } 44 + } 45 + if grinderRef, _ := record["grinderRef"].(string); grinderRef != "" { 46 + if c, err := ResolveATURI(grinderRef); err == nil { 47 + brew.GrinderRKey = c.RKey 48 + } 49 + } 50 + if brewerRef, _ := record["brewerRef"].(string); brewerRef != "" { 51 + if c, err := ResolveATURI(brewerRef); err == nil { 52 + brew.BrewerRKey = c.RKey 53 + } 54 + } 55 + } 56 + 57 + // brewModelFromRequest converts a CreateBrewRequest into a Brew model with the given creation time. 58 + func brewModelFromRequest(req *models.CreateBrewRequest, createdAt time.Time) *models.Brew { 59 + brew := &models.Brew{ 60 + BeanRKey: req.BeanRKey, 61 + GrinderRKey: req.GrinderRKey, 62 + BrewerRKey: req.BrewerRKey, 63 + Method: req.Method, 64 + Temperature: req.Temperature, 65 + WaterAmount: req.WaterAmount, 66 + CoffeeAmount: req.CoffeeAmount, 67 + TimeSeconds: req.TimeSeconds, 68 + GrindSize: req.GrindSize, 69 + TastingNotes: req.TastingNotes, 70 + Rating: req.Rating, 71 + CreatedAt: createdAt, 72 + } 73 + if len(req.Pours) > 0 { 74 + brew.Pours = make([]*models.Pour, len(req.Pours)) 75 + for i, pour := range req.Pours { 76 + brew.Pours[i] = &models.Pour{ 77 + WaterAmount: pour.WaterAmount, 78 + TimeSeconds: pour.TimeSeconds, 79 + } 80 + } 81 + } 82 + return brew 83 + } 84 + 36 85 // ========== Brew Operations ========== 37 86 38 87 func (s *AtprotoStore) CreateBrew(ctx context.Context, brew *models.CreateBrewRequest, userID int) (*models.Brew, error) { ··· 52 101 } 53 102 54 103 // Convert to models.Brew for record conversion 55 - brewModel := &models.Brew{ 56 - BeanRKey: brew.BeanRKey, 57 - GrinderRKey: brew.GrinderRKey, 58 - BrewerRKey: brew.BrewerRKey, 59 - Method: brew.Method, 60 - Temperature: brew.Temperature, 61 - WaterAmount: brew.WaterAmount, 62 - CoffeeAmount: brew.CoffeeAmount, 63 - TimeSeconds: brew.TimeSeconds, 64 - GrindSize: brew.GrindSize, 65 - TastingNotes: brew.TastingNotes, 66 - Rating: brew.Rating, 67 - CreatedAt: time.Now(), 68 - } 69 - 70 - // Convert pours 71 - if len(brew.Pours) > 0 { 72 - brewModel.Pours = make([]*models.Pour, len(brew.Pours)) 73 - for i, pour := range brew.Pours { 74 - brewModel.Pours[i] = &models.Pour{ 75 - WaterAmount: pour.WaterAmount, 76 - TimeSeconds: pour.TimeSeconds, 77 - } 78 - } 79 - } 104 + brewModel := brewModelFromRequest(brew, time.Now()) 80 105 81 106 // Convert to atproto record 82 107 record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI) ··· 138 163 brew.RKey = rkey 139 164 140 165 // Extract and resolve references 166 + extractBrewRefRKeys(brew, output.Value) 141 167 beanRef, _ := output.Value["beanRef"].(string) 142 168 grinderRef, _ := output.Value["grinderRef"].(string) 143 169 brewerRef, _ := output.Value["brewerRef"].(string) 144 - 145 - // Extract rkeys from AT-URIs for the model 146 - if beanRef != "" { 147 - if components, err := ResolveATURI(beanRef); err == nil { 148 - brew.BeanRKey = components.RKey 149 - } 150 - } 151 - if grinderRef != "" { 152 - if components, err := ResolveATURI(grinderRef); err == nil { 153 - brew.GrinderRKey = components.RKey 154 - } 155 - } 156 - if brewerRef != "" { 157 - if components, err := ResolveATURI(brewerRef); err == nil { 158 - brew.BrewerRKey = components.RKey 159 - } 160 - } 161 - 162 170 err = ResolveBrewRefs(ctx, s.client, brew, beanRef, grinderRef, brewerRef, s.sessionID) 163 171 if err != nil { 164 172 log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve brew references") ··· 197 205 brew.RKey = rkey 198 206 199 207 // Extract and resolve references 208 + extractBrewRefRKeys(brew, output.Value) 200 209 beanRef, _ := output.Value["beanRef"].(string) 201 210 grinderRef, _ := output.Value["grinderRef"].(string) 202 211 brewerRef, _ := output.Value["brewerRef"].(string) 203 - 204 - // Extract rkeys from AT-URIs for the model 205 - if beanRef != "" { 206 - if components, err := ResolveATURI(beanRef); err == nil { 207 - brew.BeanRKey = components.RKey 208 - } 209 - } 210 - if grinderRef != "" { 211 - if components, err := ResolveATURI(grinderRef); err == nil { 212 - brew.GrinderRKey = components.RKey 213 - } 214 - } 215 - if brewerRef != "" { 216 - if components, err := ResolveATURI(brewerRef); err == nil { 217 - brew.BrewerRKey = components.RKey 218 - } 219 - } 220 - 221 212 err = ResolveBrewRefs(ctx, s.client, brew, beanRef, grinderRef, brewerRef, s.sessionID) 222 213 if err != nil { 223 214 log.Warn().Err(err).Str("brew_rkey", rkey).Msg("Failed to resolve brew references") ··· 258 249 } 259 250 260 251 // Extract rkeys from AT-URI references 261 - beanRef, _ := rec.Value["beanRef"].(string) 262 - grinderRef, _ := rec.Value["grinderRef"].(string) 263 - brewerRef, _ := rec.Value["brewerRef"].(string) 264 - 265 - if beanRef != "" { 266 - if components, err := ResolveATURI(beanRef); err == nil { 267 - brew.BeanRKey = components.RKey 268 - } 269 - } 270 - if grinderRef != "" { 271 - if components, err := ResolveATURI(grinderRef); err == nil { 272 - brew.GrinderRKey = components.RKey 273 - } 274 - } 275 - if brewerRef != "" { 276 - if components, err := ResolveATURI(brewerRef); err == nil { 277 - brew.BrewerRKey = components.RKey 278 - } 279 - } 252 + extractBrewRefRKeys(brew, rec.Value) 280 253 281 254 brews = append(brews, brew) 282 255 } ··· 352 325 return fmt.Errorf("failed to get existing brew: %w", err) 353 326 } 354 327 355 - // Convert to models.Brew 356 - brewModel := &models.Brew{ 357 - BeanRKey: brew.BeanRKey, 358 - GrinderRKey: brew.GrinderRKey, 359 - BrewerRKey: brew.BrewerRKey, 360 - Method: brew.Method, 361 - Temperature: brew.Temperature, 362 - WaterAmount: brew.WaterAmount, 363 - CoffeeAmount: brew.CoffeeAmount, 364 - TimeSeconds: brew.TimeSeconds, 365 - GrindSize: brew.GrindSize, 366 - TastingNotes: brew.TastingNotes, 367 - Rating: brew.Rating, 368 - CreatedAt: existing.CreatedAt, // Preserve original creation time 369 - } 370 - 371 - // Convert pours 372 - if len(brew.Pours) > 0 { 373 - brewModel.Pours = make([]*models.Pour, len(brew.Pours)) 374 - for i, pour := range brew.Pours { 375 - brewModel.Pours[i] = &models.Pour{ 376 - WaterAmount: pour.WaterAmount, 377 - TimeSeconds: pour.TimeSeconds, 378 - } 379 - } 380 - } 328 + // Convert to models.Brew, preserving original creation time 329 + brewModel := brewModelFromRequest(brew, existing.CreatedAt) 381 330 382 331 // Convert to atproto record 383 332 record, err := BrewToRecord(brewModel, beanURI, grinderURI, brewerURI)
-28
internal/firehose/consumer.go
··· 379 379 return nil 380 380 } 381 381 382 - // BackfillKnownUsers backfills records for all known DIDs 383 - // This is useful on startup to ensure we have all existing records 384 - func (c *Consumer) BackfillKnownUsers(ctx context.Context) error { 385 - dids, err := c.index.GetKnownDIDs() 386 - if err != nil { 387 - return fmt.Errorf("failed to get known DIDs: %w", err) 388 - } 389 - 390 - log.Info().Int("count", len(dids)).Msg("firehose: backfilling known users") 391 - 392 - for _, did := range dids { 393 - select { 394 - case <-ctx.Done(): 395 - return ctx.Err() 396 - default: 397 - } 398 - 399 - if err := c.index.BackfillUser(ctx, did); err != nil { 400 - log.Warn().Err(err).Str("did", did).Msg("firehose: failed to backfill user") 401 - } 402 - 403 - // Small delay to avoid hammering PDS servers 404 - time.Sleep(100 * time.Millisecond) 405 - } 406 - 407 - return nil 408 - } 409 - 410 382 // BackfillDID backfills records for a specific DID 411 383 func (c *Consumer) BackfillDID(ctx context.Context, did string) error { 412 384 return c.index.BackfillUser(ctx, did)
+4
internal/handlers/auth.go
··· 134 134 Value: "", 135 135 Path: "/", 136 136 HttpOnly: true, 137 + Secure: h.config.SecureCookies, 138 + SameSite: http.SameSiteLaxMode, 137 139 MaxAge: -1, 138 140 }) 139 141 ··· 142 144 Value: "", 143 145 Path: "/", 144 146 HttpOnly: true, 147 + Secure: h.config.SecureCookies, 148 + SameSite: http.SameSiteLaxMode, 145 149 MaxAge: -1, 146 150 }) 147 151
+15 -28
internal/handlers/brew.go
··· 106 106 return 107 107 } 108 108 109 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 110 - userProfile := h.getUserProfile(r.Context(), didStr) 111 - 112 - // Create layout data 113 - layoutData := h.buildLayoutData(r, "Your Brews", authenticated, didStr, userProfile) 109 + layoutData, _, _ := h.layoutDataFromRequest(r, "Your Brews") 114 110 115 111 // Create brew list props 116 112 brewListProps := pages.BrewListProps{} ··· 131 127 return 132 128 } 133 129 134 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 135 - userProfile := h.getUserProfile(r.Context(), didStr) 136 - 137 130 // Don't fetch data from PDS - client will populate dropdowns from cache 138 131 // This makes the page load much faster 139 - layoutData := h.buildLayoutData(r, "New Brew", authenticated, didStr, userProfile) 132 + layoutData, _, _ := h.layoutDataFromRequest(r, "New Brew") 140 133 141 134 brewFormProps := pages.BrewFormProps{ 142 135 Brew: nil, ··· 164 157 165 158 var userProfile *bff.UserProfile 166 159 if isAuthenticated { 167 - userProfile = h.getUserProfile(r.Context(), didStr) 160 + userProfile = h.getUserProfile(r.Context(), didStr) //nolint: only partially uses layoutDataFromRequest due to complex flow 168 161 } 169 162 170 163 var brew *models.Brew ··· 348 341 return 349 342 } 350 343 351 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 352 - userProfile := h.getUserProfile(r.Context(), didStr) 353 - 354 344 brew, err := store.GetBrewByRKey(r.Context(), rkey) 355 345 if err != nil { 356 346 http.Error(w, "Brew not found", http.StatusNotFound) ··· 360 350 361 351 // Don't fetch dropdown data from PDS - client will populate from cache 362 352 // This makes the page load much faster 363 - layoutData := h.buildLayoutData(r, "Edit Brew", authenticated, didStr, userProfile) 353 + layoutData, _, _ := h.layoutDataFromRequest(r, "Edit Brew") 364 354 365 355 brewFormProps := pages.BrewFormProps{ 366 356 Brew: brew, ··· 534 524 Pours: pours, 535 525 } 536 526 527 + if err := req.Validate(); err != nil { 528 + http.Error(w, err.Error(), http.StatusBadRequest) 529 + return 530 + } 531 + 537 532 _, err := store.CreateBrew(r.Context(), req, 1) // User ID not used with atproto 538 533 if err != nil { 539 534 http.Error(w, "Failed to create brew", http.StatusInternalServerError) ··· 610 605 Pours: pours, 611 606 } 612 607 608 + if err := req.Validate(); err != nil { 609 + http.Error(w, err.Error(), http.StatusBadRequest) 610 + return 611 + } 612 + 613 613 err := store.UpdateBrewByRKey(r.Context(), rkey, req) 614 614 if err != nil { 615 615 http.Error(w, "Failed to update brew", http.StatusInternalServerError) ··· 624 624 625 625 // Delete brew 626 626 func (h *Handler) HandleBrewDelete(w http.ResponseWriter, r *http.Request) { 627 - rkey := validateRKey(w, r.PathValue("id")) 628 - if rkey == "" { 629 - return 630 - } 631 - 632 - // Require authentication 633 627 store, authenticated := h.getAtprotoStore(r) 634 628 if !authenticated { 635 629 http.Error(w, "Authentication required", http.StatusUnauthorized) 636 630 return 637 631 } 638 - 639 - if err := store.DeleteBrewByRKey(r.Context(), rkey); err != nil { 640 - http.Error(w, "Failed to delete brew", http.StatusInternalServerError) 641 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brew") 642 - return 643 - } 644 - 645 - w.WriteHeader(http.StatusOK) 632 + h.deleteEntity(w, r, store.DeleteBrewByRKey, "brew") 646 633 } 647 634 648 635 // Export brews as JSON
+5 -61
internal/handlers/entities.go
··· 252 252 return 253 253 } 254 254 255 - didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 256 - userProfile := h.getUserProfile(r.Context(), didStr) 257 - 258 - // Create layout data 259 - layoutData := h.buildLayoutData(r, "Manage", authenticated, didStr, userProfile) 255 + layoutData, _, _ := h.layoutDataFromRequest(r, "Manage") 260 256 261 257 // Create manage props 262 258 manageProps := pages.ManageProps{} ··· 336 332 } 337 333 338 334 func (h *Handler) HandleBeanDelete(w http.ResponseWriter, r *http.Request) { 339 - rkey := validateRKey(w, r.PathValue("id")) 340 - if rkey == "" { 341 - return 342 - } 343 - 344 - // Require authentication 345 335 store, authenticated := h.getAtprotoStore(r) 346 336 if !authenticated { 347 337 http.Error(w, "Authentication required", http.StatusUnauthorized) 348 338 return 349 339 } 350 - 351 - if err := store.DeleteBeanByRKey(r.Context(), rkey); err != nil { 352 - http.Error(w, "Failed to delete bean", http.StatusInternalServerError) 353 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete bean") 354 - return 355 - } 356 - 357 - w.WriteHeader(http.StatusOK) 340 + h.deleteEntity(w, r, store.DeleteBeanByRKey, "bean") 358 341 } 359 342 360 343 // Roaster update/delete handlers ··· 409 392 } 410 393 411 394 func (h *Handler) HandleRoasterDelete(w http.ResponseWriter, r *http.Request) { 412 - rkey := validateRKey(w, r.PathValue("id")) 413 - if rkey == "" { 414 - return 415 - } 416 - 417 - // Require authentication 418 395 store, authenticated := h.getAtprotoStore(r) 419 396 if !authenticated { 420 397 http.Error(w, "Authentication required", http.StatusUnauthorized) 421 398 return 422 399 } 423 - 424 - if err := store.DeleteRoasterByRKey(r.Context(), rkey); err != nil { 425 - http.Error(w, "Failed to delete roaster", http.StatusInternalServerError) 426 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete roaster") 427 - return 428 - } 429 - 430 - w.WriteHeader(http.StatusOK) 400 + h.deleteEntity(w, r, store.DeleteRoasterByRKey, "roaster") 431 401 } 432 402 433 403 // Grinder CRUD handlers ··· 523 493 } 524 494 525 495 func (h *Handler) HandleGrinderDelete(w http.ResponseWriter, r *http.Request) { 526 - rkey := validateRKey(w, r.PathValue("id")) 527 - if rkey == "" { 528 - return 529 - } 530 - 531 - // Require authentication 532 496 store, authenticated := h.getAtprotoStore(r) 533 497 if !authenticated { 534 498 http.Error(w, "Authentication required", http.StatusUnauthorized) 535 499 return 536 500 } 537 - 538 - if err := store.DeleteGrinderByRKey(r.Context(), rkey); err != nil { 539 - http.Error(w, "Failed to delete grinder", http.StatusInternalServerError) 540 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete grinder") 541 - return 542 - } 543 - 544 - w.WriteHeader(http.StatusOK) 501 + h.deleteEntity(w, r, store.DeleteGrinderByRKey, "grinder") 545 502 } 546 503 547 504 // Brewer CRUD handlers ··· 635 592 } 636 593 637 594 func (h *Handler) HandleBrewerDelete(w http.ResponseWriter, r *http.Request) { 638 - rkey := validateRKey(w, r.PathValue("id")) 639 - if rkey == "" { 640 - return 641 - } 642 - 643 - // Require authentication 644 595 store, authenticated := h.getAtprotoStore(r) 645 596 if !authenticated { 646 597 http.Error(w, "Authentication required", http.StatusUnauthorized) 647 598 return 648 599 } 649 - 650 - if err := store.DeleteBrewerByRKey(r.Context(), rkey); err != nil { 651 - http.Error(w, "Failed to delete brewer", http.StatusInternalServerError) 652 - log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brewer") 653 - return 654 - } 655 - 656 - w.WriteHeader(http.StatusOK) 600 + h.deleteEntity(w, r, store.DeleteBrewerByRKey, "brewer") 657 601 }
+1 -13
internal/handlers/feed.go
··· 8 8 "arabica/internal/feed" 9 9 "arabica/internal/models" 10 10 "arabica/internal/moderation" 11 - "arabica/internal/web/bff" 12 11 "arabica/internal/web/components" 13 12 "arabica/internal/web/pages" 14 13 ··· 51 50 52 51 // Home page 53 52 func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 54 - // Check if user is authenticated 55 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 56 - isAuthenticated := err == nil && didStr != "" 57 - 58 - // Fetch user profile for authenticated users 59 - var userProfile *bff.UserProfile 60 - if isAuthenticated { 61 - userProfile = h.getUserProfile(r.Context(), didStr) 62 - } 63 - 64 - // Create layout data 65 - layoutData := h.buildLayoutData(r, "Home", isAuthenticated, didStr, userProfile) 53 + layoutData, didStr, isAuthenticated := h.layoutDataFromRequest(r, "Home") 66 54 67 55 // Create home props 68 56 homeProps := pages.HomeProps{
+29
internal/handlers/handlers.go
··· 206 206 return store, true 207 207 } 208 208 209 + // layoutDataFromRequest extracts auth state from the request and builds layout data. 210 + // Returns the layout data, the user's DID (empty if not authenticated), and whether authenticated. 211 + func (h *Handler) layoutDataFromRequest(r *http.Request, title string) (layoutData *components.LayoutData, didStr string, isAuthenticated bool) { 212 + didStr, err := atproto.GetAuthenticatedDID(r.Context()) 213 + isAuthenticated = err == nil && didStr != "" 214 + 215 + var userProfile *bff.UserProfile 216 + if isAuthenticated { 217 + userProfile = h.getUserProfile(r.Context(), didStr) 218 + } 219 + 220 + layoutData = h.buildLayoutData(r, title, isAuthenticated, didStr, userProfile) 221 + return 222 + } 223 + 224 + // deleteEntity validates the rkey, calls the delete function, and returns 200. 225 + func (h *Handler) deleteEntity(w http.ResponseWriter, r *http.Request, deleteFn func(context.Context, string) error, entityName string) { 226 + rkey := validateRKey(w, r.PathValue("id")) 227 + if rkey == "" { 228 + return 229 + } 230 + if err := deleteFn(r.Context(), rkey); err != nil { 231 + http.Error(w, "Failed to delete "+entityName, http.StatusInternalServerError) 232 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete " + entityName) 233 + return 234 + } 235 + w.WriteHeader(http.StatusOK) 236 + } 237 + 209 238 // buildLayoutData creates a LayoutData struct with common fields populated from the request 210 239 func (h *Handler) buildLayoutData(r *http.Request, title string, isAuthenticated bool, didStr string, userProfile *bff.UserProfile) *components.LayoutData { 211 240 // Check if user is a moderator
+1 -1
internal/handlers/handlers_test.go
··· 83 83 rkey string 84 84 status int 85 85 }{ 86 - {"empty rkey", "", http.StatusBadRequest}, 86 + {"empty rkey", "", http.StatusUnauthorized}, 87 87 {"invalid format", "invalid-chars", http.StatusUnauthorized}, 88 88 } 89 89
+6 -39
internal/handlers/join.go
··· 11 11 "arabica/internal/database/boltstore" 12 12 "arabica/internal/middleware" 13 13 "arabica/internal/moderation" 14 - "arabica/internal/web/bff" 15 14 "arabica/internal/web/pages" 16 15 17 16 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 21 20 22 21 // HandleJoin renders the join request page. 23 22 func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { 24 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 25 - isAuthenticated := err == nil && didStr != "" 26 - 27 - var userProfile *bff.UserProfile 28 - if isAuthenticated { 29 - userProfile = h.getUserProfile(r.Context(), didStr) 30 - } 31 - 32 - layoutData := h.buildLayoutData(r, "Join Arabica", isAuthenticated, didStr, userProfile) 23 + layoutData, _, _ := h.layoutDataFromRequest(r, "Join Arabica") 33 24 34 25 if err := pages.Join(layoutData).Render(r.Context(), w); err != nil { 35 26 http.Error(w, "Failed to render page", http.StatusInternalServerError) ··· 95 86 } 96 87 97 88 func (h *Handler) renderJoinSuccess(w http.ResponseWriter, r *http.Request) { 98 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 99 - isAuthenticated := err == nil && didStr != "" 100 - 101 - var userProfile *bff.UserProfile 102 - if isAuthenticated { 103 - userProfile = h.getUserProfile(r.Context(), didStr) 104 - } 105 - 106 - layoutData := h.buildLayoutData(r, "Request Received", isAuthenticated, didStr, userProfile) 89 + layoutData, _, _ := h.layoutDataFromRequest(r, "Request Received") 107 90 108 91 if err := pages.JoinSuccess(layoutData).Render(r.Context(), w); err != nil { 109 92 http.Error(w, "Failed to render page", http.StatusInternalServerError) ··· 262 245 263 246 // HandleCreateAccount renders the account creation form (GET /join/create). 264 247 func (h *Handler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) { 265 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 266 - isAuthenticated := err == nil && didStr != "" 267 - 268 - var userProfile *bff.UserProfile 269 - if isAuthenticated { 270 - userProfile = h.getUserProfile(r.Context(), didStr) 271 - } 272 - 273 - layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 248 + layoutData, _, _ := h.layoutDataFromRequest(r, "Create Account") 274 249 275 250 props := pages.CreateAccountProps{ 276 251 InviteCode: r.URL.Query().Get("code"), ··· 285 260 286 261 // HandleCreateAccountSubmit processes the account creation form (POST /join/create). 287 262 func (h *Handler) HandleCreateAccountSubmit(w http.ResponseWriter, r *http.Request) { 288 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 289 - isAuthenticated := err == nil && didStr != "" 290 - 291 - var userProfile *bff.UserProfile 292 - if isAuthenticated { 293 - userProfile = h.getUserProfile(r.Context(), didStr) 294 - } 295 - 296 263 if err := r.ParseForm(); err != nil { 297 264 http.Error(w, "Invalid request", http.StatusBadRequest) 298 265 return ··· 307 274 308 275 // Honeypot check — bots fill hidden fields; show fake success 309 276 if honeypot != "" { 310 - layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 277 + layoutData, _, _ := h.layoutDataFromRequest(r, "Account Created") 311 278 _ = pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: "user.arabica.systems"}).Render(r.Context(), w) 312 279 return 313 280 } ··· 316 283 317 284 // Render form with error helper 318 285 renderError := func(msg string) { 319 - layoutData := h.buildLayoutData(r, "Create Account", isAuthenticated, didStr, userProfile) 286 + layoutData, _, _ := h.layoutDataFromRequest(r, "Create Account") 320 287 props := pages.CreateAccountProps{ 321 288 Error: msg, 322 289 InviteCode: inviteCode, ··· 383 350 384 351 log.Info().Str("handle", out.Handle).Str("did", out.Did).Msg("Account created") 385 352 386 - layoutData := h.buildLayoutData(r, "Account Created", isAuthenticated, didStr, userProfile) 353 + layoutData, _, _ := h.layoutDataFromRequest(r, "Account Created") 387 354 if err := pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: out.Handle}).Render(r.Context(), w); err != nil { 388 355 http.Error(w, "Failed to render page", http.StatusInternalServerError) 389 356 log.Error().Err(err).Msg("Failed to render create account success page")
+4 -41
internal/handlers/pages.go
··· 3 3 import ( 4 4 "net/http" 5 5 6 - "arabica/internal/atproto" 7 - "arabica/internal/web/bff" 8 6 "arabica/internal/web/pages" 9 7 10 8 "github.com/rs/zerolog/log" ··· 12 10 13 11 // About page 14 12 func (h *Handler) HandleAbout(w http.ResponseWriter, r *http.Request) { 15 - // Check if user is authenticated 16 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 17 - isAuthenticated := err == nil && didStr != "" 18 - 19 - var userProfile *bff.UserProfile 20 - if isAuthenticated { 21 - userProfile = h.getUserProfile(r.Context(), didStr) 22 - } 13 + data, _, _ := h.layoutDataFromRequest(r, "About") 23 14 24 - data := h.buildLayoutData(r, "About", isAuthenticated, didStr, userProfile) 25 - 26 - // Use templ component 27 15 if err := pages.About(data).Render(r.Context(), w); err != nil { 28 16 http.Error(w, "Failed to render page", http.StatusInternalServerError) 29 17 log.Error().Err(err).Msg("Failed to render about page") ··· 32 20 33 21 // Terms of Service page 34 22 func (h *Handler) HandleTerms(w http.ResponseWriter, r *http.Request) { 35 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 36 - isAuthenticated := err == nil 37 - 38 - var userProfile *bff.UserProfile 39 - if isAuthenticated { 40 - userProfile = h.getUserProfile(r.Context(), didStr) 41 - } 42 - 43 - layoutData := h.buildLayoutData(r, "Terms of Service", isAuthenticated, didStr, userProfile) 23 + layoutData, _, _ := h.layoutDataFromRequest(r, "Terms of Service") 44 24 45 25 if err := pages.Terms(layoutData).Render(r.Context(), w); err != nil { 46 26 http.Error(w, "Failed to render page", http.StatusInternalServerError) ··· 49 29 } 50 30 51 31 func (h *Handler) HandleATProto(w http.ResponseWriter, r *http.Request) { 52 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 53 - isAuthenticated := err == nil 54 - 55 - var userProfile *bff.UserProfile 56 - if isAuthenticated { 57 - userProfile = h.getUserProfile(r.Context(), didStr) 58 - } 59 - 60 - layoutData := h.buildLayoutData(r, "AT Protocol", isAuthenticated, didStr, userProfile) 32 + layoutData, _, _ := h.layoutDataFromRequest(r, "AT Protocol") 61 33 62 34 if err := pages.ATProto(layoutData).Render(r.Context(), w); err != nil { 63 35 http.Error(w, "Failed to render page", http.StatusInternalServerError) ··· 67 39 68 40 // HandleNotFound renders the 404 page 69 41 func (h *Handler) HandleNotFound(w http.ResponseWriter, r *http.Request) { 70 - // Check if current user is authenticated (for nav bar state) 71 - didStr, err := atproto.GetAuthenticatedDID(r.Context()) 72 - isAuthenticated := err == nil && didStr != "" 73 - 74 - var userProfile *bff.UserProfile 75 - if isAuthenticated { 76 - userProfile = h.getUserProfile(r.Context(), didStr) 77 - } 78 - 79 - layoutData := h.buildLayoutData(r, "Page Not Found", isAuthenticated, didStr, userProfile) 42 + layoutData, _, _ := h.layoutDataFromRequest(r, "Page Not Found") 80 43 81 44 w.WriteHeader(http.StatusNotFound) 82 45 if err := pages.NotFound(layoutData).Render(r.Context(), w); err != nil {
+3 -9
internal/handlers/profile.go
··· 256 256 } 257 257 258 258 // Check if current user is authenticated (for nav bar state) 259 - didStr, err := atproto.GetAuthenticatedDID(ctx) 260 - isAuthenticated := err == nil && didStr != "" 261 - 262 - var userProfile *bff.UserProfile 263 - if isAuthenticated { 264 - userProfile = h.getUserProfile(ctx, didStr) 265 - } 259 + _, didStr, isAuthenticated := h.layoutDataFromRequest(r, "Profile") 266 260 267 261 // Check if this is an Arabica user (has records or is registered in feed) 268 262 isArabicaUser := h.feedRegistry.IsRegistered(did) || ··· 271 265 len(profileData.Brewers) > 0 272 266 273 267 if !isArabicaUser { 274 - layoutData := h.buildLayoutData(r, "Profile Not Found", isAuthenticated, didStr, userProfile) 268 + layoutData, _, _ := h.layoutDataFromRequest(r, "Profile Not Found") 275 269 w.WriteHeader(http.StatusNotFound) 276 270 if err := pages.ProfileNotFound(layoutData).Render(r.Context(), w); err != nil { 277 271 log.Error().Err(err).Msg("Failed to render profile not found page") ··· 298 292 if viewedProfile.DisplayName != "" { 299 293 pageTitle = viewedProfile.DisplayName + " - Profile" 300 294 } 301 - layoutData := h.buildLayoutData(r, pageTitle, isAuthenticated, didStr, userProfile) 295 + layoutData, _, _ := h.layoutDataFromRequest(r, pageTitle) 302 296 303 297 // Create roaster options for own profile 304 298 var roasterOptions []pages.RoasterOption
+16 -1
internal/models/models.go
··· 19 19 MaxGrindSizeLength = 100 20 20 MaxGrinderTypeLength = 50 21 21 MaxBurrTypeLength = 50 22 - MaxBrewerTypeLength = 100 22 + MaxBrewerTypeLength = 100 23 + MaxTastingNotesLength = 2000 23 24 ) 24 25 25 26 // Validation errors ··· 330 331 } 331 332 if len(r.Description) > MaxDescriptionLength { 332 333 return ErrDescTooLong 334 + } 335 + return nil 336 + } 337 + 338 + // Validate checks that all string fields are within acceptable limits 339 + func (r *CreateBrewRequest) Validate() error { 340 + if len(r.Method) > MaxMethodLength { 341 + return ErrFieldTooLong 342 + } 343 + if len(r.GrindSize) > MaxGrindSizeLength { 344 + return ErrFieldTooLong 345 + } 346 + if len(r.TastingNotes) > MaxTastingNotesLength { 347 + return ErrFieldTooLong 333 348 } 334 349 return nil 335 350 }
-88
internal/web/bff/helpers.go
··· 34 34 return fmt.Sprintf("%.1f°%c", temp, unit) 35 35 } 36 36 37 - // FormatTempValue formats a temperature for use in input fields (numeric value only). 38 - func FormatTempValue(temp float64) string { 39 - return fmt.Sprintf("%.1f", temp) 40 - } 41 - 42 37 // FormatTime formats seconds into a human-readable time string (e.g., "3m 30s"). 43 38 // Returns "N/A" if seconds is 0. 44 39 func FormatTime(seconds int) string { ··· 65 60 return fmt.Sprintf("%d/10", rating) 66 61 } 67 62 68 - // FormatID converts an int to string. 69 - func FormatID(id int) string { 70 - return fmt.Sprintf("%d", id) 71 - } 72 - 73 - // FormatInt converts an int to string. 74 - func FormatInt(val int) string { 75 - return fmt.Sprintf("%d", val) 76 - } 77 - 78 - // FormatRoasterID formats a nullable roaster ID. 79 - // Returns "null" if id is nil, otherwise the ID as a string. 80 - func FormatRoasterID(id *int) string { 81 - if id == nil { 82 - return "null" 83 - } 84 - return fmt.Sprintf("%d", *id) 85 - } 86 - 87 63 // PoursToJSON serializes a slice of pours to JSON for use in JavaScript. 88 64 func PoursToJSON(pours []*models.Pour) string { 89 65 if len(pours) == 0 { ··· 111 87 return string(jsonBytes) 112 88 } 113 89 114 - // Ptr returns a pointer to the given value. 115 - func Ptr[T any](v T) *T { 116 - return &v 117 - } 118 - 119 - // PtrEquals checks if a pointer equals a value. 120 - // Returns false if the pointer is nil. 121 - func PtrEquals[T comparable](p *T, val T) bool { 122 - if p == nil { 123 - return false 124 - } 125 - return *p == val 126 - } 127 - 128 - // PtrValue returns the dereferenced value of a pointer, or zero value if nil. 129 - func PtrValue[T any](p *T) T { 130 - if p == nil { 131 - var zero T 132 - return zero 133 - } 134 - return *p 135 - } 136 - 137 - // Iterate returns a slice of ints from 0 to n-1, useful for range loops in templates. 138 - func Iterate(n int) []int { 139 - result := make([]int, n) 140 - for i := range result { 141 - result[i] = i 142 - } 143 - return result 144 - } 145 - 146 - // IterateRemaining returns a slice of ints for the remaining count, useful for star ratings. 147 - // For example, IterateRemaining(3, 5) returns [0, 1] for the 2 remaining empty stars. 148 - func IterateRemaining(current, total int) []int { 149 - remaining := total - current 150 - if remaining <= 0 { 151 - return nil 152 - } 153 - result := make([]int, remaining) 154 - for i := range result { 155 - result[i] = i 156 - } 157 - return result 158 - } 159 - 160 90 // HasTemp returns true if temperature is greater than zero 161 91 func HasTemp(temp float64) bool { 162 92 return temp > 0 ··· 252 182 s = strings.ReplaceAll(s, "\r", "\\r") 253 183 s = strings.ReplaceAll(s, "\t", "\\t") 254 184 return s 255 - } 256 - 257 - // Dict creates a map from alternating key-value arguments. 258 - // Useful for passing multiple parameters to sub-templates in Go templates. 259 - // Example: {{template "foo" dict "Key1" .Value1 "Key2" .Value2}} 260 - func Dict(values ...interface{}) (map[string]interface{}, error) { 261 - if len(values)%2 != 0 { 262 - return nil, fmt.Errorf("dict requires an even number of arguments") 263 - } 264 - dict := make(map[string]interface{}, len(values)/2) 265 - for i := 0; i < len(values); i += 2 { 266 - key, ok := values[i].(string) 267 - if !ok { 268 - return nil, fmt.Errorf("dict keys must be strings") 269 - } 270 - dict[key] = values[i+1] 271 - } 272 - return dict, nil 273 185 } 274 186 275 187 // FormatTimeAgo returns a human-readable relative time string
-139
internal/web/bff/helpers_test.go
··· 31 31 } 32 32 } 33 33 34 - func TestFormatTempValue(t *testing.T) { 35 - tests := []struct { 36 - name string 37 - temp float64 38 - expected string 39 - }{ 40 - {"zero", 0, "0.0"}, 41 - {"whole number", 93.0, "93.0"}, 42 - {"decimal", 93.5, "93.5"}, 43 - {"high precision rounds", 93.55, "93.5"}, 44 - } 45 - 46 - for _, tt := range tests { 47 - t.Run(tt.name, func(t *testing.T) { 48 - got := FormatTempValue(tt.temp) 49 - assert.Equal(t, tt.expected, got) 50 - }) 51 - } 52 - } 53 - 54 34 func TestFormatTime(t *testing.T) { 55 35 tests := []struct { 56 36 name string ··· 94 74 } 95 75 } 96 76 97 - func TestFormatID(t *testing.T) { 98 - tests := []struct { 99 - name string 100 - id int 101 - expected string 102 - }{ 103 - {"zero", 0, "0"}, 104 - {"positive", 123, "123"}, 105 - {"large number", 99999, "99999"}, 106 - } 107 - 108 - for _, tt := range tests { 109 - t.Run(tt.name, func(t *testing.T) { 110 - got := FormatID(tt.id) 111 - assert.Equal(t, tt.expected, got) 112 - }) 113 - } 114 - } 115 - 116 - func TestFormatInt(t *testing.T) { 117 - tests := []struct { 118 - name string 119 - val int 120 - expected string 121 - }{ 122 - {"zero", 0, "0"}, 123 - {"positive", 42, "42"}, 124 - {"negative", -5, "-5"}, 125 - } 126 - 127 - for _, tt := range tests { 128 - t.Run(tt.name, func(t *testing.T) { 129 - got := FormatInt(tt.val) 130 - assert.Equal(t, tt.expected, got) 131 - }) 132 - } 133 - } 134 - 135 - func TestFormatRoasterID(t *testing.T) { 136 - t.Run("nil returns null", func(t *testing.T) { 137 - got := FormatRoasterID(nil) 138 - assert.Equal(t, "null", got) 139 - }) 140 - 141 - t.Run("valid pointer", func(t *testing.T) { 142 - id := 123 143 - got := FormatRoasterID(&id) 144 - assert.Equal(t, "123", got) 145 - }) 146 - 147 - t.Run("zero pointer", func(t *testing.T) { 148 - id := 0 149 - got := FormatRoasterID(&id) 150 - assert.Equal(t, "0", got) 151 - }) 152 - } 153 - 154 77 func TestPoursToJSON(t *testing.T) { 155 78 tests := []struct { 156 79 name string ··· 199 122 }) 200 123 } 201 124 } 202 - 203 - func TestPtr(t *testing.T) { 204 - t.Run("int", func(t *testing.T) { 205 - p := Ptr(42) 206 - assert.Equal(t, 42, *p) 207 - }) 208 - 209 - t.Run("string", func(t *testing.T) { 210 - p := Ptr("hello") 211 - assert.Equal(t, "hello", *p) 212 - }) 213 - 214 - t.Run("zero value", func(t *testing.T) { 215 - p := Ptr(0) 216 - assert.Equal(t, 0, *p) 217 - }) 218 - } 219 - 220 - func TestPtrEquals(t *testing.T) { 221 - t.Run("nil pointer returns false", func(t *testing.T) { 222 - var p *int = nil 223 - assert.False(t, PtrEquals(p, 42)) 224 - }) 225 - 226 - t.Run("matching value returns true", func(t *testing.T) { 227 - val := 42 228 - assert.True(t, PtrEquals(&val, 42)) 229 - }) 230 - 231 - t.Run("non-matching value returns false", func(t *testing.T) { 232 - val := 42 233 - assert.False(t, PtrEquals(&val, 99)) 234 - }) 235 - 236 - t.Run("string comparison", func(t *testing.T) { 237 - s := "hello" 238 - assert.True(t, PtrEquals(&s, "hello")) 239 - assert.False(t, PtrEquals(&s, "world")) 240 - }) 241 - } 242 - 243 - func TestPtrValue(t *testing.T) { 244 - t.Run("nil int returns zero", func(t *testing.T) { 245 - var p *int = nil 246 - assert.Equal(t, 0, PtrValue(p)) 247 - }) 248 - 249 - t.Run("valid int returns value", func(t *testing.T) { 250 - val := 42 251 - assert.Equal(t, 42, PtrValue(&val)) 252 - }) 253 - 254 - t.Run("nil string returns empty", func(t *testing.T) { 255 - var p *string = nil 256 - assert.Equal(t, "", PtrValue(p)) 257 - }) 258 - 259 - t.Run("valid string returns value", func(t *testing.T) { 260 - s := "hello" 261 - assert.Equal(t, "hello", PtrValue(&s)) 262 - }) 263 - }