mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
342 lines
8.3 KiB
Go
342 lines
8.3 KiB
Go
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()
|
|
}
|
|
}
|