package main import ( "log" "net/http" "os" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" ) type ClubInstance struct { ClubID string `json:"club_id"` ClubName string `json:"club_name"` APIBaseURL string `json:"api_base_url"` LogoURL string `json:"logo_url"` City string `json:"city"` Country string `json:"country"` IsActive bool `json:"is_active"` Version string `json:"version"` Tags map[string]string `json:"tags"` Features []string `json:"features"` LastSeen time.Time `json:"last_seen"` RegisteredAt time.Time `json:"registered_at"` } type CentralDirectory struct { instances map[string]ClubInstance mutex sync.RWMutex token string } func NewCentralDirectory(token string) *CentralDirectory { return &CentralDirectory{ instances: make(map[string]ClubInstance), token: token, } } func (cd *CentralDirectory) HandleRegistration(c *gin.Context) { var payload ClubInstance if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } // Validate token if !cd.validateToken(c) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } // Validate required fields if payload.ClubID == "" || payload.ClubName == "" || payload.APIBaseURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields: club_id, club_name, api_base_url"}) return } cd.mutex.Lock() defer cd.mutex.Unlock() // Set registration time if not present if payload.RegisteredAt.IsZero() { payload.RegisteredAt = time.Now() } // Update or add instance if existing, exists := cd.instances[payload.ClubID]; exists { // Update existing instance but preserve registration time payload.RegisteredAt = existing.RegisteredAt cd.instances[payload.ClubID] = payload log.Printf("Updated club instance: %s (%s)", payload.ClubName, payload.ClubID) } else { cd.instances[payload.ClubID] = payload log.Printf("Registered new club instance: %s (%s)", payload.ClubName, payload.ClubID) } c.JSON(http.StatusOK, gin.H{ "status": "registered", "club_id": payload.ClubID, "timestamp": payload.LastSeen, }) } func (cd *CentralDirectory) HandleHeartbeat(c *gin.Context) { var payload map[string]interface{} if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) return } // Validate token if !cd.validateToken(c) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } clubID, ok := payload["club_id"].(string) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "missing club_id"}) return } cd.mutex.Lock() defer cd.mutex.Unlock() if instance, exists := cd.instances[clubID]; exists { instance.LastSeen = time.Now() if lastSeen, ok := payload["last_seen"].(string); ok { if parsed, err := time.Parse(time.RFC3339, lastSeen); err == nil { instance.LastSeen = parsed } } cd.instances[clubID] = instance c.JSON(http.StatusOK, gin.H{"status": "ok"}) } else { c.JSON(http.StatusNotFound, gin.H{"error": "club not found"}) } } func (cd *CentralDirectory) HandleListClubs(c *gin.Context) { search := c.Query("search") active := c.DefaultQuery("active", "true") limit := c.DefaultQuery("limit", "50") cd.mutex.RLock() defer cd.mutex.RUnlock() var results []ClubInstance for _, instance := range cd.instances { // Filter by active status if active == "true" && !instance.IsActive { continue } if active == "false" && instance.IsActive { continue } // Filter by search query if search != "" { query := strings.ToLower(search) if !strings.Contains(strings.ToLower(instance.ClubName), query) && !strings.Contains(strings.ToLower(instance.City), query) && !strings.Contains(strings.ToLower(instance.Country), query) { continue } } results = append(results, instance) } // Apply limit if len(results) > 0 { if limitInt := parseInt(limit); limitInt > 0 && limitInt < len(results) { results = results[:limitInt] } } c.JSON(http.StatusOK, results) } func (cd *CentralDirectory) HandleGetClub(c *gin.Context) { clubID := c.Param("id") cd.mutex.RLock() defer cd.mutex.RUnlock() instance, exists := cd.instances[clubID] if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "club not found"}) return } c.JSON(http.StatusOK, instance) } func (cd *CentralDirectory) HandleHealth(c *gin.Context) { cd.mutex.RLock() count := len(cd.instances) cd.mutex.RUnlock() c.JSON(http.StatusOK, gin.H{ "status": "ok", "timestamp": time.Now(), "instances": count, "service": "myclub-directory", }) } func (cd *CentralDirectory) HandleStats(c *gin.Context) { cd.mutex.RLock() defer cd.mutex.RUnlock() activeCount := 0 featureCounts := make(map[string]int) countryCounts := make(map[string]int) for _, instance := range cd.instances { if instance.IsActive { activeCount++ } for _, feature := range instance.Features { featureCounts[feature]++ } if instance.Country != "" { countryCounts[instance.Country]++ } } c.JSON(http.StatusOK, gin.H{ "total_instances": len(cd.instances), "active_instances": activeCount, "features": featureCounts, "countries": countryCounts, "timestamp": time.Now(), }) } func (cd *CentralDirectory) validateToken(c *gin.Context) bool { // Check Authorization header (same pattern as error system) authHeader := c.GetHeader("Authorization") if authHeader != "" { token := strings.TrimPrefix(authHeader, "Bearer ") return token == cd.token } // Check X-Ingest-Token header (same pattern as error system) tokenHeader := c.GetHeader("X-Ingest-Token") if tokenHeader != "" { return tokenHeader == cd.token } // Check X-Directory-Token header (fallback for direct access) directoryTokenHeader := c.GetHeader("X-Directory-Token") if directoryTokenHeader != "" { return directoryTokenHeader == cd.token } return false } func parseInt(s string) int { if i, err := strconv.Atoi(s); err == nil { return i } return 0 } func setupRoutes(cd *CentralDirectory) *gin.Engine { r := gin.Default() // CORS middleware r.Use(func(c *gin.Context) { c.Header("Access-Control-Allow-Origin", "*") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Directory-Token") if c.Request.Method == "OPTIONS" { c.AbortWithStatus(http.StatusNoContent) return } c.Next() }) // API routes api := r.Group("/api/v1") { // Public endpoints api.GET("/clubs", cd.HandleListClubs) api.GET("/clubs/:id", cd.HandleGetClub) api.GET("/health", cd.HandleHealth) api.GET("/stats", cd.HandleStats) // Protected endpoints (require token) api.POST("/directory/register", cd.HandleRegistration) api.POST("/directory/heartbeat", cd.HandleHeartbeat) } // Root endpoint r.GET("/", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "service": "MyClub Central Directory", "version": "1.0.0", "timestamp": time.Now(), }) }) r.NoRoute(func(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "endpoint not found"}) }) return r } func main() { port := os.Getenv("PORT") if port == "" { port = "8083" } token := os.Getenv("DIRECTORY_TOKEN") if token == "" { log.Fatal("DIRECTORY_TOKEN environment variable is required") } log.Printf("Starting MyClub Central Directory on port %s", port) cd := NewCentralDirectory(token) r := setupRoutes(cd) // Start cleanup goroutine to remove inactive instances go cd.cleanupInactiveInstances() if err := r.Run(":" + port); err != nil { log.Fatal("Failed to start server:", err) } } func (cd *CentralDirectory) cleanupInactiveInstances() { ticker := time.NewTicker(10 * time.Minute) // Check every 10 minutes defer ticker.Stop() for range ticker.C { cd.mutex.Lock() now := time.Now() for clubID, instance := range cd.instances { // Remove instances that haven't been seen for more than 1 hour if now.Sub(instance.LastSeen) > time.Hour { delete(cd.instances, clubID) log.Printf("Removed inactive club instance: %s (%s)", instance.ClubName, clubID) } } cd.mutex.Unlock() } }