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: best-practices fixes and general refactor

pdewey 4c57d13b 2362d69a

+760 -524
+19
README.md
··· 27 27 Environment variables: 28 28 29 29 - `PORT` - Server port (default: 18910) 30 + - `SERVER_PUBLIC_URL` - Public URL for reverse proxy deployments (e.g., https://arabica.example.com) 30 31 - `ARABICA_DB_PATH` - BoltDB path (default: ~/.local/share/arabica/arabica.db) 31 32 - `OAUTH_CLIENT_ID` - OAuth client ID (optional, uses localhost mode if not set) 32 33 - `OAUTH_REDIRECT_URI` - OAuth redirect URI (optional) ··· 70 71 ``` 71 72 72 73 ## Deployment 74 + 75 + ### Reverse Proxy Setup 76 + 77 + When deploying behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel, etc.), set the `SERVER_PUBLIC_URL` environment variable to your public-facing URL: 78 + 79 + ```bash 80 + # Example with nginx reverse proxy 81 + SERVER_PUBLIC_URL=https://arabica.example.com 82 + SECURE_COOKIES=true 83 + PORT=18910 84 + 85 + # The server listens on localhost:18910 86 + # But OAuth callbacks use https://arabica.example.com/oauth/callback 87 + ``` 88 + 89 + The `SERVER_PUBLIC_URL` is used for OAuth client metadata and callback URLs, ensuring the AT Protocol OAuth flow works correctly when the server is accessed via a different URL than it's running on. 90 + 91 + ### NixOS Deployment 73 92 74 93 See docs/nix-install.md for NixOS deployment instructions. 75 94
+35 -13
cmd/server/main.go
··· 54 54 port = "18910" 55 55 } 56 56 57 + // Get public root URL for reverse proxy deployments 58 + // This allows the server to be accessed via a different URL than it's running on 59 + // e.g., SERVER_PUBLIC_URL=https://arabica.example.com when behind a reverse proxy 60 + publicURL := os.Getenv("SERVER_PUBLIC_URL") 61 + 57 62 // Initialize BoltDB store for persistent sessions and feed registry 58 63 dbPath := os.Getenv("ARABICA_DB_PATH") 59 64 if dbPath == "" { ··· 90 95 redirectURI := os.Getenv("OAUTH_REDIRECT_URI") 91 96 92 97 if clientID == "" && redirectURI == "" { 93 - // Use localhost defaults for development 94 - redirectURI = fmt.Sprintf("http://127.0.0.1:%s/oauth/callback", port) 95 - clientID = "" // Empty triggers localhost mode 96 - log.Info().Msg("Using localhost OAuth mode (for development)") 98 + // Use public URL if set, otherwise localhost defaults for development 99 + if publicURL != "" { 100 + redirectURI = publicURL + "/oauth/callback" 101 + clientID = publicURL + "/oauth-client-metadata.json" 102 + log.Info(). 103 + Str("public_url", publicURL). 104 + Msg("Using public URL for OAuth (reverse proxy mode)") 105 + } else { 106 + redirectURI = fmt.Sprintf("http://127.0.0.1:%s/oauth/callback", port) 107 + clientID = "" // Empty triggers localhost mode 108 + log.Info().Msg("Using localhost OAuth mode (for development)") 109 + } 97 110 } 98 111 99 112 oauthManager, err := atproto.NewOAuthManager(clientID, redirectURI, sessionStore) ··· 123 136 Msg("OAuth configured") 124 137 } else { 125 138 log.Info(). 139 + Str("mode", "public"). 126 140 Str("client_id", clientID). 127 141 Str("redirect_uri", redirectURI). 128 142 Msg("OAuth configured") ··· 132 146 atprotoClient := atproto.NewClient(oauthManager) 133 147 log.Info().Msg("ATProto client initialized") 134 148 149 + // Initialize session cache for in-memory caching of user data 150 + sessionCache := atproto.NewSessionCache() 151 + stopCacheCleanup := sessionCache.StartCleanupRoutine(10 * time.Minute) 152 + defer stopCacheCleanup() 153 + log.Info().Msg("Session cache initialized with background cleanup") 154 + 135 155 // Determine if we should use secure cookies (default: false for development) 136 156 // Set SECURE_COOKIES=true in production with HTTPS 137 157 secureCookies := os.Getenv("SECURE_COOKIES") == "true" 138 158 139 - // Initialize handlers 140 - h := handlers.NewHandler() 141 - h.SetConfig(handlers.Config{ 142 - SecureCookies: secureCookies, 143 - }) 144 - h.SetOAuthManager(oauthManager) 145 - h.SetAtprotoClient(atprotoClient) 146 - h.SetFeedRegistry(feedRegistry) 147 - h.SetFeedService(feedService) 159 + // Initialize handlers with all dependencies via constructor injection 160 + h := handlers.NewHandler( 161 + oauthManager, 162 + atprotoClient, 163 + sessionCache, 164 + feedService, 165 + feedRegistry, 166 + handlers.Config{ 167 + SecureCookies: secureCookies, 168 + }, 169 + ) 148 170 149 171 // Setup router with middleware 150 172 handler := routing.SetupRouter(routing.Config{
+1
go.mod
··· 6 6 github.com/bluesky-social/indigo v0.0.0-20260103083015-78a1c1894f36 7 7 github.com/rs/zerolog v1.34.0 8 8 go.etcd.io/bbolt v1.3.8 9 + golang.org/x/sync v0.19.0 9 10 ) 10 11 11 12 require (
+2
go.sum
··· 73 73 go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= 74 74 golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 75 75 golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 76 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 77 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 76 78 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 77 79 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 78 80 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+126 -25
internal/atproto/cache.go
··· 10 10 // CacheTTL is how long cached data remains valid 11 11 const CacheTTL = 5 * time.Minute 12 12 13 - // UserCache holds cached data for a single user 13 + // UserCache holds cached data for a single user. 14 + // This struct is immutable once created - modifications create new instances. 14 15 type UserCache struct { 15 16 Beans []*models.Bean 16 17 Roasters []*models.Roaster ··· 28 29 return time.Since(c.Timestamp) < CacheTTL 29 30 } 30 31 31 - // SessionCache manages per-user caches 32 + // clone creates a shallow copy of the UserCache for safe modification 33 + func (c *UserCache) clone() *UserCache { 34 + if c == nil { 35 + return &UserCache{Timestamp: time.Now()} 36 + } 37 + return &UserCache{ 38 + Beans: c.Beans, 39 + Roasters: c.Roasters, 40 + Grinders: c.Grinders, 41 + Brewers: c.Brewers, 42 + Brews: c.Brews, 43 + Timestamp: c.Timestamp, 44 + } 45 + } 46 + 47 + // SessionCache manages per-user caches with proper synchronization. 48 + // Uses copy-on-write pattern to avoid race conditions when reading 49 + // cache entries while other goroutines are modifying them. 32 50 type SessionCache struct { 33 51 mu sync.RWMutex 34 52 caches map[string]*UserCache // keyed by session ID 35 53 } 36 54 37 - // Global session cache instance 38 - var globalCache = &SessionCache{ 39 - caches: make(map[string]*UserCache), 55 + // NewSessionCache creates a new session cache instance. 56 + // Prefer this over global state for better testability and dependency injection. 57 + func NewSessionCache() *SessionCache { 58 + return &SessionCache{ 59 + caches: make(map[string]*UserCache), 60 + } 40 61 } 41 62 42 - // GetSessionCache returns the global session cache 43 - func GetSessionCache() *SessionCache { 44 - return globalCache 45 - } 46 - 47 - // Get retrieves a user's cache by session ID 63 + // Get retrieves a user's cache by session ID. 64 + // The returned UserCache is safe to read without holding a lock. 48 65 func (sc *SessionCache) Get(sessionID string) *UserCache { 49 66 sc.mu.RLock() 50 67 defer sc.mu.RUnlock() 51 68 return sc.caches[sessionID] 52 69 } 53 70 54 - // Set stores a user's cache 71 + // Set stores a user's cache (replaces entirely) 55 72 func (sc *SessionCache) Set(sessionID string, cache *UserCache) { 56 73 sc.mu.Lock() 57 74 defer sc.mu.Unlock() 58 75 sc.caches[sessionID] = cache 59 76 } 60 77 61 - // Invalidate removes a user's cache 78 + // Invalidate removes a user's cache entirely 62 79 func (sc *SessionCache) Invalidate(sessionID string) { 63 80 sc.mu.Lock() 64 81 defer sc.mu.Unlock() 65 82 delete(sc.caches, sessionID) 66 83 } 67 84 68 - // InvalidateBeans marks that beans need to be refreshed 85 + // SetBeans updates just the beans in the cache using copy-on-write 86 + func (sc *SessionCache) SetBeans(sessionID string, beans []*models.Bean) { 87 + sc.mu.Lock() 88 + defer sc.mu.Unlock() 89 + newCache := sc.caches[sessionID].clone() 90 + newCache.Beans = beans 91 + newCache.Timestamp = time.Now() 92 + sc.caches[sessionID] = newCache 93 + } 94 + 95 + // SetRoasters updates just the roasters in the cache using copy-on-write 96 + func (sc *SessionCache) SetRoasters(sessionID string, roasters []*models.Roaster) { 97 + sc.mu.Lock() 98 + defer sc.mu.Unlock() 99 + newCache := sc.caches[sessionID].clone() 100 + newCache.Roasters = roasters 101 + newCache.Timestamp = time.Now() 102 + sc.caches[sessionID] = newCache 103 + } 104 + 105 + // SetGrinders updates just the grinders in the cache using copy-on-write 106 + func (sc *SessionCache) SetGrinders(sessionID string, grinders []*models.Grinder) { 107 + sc.mu.Lock() 108 + defer sc.mu.Unlock() 109 + newCache := sc.caches[sessionID].clone() 110 + newCache.Grinders = grinders 111 + newCache.Timestamp = time.Now() 112 + sc.caches[sessionID] = newCache 113 + } 114 + 115 + // SetBrewers updates just the brewers in the cache using copy-on-write 116 + func (sc *SessionCache) SetBrewers(sessionID string, brewers []*models.Brewer) { 117 + sc.mu.Lock() 118 + defer sc.mu.Unlock() 119 + newCache := sc.caches[sessionID].clone() 120 + newCache.Brewers = brewers 121 + newCache.Timestamp = time.Now() 122 + sc.caches[sessionID] = newCache 123 + } 124 + 125 + // SetBrews updates just the brews in the cache using copy-on-write 126 + func (sc *SessionCache) SetBrews(sessionID string, brews []*models.Brew) { 127 + sc.mu.Lock() 128 + defer sc.mu.Unlock() 129 + newCache := sc.caches[sessionID].clone() 130 + newCache.Brews = brews 131 + newCache.Timestamp = time.Now() 132 + sc.caches[sessionID] = newCache 133 + } 134 + 135 + // InvalidateBeans marks that beans need to be refreshed using copy-on-write 69 136 func (sc *SessionCache) InvalidateBeans(sessionID string) { 70 137 sc.mu.Lock() 71 138 defer sc.mu.Unlock() 72 139 if cache, ok := sc.caches[sessionID]; ok { 73 - cache.Beans = nil 140 + newCache := cache.clone() 141 + newCache.Beans = nil 142 + sc.caches[sessionID] = newCache 74 143 } 75 144 } 76 145 77 - // InvalidateRoasters marks that roasters need to be refreshed 146 + // InvalidateRoasters marks that roasters need to be refreshed using copy-on-write 78 147 func (sc *SessionCache) InvalidateRoasters(sessionID string) { 79 148 sc.mu.Lock() 80 149 defer sc.mu.Unlock() 81 150 if cache, ok := sc.caches[sessionID]; ok { 82 - cache.Roasters = nil 151 + newCache := cache.clone() 152 + newCache.Roasters = nil 83 153 // Also invalidate beans since they reference roasters 84 - cache.Beans = nil 154 + newCache.Beans = nil 155 + sc.caches[sessionID] = newCache 85 156 } 86 157 } 87 158 88 - // InvalidateGrinders marks that grinders need to be refreshed 159 + // InvalidateGrinders marks that grinders need to be refreshed using copy-on-write 89 160 func (sc *SessionCache) InvalidateGrinders(sessionID string) { 90 161 sc.mu.Lock() 91 162 defer sc.mu.Unlock() 92 163 if cache, ok := sc.caches[sessionID]; ok { 93 - cache.Grinders = nil 164 + newCache := cache.clone() 165 + newCache.Grinders = nil 166 + sc.caches[sessionID] = newCache 94 167 } 95 168 } 96 169 97 - // InvalidateBrewers marks that brewers need to be refreshed 170 + // InvalidateBrewers marks that brewers need to be refreshed using copy-on-write 98 171 func (sc *SessionCache) InvalidateBrewers(sessionID string) { 99 172 sc.mu.Lock() 100 173 defer sc.mu.Unlock() 101 174 if cache, ok := sc.caches[sessionID]; ok { 102 - cache.Brewers = nil 175 + newCache := cache.clone() 176 + newCache.Brewers = nil 177 + sc.caches[sessionID] = newCache 103 178 } 104 179 } 105 180 106 - // InvalidateBrews marks that brews need to be refreshed 181 + // InvalidateBrews marks that brews need to be refreshed using copy-on-write 107 182 func (sc *SessionCache) InvalidateBrews(sessionID string) { 108 183 sc.mu.Lock() 109 184 defer sc.mu.Unlock() 110 185 if cache, ok := sc.caches[sessionID]; ok { 111 - cache.Brews = nil 186 + newCache := cache.clone() 187 + newCache.Brews = nil 188 + sc.caches[sessionID] = newCache 112 189 } 113 190 } 114 191 115 - // Cleanup removes expired caches (call periodically) 192 + // Cleanup removes expired caches. 193 + // This should be called periodically by a background goroutine. 116 194 func (sc *SessionCache) Cleanup() { 117 195 sc.mu.Lock() 118 196 defer sc.mu.Unlock() ··· 124 202 } 125 203 } 126 204 } 205 + 206 + // StartCleanupRoutine starts a background goroutine that periodically cleans up 207 + // expired cache entries. Returns a stop function to gracefully shut down. 208 + func (sc *SessionCache) StartCleanupRoutine(interval time.Duration) (stop func()) { 209 + ticker := time.NewTicker(interval) 210 + done := make(chan struct{}) 211 + 212 + go func() { 213 + for { 214 + select { 215 + case <-ticker.C: 216 + sc.Cleanup() 217 + case <-done: 218 + ticker.Stop() 219 + return 220 + } 221 + } 222 + }() 223 + 224 + return func() { 225 + close(done) 226 + } 227 + }
+8
internal/atproto/client.go
··· 288 288 limit := int64(100) 289 289 290 290 for { 291 + // Check for context cancellation before each page request 292 + // This allows long-running pagination to be cancelled gracefully 293 + select { 294 + case <-ctx.Done(): 295 + return nil, ctx.Err() 296 + default: 297 + } 298 + 291 299 output, err := c.ListRecords(ctx, did, sessionID, &ListRecordsInput{ 292 300 Collection: collection, 293 301 Limit: &limit,
+67 -149
internal/atproto/store.go
··· 12 12 "github.com/rs/zerolog/log" 13 13 ) 14 14 15 - // AtprotoStore implements the database.Store interface using atproto records 15 + // AtprotoStore implements the database.Store interface using atproto records. 16 + // Context is passed as a parameter to each method rather than stored in the struct, 17 + // following Go best practices for context propagation. 16 18 type AtprotoStore struct { 17 19 client *Client 18 20 did syntax.DID 19 21 sessionID string 22 + cache *SessionCache 20 23 } 21 24 22 - // NewAtprotoStore creates a new atproto store for a specific user session 23 - func NewAtprotoStore(ctx context.Context, client *Client, did syntax.DID, sessionID string) database.Store { 25 + // NewAtprotoStore creates a new atproto store for a specific user session. 26 + // The cache parameter allows for dependency injection and testability. 27 + func NewAtprotoStore(client *Client, did syntax.DID, sessionID string, cache *SessionCache) database.Store { 24 28 return &AtprotoStore{ 25 29 client: client, 26 30 did: did, 27 31 sessionID: sessionID, 32 + cache: cache, 28 33 } 29 34 } 30 35 31 - // getContext returns a context for API calls 32 - // Since we don't store context in the struct anymore, callers should pass context 33 - // For now, we use background context but this should be improved 34 - func (s *AtprotoStore) getContext() context.Context { 35 - return context.Background() 36 - } 37 - 38 36 // ========== Brew Operations ========== 39 37 40 - func (s *AtprotoStore) CreateBrew(brew *models.CreateBrewRequest, userID int) (*models.Brew, error) { 41 - ctx := s.getContext() 42 - 38 + func (s *AtprotoStore) CreateBrew(ctx context.Context, brew *models.CreateBrewRequest, userID int) (*models.Brew, error) { 43 39 // Build AT-URI references from rkeys 44 40 if brew.BeanRKey == "" { 45 41 return nil, fmt.Errorf("bean_rkey is required") ··· 108 104 brewModel.RKey = rkey 109 105 110 106 // Invalidate brews cache 111 - GetSessionCache().InvalidateBrews(s.sessionID) 107 + s.cache.InvalidateBrews(s.sessionID) 112 108 113 109 // Fetch and resolve references to populate Bean, Grinder, Brewer 114 110 err = ResolveBrewRefs(ctx, s.client, brewModel, beanURI, grinderURI, brewerURI, s.sessionID) ··· 120 116 return brewModel, nil 121 117 } 122 118 123 - func (s *AtprotoStore) GetBrewByRKey(rkey string) (*models.Brew, error) { 124 - ctx := s.getContext() 125 - 119 + func (s *AtprotoStore) GetBrewByRKey(ctx context.Context, rkey string) (*models.Brew, error) { 126 120 output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ 127 121 Collection: NSIDBrew, 128 122 RKey: rkey, ··· 173 167 return brew, nil 174 168 } 175 169 176 - func (s *AtprotoStore) ListBrews(userID int) ([]*models.Brew, error) { 170 + func (s *AtprotoStore) ListBrews(ctx context.Context, userID int) ([]*models.Brew, error) { 177 171 // Check cache first 178 - cache := GetSessionCache() 179 - userCache := cache.Get(s.sessionID) 172 + userCache := s.cache.Get(s.sessionID) 180 173 if userCache != nil && userCache.Brews != nil && userCache.IsValid() { 181 174 return userCache.Brews, nil 182 175 } 183 176 184 - ctx := s.getContext() 185 - 186 177 // Use ListAllRecords to handle pagination automatically 187 178 output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDBrew) 188 179 if err != nil { ··· 230 221 // Resolve references using cached data instead of N+1 queries 231 222 // This fetches beans/grinders/brewers once (from cache if available) 232 223 // then links them to brews in memory 233 - beans, _ := s.ListBeans() 234 - grinders, _ := s.ListGrinders() 235 - brewers, _ := s.ListBrewers() 236 - roasters, _ := s.ListRoasters() 224 + beans, _ := s.ListBeans(ctx) 225 + grinders, _ := s.ListGrinders(ctx) 226 + brewers, _ := s.ListBrewers(ctx) 227 + roasters, _ := s.ListRoasters(ctx) 237 228 238 229 // Build lookup maps 239 230 beanMap := make(map[string]*models.Bean) ··· 271 262 } 272 263 273 264 // Update cache 274 - if userCache == nil { 275 - userCache = &UserCache{Timestamp: time.Now()} 276 - } 277 - userCache.Brews = brews 278 - userCache.Timestamp = time.Now() 279 - cache.Set(s.sessionID, userCache) 265 + s.cache.SetBrews(s.sessionID, brews) 280 266 281 267 return brews, nil 282 268 } 283 269 284 - func (s *AtprotoStore) UpdateBrewByRKey(rkey string, brew *models.CreateBrewRequest) error { 285 - ctx := s.getContext() 286 - 270 + func (s *AtprotoStore) UpdateBrewByRKey(ctx context.Context, rkey string, brew *models.CreateBrewRequest) error { 287 271 // Build AT-URI references from rkeys 288 272 if brew.BeanRKey == "" { 289 273 return fmt.Errorf("bean_rkey is required") ··· 300 284 } 301 285 302 286 // Get the existing record to preserve createdAt 303 - existing, err := s.GetBrewByRKey(rkey) 287 + existing, err := s.GetBrewByRKey(ctx, rkey) 304 288 if err != nil { 305 289 return fmt.Errorf("failed to get existing brew: %w", err) 306 290 } ··· 349 333 } 350 334 351 335 // Invalidate brews cache 352 - GetSessionCache().InvalidateBrews(s.sessionID) 336 + s.cache.InvalidateBrews(s.sessionID) 353 337 354 338 return nil 355 339 } 356 340 357 - func (s *AtprotoStore) DeleteBrewByRKey(rkey string) error { 358 - ctx := s.getContext() 359 - 341 + func (s *AtprotoStore) DeleteBrewByRKey(ctx context.Context, rkey string) error { 360 342 err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ 361 343 Collection: NSIDBrew, 362 344 RKey: rkey, ··· 366 348 } 367 349 368 350 // Invalidate brews cache 369 - GetSessionCache().InvalidateBrews(s.sessionID) 351 + s.cache.InvalidateBrews(s.sessionID) 370 352 371 353 return nil 372 354 } 373 355 374 356 // ========== Bean Operations ========== 375 357 376 - func (s *AtprotoStore) CreateBean(bean *models.CreateBeanRequest) (*models.Bean, error) { 377 - ctx := s.getContext() 378 - 358 + func (s *AtprotoStore) CreateBean(ctx context.Context, bean *models.CreateBeanRequest) (*models.Bean, error) { 379 359 var roasterURI string 380 360 if bean.RoasterRKey != "" { 381 361 roasterURI = BuildATURI(s.did.String(), NSIDRoaster, bean.RoasterRKey) ··· 414 394 beanModel.RKey = rkey 415 395 416 396 // Invalidate cache 417 - GetSessionCache().InvalidateBeans(s.sessionID) 397 + s.cache.InvalidateBeans(s.sessionID) 418 398 419 399 return beanModel, nil 420 400 } 421 401 422 - func (s *AtprotoStore) GetBeanByRKey(rkey string) (*models.Bean, error) { 423 - ctx := s.getContext() 424 - 402 + func (s *AtprotoStore) GetBeanByRKey(ctx context.Context, rkey string) (*models.Bean, error) { 425 403 output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ 426 404 Collection: NSIDBean, 427 405 RKey: rkey, ··· 456 434 return bean, nil 457 435 } 458 436 459 - func (s *AtprotoStore) ListBeans() ([]*models.Bean, error) { 437 + func (s *AtprotoStore) ListBeans(ctx context.Context) ([]*models.Bean, error) { 460 438 // Check cache first 461 - cache := GetSessionCache() 462 - userCache := cache.Get(s.sessionID) 439 + userCache := s.cache.Get(s.sessionID) 463 440 if userCache != nil && userCache.Beans != nil && userCache.IsValid() { 464 441 return userCache.Beans, nil 465 442 } 466 - 467 - ctx := s.getContext() 468 443 469 444 // Use ListAllRecords to handle pagination automatically 470 445 output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDBean) ··· 498 473 } 499 474 500 475 // Update cache 501 - if userCache == nil { 502 - userCache = &UserCache{Timestamp: time.Now()} 503 - } 504 - userCache.Beans = beans 505 - userCache.Timestamp = time.Now() 506 - cache.Set(s.sessionID, userCache) 476 + s.cache.SetBeans(s.sessionID, beans) 507 477 508 478 return beans, nil 509 479 } ··· 525 495 } 526 496 } 527 497 528 - func (s *AtprotoStore) UpdateBeanByRKey(rkey string, bean *models.UpdateBeanRequest) error { 529 - ctx := s.getContext() 530 - 498 + func (s *AtprotoStore) UpdateBeanByRKey(ctx context.Context, rkey string, bean *models.UpdateBeanRequest) error { 531 499 // Get existing to preserve createdAt 532 - existing, err := s.GetBeanByRKey(rkey) 500 + existing, err := s.GetBeanByRKey(ctx, rkey) 533 501 if err != nil { 534 502 return fmt.Errorf("failed to get existing bean: %w", err) 535 503 } ··· 564 532 } 565 533 566 534 // Invalidate cache 567 - GetSessionCache().InvalidateBeans(s.sessionID) 535 + s.cache.InvalidateBeans(s.sessionID) 568 536 569 537 return nil 570 538 } 571 539 572 - func (s *AtprotoStore) DeleteBeanByRKey(rkey string) error { 573 - ctx := s.getContext() 574 - 540 + func (s *AtprotoStore) DeleteBeanByRKey(ctx context.Context, rkey string) error { 575 541 err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ 576 542 Collection: NSIDBean, 577 543 RKey: rkey, ··· 581 547 } 582 548 583 549 // Invalidate cache 584 - GetSessionCache().InvalidateBeans(s.sessionID) 550 + s.cache.InvalidateBeans(s.sessionID) 585 551 586 552 return nil 587 553 } 588 554 589 555 // ========== Roaster Operations ========== 590 556 591 - func (s *AtprotoStore) CreateRoaster(roaster *models.CreateRoasterRequest) (*models.Roaster, error) { 592 - ctx := s.getContext() 593 - 557 + func (s *AtprotoStore) CreateRoaster(ctx context.Context, roaster *models.CreateRoasterRequest) (*models.Roaster, error) { 594 558 roasterModel := &models.Roaster{ 595 559 Name: roaster.Name, 596 560 Location: roaster.Location, ··· 621 585 roasterModel.RKey = rkey 622 586 623 587 // Invalidate cache 624 - GetSessionCache().InvalidateRoasters(s.sessionID) 588 + s.cache.InvalidateRoasters(s.sessionID) 625 589 626 590 return roasterModel, nil 627 591 } 628 592 629 - func (s *AtprotoStore) GetRoasterByRKey(rkey string) (*models.Roaster, error) { 630 - ctx := s.getContext() 631 - 593 + func (s *AtprotoStore) GetRoasterByRKey(ctx context.Context, rkey string) (*models.Roaster, error) { 632 594 output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ 633 595 Collection: NSIDRoaster, 634 596 RKey: rkey, ··· 648 610 return roaster, nil 649 611 } 650 612 651 - func (s *AtprotoStore) ListRoasters() ([]*models.Roaster, error) { 613 + func (s *AtprotoStore) ListRoasters(ctx context.Context) ([]*models.Roaster, error) { 652 614 // Check cache first 653 - cache := GetSessionCache() 654 - userCache := cache.Get(s.sessionID) 615 + userCache := s.cache.Get(s.sessionID) 655 616 if userCache != nil && userCache.Roasters != nil && userCache.IsValid() { 656 617 return userCache.Roasters, nil 657 618 } 658 - 659 - ctx := s.getContext() 660 619 661 620 // Use ListAllRecords to handle pagination automatically 662 621 output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDRoaster) ··· 682 641 } 683 642 684 643 // Update cache 685 - if userCache == nil { 686 - userCache = &UserCache{Timestamp: time.Now()} 687 - } 688 - userCache.Roasters = roasters 689 - userCache.Timestamp = time.Now() 690 - cache.Set(s.sessionID, userCache) 644 + s.cache.SetRoasters(s.sessionID, roasters) 691 645 692 646 return roasters, nil 693 647 } 694 648 695 - func (s *AtprotoStore) UpdateRoasterByRKey(rkey string, roaster *models.UpdateRoasterRequest) error { 696 - ctx := s.getContext() 697 - 649 + func (s *AtprotoStore) UpdateRoasterByRKey(ctx context.Context, rkey string, roaster *models.UpdateRoasterRequest) error { 698 650 // Get existing to preserve createdAt 699 - existing, err := s.GetRoasterByRKey(rkey) 651 + existing, err := s.GetRoasterByRKey(ctx, rkey) 700 652 if err != nil { 701 653 return fmt.Errorf("failed to get existing roaster: %w", err) 702 654 } ··· 723 675 } 724 676 725 677 // Invalidate cache 726 - GetSessionCache().InvalidateRoasters(s.sessionID) 678 + s.cache.InvalidateRoasters(s.sessionID) 727 679 728 680 return nil 729 681 } 730 682 731 - func (s *AtprotoStore) DeleteRoasterByRKey(rkey string) error { 732 - ctx := s.getContext() 733 - 683 + func (s *AtprotoStore) DeleteRoasterByRKey(ctx context.Context, rkey string) error { 734 684 err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ 735 685 Collection: NSIDRoaster, 736 686 RKey: rkey, ··· 740 690 } 741 691 742 692 // Invalidate cache 743 - GetSessionCache().InvalidateRoasters(s.sessionID) 693 + s.cache.InvalidateRoasters(s.sessionID) 744 694 745 695 return nil 746 696 } 747 697 748 698 // ========== Grinder Operations ========== 749 699 750 - func (s *AtprotoStore) CreateGrinder(grinder *models.CreateGrinderRequest) (*models.Grinder, error) { 751 - ctx := s.getContext() 752 - 700 + func (s *AtprotoStore) CreateGrinder(ctx context.Context, grinder *models.CreateGrinderRequest) (*models.Grinder, error) { 753 701 grinderModel := &models.Grinder{ 754 702 Name: grinder.Name, 755 703 GrinderType: grinder.GrinderType, ··· 781 729 grinderModel.RKey = rkey 782 730 783 731 // Invalidate cache 784 - GetSessionCache().InvalidateGrinders(s.sessionID) 732 + s.cache.InvalidateGrinders(s.sessionID) 785 733 786 734 return grinderModel, nil 787 735 } 788 736 789 - func (s *AtprotoStore) GetGrinderByRKey(rkey string) (*models.Grinder, error) { 790 - ctx := s.getContext() 791 - 737 + func (s *AtprotoStore) GetGrinderByRKey(ctx context.Context, rkey string) (*models.Grinder, error) { 792 738 output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ 793 739 Collection: NSIDGrinder, 794 740 RKey: rkey, ··· 808 754 return grinder, nil 809 755 } 810 756 811 - func (s *AtprotoStore) ListGrinders() ([]*models.Grinder, error) { 757 + func (s *AtprotoStore) ListGrinders(ctx context.Context) ([]*models.Grinder, error) { 812 758 // Check cache first 813 - cache := GetSessionCache() 814 - userCache := cache.Get(s.sessionID) 759 + userCache := s.cache.Get(s.sessionID) 815 760 if userCache != nil && userCache.Grinders != nil && userCache.IsValid() { 816 761 return userCache.Grinders, nil 817 762 } 818 763 819 - ctx := s.getContext() 820 - 821 764 // Use ListAllRecords to handle pagination automatically 822 765 output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDGrinder) 823 766 if err != nil { ··· 842 785 } 843 786 844 787 // Update cache 845 - if userCache == nil { 846 - userCache = &UserCache{Timestamp: time.Now()} 847 - } 848 - userCache.Grinders = grinders 849 - userCache.Timestamp = time.Now() 850 - cache.Set(s.sessionID, userCache) 788 + s.cache.SetGrinders(s.sessionID, grinders) 851 789 852 790 return grinders, nil 853 791 } 854 792 855 - func (s *AtprotoStore) UpdateGrinderByRKey(rkey string, grinder *models.UpdateGrinderRequest) error { 856 - ctx := s.getContext() 857 - 793 + func (s *AtprotoStore) UpdateGrinderByRKey(ctx context.Context, rkey string, grinder *models.UpdateGrinderRequest) error { 858 794 // Get existing to preserve createdAt 859 - existing, err := s.GetGrinderByRKey(rkey) 795 + existing, err := s.GetGrinderByRKey(ctx, rkey) 860 796 if err != nil { 861 797 return fmt.Errorf("failed to get existing grinder: %w", err) 862 798 } ··· 884 820 } 885 821 886 822 // Invalidate cache 887 - GetSessionCache().InvalidateGrinders(s.sessionID) 823 + s.cache.InvalidateGrinders(s.sessionID) 888 824 889 825 return nil 890 826 } 891 827 892 - func (s *AtprotoStore) DeleteGrinderByRKey(rkey string) error { 893 - ctx := s.getContext() 894 - 828 + func (s *AtprotoStore) DeleteGrinderByRKey(ctx context.Context, rkey string) error { 895 829 err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ 896 830 Collection: NSIDGrinder, 897 831 RKey: rkey, ··· 901 835 } 902 836 903 837 // Invalidate cache 904 - GetSessionCache().InvalidateGrinders(s.sessionID) 838 + s.cache.InvalidateGrinders(s.sessionID) 905 839 906 840 return nil 907 841 } 908 842 909 843 // ========== Brewer Operations ========== 910 844 911 - func (s *AtprotoStore) CreateBrewer(brewer *models.CreateBrewerRequest) (*models.Brewer, error) { 912 - ctx := s.getContext() 913 - 845 + func (s *AtprotoStore) CreateBrewer(ctx context.Context, brewer *models.CreateBrewerRequest) (*models.Brewer, error) { 914 846 brewerModel := &models.Brewer{ 915 847 Name: brewer.Name, 916 848 Description: brewer.Description, ··· 940 872 brewerModel.RKey = rkey 941 873 942 874 // Invalidate cache 943 - GetSessionCache().InvalidateBrewers(s.sessionID) 875 + s.cache.InvalidateBrewers(s.sessionID) 944 876 945 877 return brewerModel, nil 946 878 } 947 879 948 - func (s *AtprotoStore) GetBrewerByRKey(rkey string) (*models.Brewer, error) { 949 - ctx := s.getContext() 950 - 880 + func (s *AtprotoStore) GetBrewerByRKey(ctx context.Context, rkey string) (*models.Brewer, error) { 951 881 output, err := s.client.GetRecord(ctx, s.did, s.sessionID, &GetRecordInput{ 952 882 Collection: NSIDBrewer, 953 883 RKey: rkey, ··· 967 897 return brewer, nil 968 898 } 969 899 970 - func (s *AtprotoStore) ListBrewers() ([]*models.Brewer, error) { 900 + func (s *AtprotoStore) ListBrewers(ctx context.Context) ([]*models.Brewer, error) { 971 901 // Check cache first 972 - cache := GetSessionCache() 973 - userCache := cache.Get(s.sessionID) 902 + userCache := s.cache.Get(s.sessionID) 974 903 if userCache != nil && userCache.Brewers != nil && userCache.IsValid() { 975 904 return userCache.Brewers, nil 976 905 } 977 - 978 - ctx := s.getContext() 979 906 980 907 // Use ListAllRecords to handle pagination automatically 981 908 output, err := s.client.ListAllRecords(ctx, s.did, s.sessionID, NSIDBrewer) ··· 1001 928 } 1002 929 1003 930 // Update cache 1004 - if userCache == nil { 1005 - userCache = &UserCache{Timestamp: time.Now()} 1006 - } 1007 - userCache.Brewers = brewers 1008 - userCache.Timestamp = time.Now() 1009 - cache.Set(s.sessionID, userCache) 931 + s.cache.SetBrewers(s.sessionID, brewers) 1010 932 1011 933 return brewers, nil 1012 934 } 1013 935 1014 - func (s *AtprotoStore) UpdateBrewerByRKey(rkey string, brewer *models.UpdateBrewerRequest) error { 1015 - ctx := s.getContext() 1016 - 936 + func (s *AtprotoStore) UpdateBrewerByRKey(ctx context.Context, rkey string, brewer *models.UpdateBrewerRequest) error { 1017 937 // Get existing to preserve createdAt 1018 - existing, err := s.GetBrewerByRKey(rkey) 938 + existing, err := s.GetBrewerByRKey(ctx, rkey) 1019 939 if err != nil { 1020 940 return fmt.Errorf("failed to get existing brewer: %w", err) 1021 941 } ··· 1041 961 } 1042 962 1043 963 // Invalidate cache 1044 - GetSessionCache().InvalidateBrewers(s.sessionID) 964 + s.cache.InvalidateBrewers(s.sessionID) 1045 965 1046 966 return nil 1047 967 } 1048 968 1049 - func (s *AtprotoStore) DeleteBrewerByRKey(rkey string) error { 1050 - ctx := s.getContext() 1051 - 969 + func (s *AtprotoStore) DeleteBrewerByRKey(ctx context.Context, rkey string) error { 1052 970 err := s.client.DeleteRecord(ctx, s.did, s.sessionID, &DeleteRecordInput{ 1053 971 Collection: NSIDBrewer, 1054 972 RKey: rkey, ··· 1058 976 } 1059 977 1060 978 // Invalidate cache 1061 - GetSessionCache().InvalidateBrewers(s.sessionID) 979 + s.cache.InvalidateBrewers(s.sessionID) 1062 980 1063 981 return nil 1064 982 }
+34 -28
internal/database/store.go
··· 1 1 package database 2 2 3 - import "arabica/internal/models" 3 + import ( 4 + "context" 4 5 5 - // Store defines the interface for all database operations 6 - // This abstraction allows swapping SQLite for ATProto or other backends 6 + "arabica/internal/models" 7 + ) 8 + 9 + // Store defines the interface for all database operations. 10 + // This abstraction allows swapping SQLite for ATProto or other backends. 11 + // All methods accept a context.Context as the first parameter to support 12 + // cancellation, timeouts, and request-scoped values. 7 13 type Store interface { 8 14 // Brew operations 9 15 // Note: userID parameter is deprecated for ATProto (user is implicit from DID) 10 16 // It remains for SQLite compatibility but should not be relied upon 11 - CreateBrew(brew *models.CreateBrewRequest, userID int) (*models.Brew, error) 12 - GetBrewByRKey(rkey string) (*models.Brew, error) 13 - ListBrews(userID int) ([]*models.Brew, error) 14 - UpdateBrewByRKey(rkey string, brew *models.CreateBrewRequest) error 15 - DeleteBrewByRKey(rkey string) error 17 + CreateBrew(ctx context.Context, brew *models.CreateBrewRequest, userID int) (*models.Brew, error) 18 + GetBrewByRKey(ctx context.Context, rkey string) (*models.Brew, error) 19 + ListBrews(ctx context.Context, userID int) ([]*models.Brew, error) 20 + UpdateBrewByRKey(ctx context.Context, rkey string, brew *models.CreateBrewRequest) error 21 + DeleteBrewByRKey(ctx context.Context, rkey string) error 16 22 17 23 // Bean operations 18 - CreateBean(bean *models.CreateBeanRequest) (*models.Bean, error) 19 - GetBeanByRKey(rkey string) (*models.Bean, error) 20 - ListBeans() ([]*models.Bean, error) 21 - UpdateBeanByRKey(rkey string, bean *models.UpdateBeanRequest) error 22 - DeleteBeanByRKey(rkey string) error 24 + CreateBean(ctx context.Context, bean *models.CreateBeanRequest) (*models.Bean, error) 25 + GetBeanByRKey(ctx context.Context, rkey string) (*models.Bean, error) 26 + ListBeans(ctx context.Context) ([]*models.Bean, error) 27 + UpdateBeanByRKey(ctx context.Context, rkey string, bean *models.UpdateBeanRequest) error 28 + DeleteBeanByRKey(ctx context.Context, rkey string) error 23 29 24 30 // Roaster operations 25 - CreateRoaster(roaster *models.CreateRoasterRequest) (*models.Roaster, error) 26 - GetRoasterByRKey(rkey string) (*models.Roaster, error) 27 - ListRoasters() ([]*models.Roaster, error) 28 - UpdateRoasterByRKey(rkey string, roaster *models.UpdateRoasterRequest) error 29 - DeleteRoasterByRKey(rkey string) error 31 + CreateRoaster(ctx context.Context, roaster *models.CreateRoasterRequest) (*models.Roaster, error) 32 + GetRoasterByRKey(ctx context.Context, rkey string) (*models.Roaster, error) 33 + ListRoasters(ctx context.Context) ([]*models.Roaster, error) 34 + UpdateRoasterByRKey(ctx context.Context, rkey string, roaster *models.UpdateRoasterRequest) error 35 + DeleteRoasterByRKey(ctx context.Context, rkey string) error 30 36 31 37 // Grinder operations 32 - CreateGrinder(grinder *models.CreateGrinderRequest) (*models.Grinder, error) 33 - GetGrinderByRKey(rkey string) (*models.Grinder, error) 34 - ListGrinders() ([]*models.Grinder, error) 35 - UpdateGrinderByRKey(rkey string, grinder *models.UpdateGrinderRequest) error 36 - DeleteGrinderByRKey(rkey string) error 38 + CreateGrinder(ctx context.Context, grinder *models.CreateGrinderRequest) (*models.Grinder, error) 39 + GetGrinderByRKey(ctx context.Context, rkey string) (*models.Grinder, error) 40 + ListGrinders(ctx context.Context) ([]*models.Grinder, error) 41 + UpdateGrinderByRKey(ctx context.Context, rkey string, grinder *models.UpdateGrinderRequest) error 42 + DeleteGrinderByRKey(ctx context.Context, rkey string) error 37 43 38 44 // Brewer operations 39 - CreateBrewer(brewer *models.CreateBrewerRequest) (*models.Brewer, error) 40 - GetBrewerByRKey(rkey string) (*models.Brewer, error) 41 - ListBrewers() ([]*models.Brewer, error) 42 - UpdateBrewerByRKey(rkey string, brewer *models.UpdateBrewerRequest) error 43 - DeleteBrewerByRKey(rkey string) error 45 + CreateBrewer(ctx context.Context, brewer *models.CreateBrewerRequest) (*models.Brewer, error) 46 + GetBrewerByRKey(ctx context.Context, rkey string) (*models.Brewer, error) 47 + ListBrewers(ctx context.Context) ([]*models.Brewer, error) 48 + UpdateBrewerByRKey(ctx context.Context, rkey string, brewer *models.UpdateBrewerRequest) error 49 + DeleteBrewerByRKey(ctx context.Context, rkey string) error 44 50 45 51 // Close the database connection 46 52 Close() error
+45 -20
internal/handlers/auth.go
··· 11 11 "github.com/rs/zerolog/log" 12 12 ) 13 13 14 - // httpClientWithTimeout creates an HTTP client with reasonable timeouts for API calls 15 - func httpClientWithTimeout() *http.Client { 16 - return &http.Client{ 17 - Timeout: 10 * time.Second, 18 - } 14 + // defaultHTTPClient is a shared HTTP client with connection pooling. 15 + // Reusing http.Client is recommended by the Go documentation as it 16 + // manages connection pooling and is safe for concurrent use. 17 + var defaultHTTPClient = &http.Client{ 18 + Timeout: 10 * time.Second, 19 + Transport: &http.Transport{ 20 + MaxIdleConns: 100, 21 + MaxIdleConnsPerHost: 10, 22 + IdleConnTimeout: 90 * time.Second, 23 + }, 19 24 } 20 25 21 26 // HandleLogin redirects to the home page ··· 176 181 177 182 // Use a public API client to resolve the handle 178 183 // We don't need authentication for this 179 - apiClient := httpClientWithTimeout() 184 + apiClient := defaultHTTPClient 180 185 181 186 // First resolve the handle to a DID using the public API 182 187 // Note: public.api.bsky.app is the public Bluesky API endpoint that works for any handle ··· 192 197 if resp.StatusCode == 404 { 193 198 w.Header().Set("Content-Type", "application/json") 194 199 w.WriteHeader(http.StatusNotFound) 195 - json.NewEncoder(w).Encode(map[string]string{"error": "Handle not found"}) 200 + if err := json.NewEncoder(w).Encode(map[string]string{"error": "Handle not found"}); err != nil { 201 + log.Error().Err(err).Msg("Failed to encode error response") 202 + } 196 203 return 197 204 } 198 205 ··· 209 216 if resp.StatusCode == 400 { 210 217 w.Header().Set("Content-Type", "application/json") 211 218 w.WriteHeader(http.StatusBadRequest) 212 - json.NewEncoder(w).Encode(map[string]string{"error": "Invalid handle format"}) 219 + if err := json.NewEncoder(w).Encode(map[string]string{"error": "Invalid handle format"}); err != nil { 220 + log.Error().Err(err).Msg("Failed to encode error response") 221 + } 213 222 return 214 223 } 215 224 ··· 233 242 log.Warn().Err(err).Str("did", resolveResult.DID).Msg("Failed to fetch profile") 234 243 // Return just the DID if we can't get the profile 235 244 w.Header().Set("Content-Type", "application/json") 236 - json.NewEncoder(w).Encode(map[string]interface{}{ 245 + if err := json.NewEncoder(w).Encode(map[string]interface{}{ 237 246 "did": resolveResult.DID, 238 247 "handle": handle, 239 - }) 248 + }); err != nil { 249 + log.Error().Err(err).Msg("Failed to encode response") 250 + } 240 251 return 241 252 } 242 253 defer profileResp.Body.Close() ··· 244 255 if profileResp.StatusCode != 200 { 245 256 // Return just the DID if we can't get the profile 246 257 w.Header().Set("Content-Type", "application/json") 247 - json.NewEncoder(w).Encode(map[string]interface{}{ 258 + if err := json.NewEncoder(w).Encode(map[string]interface{}{ 248 259 "did": resolveResult.DID, 249 260 "handle": handle, 250 - }) 261 + }); err != nil { 262 + log.Error().Err(err).Msg("Failed to encode response") 263 + } 251 264 return 252 265 } 253 266 ··· 261 274 log.Warn().Err(err).Str("did", resolveResult.DID).Msg("Failed to decode profile") 262 275 // Return just the DID if we can't parse the profile 263 276 w.Header().Set("Content-Type", "application/json") 264 - json.NewEncoder(w).Encode(map[string]interface{}{ 277 + if err := json.NewEncoder(w).Encode(map[string]interface{}{ 265 278 "did": resolveResult.DID, 266 279 "handle": handle, 267 - }) 280 + }); err != nil { 281 + log.Error().Err(err).Msg("Failed to encode response") 282 + } 268 283 return 269 284 } 270 285 271 286 // Return the profile info 272 287 w.Header().Set("Content-Type", "application/json") 273 - json.NewEncoder(w).Encode(profile) 288 + if err := json.NewEncoder(w).Encode(profile); err != nil { 289 + log.Error().Err(err).Msg("Failed to encode profile response") 290 + } 274 291 } 275 292 276 293 // HandleSearchActors searches for actors by handle or display name ··· 283 300 } 284 301 285 302 // Use a public API client with timeout 286 - apiClient := httpClientWithTimeout() 303 + apiClient := defaultHTTPClient 287 304 288 305 // Try using the public API endpoint with typeahead parameter 289 306 // Some PDS instances support public search ··· 293 310 log.Warn().Err(err).Str("query", query).Msg("Failed to search actors") 294 311 // Return empty results instead of error 295 312 w.Header().Set("Content-Type", "application/json") 296 - json.NewEncoder(w).Encode(map[string]interface{}{"actors": []interface{}{}}) 313 + if err := json.NewEncoder(w).Encode(map[string]interface{}{"actors": []interface{}{}}); err != nil { 314 + log.Error().Err(err).Msg("Failed to encode empty actors response") 315 + } 297 316 return 298 317 } 299 318 defer resp.Body.Close() ··· 307 326 Msg("Unexpected status searching actors") 308 327 // Return empty results instead of error 309 328 w.Header().Set("Content-Type", "application/json") 310 - json.NewEncoder(w).Encode(map[string]interface{}{"actors": []interface{}{}}) 329 + if err := json.NewEncoder(w).Encode(map[string]interface{}{"actors": []interface{}{}}); err != nil { 330 + log.Error().Err(err).Msg("Failed to encode empty actors response") 331 + } 311 332 return 312 333 } 313 334 ··· 324 345 log.Warn().Err(err).Str("query", query).Msg("Failed to decode search response") 325 346 // Return empty results instead of error 326 347 w.Header().Set("Content-Type", "application/json") 327 - json.NewEncoder(w).Encode(map[string]interface{}{"actors": []interface{}{}}) 348 + if err := json.NewEncoder(w).Encode(map[string]interface{}{"actors": []interface{}{}}); err != nil { 349 + log.Error().Err(err).Msg("Failed to encode empty actors response") 350 + } 328 351 return 329 352 } 330 353 331 354 // Return the actors 332 355 w.Header().Set("Content-Type", "application/json") 333 - json.NewEncoder(w).Encode(searchResult) 356 + if err := json.NewEncoder(w).Encode(searchResult); err != nil { 357 + log.Error().Err(err).Msg("Failed to encode search result response") 358 + } 334 359 }
+420 -255
internal/handlers/handlers.go
··· 1 1 package handlers 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "net/http" 6 7 "strconv" ··· 10 11 "arabica/internal/database" 11 12 "arabica/internal/feed" 12 13 "arabica/internal/models" 14 + 15 + "github.com/rs/zerolog/log" 16 + "golang.org/x/sync/errgroup" 13 17 ) 14 18 15 19 // Config holds handler configuration options ··· 19 23 SecureCookies bool 20 24 } 21 25 26 + // Handler contains all HTTP handler methods and their dependencies. 27 + // Dependencies are injected via the constructor for better testability. 22 28 type Handler struct { 23 29 oauth *atproto.OAuthManager 24 30 atprotoClient *atproto.Client 31 + sessionCache *atproto.SessionCache 25 32 config Config 26 33 feedService *feed.Service 27 34 feedRegistry *feed.Registry 28 35 } 29 36 30 - func NewHandler() *Handler { 31 - return &Handler{} 37 + // NewHandler creates a new Handler with all required dependencies. 38 + // This constructor pattern ensures the Handler is always fully initialized. 39 + func NewHandler( 40 + oauth *atproto.OAuthManager, 41 + atprotoClient *atproto.Client, 42 + sessionCache *atproto.SessionCache, 43 + feedService *feed.Service, 44 + feedRegistry *feed.Registry, 45 + config Config, 46 + ) *Handler { 47 + return &Handler{ 48 + oauth: oauth, 49 + atprotoClient: atprotoClient, 50 + sessionCache: sessionCache, 51 + config: config, 52 + feedService: feedService, 53 + feedRegistry: feedRegistry, 54 + } 32 55 } 33 56 34 - // SetConfig sets the handler configuration 35 - func (h *Handler) SetConfig(config Config) { 36 - h.config = config 37 - } 38 - 39 - // SetOAuthManager sets the OAuth manager for authentication 40 - func (h *Handler) SetOAuthManager(oauth *atproto.OAuthManager) { 41 - h.oauth = oauth 42 - } 43 - 44 - // SetAtprotoClient sets the atproto client for record operations 45 - func (h *Handler) SetAtprotoClient(client *atproto.Client) { 46 - h.atprotoClient = client 47 - } 48 - 49 - // SetFeedService sets the feed service for social feed 50 - func (h *Handler) SetFeedService(service *feed.Service) { 51 - h.feedService = service 52 - } 53 - 54 - // SetFeedRegistry sets the feed registry for tracking users 55 - func (h *Handler) SetFeedRegistry(registry *feed.Registry) { 56 - h.feedRegistry = registry 57 - } 58 - 59 - // getAtprotoStore creates a user-scoped atproto store from the request context 60 - // Returns the store and true if authenticated, or nil and false if not authenticated 57 + // getAtprotoStore creates a user-scoped atproto store from the request context. 58 + // Returns the store and true if authenticated, or nil and false if not authenticated. 61 59 func (h *Handler) getAtprotoStore(r *http.Request) (database.Store, bool) { 62 60 // Get authenticated DID from context 63 61 didStr, err := atproto.GetAuthenticatedDID(r.Context()) ··· 77 75 return nil, false 78 76 } 79 77 80 - // Create user-scoped atproto store 81 - store := atproto.NewAtprotoStore(r.Context(), h.atprotoClient, did, sessionID) 78 + // Create user-scoped atproto store with injected cache 79 + store := atproto.NewAtprotoStore(h.atprotoClient, did, sessionID, h.sessionCache) 82 80 return store, true 83 81 } 84 82 ··· 90 88 91 89 // Don't fetch feed items here - let them load async via HTMX 92 90 if err := bff.RenderHome(w, isAuthenticated, didStr, nil); err != nil { 93 - http.Error(w, err.Error(), http.StatusInternalServerError) 91 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 92 + log.Error().Err(err).Msg("Failed to render home page") 94 93 } 95 94 } 96 95 ··· 102 101 } 103 102 104 103 if err := bff.RenderFeedPartial(w, feedItems); err != nil { 105 - http.Error(w, err.Error(), http.StatusInternalServerError) 104 + http.Error(w, "Failed to render feed", http.StatusInternalServerError) 105 + log.Error().Err(err).Msg("Failed to render feed partial") 106 106 } 107 107 } 108 108 ··· 115 115 return 116 116 } 117 117 118 - brews, err := store.ListBrews(1) // User ID is not used with atproto 118 + brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 119 119 if err != nil { 120 - http.Error(w, "Failed to fetch brews: "+err.Error(), http.StatusInternalServerError) 120 + http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 121 + log.Error().Err(err).Msg("Failed to fetch brews") 121 122 return 122 123 } 123 124 124 125 if err := bff.RenderBrewListPartial(w, brews); err != nil { 125 - http.Error(w, "Failed to render: "+err.Error(), http.StatusInternalServerError) 126 + http.Error(w, "Failed to render content", http.StatusInternalServerError) 127 + log.Error().Err(err).Msg("Failed to render brew list partial") 126 128 } 127 129 } 128 130 ··· 135 137 return 136 138 } 137 139 138 - // Fetch all collections in parallel for better performance 139 - type result struct { 140 - beans []*models.Bean 141 - roasters []*models.Roaster 142 - grinders []*models.Grinder 143 - brewers []*models.Brewer 144 - err error 145 - which string 146 - } 140 + ctx := r.Context() 147 141 148 - results := make(chan result, 4) 149 - 150 - // Launch parallel fetches 151 - go func() { 152 - beans, err := store.ListBeans() 153 - results <- result{beans: beans, err: err, which: "beans"} 154 - }() 155 - go func() { 156 - roasters, err := store.ListRoasters() 157 - results <- result{roasters: roasters, err: err, which: "roasters"} 158 - }() 159 - go func() { 160 - grinders, err := store.ListGrinders() 161 - results <- result{grinders: grinders, err: err, which: "grinders"} 162 - }() 163 - go func() { 164 - brewers, err := store.ListBrewers() 165 - results <- result{brewers: brewers, err: err, which: "brewers"} 166 - }() 142 + // Fetch all collections in parallel using errgroup for proper error handling 143 + // and automatic context cancellation on first error 144 + g, ctx := errgroup.WithContext(ctx) 167 145 168 - // Collect results 169 146 var beans []*models.Bean 170 147 var roasters []*models.Roaster 171 148 var grinders []*models.Grinder 172 149 var brewers []*models.Brewer 173 150 174 - for i := 0; i < 4; i++ { 175 - res := <-results 176 - if res.err != nil { 177 - http.Error(w, "Failed to fetch "+res.which+": "+res.err.Error(), http.StatusInternalServerError) 178 - return 179 - } 180 - switch res.which { 181 - case "beans": 182 - beans = res.beans 183 - case "roasters": 184 - roasters = res.roasters 185 - case "grinders": 186 - grinders = res.grinders 187 - case "brewers": 188 - brewers = res.brewers 189 - } 151 + g.Go(func() error { 152 + var err error 153 + beans, err = store.ListBeans(ctx) 154 + return err 155 + }) 156 + g.Go(func() error { 157 + var err error 158 + roasters, err = store.ListRoasters(ctx) 159 + return err 160 + }) 161 + g.Go(func() error { 162 + var err error 163 + grinders, err = store.ListGrinders(ctx) 164 + return err 165 + }) 166 + g.Go(func() error { 167 + var err error 168 + brewers, err = store.ListBrewers(ctx) 169 + return err 170 + }) 171 + 172 + if err := g.Wait(); err != nil { 173 + http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 174 + log.Error().Err(err).Msg("Failed to fetch manage page data") 175 + return 190 176 } 191 177 192 178 // Link beans to their roasters 193 179 atproto.LinkBeansToRoasters(beans, roasters) 194 180 195 181 if err := bff.RenderManagePartial(w, beans, roasters, grinders, brewers); err != nil { 196 - http.Error(w, "Failed to render: "+err.Error(), http.StatusInternalServerError) 182 + http.Error(w, "Failed to render content", http.StatusInternalServerError) 183 + log.Error().Err(err).Msg("Failed to render manage partial") 197 184 } 198 185 } 199 186 ··· 210 197 211 198 // Don't fetch brews here - let them load async via HTMX 212 199 if err := bff.RenderBrewList(w, nil, authenticated, didStr); err != nil { 213 - http.Error(w, err.Error(), http.StatusInternalServerError) 200 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 201 + log.Error().Err(err).Msg("Failed to render brew list page") 214 202 } 215 203 } 216 204 ··· 228 216 // Don't fetch data from PDS - client will populate dropdowns from cache 229 217 // This makes the page load much faster 230 218 if err := bff.RenderBrewForm(w, nil, nil, nil, nil, nil, authenticated, didStr); err != nil { 231 - http.Error(w, err.Error(), http.StatusInternalServerError) 219 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 220 + log.Error().Err(err).Msg("Failed to render brew form") 232 221 } 233 222 } 234 223 ··· 245 234 246 235 didStr, _ := atproto.GetAuthenticatedDID(r.Context()) 247 236 248 - brew, err := store.GetBrewByRKey(rkey) 237 + brew, err := store.GetBrewByRKey(r.Context(), rkey) 249 238 if err != nil { 250 - http.Error(w, err.Error(), http.StatusInternalServerError) 239 + http.Error(w, "Brew not found", http.StatusNotFound) 240 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brew for edit") 251 241 return 252 242 } 253 243 254 244 // Don't fetch dropdown data from PDS - client will populate from cache 255 245 // This makes the page load much faster 256 246 if err := bff.RenderBrewForm(w, nil, nil, nil, nil, brew, authenticated, didStr); err != nil { 257 - http.Error(w, err.Error(), http.StatusInternalServerError) 247 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 248 + log.Error().Err(err).Msg("Failed to render brew edit form") 258 249 } 259 250 } 260 251 261 - // Create new brew 262 - func (h *Handler) HandleBrewCreate(w http.ResponseWriter, r *http.Request) { 263 - // Require authentication first 264 - store, authenticated := h.getAtprotoStore(r) 265 - if !authenticated { 266 - http.Redirect(w, r, "/login", http.StatusFound) 267 - return 268 - } 269 - 270 - if err := r.ParseForm(); err != nil { 271 - http.Error(w, err.Error(), http.StatusBadRequest) 272 - return 273 - } 274 - 275 - temperature, _ := strconv.ParseFloat(r.FormValue("temperature"), 64) 276 - waterAmount, _ := strconv.Atoi(r.FormValue("water_amount")) 277 - coffeeAmount, _ := strconv.Atoi(r.FormValue("coffee_amount")) 278 - timeSeconds, _ := strconv.Atoi(r.FormValue("time_seconds")) 279 - rating, _ := strconv.Atoi(r.FormValue("rating")) 252 + // maxPours is the maximum number of pours allowed in a single brew 253 + const maxPours = 100 280 254 281 - // Parse pours 255 + // parsePours extracts pour data from form values with bounds checking 256 + func parsePours(r *http.Request) []models.CreatePourData { 282 257 var pours []models.CreatePourData 283 - i := 0 284 - for { 258 + 259 + for i := 0; i < maxPours; i++ { 285 260 waterKey := "pour_water_" + strconv.Itoa(i) 286 261 timeKey := "pour_time_" + strconv.Itoa(i) 287 262 ··· 293 268 } 294 269 295 270 water, _ := strconv.Atoi(waterStr) 296 - time, _ := strconv.Atoi(timeStr) 271 + pourTime, _ := strconv.Atoi(timeStr) 297 272 298 - if water > 0 && time >= 0 { 273 + if water > 0 && pourTime >= 0 { 299 274 pours = append(pours, models.CreatePourData{ 300 275 WaterAmount: water, 301 - TimeSeconds: time, 276 + TimeSeconds: pourTime, 302 277 }) 303 278 } 304 - i++ 279 + } 280 + 281 + return pours 282 + } 283 + 284 + // ValidationError represents a validation error with field name and message 285 + type ValidationError struct { 286 + Field string 287 + Message string 288 + } 289 + 290 + // validateBrewRequest validates brew form input and returns any validation errors 291 + func validateBrewRequest(r *http.Request) (temperature float64, waterAmount, coffeeAmount, timeSeconds, rating int, pours []models.CreatePourData, errs []ValidationError) { 292 + // Parse and validate temperature 293 + if tempStr := r.FormValue("temperature"); tempStr != "" { 294 + var err error 295 + temperature, err = strconv.ParseFloat(tempStr, 64) 296 + if err != nil { 297 + errs = append(errs, ValidationError{Field: "temperature", Message: "invalid temperature format"}) 298 + } else if temperature < 0 || temperature > 212 { 299 + errs = append(errs, ValidationError{Field: "temperature", Message: "temperature must be between 0 and 212"}) 300 + } 301 + } 302 + 303 + // Parse and validate water amount 304 + if waterStr := r.FormValue("water_amount"); waterStr != "" { 305 + var err error 306 + waterAmount, err = strconv.Atoi(waterStr) 307 + if err != nil { 308 + errs = append(errs, ValidationError{Field: "water_amount", Message: "invalid water amount"}) 309 + } else if waterAmount < 0 || waterAmount > 10000 { 310 + errs = append(errs, ValidationError{Field: "water_amount", Message: "water amount must be between 0 and 10000ml"}) 311 + } 312 + } 313 + 314 + // Parse and validate coffee amount 315 + if coffeeStr := r.FormValue("coffee_amount"); coffeeStr != "" { 316 + var err error 317 + coffeeAmount, err = strconv.Atoi(coffeeStr) 318 + if err != nil { 319 + errs = append(errs, ValidationError{Field: "coffee_amount", Message: "invalid coffee amount"}) 320 + } else if coffeeAmount < 0 || coffeeAmount > 1000 { 321 + errs = append(errs, ValidationError{Field: "coffee_amount", Message: "coffee amount must be between 0 and 1000g"}) 322 + } 323 + } 324 + 325 + // Parse and validate time 326 + if timeStr := r.FormValue("time_seconds"); timeStr != "" { 327 + var err error 328 + timeSeconds, err = strconv.Atoi(timeStr) 329 + if err != nil { 330 + errs = append(errs, ValidationError{Field: "time_seconds", Message: "invalid time"}) 331 + } else if timeSeconds < 0 || timeSeconds > 3600 { 332 + errs = append(errs, ValidationError{Field: "time_seconds", Message: "brew time must be between 0 and 3600 seconds"}) 333 + } 334 + } 335 + 336 + // Parse and validate rating 337 + if ratingStr := r.FormValue("rating"); ratingStr != "" { 338 + var err error 339 + rating, err = strconv.Atoi(ratingStr) 340 + if err != nil { 341 + errs = append(errs, ValidationError{Field: "rating", Message: "invalid rating"}) 342 + } else if rating < 0 || rating > 10 { 343 + errs = append(errs, ValidationError{Field: "rating", Message: "rating must be between 0 and 10"}) 344 + } 345 + } 346 + 347 + // Parse pours 348 + pours = parsePours(r) 349 + 350 + return 351 + } 352 + 353 + // Create new brew 354 + func (h *Handler) HandleBrewCreate(w http.ResponseWriter, r *http.Request) { 355 + // Require authentication first 356 + store, authenticated := h.getAtprotoStore(r) 357 + if !authenticated { 358 + http.Redirect(w, r, "/login", http.StatusFound) 359 + return 360 + } 361 + 362 + if err := r.ParseForm(); err != nil { 363 + http.Error(w, "Invalid form data", http.StatusBadRequest) 364 + return 365 + } 366 + 367 + // Validate input 368 + temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r) 369 + if len(validationErrs) > 0 { 370 + // Return first validation error 371 + http.Error(w, validationErrs[0].Message, http.StatusBadRequest) 372 + return 373 + } 374 + 375 + // Validate required fields 376 + beanRKey := r.FormValue("bean_rkey") 377 + if beanRKey == "" { 378 + http.Error(w, "Bean selection is required", http.StatusBadRequest) 379 + return 305 380 } 306 381 307 382 req := &models.CreateBrewRequest{ 308 - BeanRKey: r.FormValue("bean_rkey"), 383 + BeanRKey: beanRKey, 309 384 Method: r.FormValue("method"), 310 385 Temperature: temperature, 311 386 WaterAmount: waterAmount, ··· 316 391 BrewerRKey: r.FormValue("brewer_rkey"), 317 392 TastingNotes: r.FormValue("tasting_notes"), 318 393 Rating: rating, 319 - Pours: pours, // Pours are embedded in the brew record for ATProto 394 + Pours: pours, 320 395 } 321 396 322 - _, err := store.CreateBrew(req, 1) // User ID not used with atproto 397 + _, err := store.CreateBrew(r.Context(), req, 1) // User ID not used with atproto 323 398 if err != nil { 324 - http.Error(w, err.Error(), http.StatusInternalServerError) 399 + http.Error(w, "Failed to create brew", http.StatusInternalServerError) 400 + log.Error().Err(err).Msg("Failed to create brew") 325 401 return 326 402 } 327 403 ··· 342 418 } 343 419 344 420 if err := r.ParseForm(); err != nil { 345 - http.Error(w, err.Error(), http.StatusBadRequest) 421 + http.Error(w, "Invalid form data", http.StatusBadRequest) 346 422 return 347 423 } 348 424 349 - temperature, _ := strconv.ParseFloat(r.FormValue("temperature"), 64) 350 - waterAmount, _ := strconv.Atoi(r.FormValue("water_amount")) 351 - coffeeAmount, _ := strconv.Atoi(r.FormValue("coffee_amount")) 352 - timeSeconds, _ := strconv.Atoi(r.FormValue("time_seconds")) 353 - rating, _ := strconv.Atoi(r.FormValue("rating")) 425 + // Validate input 426 + temperature, waterAmount, coffeeAmount, timeSeconds, rating, pours, validationErrs := validateBrewRequest(r) 427 + if len(validationErrs) > 0 { 428 + http.Error(w, validationErrs[0].Message, http.StatusBadRequest) 429 + return 430 + } 354 431 355 - // Parse pours 356 - var pours []models.CreatePourData 357 - i := 0 358 - for { 359 - waterKey := "pour_water_" + strconv.Itoa(i) 360 - timeKey := "pour_time_" + strconv.Itoa(i) 361 - 362 - waterStr := r.FormValue(waterKey) 363 - timeStr := r.FormValue(timeKey) 364 - 365 - if waterStr == "" && timeStr == "" { 366 - break 367 - } 368 - 369 - water, _ := strconv.Atoi(waterStr) 370 - time, _ := strconv.Atoi(timeStr) 371 - 372 - if water > 0 && time >= 0 { 373 - pours = append(pours, models.CreatePourData{ 374 - WaterAmount: water, 375 - TimeSeconds: time, 376 - }) 377 - } 378 - i++ 432 + // Validate required fields 433 + beanRKey := r.FormValue("bean_rkey") 434 + if beanRKey == "" { 435 + http.Error(w, "Bean selection is required", http.StatusBadRequest) 436 + return 379 437 } 380 438 381 439 req := &models.CreateBrewRequest{ 382 - BeanRKey: r.FormValue("bean_rkey"), 440 + BeanRKey: beanRKey, 383 441 Method: r.FormValue("method"), 384 442 Temperature: temperature, 385 443 WaterAmount: waterAmount, ··· 393 451 Pours: pours, 394 452 } 395 453 396 - err := store.UpdateBrewByRKey(rkey, req) 454 + err := store.UpdateBrewByRKey(r.Context(), rkey, req) 397 455 if err != nil { 398 - http.Error(w, err.Error(), http.StatusInternalServerError) 456 + http.Error(w, "Failed to update brew", http.StatusInternalServerError) 457 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brew") 399 458 return 400 459 } 401 460 ··· 415 474 return 416 475 } 417 476 418 - if err := store.DeleteBrewByRKey(rkey); err != nil { 419 - http.Error(w, err.Error(), http.StatusInternalServerError) 477 + if err := store.DeleteBrewByRKey(r.Context(), rkey); err != nil { 478 + http.Error(w, "Failed to delete brew", http.StatusInternalServerError) 479 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brew") 420 480 return 421 481 } 422 482 ··· 432 492 return 433 493 } 434 494 435 - brews, err := store.ListBrews(1) // User ID is not used with atproto 495 + brews, err := store.ListBrews(r.Context(), 1) // User ID is not used with atproto 436 496 if err != nil { 437 - http.Error(w, err.Error(), http.StatusInternalServerError) 497 + http.Error(w, "Failed to fetch brews", http.StatusInternalServerError) 498 + log.Error().Err(err).Msg("Failed to list brews for export") 438 499 return 439 500 } 440 501 ··· 443 504 444 505 encoder := json.NewEncoder(w) 445 506 encoder.SetIndent("", " ") 446 - encoder.Encode(brews) 507 + if err := encoder.Encode(brews); err != nil { 508 + log.Error().Err(err).Msg("Failed to encode brews for export") 509 + } 447 510 } 448 511 449 512 // API endpoint to list all user data (beans, roasters, grinders, brewers, brews) ··· 455 518 return 456 519 } 457 520 458 - // Fetch all collections in parallel 459 - type result struct { 460 - beans []*models.Bean 461 - roasters []*models.Roaster 462 - grinders []*models.Grinder 463 - brewers []*models.Brewer 464 - brews []*models.Brew 465 - err error 466 - which string 467 - } 521 + ctx := r.Context() 468 522 469 - results := make(chan result, 5) 470 - 471 - go func() { 472 - beans, err := store.ListBeans() 473 - results <- result{beans: beans, err: err, which: "beans"} 474 - }() 475 - go func() { 476 - roasters, err := store.ListRoasters() 477 - results <- result{roasters: roasters, err: err, which: "roasters"} 478 - }() 479 - go func() { 480 - grinders, err := store.ListGrinders() 481 - results <- result{grinders: grinders, err: err, which: "grinders"} 482 - }() 483 - go func() { 484 - brewers, err := store.ListBrewers() 485 - results <- result{brewers: brewers, err: err, which: "brewers"} 486 - }() 487 - go func() { 488 - brews, err := store.ListBrews(1) // User ID not used with atproto 489 - results <- result{brews: brews, err: err, which: "brews"} 490 - }() 523 + // Fetch all collections in parallel using errgroup 524 + g, ctx := errgroup.WithContext(ctx) 491 525 492 526 var beans []*models.Bean 493 527 var roasters []*models.Roaster ··· 495 529 var brewers []*models.Brewer 496 530 var brews []*models.Brew 497 531 498 - for i := 0; i < 5; i++ { 499 - res := <-results 500 - if res.err != nil { 501 - http.Error(w, res.err.Error(), http.StatusInternalServerError) 502 - return 503 - } 504 - switch res.which { 505 - case "beans": 506 - beans = res.beans 507 - case "roasters": 508 - roasters = res.roasters 509 - case "grinders": 510 - grinders = res.grinders 511 - case "brewers": 512 - brewers = res.brewers 513 - case "brews": 514 - brews = res.brews 515 - } 532 + g.Go(func() error { 533 + var err error 534 + beans, err = store.ListBeans(ctx) 535 + return err 536 + }) 537 + g.Go(func() error { 538 + var err error 539 + roasters, err = store.ListRoasters(ctx) 540 + return err 541 + }) 542 + g.Go(func() error { 543 + var err error 544 + grinders, err = store.ListGrinders(ctx) 545 + return err 546 + }) 547 + g.Go(func() error { 548 + var err error 549 + brewers, err = store.ListBrewers(ctx) 550 + return err 551 + }) 552 + g.Go(func() error { 553 + var err error 554 + brews, err = store.ListBrews(ctx, 1) // User ID not used with atproto 555 + return err 556 + }) 557 + 558 + if err := g.Wait(); err != nil { 559 + http.Error(w, "Failed to fetch data", http.StatusInternalServerError) 560 + log.Error().Err(err).Msg("Failed to fetch all data for API") 561 + return 516 562 } 517 563 518 564 // Link beans to roasters ··· 527 573 } 528 574 529 575 w.Header().Set("Content-Type", "application/json") 530 - json.NewEncoder(w).Encode(response) 576 + if err := json.NewEncoder(w).Encode(response); err != nil { 577 + log.Error().Err(err).Msg("Failed to encode API response") 578 + } 531 579 } 532 580 533 581 // API endpoint to create bean 534 582 func (h *Handler) HandleBeanCreate(w http.ResponseWriter, r *http.Request) { 535 583 var req models.CreateBeanRequest 536 584 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 537 - http.Error(w, err.Error(), http.StatusBadRequest) 585 + http.Error(w, "Invalid request body", http.StatusBadRequest) 538 586 return 539 587 } 540 588 ··· 545 593 return 546 594 } 547 595 548 - bean, err := store.CreateBean(&req) 596 + // Validate required fields 597 + if req.Name == "" { 598 + http.Error(w, "Bean name is required", http.StatusBadRequest) 599 + return 600 + } 601 + 602 + bean, err := store.CreateBean(r.Context(), &req) 549 603 if err != nil { 550 - http.Error(w, err.Error(), http.StatusInternalServerError) 604 + http.Error(w, "Failed to create bean", http.StatusInternalServerError) 605 + log.Error().Err(err).Msg("Failed to create bean") 551 606 return 552 607 } 553 608 554 609 w.Header().Set("Content-Type", "application/json") 555 - json.NewEncoder(w).Encode(bean) 610 + if err := json.NewEncoder(w).Encode(bean); err != nil { 611 + log.Error().Err(err).Msg("Failed to encode bean response") 612 + } 556 613 } 557 614 558 615 // API endpoint to create roaster 559 616 func (h *Handler) HandleRoasterCreate(w http.ResponseWriter, r *http.Request) { 560 617 var req models.CreateRoasterRequest 561 618 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 562 - http.Error(w, err.Error(), http.StatusBadRequest) 619 + http.Error(w, "Invalid request body", http.StatusBadRequest) 563 620 return 564 621 } 565 622 ··· 570 627 return 571 628 } 572 629 573 - roaster, err := store.CreateRoaster(&req) 630 + // Validate required fields 631 + if req.Name == "" { 632 + http.Error(w, "Roaster name is required", http.StatusBadRequest) 633 + return 634 + } 635 + 636 + roaster, err := store.CreateRoaster(r.Context(), &req) 574 637 if err != nil { 575 - http.Error(w, err.Error(), http.StatusInternalServerError) 638 + http.Error(w, "Failed to create roaster", http.StatusInternalServerError) 639 + log.Error().Err(err).Msg("Failed to create roaster") 576 640 return 577 641 } 578 642 579 643 w.Header().Set("Content-Type", "application/json") 580 - json.NewEncoder(w).Encode(roaster) 644 + if err := json.NewEncoder(w).Encode(roaster); err != nil { 645 + log.Error().Err(err).Msg("Failed to encode roaster response") 646 + } 581 647 } 582 648 583 649 // Manage page ··· 593 659 594 660 // Don't fetch data here - let it load async via HTMX 595 661 if err := bff.RenderManage(w, nil, nil, nil, nil, authenticated, didStr); err != nil { 596 - http.Error(w, err.Error(), http.StatusInternalServerError) 662 + http.Error(w, "Failed to render page", http.StatusInternalServerError) 663 + log.Error().Err(err).Msg("Failed to render manage page") 597 664 } 598 665 } 599 666 ··· 610 677 611 678 var req models.UpdateBeanRequest 612 679 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 613 - http.Error(w, err.Error(), http.StatusBadRequest) 680 + http.Error(w, "Invalid request body", http.StatusBadRequest) 614 681 return 615 682 } 616 683 617 - if err := store.UpdateBeanByRKey(rkey, &req); err != nil { 618 - http.Error(w, err.Error(), http.StatusInternalServerError) 684 + // Validate required fields 685 + if req.Name == "" { 686 + http.Error(w, "Bean name is required", http.StatusBadRequest) 687 + return 688 + } 689 + 690 + if err := store.UpdateBeanByRKey(r.Context(), rkey, &req); err != nil { 691 + http.Error(w, "Failed to update bean", http.StatusInternalServerError) 692 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update bean") 619 693 return 620 694 } 621 695 622 - bean, err := store.GetBeanByRKey(rkey) 696 + bean, err := store.GetBeanByRKey(r.Context(), rkey) 623 697 if err != nil { 624 - http.Error(w, err.Error(), http.StatusInternalServerError) 698 + http.Error(w, "Failed to fetch updated bean", http.StatusInternalServerError) 699 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get bean after update") 625 700 return 626 701 } 627 702 628 703 w.Header().Set("Content-Type", "application/json") 629 - json.NewEncoder(w).Encode(bean) 704 + if err := json.NewEncoder(w).Encode(bean); err != nil { 705 + log.Error().Err(err).Msg("Failed to encode bean response") 706 + } 630 707 } 631 708 632 709 func (h *Handler) HandleBeanDelete(w http.ResponseWriter, r *http.Request) { ··· 639 716 return 640 717 } 641 718 642 - if err := store.DeleteBeanByRKey(rkey); err != nil { 643 - http.Error(w, err.Error(), http.StatusInternalServerError) 719 + if err := store.DeleteBeanByRKey(r.Context(), rkey); err != nil { 720 + http.Error(w, "Failed to delete bean", http.StatusInternalServerError) 721 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete bean") 644 722 return 645 723 } 646 724 ··· 660 738 661 739 var req models.UpdateRoasterRequest 662 740 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 663 - http.Error(w, err.Error(), http.StatusBadRequest) 741 + http.Error(w, "Invalid request body", http.StatusBadRequest) 664 742 return 665 743 } 666 744 667 - if err := store.UpdateRoasterByRKey(rkey, &req); err != nil { 668 - http.Error(w, err.Error(), http.StatusInternalServerError) 745 + // Validate required fields 746 + if req.Name == "" { 747 + http.Error(w, "Roaster name is required", http.StatusBadRequest) 669 748 return 670 749 } 671 750 672 - roaster, err := store.GetRoasterByRKey(rkey) 751 + if err := store.UpdateRoasterByRKey(r.Context(), rkey, &req); err != nil { 752 + http.Error(w, "Failed to update roaster", http.StatusInternalServerError) 753 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update roaster") 754 + return 755 + } 756 + 757 + roaster, err := store.GetRoasterByRKey(r.Context(), rkey) 673 758 if err != nil { 674 - http.Error(w, err.Error(), http.StatusInternalServerError) 759 + http.Error(w, "Failed to fetch updated roaster", http.StatusInternalServerError) 760 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get roaster after update") 675 761 return 676 762 } 677 763 678 764 w.Header().Set("Content-Type", "application/json") 679 - json.NewEncoder(w).Encode(roaster) 765 + if err := json.NewEncoder(w).Encode(roaster); err != nil { 766 + log.Error().Err(err).Msg("Failed to encode roaster response") 767 + } 680 768 } 681 769 682 770 func (h *Handler) HandleRoasterDelete(w http.ResponseWriter, r *http.Request) { ··· 689 777 return 690 778 } 691 779 692 - if err := store.DeleteRoasterByRKey(rkey); err != nil { 693 - http.Error(w, err.Error(), http.StatusInternalServerError) 780 + if err := store.DeleteRoasterByRKey(r.Context(), rkey); err != nil { 781 + http.Error(w, "Failed to delete roaster", http.StatusInternalServerError) 782 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete roaster") 694 783 return 695 784 } 696 785 ··· 701 790 func (h *Handler) HandleGrinderCreate(w http.ResponseWriter, r *http.Request) { 702 791 var req models.CreateGrinderRequest 703 792 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 704 - http.Error(w, err.Error(), http.StatusBadRequest) 793 + http.Error(w, "Invalid request body", http.StatusBadRequest) 705 794 return 706 795 } 707 796 ··· 712 801 return 713 802 } 714 803 715 - grinder, err := store.CreateGrinder(&req) 804 + // Validate required fields 805 + if req.Name == "" { 806 + http.Error(w, "Grinder name is required", http.StatusBadRequest) 807 + return 808 + } 809 + 810 + grinder, err := store.CreateGrinder(r.Context(), &req) 716 811 if err != nil { 717 - http.Error(w, err.Error(), http.StatusInternalServerError) 812 + http.Error(w, "Failed to create grinder", http.StatusInternalServerError) 813 + log.Error().Err(err).Msg("Failed to create grinder") 718 814 return 719 815 } 720 816 721 817 w.Header().Set("Content-Type", "application/json") 722 - json.NewEncoder(w).Encode(grinder) 818 + if err := json.NewEncoder(w).Encode(grinder); err != nil { 819 + log.Error().Err(err).Msg("Failed to encode grinder response") 820 + } 723 821 } 724 822 725 823 func (h *Handler) HandleGrinderUpdate(w http.ResponseWriter, r *http.Request) { ··· 734 832 735 833 var req models.UpdateGrinderRequest 736 834 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 737 - http.Error(w, err.Error(), http.StatusBadRequest) 835 + http.Error(w, "Invalid request body", http.StatusBadRequest) 738 836 return 739 837 } 740 838 741 - if err := store.UpdateGrinderByRKey(rkey, &req); err != nil { 742 - http.Error(w, err.Error(), http.StatusInternalServerError) 839 + // Validate required fields 840 + if req.Name == "" { 841 + http.Error(w, "Grinder name is required", http.StatusBadRequest) 842 + return 843 + } 844 + 845 + if err := store.UpdateGrinderByRKey(r.Context(), rkey, &req); err != nil { 846 + http.Error(w, "Failed to update grinder", http.StatusInternalServerError) 847 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update grinder") 743 848 return 744 849 } 745 850 746 - grinder, err := store.GetGrinderByRKey(rkey) 851 + grinder, err := store.GetGrinderByRKey(r.Context(), rkey) 747 852 if err != nil { 748 - http.Error(w, err.Error(), http.StatusInternalServerError) 853 + http.Error(w, "Failed to fetch updated grinder", http.StatusInternalServerError) 854 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get grinder after update") 749 855 return 750 856 } 751 857 752 858 w.Header().Set("Content-Type", "application/json") 753 - json.NewEncoder(w).Encode(grinder) 859 + if err := json.NewEncoder(w).Encode(grinder); err != nil { 860 + log.Error().Err(err).Msg("Failed to encode grinder response") 861 + } 754 862 } 755 863 756 864 func (h *Handler) HandleGrinderDelete(w http.ResponseWriter, r *http.Request) { ··· 763 871 return 764 872 } 765 873 766 - if err := store.DeleteGrinderByRKey(rkey); err != nil { 767 - http.Error(w, err.Error(), http.StatusInternalServerError) 874 + if err := store.DeleteGrinderByRKey(r.Context(), rkey); err != nil { 875 + http.Error(w, "Failed to delete grinder", http.StatusInternalServerError) 876 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete grinder") 768 877 return 769 878 } 770 879 ··· 775 884 func (h *Handler) HandleBrewerCreate(w http.ResponseWriter, r *http.Request) { 776 885 var req models.CreateBrewerRequest 777 886 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 778 - http.Error(w, err.Error(), http.StatusBadRequest) 887 + http.Error(w, "Invalid request body", http.StatusBadRequest) 779 888 return 780 889 } 781 890 ··· 786 895 return 787 896 } 788 897 789 - brewer, err := store.CreateBrewer(&req) 898 + // Validate required fields 899 + if req.Name == "" { 900 + http.Error(w, "Brewer name is required", http.StatusBadRequest) 901 + return 902 + } 903 + 904 + brewer, err := store.CreateBrewer(r.Context(), &req) 790 905 if err != nil { 791 - http.Error(w, err.Error(), http.StatusInternalServerError) 906 + http.Error(w, "Failed to create brewer", http.StatusInternalServerError) 907 + log.Error().Err(err).Msg("Failed to create brewer") 792 908 return 793 909 } 794 910 795 911 w.Header().Set("Content-Type", "application/json") 796 - json.NewEncoder(w).Encode(brewer) 912 + if err := json.NewEncoder(w).Encode(brewer); err != nil { 913 + log.Error().Err(err).Msg("Failed to encode brewer response") 914 + } 797 915 } 798 916 799 917 func (h *Handler) HandleBrewerUpdate(w http.ResponseWriter, r *http.Request) { ··· 808 926 809 927 var req models.UpdateBrewerRequest 810 928 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 811 - http.Error(w, err.Error(), http.StatusBadRequest) 929 + http.Error(w, "Invalid request body", http.StatusBadRequest) 930 + return 931 + } 932 + 933 + // Validate required fields 934 + if req.Name == "" { 935 + http.Error(w, "Brewer name is required", http.StatusBadRequest) 812 936 return 813 937 } 814 938 815 - if err := store.UpdateBrewerByRKey(rkey, &req); err != nil { 816 - http.Error(w, err.Error(), http.StatusInternalServerError) 939 + if err := store.UpdateBrewerByRKey(r.Context(), rkey, &req); err != nil { 940 + http.Error(w, "Failed to update brewer", http.StatusInternalServerError) 941 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to update brewer") 817 942 return 818 943 } 819 944 820 - brewer, err := store.GetBrewerByRKey(rkey) 945 + brewer, err := store.GetBrewerByRKey(r.Context(), rkey) 821 946 if err != nil { 822 - http.Error(w, err.Error(), http.StatusInternalServerError) 947 + http.Error(w, "Failed to fetch updated brewer", http.StatusInternalServerError) 948 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to get brewer after update") 823 949 return 824 950 } 825 951 826 952 w.Header().Set("Content-Type", "application/json") 827 - json.NewEncoder(w).Encode(brewer) 953 + if err := json.NewEncoder(w).Encode(brewer); err != nil { 954 + log.Error().Err(err).Msg("Failed to encode brewer response") 955 + } 828 956 } 829 957 830 958 func (h *Handler) HandleBrewerDelete(w http.ResponseWriter, r *http.Request) { ··· 837 965 return 838 966 } 839 967 840 - if err := store.DeleteBrewerByRKey(rkey); err != nil { 841 - http.Error(w, err.Error(), http.StatusInternalServerError) 968 + if err := store.DeleteBrewerByRKey(r.Context(), rkey); err != nil { 969 + http.Error(w, "Failed to delete brewer", http.StatusInternalServerError) 970 + log.Error().Err(err).Str("rkey", rkey).Msg("Failed to delete brewer") 842 971 return 843 972 } 844 973 845 974 w.WriteHeader(http.StatusOK) 846 975 } 976 + 977 + // fetchAllData is a helper that fetches all data types in parallel using errgroup. 978 + // This is used by handlers that need beans, roasters, grinders, and brewers. 979 + func fetchAllData(ctx context.Context, store database.Store) ( 980 + beans []*models.Bean, 981 + roasters []*models.Roaster, 982 + grinders []*models.Grinder, 983 + brewers []*models.Brewer, 984 + err error, 985 + ) { 986 + g, ctx := errgroup.WithContext(ctx) 987 + 988 + g.Go(func() error { 989 + var fetchErr error 990 + beans, fetchErr = store.ListBeans(ctx) 991 + return fetchErr 992 + }) 993 + g.Go(func() error { 994 + var fetchErr error 995 + roasters, fetchErr = store.ListRoasters(ctx) 996 + return fetchErr 997 + }) 998 + g.Go(func() error { 999 + var fetchErr error 1000 + grinders, fetchErr = store.ListGrinders(ctx) 1001 + return fetchErr 1002 + }) 1003 + g.Go(func() error { 1004 + var fetchErr error 1005 + brewers, fetchErr = store.ListBrewers(ctx) 1006 + return fetchErr 1007 + }) 1008 + 1009 + err = g.Wait() 1010 + return 1011 + }
+3 -34
internal/middleware/logging.go
··· 28 28 // Calculate duration 29 29 duration := time.Since(start) 30 30 31 - // Build log context (data to include in the log) 32 - logContext := zerolog.Dict(). 33 - Str("method", r.Method). 34 - Str("path", r.URL.Path). 35 - Str("query", r.URL.RawQuery). 36 - Int("status", rw.statusCode). 37 - Dur("duration", duration). 38 - Str("remote_addr", r.RemoteAddr). 39 - Str("user_agent", r.UserAgent()). 40 - Int64("bytes_written", rw.bytesWritten). 41 - Str("proto", r.Proto) 42 - 43 - // Add referer if present 44 - if referer := r.Referer(); referer != "" { 45 - logContext.Str("referer", referer) 46 - } 47 - 48 - // Add request ID if present (could be added by another middleware) 49 - if reqID := r.Header.Get("X-Request-ID"); reqID != "" { 50 - logContext.Str("request_id", reqID) 51 - } 52 - 53 - // Add content type if present 54 - if contentType := r.Header.Get("Content-Type"); contentType != "" { 55 - logContext.Str("content_type", contentType) 56 - } 57 - 58 - // Add authenticated user DID if present 59 - if did, err := atproto.GetAuthenticatedDID(r.Context()); err == nil && did != "" { 60 - logContext.Str("user_did", did) 61 - } 62 - 63 - // Select log level based on status code and log with context 31 + // Select log level based on status code 64 32 var logEvent *zerolog.Event 65 33 if rw.statusCode >= 500 { 66 34 logEvent = logger.Error() ··· 70 38 logEvent = logger.Info() 71 39 } 72 40 41 + // Add core fields 73 42 logEvent. 74 43 Str("method", r.Method). 75 44 Str("path", r.URL.Path). ··· 81 50 Int64("bytes_written", rw.bytesWritten). 82 51 Str("proto", r.Proto) 83 52 84 - // Add optional fields 53 + // Add optional fields only if present 85 54 if referer := r.Referer(); referer != "" { 86 55 logEvent.Str("referer", referer) 87 56 }