This commit is contained in:
Tomas Dvorak
2026-01-26 08:13:18 +01:00
parent aa036b6550
commit dfc079288f
505 changed files with 95755 additions and 5712 deletions
@@ -0,0 +1,356 @@
package controllers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type DirectoryController struct {
DB *gorm.DB
request *gin.Context
}
func NewDirectoryController(db *gorm.DB) *DirectoryController {
return &DirectoryController{DB: db}
}
type InstanceRegistrationPayload 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"`
}
// RegisterInstance registers this club instance with the central directory
func (dc *DirectoryController) RegisterInstance(c *gin.Context) {
// Store request context for helper methods
dc.request = c
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
// Build registration payload
payload := InstanceRegistrationPayload{
ClubID: strings.TrimSpace(s.ClubID),
ClubName: strings.TrimSpace(s.ClubName),
APIBaseURL: dc.getPublicURL(),
LogoURL: strings.TrimSpace(s.ClubLogoURL),
City: strings.TrimSpace(s.ContactCity),
Country: strings.TrimSpace(s.ContactCountry),
IsActive: true,
Version: strings.TrimSpace(os.Getenv("APP_VERSION")),
LastSeen: time.Now(),
Tags: map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
"instance_id": dc.getInstanceID(),
},
Features: dc.getEnabledFeatures(),
}
// 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
}
// Send to central directory
if err := dc.sendToCentralDirectory("/api/v1/directory/register", payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register with central directory", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "registered",
"club_id": payload.ClubID,
"timestamp": payload.LastSeen,
})
}
// Heartbeat sends a heartbeat to central directory
func (dc *DirectoryController) Heartbeat(c *gin.Context) {
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
payload := map[string]interface{}{
"club_id": strings.TrimSpace(s.ClubID),
"last_seen": time.Now(),
"status": "active",
"tags": map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
},
}
if err := dc.sendToCentralDirectory("/api/v1/directory/heartbeat", payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send heartbeat", "details": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "timestamp": time.Now()})
}
// GetInstanceInfo returns this instance's information
func (dc *DirectoryController) GetInstanceInfo(c *gin.Context) {
// Store request context for helper methods
dc.request = c
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
return
}
info := InstanceRegistrationPayload{
ClubID: strings.TrimSpace(s.ClubID),
ClubName: strings.TrimSpace(s.ClubName),
APIBaseURL: dc.getPublicURL(),
LogoURL: strings.TrimSpace(s.ClubLogoURL),
City: strings.TrimSpace(s.ContactCity),
Country: strings.TrimSpace(s.ContactCountry),
IsActive: true,
Version: strings.TrimSpace(os.Getenv("APP_VERSION")),
LastSeen: time.Now(),
Tags: map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
"instance_id": dc.getInstanceID(),
},
Features: dc.getEnabledFeatures(),
}
c.JSON(http.StatusOK, info)
}
// Helper methods
func (dc *DirectoryController) getPublicURL() string {
// Try to get the public URL from settings or environment
if config.AppConfig != nil && config.AppConfig.FrontendBaseURL != "" {
return strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/") + "/api/v1"
}
// Fallback to constructing from request
scheme := "https"
if dc.request == nil || dc.request.Request.TLS == nil {
scheme = "http"
}
host := "localhost"
if dc.request != nil {
host = dc.request.Request.Host
if idx := strings.Index(host, ":"); idx >= 0 {
host = host[:idx]
}
}
return scheme + "://" + host + "/api/v1"
}
func (dc *DirectoryController) getHostname() string {
host := "localhost"
if dc.request != nil {
host = dc.request.Request.Host
if idx := strings.Index(host, ":"); idx >= 0 {
host = host[:idx]
}
}
return host
}
func (dc *DirectoryController) getInstanceID() string {
// Try to get instance ID from environment or generate from hostname
if id := strings.TrimSpace(os.Getenv("INSTANCE_ID")); id != "" {
return id
}
hostname := dc.getHostname()
if hostname != "" {
return strings.ReplaceAll(hostname, ".", "-")
}
return "unknown"
}
func (dc *DirectoryController) getEnabledFeatures() []string {
var features []string
// Always add basic features
features = append(features, "dashboard", "news", "auth")
// Check which features are enabled based on settings
var s models.Settings
if err := dc.DB.First(&s).Error; err == nil {
if s.VideosModuleEnabled {
features = append(features, "videos")
}
if s.MerchModuleEnabled {
features = append(features, "merch")
}
if s.NewsletterEnabled {
features = append(features, "newsletter")
}
if s.ShowMapOnHomepage {
features = append(features, "map")
}
if s.GalleryURL != "" {
features = append(features, "gallery")
}
// Add matches feature (always enabled for now)
features = append(features, "matches")
// Add blog feature (always enabled for now)
features = append(features, "blog")
}
return features
}
func (dc *DirectoryController) sendToCentralDirectory(endpoint string, payload interface{}) error {
// Get central directory URL from environment
baseURL := strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_URL"))
if baseURL == "" {
return nil // Silently skip if not configured
}
// Build full URL
fullURL := strings.TrimSuffix(baseURL, "/") + endpoint
// Marshal payload
b, err := json.Marshal(payload)
if err != nil {
return err
}
// Send request with timeout
post := func(u string) bool {
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
if err != nil {
return false
}
req.Header.Set("Content-Type", "application/json")
// Use same token pattern as error system
token := strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_TOKEN"))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Ingest-Token", token)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300
}
if post(fullURL) {
return nil
}
// Try Docker host fallback for local development
if u, err := url.Parse(fullURL); err == nil {
h := u.Hostname()
if h == "127.0.0.1" || h == "localhost" {
u.Host = strings.Replace(u.Host, h, "host.docker.internal", 1)
if post(u.String()) {
return nil
}
}
}
return fmt.Errorf("failed to send to central directory")
}
// StartDirectoryHeartbeat starts the background heartbeat process
func (dc *DirectoryController) StartDirectoryHeartbeat() {
// Check if directory registration is enabled
if strings.TrimSpace(os.Getenv("DIRECTORY_INGEST_URL")) == "" {
return
}
ticker := time.NewTicker(5 * time.Minute) // Heartbeat every 5 minutes
go func() {
for range ticker.C {
// Send heartbeat in background
go func() {
payload := map[string]interface{}{
"club_id": dc.getClubID(),
"last_seen": time.Now(),
"status": "active",
"tags": map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
},
}
dc.sendToCentralDirectory("/api/v1/directory/heartbeat", payload)
}()
}
}()
// Also register immediately on startup
go func() {
time.Sleep(2 * time.Second) // Wait for server to be ready
dc.registerOnStartup()
}()
}
func (dc *DirectoryController) getClubID() string {
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
return ""
}
return strings.TrimSpace(s.ClubID)
}
func (dc *DirectoryController) registerOnStartup() {
var s models.Settings
if err := dc.DB.First(&s).Error; err != nil {
return
}
payload := InstanceRegistrationPayload{
ClubID: strings.TrimSpace(s.ClubID),
ClubName: strings.TrimSpace(s.ClubName),
APIBaseURL: dc.getPublicURL(),
LogoURL: strings.TrimSpace(s.ClubLogoURL),
City: strings.TrimSpace(s.ContactCity),
Country: strings.TrimSpace(s.ContactCountry),
IsActive: true,
Version: strings.TrimSpace(os.Getenv("APP_VERSION")),
LastSeen: time.Now(),
Tags: map[string]string{
"instance_host": dc.getHostname(),
"environment": config.AppConfig.AppEnv,
"instance_id": dc.getInstanceID(),
},
Features: dc.getEnabledFeatures(),
}
dc.sendToCentralDirectory("/api/v1/directory/register", payload)
}