This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
package config
import (
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
// Config holds all configuration for the application
type Config struct {
// App settings
AppEnv string
Port string
Debug bool
// Database settings
DatabaseURL string
MaxIdleConnections int
MaxOpenConnections int
ConnMaxLifetime time.Duration
// JWT settings
JWTSecret string
JWTExpiration time.Duration
// Server settings
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
// Security
ContentSecurityPolicy string
// File upload settings
UploadDir string
MaxUploadSize int64
AllowedMimeTypes []string
// Email settings
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPassword string
SMTPFrom string
SMTPFromName string
SMTPEncryption string
SMTPAuth bool
SMTPSkipVerify bool
// Email templates
EmailTemplateDir string
// Contact settings
ContactEmail string
AdminEmail string
// Admin access token (optional) to allow token-based admin access
AdminAccessToken string
// Newsletter settings
NewsletterEnabled bool
// CORS settings
AllowedOrigins []string
// External services
ScraperBaseURL string
FrontendBaseURL string
PublicAPIBaseURL string
// Umami Analytics
UmamiURL string
UmamiUsername string
UmamiPassword string
UmamiWebsiteID string // If empty, will auto-create on production
}
var AppConfig *Config
// LoadConfig loads the configuration from environment variables
func LoadConfig() {
// Load .env file if it exists
_ = godotenv.Load()
AppConfig = &Config{
// App settings
AppEnv: getEnv("APP_ENV", "development"),
Port: getEnv("PORT", "8080"),
Debug: getEnvAsBool("DEBUG", true),
// Database settings
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/fotbal_club?sslmode=disable"),
MaxIdleConnections: getEnvAsInt("DB_MAX_IDLE_CONNS", 10),
MaxOpenConnections: getEnvAsInt("DB_MAX_OPEN_CONNS", 100),
ConnMaxLifetime: time.Duration(getEnvAsInt("DB_CONN_MAX_LIFETIME", 60)) * time.Minute,
// JWT settings
JWTSecret: getEnv("JWT_SECRET", "default-secret-key-change-in-production"),
JWTExpiration: time.Duration(getEnvAsInt("JWT_EXPIRATION_HOURS", 24)) * time.Hour,
// Server settings
ReadTimeout: time.Duration(getEnvAsInt("READ_TIMEOUT", 5)) * time.Second,
WriteTimeout: time.Duration(getEnvAsInt("WRITE_TIMEOUT", 10)) * time.Second,
IdleTimeout: time.Duration(getEnvAsInt("IDLE_TIMEOUT", 120)) * time.Second,
// Security
ContentSecurityPolicy: getEnv("CONTENT_SECURITY_POLICY", "default-src 'self' data: blob: https: http:; img-src * data: blob:; style-src 'self' 'unsafe-inline' https: http:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: http:; connect-src *;"),
// File upload settings
UploadDir: getEnv("UPLOAD_DIR", "./uploads"),
MaxUploadSize: int64(getEnvAsInt("MAX_UPLOAD_SIZE", 10)) * 1024 * 1024, // 10MB default
AllowedMimeTypes: []string{
// Images
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
// Documents
"application/pdf",
"application/msword", // .doc
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.ms-excel", // .xls
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
"application/vnd.ms-powerpoint", // .ppt
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
// Text
"text/plain",
// Archives
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/vnd.rar",
},
// Email settings
SMTPHost: getEnv("SMTP_HOST", "smtp.example.com"),
SMTPPort: getEnvAsInt("SMTP_PORT", 587),
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", "noreply@fotbal-club.cz"),
SMTPFromName: getEnv("SMTP_FROM_NAME", "Fotbal Club"),
SMTPEncryption: getEnv("SMTP_ENCRYPTION", "tls"),
SMTPAuth: getEnvAsBool("SMTP_AUTH", true),
SMTPSkipVerify: getEnvAsBool("SMTP_SKIP_VERIFY", false),
// Email templates - using absolute path to templates
EmailTemplateDir: getEnv("EMAIL_TEMPLATE_DIR", filepath.Join("templates", "emails")),
// Contact settings
ContactEmail: getEnv("CONTACT_EMAIL", "help@tdvorak.dev"),
AdminEmail: getEnv("ADMIN_EMAIL", "help@tdvorak.dev"),
AdminAccessToken: getEnv("ADMIN_ACCESS_TOKEN", ""),
// Newsletter settings
NewsletterEnabled: getEnvAsBool("NEWSLETTER_ENABLED", true),
// CORS settings
AllowedOrigins: []string{
"http://localhost:3000",
"http://localhost:8080",
},
// External services
ScraperBaseURL: getEnv("FACR_SCRAPER_BASE_URL", "http://localhost:8081"),
FrontendBaseURL: getEnv("FRONTEND_BASE_URL", "http://localhost:3000"),
PublicAPIBaseURL: getEnv("PUBLIC_API_BASE_URL", "http://localhost:8080/api/v1"),
// Umami Analytics
UmamiURL: getEnv("UMAMI_URL", ""),
UmamiUsername: getEnv("UMAMI_USERNAME", ""),
UmamiPassword: getEnv("UMAMI_PASSWORD", ""),
UmamiWebsiteID: getEnv("UMAMI_WEBSITE_ID", ""),
}
// Override allowed origins if specified in environment (comma-separated)
if origins := os.Getenv("ALLOWED_ORIGINS"); origins != "" {
parts := strings.Split(origins, ",")
var list []string
for _, p := range parts {
v := strings.TrimSpace(p)
if v != "" {
list = append(list, v)
}
}
if len(list) > 0 {
AppConfig.AllowedOrigins = list
}
}
}
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func getEnvAsInt(key string, defaultValue int) int {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultValue
}
value, err := strconv.Atoi(valueStr)
if err != nil {
log.Printf("Invalid value for %s: %v. Using default: %d", key, err, defaultValue)
return defaultValue
}
return value
}
func getEnvAsBool(key string, defaultValue bool) bool {
valueStr := os.Getenv(key)
if valueStr == "" {
return defaultValue
}
value, err := strconv.ParseBool(valueStr)
if err != nil {
log.Printf("Invalid boolean value for %s: %v. Using default: %t", key, err, defaultValue)
return defaultValue
}
return value
}
+125
View File
@@ -0,0 +1,125 @@
package controllers
import (
"net/http"
"strings"
"fotbal-club/internal/models"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AboutController struct {
DB *gorm.DB
}
func NewAboutController(db *gorm.DB) *AboutController {
return &AboutController{DB: db}
}
// GetPublicAboutPage returns the published About page
func (ac *AboutController) GetPublicAboutPage(c *gin.Context) {
var page models.AboutPage
err := ac.DB.Where("published = ?", true).Order("updated_at DESC").First(&page).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "About page not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, page)
}
// Admin: Get current About page (published or draft)
func (ac *AboutController) GetAdminAboutPage(c *gin.Context) {
var page models.AboutPage
err := ac.DB.Order("updated_at DESC").First(&page).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// Return empty structure for new page
c.JSON(http.StatusOK, models.AboutPage{
Style: "default",
Published: true,
})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, page)
}
// Admin: Create or update About page
func (ac *AboutController) UpsertAboutPage(c *gin.Context) {
var payload struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Style string `json:"style"`
Content string `json:"content"`
HeroImage string `json:"hero_image"`
Sections string `json:"sections"`
SEOTitle string `json:"seo_title"`
SEODesc string `json:"seo_description"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate required fields
if strings.TrimSpace(payload.Title) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required"})
return
}
// Get existing page or create new
var page models.AboutPage
err := ac.DB.Order("updated_at DESC").First(&page).Error
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// Update fields
page.Title = payload.Title
page.Subtitle = payload.Subtitle
page.Style = payload.Style
page.Content = payload.Content
page.HeroImage = payload.HeroImage
page.Sections = payload.Sections
// Always publish automatically
page.Published = true
page.SEOTitle = payload.SEOTitle
page.SEODesc = payload.SEODesc
// Create or save
if page.ID == 0 {
if err := ac.DB.Create(&page).Error; err != nil {
logger.Error("Failed to create about page: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create page"})
return
}
} else {
if err := ac.DB.Save(&page).Error; err != nil {
logger.Error("Failed to update about page: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update page"})
return
}
}
c.JSON(http.StatusOK, page)
}
// Admin: Delete About page
func (ac *AboutController) DeleteAboutPage(c *gin.Context) {
if err := ac.DB.Where("1 = 1").Delete(&models.AboutPage{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete page"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+474
View File
@@ -0,0 +1,474 @@
package controllers
import (
"bytes"
"encoding/json"
"fmt"
"html"
"net/http"
"regexp"
"strings"
"time"
"os"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AIController handles AI-assisted endpoints
type AIController struct {
DB *gorm.DB
}
// GenerateAboutPage creates about page content using the OpenRouter API
func (ac *AIController) GenerateAboutPage(c *gin.Context) {
var req aiAboutRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
clubName := strings.TrimSpace(req.ClubName)
if clubName == "" {
clubName = "Fotbalový klub"
}
style := strings.TrimSpace(req.Style)
if style == "" {
style = "default"
}
audience := strings.TrimSpace(req.Audience)
if audience == "" {
audience = "fanoušci klubu"
}
system := "Jsi zkušený editor klubových webů. Pomůžeš napsat stránku 'O klubu'. Odpovídej česky, srozumitelně a profesionálně."
user := fmt.Sprintf("Poznámky k vytvoření stránky O klubu:\n---\n%s\n---\nNázev klubu: %s\nPreferovaný styl: %s\nCílové publikum: %s\n\nPovinné požadavky:\n1) Zachovej fakta z poznámek a rozšiř je o kontext (historie, hodnoty, úspěchy, tým, zázemí, komunita).\n2) Rozděl text do sekcí s HTML nadpisy (h2/h3) a odstavci (p). Můžeš použít seznamy (ul/li) tam, kde to dává smysl. Bez inline stylů.\n3) Napiš krátký podnadpis (subtitle) vystihující náladu klubu.\n4) Vytvoř SEO titulek (do 60 znaků) a SEO popis (do 160 znaků).\n5) Odpověz POUZE JSON: {\"title\":\"...\", \"subtitle\":\"...\", \"html\":\"...\", \"seo_title\":\"...\", \"seo_description\":\"...\"}.\n6) HTML pole musí obsahovat kompletní obsah stránky dle požadavků.\n", strings.TrimSpace(req.Prompt), clubName, style, audience)
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
model := getOpenRouterModel()
if model == "" {
model = "mistralai/mistral-small-3.2-24b-instruct:free"
}
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" {
fallbackModel = "mistralai/mistral-nemo:free"
}
callModel := func(modelName string) (string, int, error) {
payload := map[string]interface{}{
"model": modelName,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"temperature": 0.5,
"max_tokens": 2200,
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil {
return "", http.StatusInternalServerError, err
}
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" {
reqHTTP.Header.Set("HTTP-Referer", ref)
}
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" {
reqHTTP.Header.Set("X-Title", ttl)
}
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(reqHTTP)
if err != nil {
return "", http.StatusBadGateway, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&e)
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
}
var or struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil {
return "", http.StatusBadGateway, err
}
if len(or.Choices) == 0 {
return "", http.StatusBadGateway, fmt.Errorf("empty choices")
}
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
}
content, _, err := callModel(model)
if err != nil || strings.TrimSpace(content) == "" {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()})
} else if fbErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()})
} else {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"})
}
return
}
}
var out aiAboutResponse
sanitized := sanitizeAIResponse(content)
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
if out.HTML != "" {
out.HTML = html.UnescapeString(out.HTML)
}
if out.SEOTitle == "" {
out.SEOTitle = fmt.Sprintf("%s | %s", clubName, "Oficiální informace")
}
if out.SEODescription == "" {
out.SEODescription = fmt.Sprintf("Přečtěte si informace o klubu %s.", clubName)
}
if out.Title == "" {
out.Title = clubName
}
if out.Subtitle == "" {
out.Subtitle = "Oficiální klubový profil"
}
if out.HTML == "" {
out.HTML = fmt.Sprintf("<h2>O klubu %s</h2><p>%s</p>", htmlEscape(clubName), htmlEscape(strings.TrimSpace(req.Prompt)))
}
c.JSON(http.StatusOK, out)
}
func NewAIController(db *gorm.DB) *AIController {
return &AIController{DB: db}
}
type aiBlogRequest struct {
// Short user input that describes the topic/notes for the blog in Czech
Prompt string `json:"prompt" binding:"required"`
// Optional extra hints
Audience string `json:"audience"`
MinWords int `json:"min_words"`
}
type aiBlogResponse struct {
Title string `json:"title"`
Slug string `json:"slug"`
HTML string `json:"html"`
}
type aiAboutRequest struct {
Prompt string `json:"prompt" binding:"required"`
ClubName string `json:"club_name"`
Style string `json:"style"`
Audience string `json:"audience"`
}
type aiAboutResponse struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
HTML string `json:"html"`
SEOTitle string `json:"seo_title"`
SEODescription string `json:"seo_description"`
}
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
func (ac *AIController) GenerateBlog(c *gin.Context) {
var req aiBlogRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.MinWords <= 0 {
req.MinWords = 450
}
// Build instruction in Czech - emphasizing user text as primary source, but allow expansion if needed
system := "Jsi asistent pro tvorbu článků. Tvým HLAVNÍM úkolem je: PŘEVZÍT TEXT OD UŽIVATELE a rozvinout ho do čitelného článku. Vždy vycházej z textu uživatele - zachovej VŠECHNY jeho informace, fakta a události. Pokud je text krátký, přidej kontext, rozvinutí a souvislosti, ale vždy kolem témat a informací z textu uživatele. Rozděl text do logických odstavců, přidej vhodné HTML značky (nadpisy h2/h3, odstavce p, seznamy ul/ol). Píšeš česky, srozumitelně a čtivě pro fotbalové fanoušky. HTML výstup bez inline stylů."
user := fmt.Sprintf("Text od uživatele (VŽDY z něj vycházej, zachovej všechny jeho informace):\n---\n%s\n---\nPublikum: %s\nCílová délka: %d slov.\n\nPOVINNÉ POŽADAVKY:\n1) ZACHOVEJ všechny informace, jména, události a fakta z textu uživatele. To je ZÁKLAD článku.\n2) Pokud je text krátký (pod %d slov), ROZVIŇ ho - přidej kontext, atmosféru, detaily kolem událostí z textu uživatele. Buď čtivý a zajímavý.\n3) Pokud je text dostatečně dlouhý, pouze ho strukturuj do HTML s nadpisy a odstavci.\n4) Vygeneruj výstižný titulek vycházející z obsahu textu uživatele.\n5) Vytvoř URL slug (3-5 slov, max. 40 znaků, lowercase, bez diakritiky, jen písmena/číslice a pomlčky).\n6) Odpověz POUZE JSON: {\"title\": \"...\", \"slug\": \"...\", \"html\": \"...\"}\n7) HTML obsah = text uživatele + rozvinutí (pokud nutné) strukturovaný do HTML tagů (h2, p, ul, ol). BEZ inline stylů.\n\nPAMATUJ: Text uživatele = základ. Pokud je krátký, rozviň ho čtivě a zajímavě pro %s.\n", strings.TrimSpace(req.Prompt), strings.TrimSpace(req.Audience), req.MinWords, req.MinWords, strings.TrimSpace(req.Audience))
// Prepare OpenRouter request
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
// Primary and fallback models
model := getOpenRouterModel()
if model == "" {
model = "mistralai/mistral-small-3.2-24b-instruct:free"
}
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" {
fallbackModel = "mistralai/mistral-nemo:free"
}
// Helper to call OpenRouter with a given model and return content
callModel := func(modelName string) (string, int, error) {
payload := map[string]interface{}{
"model": modelName,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"temperature": 0.5,
"max_tokens": 2000,
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil {
return "", http.StatusInternalServerError, err
}
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
// Optional but recommended headers for OpenRouter
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(reqHTTP)
if err != nil {
return "", http.StatusBadGateway, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&e)
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
}
// OpenAI-compatible response
var or struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil {
return "", http.StatusBadGateway, err
}
if len(or.Choices) == 0 {
return "", http.StatusBadGateway, fmt.Errorf("empty choices")
}
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
}
// Try primary, then fallback
content, _, err := callModel(model)
if err != nil || strings.TrimSpace(content) == "" {
// Attempt fallback model
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
// Provide the primary error if available
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()})
} else if fbErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()})
} else {
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"})
}
return
}
}
// Sanitize and parse JSON returned by the model
var out aiBlogResponse
// Clean up the response: remove markdown code blocks, backticks, etc.
sanitized := sanitizeAIResponse(content)
// Try to parse the sanitized content
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
// Best-effort: try to find JSON block
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
// Decode HTML entities in the html field
if out.HTML != "" {
out.HTML = html.UnescapeString(out.HTML)
}
// Fallbacks if the model did not provide title/slug
if out.Title == "" {
out.Title = deriveTitle(req.Prompt)
}
// Validate slug: short, independent from title. If not valid, derive from prompt.
if !isValidShortSlug(out.Slug) || out.Slug == slugify(out.Title) {
out.Slug = shortSlugFromPrompt(req.Prompt)
}
if out.HTML == "" {
// Wrap raw content as paragraph fallback
out.HTML = "<h1>" + htmlEscape(out.Title) + "</h1><p>" + htmlEscape(content) + "</p>"
}
c.JSON(http.StatusOK, out)
}
// Helpers for OpenRouter config
func getOpenRouterAPIKey() string {
if v := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(getenv("OPENROUTER_API_KEY")), "\"")); v != "" {
return v
}
return ""
}
func getOpenRouterBaseURL() string {
if v := strings.TrimSpace(getenv("OPENROUTER_BASE_URL")); v != "" {
return v
}
return "https://openrouter.ai/api/v1"
}
func getOpenRouterModel() string {
if v := strings.TrimSpace(getenv("OPENROUTER_MODEL")); v != "" {
return v
}
return ""
}
func getOpenRouterFallbackModel() string {
if v := strings.TrimSpace(getenv("OPENROUTER_FALLBACK_MODEL")); v != "" {
return v
}
return ""
}
// Small utility wrappers to avoid importing os directly multiple times
func getenv(k string) string { return strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(os.Getenv(k)), "\r", ""), "\n", "")) }
// deriveTitle returns a readable title from user prompt
func deriveTitle(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "Novinky z klubu"
}
// Capitalize first letter, keep it concise
if len(s) > 120 {
s = s[:120]
}
return strings.ToUpper(string([]rune(s)[:1])) + s[1:]
}
// slugify creates a URL-friendly slug without diacritics
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
// Replace diacritics (basic map); for full support, consider x/text/unicode/norm and transform
replacer := strings.NewReplacer(
"á", "a", "č", "c", "ď", "d", "é", "e", "ě", "e", "í", "i", "ň", "n",
"ó", "o", "ř", "r", "š", "s", "ť", "t", "ú", "u", "ů", "u", "ý", "y", "ž", "z",
)
s = replacer.Replace(s)
// Replace any non alnum with hyphen
re := regexp.MustCompile("[^a-z0-9]+")
s = re.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
return "clanek"
}
return s
}
// isValidShortSlug checks basic constraints: non-empty, <= 40 chars, 3-5 words (by hyphens), allowed charset
func isValidShortSlug(s string) bool {
s = strings.TrimSpace(s)
if s == "" { return false }
if len(s) > 40 { return false }
parts := strings.Split(s, "-")
// filter empty parts
w := 0
for _, p := range parts { if p != "" { w++ } }
if w < 3 || w > 5 { return false }
// allowed chars: a-z0-9-
re := regexp.MustCompile(`^[a-z0-9-]+$`)
return re.MatchString(s)
}
// shortSlugFromPrompt creates a compact, independent slug from the prompt text
func shortSlugFromPrompt(prompt string) string {
p := strings.ToLower(strings.TrimSpace(prompt))
if p == "" { return "clanek" }
// basic diacritics removal via slugify, then split to words
p = slugify(p)
parts := strings.Split(p, "-")
// simple Czech stopwords list (subset)
stop := map[string]struct{}{"a":{},"i":{},"v":{},"ve":{},"z":{},"za":{},"od":{},"do":{},"u":{},"o":{},"s":{},"se":{},"na":{},"po":{},"pod":{},"nad":{},"proti":{},"pri":{},"bez":{},"k":{},"ke":{},"ten":{},"ta":{},"to":{},"ty":{},"tento":{},"tato":{},"toto":{},"jak":{},"jako":{},"ze":{}}
var kept []string
for _, w := range parts {
if w == "" { continue }
if _, ok := stop[w]; ok { continue }
kept = append(kept, w)
if len(kept) >= 5 { break }
}
if len(kept) == 0 { kept = parts }
// prefer 3-5 words, trim to 4 if too many
if len(kept) > 5 { kept = kept[:5] }
if len(kept) >= 4 { kept = kept[:4] }
s := strings.Join(kept, "-")
if len(s) > 40 { s = s[:40] }
s = strings.Trim(s, "-")
if !isValidShortSlug(s) {
// final fallback
s = slugify(deriveTitle(prompt))
if len(s) > 40 { s = s[:40] }
s = strings.Trim(s, "-")
}
return s
}
func htmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
return s
}
// sanitizeAIResponse cleans up AI response to extract valid JSON
// Handles markdown code blocks, extra backticks, and other formatting issues
func sanitizeAIResponse(content string) string {
// Trim whitespace
content = strings.TrimSpace(content)
// Remove markdown code block markers (```json, ``json, `, etc.)
// Handle various formats: ```json\n{...}\n```, ``json{...}``, `{...}`
content = regexp.MustCompile(`^\s*`+"`"+`{1,3}\s*json\s*`).ReplaceAllString(content, "")
content = regexp.MustCompile(`\s*`+"`"+`{1,3}\s*$`).ReplaceAllString(content, "")
// Remove any remaining backticks at start/end
content = strings.Trim(content, "`")
content = strings.TrimSpace(content)
return content
}
@@ -0,0 +1,435 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AnalyticsController struct {
DB *gorm.DB
umamiService *services.UmamiService
}
func NewAnalyticsController(db *gorm.DB) *AnalyticsController {
return &AnalyticsController{
DB: db,
umamiService: services.NewUmamiService(),
}
}
// resolveWebsiteID attempts to obtain a usable Umami website ID for requests
func (ac *AnalyticsController) resolveWebsiteID() (string, error) {
if id := strings.TrimSpace(config.AppConfig.UmamiWebsiteID); id != "" {
return id, nil
}
// Try to get the first available website
id, err := ac.umamiService.GetDefaultWebsiteID()
if err != nil {
return "", err
}
config.AppConfig.UmamiWebsiteID = id
return id, nil
}
// getClientIP extracts the real client IP address
func getClientIP(c *gin.Context) string {
// Check X-Forwarded-For header
xff := c.GetHeader("X-Forwarded-For")
if xff != "" {
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
}
// Check X-Real-IP header
xri := c.GetHeader("X-Real-IP")
if xri != "" {
return xri
}
// Fall back to RemoteAddr
return c.ClientIP()
}
// hashIP creates a privacy-preserving hash of IP address
func hashIP(ip string) string {
// Add salt to make it harder to reverse
salted := ip + "_fotbal_club_2025"
hash := sha256.Sum256([]byte(salted))
return hex.EncodeToString(hash[:])
}
// Track generic event (page view, click, etc.)
func (ac *AnalyticsController) TrackEvent(c *gin.Context) {
var payload models.VisitorEvent
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Set client IP from request (hashed for privacy)
realIP := getClientIP(c)
payload.IPAddress = hashIP(realIP)
payload.UserAgent = c.GetHeader("User-Agent")
payload.Referrer = c.GetHeader("Referer")
// Synchronize Page and PagePath fields
if payload.PagePath != "" && payload.Page == "" {
payload.Page = payload.PagePath
} else if payload.Page != "" && payload.PagePath == "" {
payload.PagePath = payload.Page
}
if err := ac.DB.Create(&payload).Error; err != nil {
logger.Error("Failed to track event: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to track event"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GetAnalytics returns general analytics summary with users, events, and articles stats
func (ac *AnalyticsController) GetAnalytics(c *gin.Context) {
// Get users stats
var totalUsers int64
ac.DB.Model(&models.User{}).Count(&totalUsers)
// Get new users this week
weekAgo := time.Now().AddDate(0, 0, -7)
var newUsersThisWeek int64
ac.DB.Model(&models.User{}).Where("created_at >= ?", weekAgo).Count(&newUsersThisWeek)
// Get events stats
var totalEvents int64
ac.DB.Model(&models.Event{}).Count(&totalEvents)
// Get upcoming events (events with start_time in the future)
now := time.Now()
var upcomingEvents int64
ac.DB.Model(&models.Event{}).Where("start_time > ?", now).Count(&upcomingEvents)
// Get articles stats
var totalArticles int64
ac.DB.Model(&models.Article{}).Count(&totalArticles)
var publishedArticles int64
ac.DB.Model(&models.Article{}).Where("published = ?", true).Count(&publishedArticles)
c.JSON(http.StatusOK, gin.H{
"users": gin.H{
"total": totalUsers,
"new_this_week": newUsersThisWeek,
},
"events": gin.H{
"total": totalEvents,
"upcoming": upcomingEvents,
},
"articles": gin.H{
"total": totalArticles,
"published": publishedArticles,
},
})
}
// GetVisitors returns visitor statistics grouped by day
func (ac *AnalyticsController) GetVisitors(c *gin.Context) {
// Get parameters
days := 30
if d := c.Query("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
days = parsed
}
}
groupBy := c.DefaultQuery("groupBy", "day")
startDate := time.Now().AddDate(0, 0, -days)
type VisitorStat struct {
Date string `json:"date"`
PageViews int64 `json:"pageViews"`
UniqueVisitors int64 `json:"uniqueVisitors"`
}
var stats []VisitorStat
// Group by date
if groupBy == "day" {
ac.DB.Model(&models.VisitorEvent{}).
Select("DATE(created_at) as date, COUNT(*) as page_views, COUNT(DISTINCT ip_address) as unique_visitors").
Where("event_type = ? AND created_at >= ?", "page_view", startDate).
Group("DATE(created_at)").
Order("date ASC").
Scan(&stats)
} else {
// Default to day grouping
ac.DB.Model(&models.VisitorEvent{}).
Select("DATE(created_at) as date, COUNT(*) as page_views, COUNT(DISTINCT ip_address) as unique_visitors").
Where("event_type = ? AND created_at >= ?", "page_view", startDate).
Group("DATE(created_at)").
Order("date ASC").
Scan(&stats)
}
// Transform data for chart format
labels := make([]string, 0, len(stats))
pageViewsData := make([]int64, 0, len(stats))
uniqueVisitorsData := make([]int64, 0, len(stats))
var totalVisitors int64
for _, stat := range stats {
// Format date as "d. M." (e.g., "5. 10.")
t, err := time.Parse("2006-01-02", stat.Date)
if err == nil {
labels = append(labels, fmt.Sprintf("%d. %d.", t.Day(), int(t.Month())))
} else {
labels = append(labels, stat.Date)
}
pageViewsData = append(pageViewsData, stat.PageViews)
uniqueVisitorsData = append(uniqueVisitorsData, stat.UniqueVisitors)
totalVisitors += stat.UniqueVisitors
}
// Calculate change percentage (compare last 7 days with previous 7 days)
var changePercentage float64
if len(stats) >= 14 {
var recentSum, previousSum int64
for i := len(stats) - 7; i < len(stats); i++ {
recentSum += stats[i].UniqueVisitors
}
for i := len(stats) - 14; i < len(stats)-7; i++ {
previousSum += stats[i].UniqueVisitors
}
if previousSum > 0 {
changePercentage = float64(recentSum-previousSum) / float64(previousSum) * 100
}
}
response := gin.H{
"totalVisitors": totalVisitors,
"changePercentage": changePercentage,
"chartData": gin.H{
"labels": labels,
"datasets": []gin.H{
{
"label": "Návštěvníci",
"data": uniqueVisitorsData,
"borderColor": "rgba(66, 153, 225, 1)",
"backgroundColor": "rgba(66, 153, 225, 0.5)",
"tension": 0.3,
"fill": true,
},
},
},
}
c.JSON(http.StatusOK, response)
}
// GetAnalyticsOverview returns overview statistics for admin dashboard
func (ac *AnalyticsController) GetAnalyticsOverview(c *gin.Context) {
var totalPageViews, uniqueVisitors, pageViewsToday, pageViewsWeek, uniqueVisitorsWeek int64
// Try to fetch from Umami first
websiteID, err := ac.resolveWebsiteID()
if err == nil && websiteID != "" {
// Fetch overall stats (last 365 days for total)
endAt := time.Now().Unix() * 1000
startAt := time.Now().AddDate(-1, 0, 0).Unix() * 1000
stats, err := ac.umamiService.GetWebsiteStats(websiteID, startAt, endAt)
if err == nil {
if pv, ok := stats["pageviews"].(map[string]interface{}); ok {
if val, ok := pv["value"].(float64); ok {
totalPageViews = int64(val)
}
}
if v, ok := stats["visitors"].(map[string]interface{}); ok {
if val, ok := v["value"].(float64); ok {
uniqueVisitors = int64(val)
}
}
}
// Fetch today's stats
todayStart := time.Now().Truncate(24 * time.Hour).Unix() * 1000
todayEnd := time.Now().Unix() * 1000
todayStats, err := ac.umamiService.GetWebsiteStats(websiteID, todayStart, todayEnd)
if err == nil {
if pv, ok := todayStats["pageviews"].(map[string]interface{}); ok {
if val, ok := pv["value"].(float64); ok {
pageViewsToday = int64(val)
}
}
}
// Fetch this week's stats
weekStart := time.Now().AddDate(0, 0, -7).Unix() * 1000
weekEnd := time.Now().Unix() * 1000
weekStats, err := ac.umamiService.GetWebsiteStats(websiteID, weekStart, weekEnd)
if err == nil {
if pv, ok := weekStats["pageviews"].(map[string]interface{}); ok {
if val, ok := pv["value"].(float64); ok {
pageViewsWeek = int64(val)
}
}
if v, ok := weekStats["visitors"].(map[string]interface{}); ok {
if val, ok := v["value"].(float64); ok {
uniqueVisitorsWeek = int64(val)
}
}
}
} else {
// Fallback to internal analytics if Umami is not available
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ?", "page_view").Count(&totalPageViews)
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ?", "page_view").Distinct("ip_address").Count(&uniqueVisitors)
today := time.Now().Format("2006-01-02")
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND DATE(created_at) = ?", "page_view", today).Count(&pageViewsToday)
weekAgo := time.Now().AddDate(0, 0, -7)
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND created_at >= ?", "page_view", weekAgo).Count(&pageViewsWeek)
ac.DB.Model(&models.VisitorEvent{}).Where("event_type = ? AND created_at >= ?", "page_view", weekAgo).Distinct("ip_address").Count(&uniqueVisitorsWeek)
}
// Total and published articles (always from DB)
var totalArticles, publishedArticles int64
ac.DB.Model(&models.Article{}).Count(&totalArticles)
ac.DB.Model(&models.Article{}).Where("published = ?", true).Count(&publishedArticles)
c.JSON(http.StatusOK, gin.H{
"total_page_views": totalPageViews,
"unique_visitors": uniqueVisitors,
"total_articles": totalArticles,
"published_articles": publishedArticles,
"page_views_today": pageViewsToday,
"page_views_week": pageViewsWeek,
"unique_visitors_week": uniqueVisitorsWeek,
})
}
// GetTopPages returns the most visited pages
func (ac *AnalyticsController) GetTopPages(c *gin.Context) {
limit := 10
if l := c.Query("limit"); l != "" {
fmt.Sscanf(l, "%d", &limit)
}
type PageStats struct {
PagePath string `json:"page_path"`
PageName string `json:"page_name"`
ViewCount int64 `json:"view_count"`
UniqueVisitors int64 `json:"unique_visitors"`
}
var pages []PageStats
// Try to fetch from Umami first
websiteID, err := ac.resolveWebsiteID()
if err == nil && websiteID != "" {
// Fetch URL metrics from Umami (last 30 days)
endAt := time.Now().Unix() * 1000
startAt := time.Now().AddDate(0, 0, -30).Unix() * 1000
metrics, err := ac.umamiService.GetWebsiteMetrics(websiteID, "url", startAt, endAt)
if err == nil && metrics != nil {
// Convert Umami metrics to PageStats format
for i, metricMap := range metrics {
if i >= limit {
break
}
pagePath := ""
viewCount := int64(0)
if x, ok := metricMap["x"].(string); ok {
pagePath = x
}
if y, ok := metricMap["y"].(float64); ok {
viewCount = int64(y)
}
pages = append(pages, PageStats{
PagePath: pagePath,
PageName: pagePath,
ViewCount: viewCount,
UniqueVisitors: viewCount, // Umami doesn't separate these
})
}
c.JSON(http.StatusOK, pages)
return
}
}
// Fallback to internal analytics
ac.DB.Model(&models.VisitorEvent{}).
Where("event_type = ?", "page_view").
Select("page_path, page_name, COUNT(*) as view_count, COUNT(DISTINCT ip_address) as unique_visitors").
Group("page_path, page_name").
Order("view_count DESC").
Limit(limit).
Scan(&pages)
c.JSON(http.StatusOK, pages)
}
// GetTopArticles returns the most viewed articles
func (ac *AnalyticsController) GetTopArticles(c *gin.Context) {
limit := 10
if l := c.Query("limit"); l != "" {
fmt.Sscanf(l, "%d", &limit)
}
var articles []models.Article
ac.DB.Where("published = ?", true).
Order("view_count DESC").
Limit(limit).
Find(&articles)
c.JSON(http.StatusOK, articles)
}
type TopInteraction struct {
Page string `json:"page"`
Element string `json:"element"`
Count int64 `json:"count"`
}
func (ctrl *AnalyticsController) GetTopInteractions(c *gin.Context) {
daysParam := c.DefaultQuery("days", "30")
limitParam := c.DefaultQuery("limit", "10")
days, _ := strconv.Atoi(daysParam)
if days <= 0 || days > 365 { days = 30 }
limit, _ := strconv.Atoi(limitParam)
if limit <= 0 || limit > 100 { limit = 10 }
start := time.Now().AddDate(0, 0, -days)
var rows []TopInteraction
err := ctrl.DB.
Model(&models.VisitorEvent{}).
Select("page, element, COUNT(*) as count").
Where("event_type IN ? AND created_at >= ?", []string{"click", "interaction"}, start).
Group("page, element").
Order("count DESC").
Limit(limit).
Scan(&rows).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load interactions"})
return
}
c.JSON(http.StatusOK, gin.H{"items": rows})
}
+585
View File
@@ -0,0 +1,585 @@
package controllers
import (
"log"
"net/http"
"strconv"
"strings"
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"fotbal-club/pkg/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// RegisterRequest represents the request body for user registration
type RegisterRequest struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Name string `json:"name"`
}
// LoginRequest represents the request body for user login
type LoginRequest struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
// AuthResponse represents the response for authentication endpoints
type AuthResponse struct {
Token string `json:"token"`
User *UserModel `json:"user"`
}
// UserModel represents the user data in the response
type UserModel struct {
ID uint `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
// AuthController handles authentication related requests
type AuthController struct {
DB *gorm.DB
setupService *services.SetupService
}
// NewAuthController creates a new AuthController
func NewAuthController(db *gorm.DB) *AuthController {
return &AuthController{
DB: db,
setupService: services.NewSetupService(db),
}
}
// Register handles user registration
func (ac *AuthController) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Backward compatibility: allow a single 'name' and split to first/last
if (req.FirstName == "" || req.LastName == "") && req.Name != "" {
// Split by whitespace, first token = first name, rest joined as last name
parts := strings.Fields(req.Name)
if len(parts) > 0 {
req.FirstName = parts[0]
}
if len(parts) > 1 {
req.LastName = strings.Join(parts[1:], " ")
}
}
if req.FirstName == "" || req.LastName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "first_name and last_name are required (you can also provide 'name' with both)"})
return
}
// Normalize email to lowercase and trim spaces (supports unicode)
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || !strings.Contains(req.Email, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email address"})
return
}
// Check if email already exists (case-insensitive)
var existingUser models.User
if err := ac.DB.Where("LOWER(email) = LOWER(?)", req.Email).First(&existingUser).Error; err == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already registered"})
return
}
// Hash password
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// Check if this is the first user (admin)
var userCount int64
ac.DB.Model(&models.User{}).Count(&userCount)
role := "editor"
isFirstUser := userCount == 0
if isFirstUser {
role = "admin"
}
// Create user
user := models.User{
Email: req.Email,
Password: hashedPassword,
FirstName: req.FirstName,
LastName: req.LastName,
Role: role,
}
if err := ac.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
// For first user, ensure setup info exists
if isFirstUser {
_, err := ac.setupService.GetSetupStatus()
if err != nil {
// If there's an error getting setup status, log it but continue
log.Printf("Warning: Failed to initialize setup status: %v", err)
}
}
// Generate JWT token
token, err := utils.GenerateJWT(user.ID, user.Email, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Also set secure HttpOnly cookie for browser-based auth (same behavior as Login)
secure := c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(c.Writer, &http.Cookie{
Name: "auth_token",
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(24 * time.Hour),
})
// Prepare response
response := gin.H{
"token": token,
"user": ac.toUserModel(&user),
}
// If this is the first user, include setup status
if isFirstUser {
setupInfo, _ := ac.setupService.GetSetupStatus()
if setupInfo != nil {
response["requires_initial_setup"] = true
response["setup_status"] = setupInfo.Status
}
}
c.JSON(http.StatusCreated, response)
}
// Login handles user login
func (ac *AuthController) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Normalize and find user by email (case-insensitive)
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" || !strings.Contains(req.Email, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email address"})
return
}
// Find user by email (case-insensitive)
var user models.User
if err := ac.DB.Where("LOWER(email) = LOWER(?)", req.Email).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Verify password
if err := utils.CheckPassword(req.Password, user.Password); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
// Update last login time
now := time.Now()
user.LastLogin = &now
ac.DB.Save(&user)
// Generate JWT token
token, err := utils.GenerateJWT(user.ID, user.Email, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Also set secure HttpOnly cookie for browser-based auth (optional to use)
// Determine secure flag: HTTPS or forwarded proto
secure := c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(c.Writer, &http.Cookie{
Name: "auth_token",
Value: token,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(24 * time.Hour),
})
// Return user data and token (backward compatibility for clients using Authorization header)
c.JSON(http.StatusOK, AuthResponse{
Token: token,
User: ac.toUserModel(&user),
})
}
// Logout clears the auth cookie (stateless JWT)
func (ac *AuthController) Logout(c *gin.Context) {
// Expire the cookie in the past
secure := c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(c.Writer, &http.Cookie{
Name: "auth_token",
Value: "",
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),
MaxAge: -1,
})
c.JSON(http.StatusOK, gin.H{"success": true})
}
// CheckEmail checks if an email is already registered
func (ac *AuthController) CheckEmail(c *gin.Context) {
email := c.Query("email")
if email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
return
}
var user models.User
if err := ac.DB.Where("email = ?", email).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"available": true})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, gin.H{"available": false})
}
// GetCurrentUser returns the currently authenticated user
func (ac *AuthController) GetCurrentUser(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(user.(*models.User))})
}
// AdminExists returns whether any admin user exists
func (ac *AuthController) AdminExists(c *gin.Context) {
var count int64
if err := ac.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
c.JSON(http.StatusOK, gin.H{"hasAdmin": count > 0})
}
// MakeAdmin promotes the current authenticated user to admin if no admin exists yet
func (ac *AuthController) MakeAdmin(c *gin.Context) {
// Ensure an admin doesn't already exist
var count int64
if err := ac.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if count > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Admin already exists"})
return
}
// Get current user from context
u, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
user := u.(*models.User)
// Promote to admin
user.Role = "admin"
if err := ac.DB.Save(user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to promote user"})
return
}
c.JSON(http.StatusOK, gin.H{"user": ac.toUserModel(user)})
}
// toUserModel converts a User model to a UserModel for JSON response
func (ac *AuthController) toUserModel(user *models.User) *UserModel {
return &UserModel{
ID: user.ID,
Email: user.Email,
FirstName: user.FirstName,
LastName: user.LastName,
Role: user.Role,
CreatedAt: user.CreatedAt,
}
}
// ListUsers returns a list of users (admin only)
// Admin list item matching frontend expectations
type AdminUserListItem struct {
ID uint `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
}
func (ac *AuthController) ListUsers(c *gin.Context) {
// Ensure admin
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var users []models.User
if err := ac.DB.Order("created_at DESC").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
out := make([]AdminUserListItem, 0, len(users))
for _, u := range users {
name := strings.TrimSpace(strings.TrimSpace(u.FirstName + " " + u.LastName))
out = append(out, AdminUserListItem{
ID: u.ID,
Email: u.Email,
Name: name,
Role: u.Role,
IsActive: u.IsActive,
CreatedAt: u.CreatedAt,
})
}
c.JSON(http.StatusOK, out)
}
// AdminCreateUserRequest request body
type AdminCreateUserRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
Role string `json:"role"`
IsActive *bool `json:"isActive"`
}
// AdminUpdateUserRequest request body
type AdminUpdateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
IsActive *bool `json:"isActive"`
CurrentPassword string `json:"current_password"`
}
func splitName(name string) (string, string) {
parts := strings.Fields(strings.TrimSpace(name))
if len(parts) == 0 {
return "", ""
}
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], strings.Join(parts[1:], " ")
}
// AdminCreateUser creates a user (admin only)
func (ac *AuthController) AdminCreateUser(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var req AdminCreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
email := strings.TrimSpace(strings.ToLower(req.Email))
if email == "" || !strings.Contains(email, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email address"})
return
}
// check duplicate
var existing models.User
if err := ac.DB.Where("LOWER(email) = LOWER(?)", email).First(&existing).Error; err == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already registered"})
return
}
// hash password
hashed, err := utils.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
// role
role := req.Role
if role != "admin" && role != "editor" {
role = "editor"
}
// active
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
fn, ln := splitName(req.Name)
u := models.User{
Email: email,
Password: hashed,
FirstName: fn,
LastName: ln,
Role: role,
IsActive: isActive,
}
if err := ac.DB.Create(&u).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": u.ID})
}
// AdminUpdateUser updates a user (admin only)
func (ac *AuthController) AdminUpdateUser(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var user models.User
if err := ac.DB.First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
}
return
}
var req AdminUpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If editing an admin account, require current password confirmation from the requester
if user.Role == "admin" {
if strings.TrimSpace(req.CurrentPassword) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "current_password is required to modify an admin account"})
return
}
// verify current user's password
cu, ok := c.Get("user")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
currentUser := cu.(*models.User)
if err := utils.CheckPassword(req.CurrentPassword, currentUser.Password); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid current password"})
return
}
}
// Apply updates
if req.Name != "" {
fn, ln := splitName(req.Name)
user.FirstName, user.LastName = fn, ln
}
if req.Email != "" {
email := strings.TrimSpace(strings.ToLower(req.Email))
if email == "" || !strings.Contains(email, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email address"})
return
}
// ensure uniqueness
var cnt int64
ac.DB.Model(&models.User{}).Where("LOWER(email) = LOWER(?) AND id <> ?", email, user.ID).Count(&cnt)
if cnt > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already in use"})
return
}
user.Email = email
}
if req.Role != "" {
if req.Role != "admin" && req.Role != "editor" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid role"})
return
}
user.Role = req.Role
}
if req.IsActive != nil {
user.IsActive = *req.IsActive
}
if err := ac.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// AdminDeleteUser deletes a user (admin only)
func (ac *AuthController) AdminDeleteUser(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var user models.User
if err := ac.DB.First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
}
return
}
// Disallow deleting admins and self-delete safety
if user.Role == "admin" {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete admin user"})
return
}
if cu, ok := c.Get("user"); ok {
currentUser := cu.(*models.User)
if currentUser.ID == user.ID {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete yourself"})
return
}
}
if err := ac.DB.Delete(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
package controllers
import (
"fotbal-club/internal/models"
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ClothingController handles clothing/merch endpoints
type ClothingController struct {
DB *gorm.DB
}
// NewClothingController creates a new clothing controller
func NewClothingController(db *gorm.DB) *ClothingController {
return &ClothingController{DB: db}
}
// GetClothing retrieves all active clothing items (public endpoint)
func (ctrl *ClothingController) GetClothing(c *gin.Context) {
db := ctrl.DB
var items []models.Clothing
if err := db.Where("is_active = ?", true).Order("display_order ASC, created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch clothing items"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
// GetClothingAdmin retrieves all clothing items for admin
func (ctrl *ClothingController) GetClothingAdmin(c *gin.Context) {
db := ctrl.DB
var items []models.Clothing
if err := db.Order("display_order ASC, created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch clothing items"})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}
// GetClothingByID retrieves a single clothing item by ID
func (ctrl *ClothingController) GetClothingByID(c *gin.Context) {
db := ctrl.DB
id := c.Param("id")
var item models.Clothing
if err := db.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Clothing item not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch clothing item"})
return
}
c.JSON(http.StatusOK, item)
}
// CreateClothing creates a new clothing item
func (ctrl *ClothingController) CreateClothing(c *gin.Context) {
db := ctrl.DB
var input models.Clothing
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := db.Create(&input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create clothing item"})
return
}
c.JSON(http.StatusCreated, input)
}
// UpdateClothing updates a clothing item
func (ctrl *ClothingController) UpdateClothing(c *gin.Context) {
db := ctrl.DB
id := c.Param("id")
var item models.Clothing
if err := db.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Clothing item not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch clothing item"})
return
}
var input models.Clothing
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update fields
item.Title = input.Title
item.Description = input.Description
item.Price = input.Price
item.Currency = input.Currency
item.ImageURL = input.ImageURL
item.URL = input.URL
item.IsActive = input.IsActive
item.DisplayOrder = input.DisplayOrder
if err := db.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update clothing item"})
return
}
c.JSON(http.StatusOK, item)
}
// DeleteClothing deletes a clothing item (soft delete)
func (ctrl *ClothingController) DeleteClothing(c *gin.Context) {
db := ctrl.DB
id := c.Param("id")
var item models.Clothing
if err := db.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Clothing item not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch clothing item"})
return
}
if err := db.Delete(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete clothing item"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Clothing item deleted"})
}
// UpdateClothingOrder updates the display order of clothing items
func (ctrl *ClothingController) UpdateClothingOrder(c *gin.Context) {
db := ctrl.DB
var input []struct {
ID uint `json:"id"`
DisplayOrder int `json:"display_order"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update each item's display order
for _, item := range input {
if err := db.Model(&models.Clothing{}).Where("id = ?", item.ID).Update("display_order", item.DisplayOrder).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update order"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "Order updated"})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,359 @@
package controllers
import (
"net/http"
"strings"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ContactInfoController handles contact information and categories
type ContactInfoController struct {
DB *gorm.DB
}
// NewContactInfoController creates a new ContactInfoController
func NewContactInfoController(db *gorm.DB) *ContactInfoController {
return &ContactInfoController{DB: db}
}
// ==================== PUBLIC ENDPOINTS ====================
// GetPublicContacts returns all active contacts grouped by category (public)
func (ctrl *ContactInfoController) GetPublicContacts(c *gin.Context) {
var contacts []models.Contact
if err := ctrl.DB.Preload("Category").
Where("is_active = ?", true).
Order("display_order ASC, created_at ASC").
Find(&contacts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load contacts"})
return
}
// Group by category
grouped := make(map[string][]models.Contact)
uncategorized := []models.Contact{}
for _, contact := range contacts {
if contact.Category != nil && contact.Category.IsActive {
grouped[contact.Category.Name] = append(grouped[contact.Category.Name], contact)
} else {
uncategorized = append(uncategorized, contact)
}
}
c.JSON(http.StatusOK, gin.H{
"categories": grouped,
"uncategorized": uncategorized,
})
}
// GetPublicContactCategories returns all active contact categories (public)
func (ctrl *ContactInfoController) GetPublicContactCategories(c *gin.Context) {
var categories []models.ContactCategory
if err := ctrl.DB.Where("is_active = ?", true).
Order("display_order ASC, name ASC").
Find(&categories).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load categories"})
return
}
c.JSON(http.StatusOK, categories)
}
// ==================== ADMIN: CONTACT CATEGORIES ====================
// GetContactCategories lists all contact categories (admin)
func (ctrl *ContactInfoController) GetContactCategories(c *gin.Context) {
var categories []models.ContactCategory
if err := ctrl.DB.Order("display_order ASC, name ASC").Find(&categories).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load categories"})
return
}
c.JSON(http.StatusOK, categories)
}
// CreateContactCategory creates a new contact category (admin)
func (ctrl *ContactInfoController) CreateContactCategory(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
DisplayOrder *int `json:"display_order"`
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
category := models.ContactCategory{
Name: strings.TrimSpace(body.Name),
Description: strings.TrimSpace(body.Description),
}
if body.DisplayOrder != nil {
category.DisplayOrder = *body.DisplayOrder
}
if body.IsActive != nil {
category.IsActive = *body.IsActive
} else {
category.IsActive = true
}
if err := ctrl.DB.Create(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
return
}
c.JSON(http.StatusCreated, category)
}
// UpdateContactCategory updates a contact category (admin)
func (ctrl *ContactInfoController) UpdateContactCategory(c *gin.Context) {
id := c.Param("id")
var category models.ContactCategory
if err := ctrl.DB.First(&category, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
DisplayOrder *int `json:"display_order"`
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.Name != nil {
category.Name = strings.TrimSpace(*body.Name)
}
if body.Description != nil {
category.Description = strings.TrimSpace(*body.Description)
}
if body.DisplayOrder != nil {
category.DisplayOrder = *body.DisplayOrder
}
if body.IsActive != nil {
category.IsActive = *body.IsActive
}
if err := ctrl.DB.Save(&category).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update category"})
return
}
c.JSON(http.StatusOK, category)
}
// DeleteContactCategory deletes a contact category (admin)
func (ctrl *ContactInfoController) DeleteContactCategory(c *gin.Context) {
id := c.Param("id")
// Check if any contacts use this category
var count int64
if err := ctrl.DB.Model(&models.Contact{}).Where("category_id = ?", id).Count(&count).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "Cannot delete category with contacts. Remove contacts first."})
return
}
if err := ctrl.DB.Delete(&models.ContactCategory{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete category"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Category deleted"})
}
// ==================== ADMIN: CONTACTS ====================
// GetContacts lists all contacts (admin)
func (ctrl *ContactInfoController) GetContacts(c *gin.Context) {
var contacts []models.Contact
if err := ctrl.DB.Preload("Category").
Order("display_order ASC, created_at ASC").
Find(&contacts).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load contacts"})
return
}
c.JSON(http.StatusOK, contacts)
}
// CreateContact creates a new contact (admin)
func (ctrl *ContactInfoController) CreateContact(c *gin.Context) {
var body struct {
CategoryID *uint `json:"category_id"`
Name string `json:"name" binding:"required"`
Position string `json:"position"`
Email string `json:"email"`
Phone string `json:"phone"`
ImageURL string `json:"image_url"`
Description string `json:"description"`
DisplayOrder *int `json:"display_order"`
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
contact := models.Contact{
CategoryID: body.CategoryID,
Name: strings.TrimSpace(body.Name),
Position: strings.TrimSpace(body.Position),
Email: strings.TrimSpace(body.Email),
Phone: strings.TrimSpace(body.Phone),
ImageURL: strings.TrimSpace(body.ImageURL),
Description: strings.TrimSpace(body.Description),
}
if body.DisplayOrder != nil {
contact.DisplayOrder = *body.DisplayOrder
}
if body.IsActive != nil {
contact.IsActive = *body.IsActive
} else {
contact.IsActive = true
}
// Validate category if provided
if contact.CategoryID != nil {
var cat models.ContactCategory
if err := ctrl.DB.First(&cat, *contact.CategoryID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category_id"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
}
if err := ctrl.DB.Create(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create contact"})
return
}
// Reload with category
ctrl.DB.Preload("Category").First(&contact, contact.ID)
c.JSON(http.StatusCreated, contact)
}
// UpdateContact updates a contact (admin)
func (ctrl *ContactInfoController) UpdateContact(c *gin.Context) {
id := c.Param("id")
var contact models.Contact
if err := ctrl.DB.First(&contact, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Contact not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var body struct {
CategoryID *uint `json:"category_id"`
Name *string `json:"name"`
Position *string `json:"position"`
Email *string `json:"email"`
Phone *string `json:"phone"`
ImageURL *string `json:"image_url"`
Description *string `json:"description"`
DisplayOrder *int `json:"display_order"`
IsActive *bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Allow setting category to null by sending explicit null
if c.Request.Header.Get("Content-Type") == "application/json" {
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err == nil {
if _, exists := raw["category_id"]; exists {
contact.CategoryID = body.CategoryID
}
}
} else if body.CategoryID != nil {
// Validate new category
var cat models.ContactCategory
if err := ctrl.DB.First(&cat, *body.CategoryID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category_id"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
contact.CategoryID = body.CategoryID
}
if body.Name != nil {
contact.Name = strings.TrimSpace(*body.Name)
}
if body.Position != nil {
contact.Position = strings.TrimSpace(*body.Position)
}
if body.Email != nil {
contact.Email = strings.TrimSpace(*body.Email)
}
if body.Phone != nil {
contact.Phone = strings.TrimSpace(*body.Phone)
}
if body.ImageURL != nil {
contact.ImageURL = strings.TrimSpace(*body.ImageURL)
}
if body.Description != nil {
contact.Description = strings.TrimSpace(*body.Description)
}
if body.DisplayOrder != nil {
contact.DisplayOrder = *body.DisplayOrder
}
if body.IsActive != nil {
contact.IsActive = *body.IsActive
}
if err := ctrl.DB.Save(&contact).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update contact"})
return
}
// Reload with category
ctrl.DB.Preload("Category").First(&contact, contact.ID)
c.JSON(http.StatusOK, contact)
}
// DeleteContact deletes a contact (admin)
func (ctrl *ContactInfoController) DeleteContact(c *gin.Context) {
id := c.Param("id")
if err := ctrl.DB.Delete(&models.Contact{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete contact"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Contact deleted"})
}
+244
View File
@@ -0,0 +1,244 @@
package controllers
import (
"encoding/base64"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// EmailController handles tracking endpoints and basic analytics
type EmailController struct {
DB *gorm.DB
}
// sendUmamiEmailEvent sends a custom event to Umami if configured.
func sendUmamiEmailEvent(name, urlPath, title string, data map[string]any) {
cfg := config.AppConfig
if cfg == nil || cfg.UmamiURL == "" || cfg.UmamiWebsiteID == "" {
return
}
svc := services.NewUmamiService()
// Best-effort, ignore error
_ = svc.SendEvent(cfg.UmamiWebsiteID, name, urlPath, title, data, "email")
}
// Admin: events for a specific email log
// GET /api/v1/admin/newsletter/stats/:id/events
func (ec *EmailController) GetEmailEventsForLog(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
// Verify log exists
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "email log not found"})
return
}
var events []models.EmailEvent
if err := ec.DB.Where("email_log_id = ?", log.ID).Order("created_at ASC").Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load events"})
return
}
c.JSON(http.StatusOK, gin.H{"data": events})
}
func NewEmailController(db *gorm.DB) *EmailController {
return &EmailController{DB: db}
}
// 1x1 transparent GIF bytes
var oneByOneGIF = []byte{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00,
0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00,
0xff, 0xff, 0xff, 0x21, 0xf9, 0x04, 0x01, 0x00,
0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44,
0x01, 0x00, 0x3b,
}
// OpenPixel records an open event and returns a transparent GIF
// GET /api/v1/email/open.gif?m=<log_id>&t=<token>
func (ec *EmailController) OpenPixel(c *gin.Context) {
idStr := c.Query("m")
tok := strings.TrimSpace(c.Query("t"))
if idStr == "" || tok == "" {
c.Data(http.StatusOK, "image/gif", oneByOneGIF)
return
}
id, _ := strconv.Atoi(idStr)
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok {
meta := models.EmailEvent{EmailLogID: log.ID, EventType: "open", Meta: map[string]any{
"ua": c.Request.UserAgent(),
"ip": c.ClientIP(),
}}
_ = ec.DB.Create(&meta).Error
// Send Umami event (non-blocking)
go sendUmamiEmailEvent("Email Open", "/email/open", "Email Open", map[string]any{
"recipient": log.RecipientEmail,
"subject": log.Subject,
})
}
c.Header("Cache-Control", "no-store, must-revalidate")
c.Data(http.StatusOK, "image/gif", oneByOneGIF)
}
// ClickRedirect records a click event then redirects to the target URL
// GET /api/v1/email/click?m=<log_id>&t=<token>&u=<encoded_url>
func (ec *EmailController) ClickRedirect(c *gin.Context) {
idStr := c.Query("m")
tok := strings.TrimSpace(c.Query("t"))
target := c.Query("u")
if target == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing u"})
return
}
// allow both raw absolute or base64-encoded URLs
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
// ok
} else {
if decoded, err := base64.URLEncoding.DecodeString(target); err == nil {
target = string(decoded)
}
}
// Validate URL
if u, err := url.Parse(target); err != nil || (u.Scheme != "http" && u.Scheme != "https") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
return
}
if idStr != "" && tok != "" {
if id, err := strconv.Atoi(idStr); err == nil {
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok {
meta := models.EmailEvent{EmailLogID: log.ID, EventType: "click", Meta: map[string]any{
"url": target,
"ua": c.Request.UserAgent(),
"ip": c.ClientIP(),
}}
_ = ec.DB.Create(&meta).Error
// Send Umami event (non-blocking)
go sendUmamiEmailEvent("Email Click", "/email/click", "Email Click", map[string]any{
"recipient": log.RecipientEmail,
"subject": log.Subject,
"url": target,
})
}
}
}
c.Redirect(http.StatusFound, target)
}
// MarkSpam marks an email as spam and (optionally) deactivates the subscriber
// GET /api/v1/email/spam?m=<log_id>&t=<token>
func (ec *EmailController) MarkSpam(c *gin.Context) {
idStr := c.Query("m")
tok := strings.TrimSpace(c.Query("t"))
id, _ := strconv.Atoi(idStr)
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok {
_ = ec.DB.Create(&models.EmailEvent{EmailLogID: log.ID, EventType: "spam", Meta: map[string]any{"at": time.Now().Format(time.RFC3339)}}).Error
// try to deactivate subscription
_ = ec.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", log.RecipientEmail).Update("is_active", false).Error
// Send Umami event (non-blocking)
go sendUmamiEmailEvent("Email Spam", "/email/spam", "Email Marked Spam", map[string]any{
"recipient": log.RecipientEmail,
"subject": log.Subject,
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Unsubscribe marks unsubscribe and deactivates the subscriber
// GET /api/v1/email/unsubscribe?m=<log_id>&t=<token>
func (ec *EmailController) Unsubscribe(c *gin.Context) {
idStr := c.Query("m")
tok := strings.TrimSpace(c.Query("t"))
id, _ := strconv.Atoi(idStr)
var log models.EmailLog
if err := ec.DB.First(&log, id).Error; err == nil && log.Token == tok {
_ = ec.DB.Create(&models.EmailEvent{EmailLogID: log.ID, EventType: "unsubscribe", Meta: map[string]any{"at": time.Now().Format(time.RFC3339)}}).Error
_ = ec.DB.Model(&models.NewsletterSubscription{}).Where("email = ?", log.RecipientEmail).Update("is_active", false).Error
// Send Umami event (non-blocking)
go sendUmamiEmailEvent("Email Unsubscribe", "/email/unsubscribe", "Email Unsubscribe", map[string]any{
"recipient": log.RecipientEmail,
"subject": log.Subject,
})
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: recent email logs with event counts
// GET /api/v1/admin/newsletter/stats/recent
func (ec *EmailController) GetRecentEmailStats(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var logs []models.EmailLog
_ = ec.DB.Order("created_at DESC").Limit(50).Find(&logs).Error
// aggregate events per log
type Row struct {
ID uint
Opens int64
Clicks int64
Spam int64
Unsubs int64
}
rows := map[uint]*Row{}
var evs []models.EmailEvent
_ = ec.DB.Where("email_log_id IN ?", func() []uint {
ids := make([]uint, 0, len(logs))
for _, l := range logs { ids = append(ids, l.ID) }
if len(ids) == 0 { ids = []uint{0} }
return ids
}()).Find(&evs).Error
for _, e := range evs {
r := rows[e.EmailLogID]
if r == nil { r = &Row{ID: e.EmailLogID}; rows[e.EmailLogID] = r }
switch strings.ToLower(e.EventType) {
case "open": r.Opens++
case "click": r.Clicks++
case "spam": r.Spam++
case "unsubscribe": r.Unsubs++
}
}
// build response
out := make([]gin.H, 0, len(logs))
for _, l := range logs {
r := rows[l.ID]
var opens, clicks, spam, unsubs int64
if r != nil {
opens, clicks, spam, unsubs = r.Opens, r.Clicks, r.Spam, r.Unsubs
}
out = append(out, gin.H{
"id": l.ID,
"created_at": l.CreatedAt,
"subject": l.Subject,
"recipient": l.RecipientEmail,
"type": l.Type,
"status": l.Status,
"opens": opens,
"clicks": clicks,
"spam": spam,
"unsubs": unsubs,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
+217
View File
@@ -0,0 +1,217 @@
package controllers
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
)
type EventController struct{ DB *gorm.DB }
// GetEventByID returns a single event by its ID (public; returns only public events unless owner)
func (ctrl *EventController) GetEventByID(c *gin.Context) {
id := c.Param("id")
var ev models.Event
if err := ctrl.DB.Preload("Attachments").First(&ev, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// If not public, allow only owner (when identified upstream)
if !ev.IsPublic {
if userID, exists := c.Get("userID"); !exists || ev.CreatedByID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Not allowed"})
return
}
}
c.JSON(http.StatusOK, ev)
}
type EventInput struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
StartTime time.Time `json:"start_time" binding:"required"`
EndTime *time.Time `json:"end_time"`
Location string `json:"location"`
Type string `json:"type" binding:"required,oneof=match training meeting other"`
IsPublic bool `json:"is_public"`
CategoryName string `json:"category_name"`
ImageURL string `json:"image_url"`
FileURL string `json:"file_url"`
YoutubeURL string `json:"youtube_url"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Attachments []struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
} `json:"attachments"`
}
func (ctrl *EventController) CreateEvent(c *gin.Context) {
// Ensure latest schema (adds columns if missing)
_ = ctrl.DB.AutoMigrate(&models.Event{}, &models.EventAttachment{})
var input EventInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("userID")
event := models.Event{
Title: input.Title,
Description: input.Description,
StartTime: input.StartTime,
EndTime: input.EndTime,
Location: input.Location,
Type: models.EventType(input.Type),
IsPublic: input.IsPublic,
CreatedByID: userID.(uint),
CategoryName: input.CategoryName,
ImageURL: input.ImageURL,
FileURL: input.FileURL,
YoutubeURL: input.YoutubeURL,
Latitude: input.Latitude,
Longitude: input.Longitude,
}
if err := ctrl.DB.Create(&event).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event"})
return
}
// Create attachments if any
if len(input.Attachments) > 0 {
var atts []models.EventAttachment
for _, a := range input.Attachments {
if a.URL == "" { continue }
atts = append(atts, models.EventAttachment{ EventID: event.ID, Name: a.Name, URL: a.URL, MimeType: a.MimeType, Size: a.Size })
}
if len(atts) > 0 {
if err := ctrl.DB.Create(&atts).Error; err != nil {
// non-fatal
}
}
}
// Reload with attachments
var out models.Event
_ = ctrl.DB.Preload("Attachments").First(&out, event.ID).Error
// Track file usage
fileTracker := services.NewFileTracker(ctrl.DB)
go fileTracker.TrackEventFiles(&out)
c.JSON(http.StatusCreated, out)
}
func (ctrl *EventController) GetEvents(c *gin.Context) {
var events []models.Event
query := ctrl.DB.Preload("Attachments")
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
}
func (ctrl *EventController) GetUpcomingEvents(c *gin.Context) {
var events []models.Event
query := ctrl.DB.Preload("Attachments").Where("start_time >= ?", time.Now()).Order("start_time ASC").Limit(5)
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
}
// UpdateEvent updates an existing event (protected)
func (ctrl *EventController) UpdateEvent(c *gin.Context) {
// Ensure latest schema (adds columns if missing)
_ = ctrl.DB.AutoMigrate(&models.Event{}, &models.EventAttachment{})
id := c.Param("id")
var ev models.Event
if err := ctrl.DB.First(&ev, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var input EventInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ev.Title = input.Title
ev.Description = input.Description
ev.StartTime = input.StartTime
ev.EndTime = input.EndTime
ev.Location = input.Location
ev.Type = models.EventType(input.Type)
ev.IsPublic = input.IsPublic
ev.CategoryName = input.CategoryName
ev.ImageURL = input.ImageURL
ev.FileURL = input.FileURL
ev.YoutubeURL = input.YoutubeURL
ev.Latitude = input.Latitude
ev.Longitude = input.Longitude
if err := ctrl.DB.Save(&ev).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update event"})
return
}
// Replace attachments (simple strategy)
if err := ctrl.DB.Where("event_id = ?", ev.ID).Delete(&models.EventAttachment{}).Error; err == nil {
if len(input.Attachments) > 0 {
var atts []models.EventAttachment
for _, a := range input.Attachments {
if a.URL == "" { continue }
atts = append(atts, models.EventAttachment{ EventID: ev.ID, Name: a.Name, URL: a.URL, MimeType: a.MimeType, Size: a.Size })
}
if len(atts) > 0 {
_ = ctrl.DB.Create(&atts).Error
}
}
}
var out models.Event
_ = ctrl.DB.Preload("Attachments").First(&out, ev.ID).Error
// Track file usage
fileTracker := services.NewFileTracker(ctrl.DB)
go fileTracker.TrackEventFiles(&out)
c.JSON(http.StatusOK, out)
}
// DeleteEvent removes an event (protected)
func (ctrl *EventController) DeleteEvent(c *gin.Context) {
id := c.Param("id")
if err := ctrl.DB.Delete(&models.Event{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete event"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+537
View File
@@ -0,0 +1,537 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"fotbal-club/internal/models"
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// In-memory cache for logo lookup
var logoCache = map[string]string{}
// club data cache (JSON bytes) with TTL and disk persistence
type cachedItem struct {
Data []byte `json:"data"`
StoredAt time.Time `json:"stored_at"`
}
var (
clubCacheMu sync.RWMutex
clubCache = map[string]cachedItem{}
cacheTTL = 30 * time.Minute
)
func cacheDir() string {
return filepath.Join("cache", "facr")
}
func cachePath(key string) string {
return filepath.Join(cacheDir(), key+".json")
}
func getCachedJSON(key string) ([]byte, bool) {
// memory first
clubCacheMu.RLock()
item, ok := clubCache[key]
clubCacheMu.RUnlock()
if ok && time.Since(item.StoredAt) < cacheTTL {
return item.Data, true
}
// disk fallback
b, err := os.ReadFile(cachePath(key))
if err == nil {
var disk cachedItem
if json.Unmarshal(b, &disk) == nil {
if time.Since(disk.StoredAt) < cacheTTL {
// warm memory
clubCacheMu.Lock()
clubCache[key] = disk
clubCacheMu.Unlock()
return disk.Data, true
}
}
}
return nil, false
}
func setCachedJSON(key string, data []byte) {
item := cachedItem{Data: data, StoredAt: time.Now()}
clubCacheMu.Lock()
clubCache[key] = item
clubCacheMu.Unlock()
// persist to disk (best-effort)
_ = os.MkdirAll(cacheDir(), 0o755)
if b, err := json.Marshal(item); err == nil {
_ = os.WriteFile(cachePath(key), b, 0o644)
}
}
// ----- Types (mirroring facr-scraper) -----
type Competition struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
TeamCount string `json:"team_count"`
MatchesLink string `json:"matches_link"`
Matches []Match `json:"matches,omitempty"`
Table *CompetitionTable `json:"table,omitempty"`
}
type CompetitionTable struct {
Overall []TableRow `json:"overall"`
}
type ClubInfo struct {
Name string `json:"name"`
ClubID string `json:"club_id"`
ClubType string `json:"club_type"`
ClubInternalID string `json:"club_internal_id,omitempty"`
URL string `json:"url,omitempty"`
LogoURL string `json:"logo_url,omitempty"`
Address string `json:"address,omitempty"`
Category string `json:"category,omitempty"`
Competitions []Competition `json:"competitions"`
}
type SearchResult struct {
Name string `json:"name"`
ClubID string `json:"club_id"`
ClubType string `json:"club_type"`
URL string `json:"url"`
LogoURL string `json:"logo_url"`
Category string `json:"category,omitempty"`
Address string `json:"address,omitempty"`
}
type Match struct {
DateTime string `json:"date_time"`
Home string `json:"home"`
HomeID string `json:"home_id,omitempty"`
HomeLogoURL string `json:"home_logo_url,omitempty"`
Away string `json:"away"`
AwayID string `json:"away_id,omitempty"`
AwayLogoURL string `json:"away_logo_url,omitempty"`
Score string `json:"score"`
Venue string `json:"venue"`
Note string `json:"note,omitempty"`
MatchID string `json:"match_id"`
ReportURL string `json:"report_url,omitempty"`
DelegationURL string `json:"delegation_url,omitempty"`
}
type TableRow struct {
Rank string `json:"rank"`
Team string `json:"team"`
TeamID string `json:"team_id,omitempty"`
TeamLogoURL string `json:"team_logo_url,omitempty"`
Played string `json:"played"`
Wins string `json:"wins"`
Draws string `json:"draws"`
Losses string `json:"losses"`
Score string `json:"score"`
Points string `json:"points"`
}
// ----- Helpers -----
func containsFold(s, substr string) bool {
s = strings.ToLower(strings.TrimSpace(s))
substr = strings.ToLower(strings.TrimSpace(substr))
if substr == "" {
return false
}
return strings.Contains(s, substr)
}
func extractUUIDFromHref(href string) string {
href = strings.TrimSpace(href)
if href == "" {
return ""
}
re := regexp.MustCompile(`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`)
if m := re.FindString(href); m != "" {
return m
}
parts := strings.Split(href, "/")
if len(parts) > 0 {
cand := parts[len(parts)-1]
if re.MatchString(cand) {
return cand
}
}
return ""
}
func getLogoBySearch(name string) string {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
return ""
}
if v, ok := logoCache[key]; ok {
return v
}
// Query local API routed through this same server
apiURL := fmt.Sprintf("http://localhost:8080/api/v1/facr/club/search?q=%s", neturl.QueryEscape(name))
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return ""
}
var payload struct {
Results []struct {
Name string `json:"name"`
LogoURL string `json:"logo_url"`
} `json:"results"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return ""
}
best := ""
for _, r := range payload.Results {
if strings.EqualFold(strings.TrimSpace(r.Name), strings.TrimSpace(name)) {
best = r.LogoURL
break
}
}
if best == "" {
for _, r := range payload.Results {
if strings.Contains(strings.ToLower(r.Name), key) || strings.Contains(key, strings.ToLower(r.Name)) {
best = r.LogoURL
break
}
}
}
if best == "" && len(payload.Results) > 0 {
best = payload.Results[0].LogoURL
}
if best != "" {
logoCache[key] = best
return best
}
// Fallback: directly scrape fotbal.cz search (same logic as SearchClubs)
vals := neturl.Values{}
vals.Set("q", name)
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return ""
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://www.fotbal.cz/club/hledej")
resp2, err := client.Do(req)
if err != nil {
return ""
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp2.Body)
return ""
}
doc, err := goquery.NewDocumentFromReader(resp2.Body)
if err != nil {
return ""
}
// choose first exact match, else first contains, else empty
exact := ""
partial := ""
doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) {
a := li.Find("a.Link--inverted").First()
n := strings.TrimSpace(a.Find("span.H7").First().Text())
if n == "" {
n = strings.TrimSpace(a.Text())
}
img := a.Find("img").First()
src, _ := img.Attr("src")
if src == "" {
return
}
if exact == "" && strings.EqualFold(n, name) {
exact = src
}
if partial == "" && (strings.Contains(strings.ToLower(n), key) || strings.Contains(key, strings.ToLower(n))) {
partial = src
}
})
best = exact
if best == "" {
best = partial
}
if best != "" {
logoCache[key] = best
}
return best
}
func getLogo(teamName, teamID string) string {
placeholder := "/dist/img/logo-club-empty.svg"
name := strings.ToLower(strings.TrimSpace(teamName))
if name == "" || strings.Contains(name, "volno") || strings.Contains(name, "volný los") || strings.Contains(name, "volny los") || strings.Contains(name, "bye") {
return placeholder
}
if logo := getLogoBySearch(teamName); logo != "" {
return logo
}
tid := strings.TrimSpace(teamID)
if tid != "" {
return fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
}
return placeholder
}
func resolveISURL(href string) string {
href = strings.TrimSpace(href)
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
if u, err := neturl.Parse(href); err == nil {
u.Scheme = "https"
u.Host = "is.fotbal.cz"
if !strings.HasPrefix(u.Path, "/public/") {
if strings.HasPrefix(u.Path, "/zapasy/") {
u.Path = "/public" + u.Path
}
}
q := u.Query()
q.Del("discipline")
u.RawQuery = q.Encode()
return u.String()
}
return href
}
href = strings.TrimPrefix(href, "./")
for strings.HasPrefix(href, "../") {
href = strings.TrimPrefix(href, "../")
}
href = strings.TrimPrefix(href, "/")
path := "/public/" + href
u := neturl.URL{Scheme: "https", Host: "is.fotbal.cz", Path: path}
return u.String()
}
// ----- Controller -----
type FACRController struct{ DB *gorm.DB }
func NewFACRController(db *gorm.DB) *FACRController { return &FACRController{DB: db} }
// GET /api/v1/facr/club/search?q=
func (fc *FACRController) SearchClubs(c *gin.Context) {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "query parameter 'q' is required"})
return
}
vals := neturl.Values{}
vals.Set("q", q)
searchURL := "https://www.fotbal.cz/club/hledej?" + vals.Encode()
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error creating request: %v", err)})
return
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
req.Header.Set("Referer", "https://www.fotbal.cz/club/hledej")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error fetching search page: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Try once more with quoted query if short tokens present
resp.Body.Close()
searchURL2 := searchURL
tokens := strings.Fields(q)
for _, t := range tokens {
if len([]rune(t)) <= 2 {
vals2 := neturl.Values{}
vals2.Set("q", "\""+q+"\"")
searchURL2 = "https://www.fotbal.cz/club/hledej?" + vals2.Encode()
break
}
}
req2, _ := http.NewRequest("GET", searchURL2, nil)
req2.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
req2.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req2.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp2, err2 := client.Do(req2)
if err2 != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Error fetching (retry): %v", err2)})
return
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
c.JSON(http.StatusOK, gin.H{"query": q, "count": 0, "results": []SearchResult{}})
return
}
resp = resp2
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Error parsing HTML: %v", err)})
return
}
var results []SearchResult
doc.Find("li.ListItemSplit").Each(func(_ int, li *goquery.Selection) {
a := li.Find("a.Link--inverted").First()
href, _ := a.Attr("href")
if href == "" {
return
}
name := strings.TrimSpace(a.Find("span.H7").First().Text())
if name == "" {
name = strings.TrimSpace(a.Text())
}
img := a.Find("img").First()
logoURL, _ := img.Attr("src")
category := strings.TrimSpace(li.Find(".ClubCategories .BadgeCategory").First().Text())
address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text())
clubType := "football"
if strings.Contains(strings.ToLower(href), "/futsal/") {
clubType = "futsal"
}
parts := strings.Split(strings.TrimRight(href, "/"), "/")
clubID := ""
if len(parts) > 0 {
clubID = parts[len(parts)-1]
}
if !strings.HasPrefix(href, "http://") && !strings.HasPrefix(href, "https://") {
href = "https://www.fotbal.cz" + href
}
results = append(results, SearchResult{
Name: name,
ClubID: clubID,
ClubType: clubType,
URL: href,
LogoURL: logoURL,
Category: category,
Address: address,
})
})
// If setup is completed and a sport is configured, filter results to that sport only
if fc.DB != nil {
var s models.Settings
if err := fc.DB.First(&s).Error; err == nil {
if strings.TrimSpace(s.ClubID) != "" && strings.TrimSpace(s.ClubType) != "" {
filtered := make([]SearchResult, 0, len(results))
want := strings.ToLower(strings.TrimSpace(s.ClubType))
for _, r := range results {
if strings.ToLower(strings.TrimSpace(r.ClubType)) == want {
filtered = append(filtered, r)
}
}
results = filtered
}
}
}
// respond and close the function
c.JSON(http.StatusOK, gin.H{
"query": q,
"count": len(results),
"results": results,
})
}
// GET /api/v1/facr/club/:type/:id
func (fc *FACRController) GetClubInfo(c *gin.Context) {
clubID := c.Param("id")
clubType := c.Param("type")
if clubID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Club ID is required"})
return
}
cacheKey := fmt.Sprintf("%s_%s_info", clubType, clubID)
force := c.Query("force") == "1"
if !force {
if data, ok := getCachedJSON(cacheKey); ok {
c.Data(http.StatusOK, "application/json", data)
return
}
}
external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s", clubType, clubID)
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Get(external)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
c.Data(resp.StatusCode, "application/json", body)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
return
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
}
// GET /api/v1/facr/club/:type/:id/table
func (fc *FACRController) GetClubTables(c *gin.Context) {
clubID := c.Param("id")
clubType := c.Param("type")
if clubID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Club ID is required"})
return
}
cacheKey := fmt.Sprintf("%s_%s_table", clubType, clubID)
force := c.Query("force") == "1"
if !force {
if data, ok := getCachedJSON(cacheKey); ok {
c.Data(http.StatusOK, "application/json", data)
return
}
}
external := fmt.Sprintf("https://facr.tdvorak.dev/club/%s/%s/table", clubType, clubID)
httpClient := &http.Client{Timeout: 15 * time.Second}
resp, err := httpClient.Get(external)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("proxy error: %v", err)})
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
c.Data(resp.StatusCode, "application/json", body)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
return
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
}
+425
View File
@@ -0,0 +1,425 @@
package controllers
import (
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"fotbal-club/internal/models"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type FilesController struct {
DB *gorm.DB
}
// FileInfo represents detailed file information with usage tracking
type FileInfo struct {
ID uint `json:"id"`
Filename string `json:"filename"`
FilePath string `json:"file_path"`
FileURL string `json:"file_url"`
FileSize int64 `json:"file_size"`
MimeType string `json:"mime_type"`
UploadedBy *models.User `json:"uploaded_by,omitempty"`
CreatedAt string `json:"created_at"`
Usages []models.FileUsage `json:"usages,omitempty"`
UsageCount int `json:"usage_count"`
MD5Hash string `json:"md5_hash,omitempty"`
}
// GetAllFiles returns all uploaded files with usage information
func (fc *FilesController) GetAllFiles(c *gin.Context) {
var files []models.UploadedFile
query := fc.DB.Preload("UploadedBy").Preload("Usages")
// Optional filtering
if search := c.Query("search"); search != "" {
query = query.Where("filename ILIKE ?", "%"+search+"%")
}
if mimeType := c.Query("mime_type"); mimeType != "" {
query = query.Where("mime_type LIKE ?", mimeType+"%")
}
// Sorting
sortBy := c.DefaultQuery("sort_by", "created_at")
sortOrder := c.DefaultQuery("sort_order", "desc")
query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder))
if err := query.Find(&files).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch files"})
return
}
// Convert to FileInfo format
fileInfos := make([]FileInfo, len(files))
for i, file := range files {
fileInfos[i] = FileInfo{
ID: file.ID,
Filename: file.Filename,
FilePath: file.FilePath,
FileURL: file.FileURL,
FileSize: file.FileSize,
MimeType: file.MimeType,
UploadedBy: file.UploadedBy,
CreatedAt: file.CreatedAt.Format("2006-01-02 15:04:05"),
Usages: file.Usages,
UsageCount: len(file.Usages),
}
}
c.JSON(http.StatusOK, fileInfos)
}
// GetUnusedFiles returns files that have no usage records
func (fc *FilesController) GetUnusedFiles(c *gin.Context) {
var files []models.UploadedFile
// Find files with no usages
if err := fc.DB.Preload("UploadedBy").
Preload("Usages").
Joins("LEFT JOIN file_usages ON file_usages.file_id = uploaded_files.id").
Where("file_usages.id IS NULL").
Group("uploaded_files.id").
Order("uploaded_files.created_at DESC").
Find(&files).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch unused files"})
return
}
fileInfos := make([]FileInfo, len(files))
for i, file := range files {
fileInfos[i] = FileInfo{
ID: file.ID,
Filename: file.Filename,
FilePath: file.FilePath,
FileURL: file.FileURL,
FileSize: file.FileSize,
MimeType: file.MimeType,
UploadedBy: file.UploadedBy,
CreatedAt: file.CreatedAt.Format("2006-01-02 15:04:05"),
Usages: file.Usages,
UsageCount: 0,
}
}
c.JSON(http.StatusOK, fileInfos)
}
// GetDuplicateFiles finds files with the same content (based on MD5 hash)
func (fc *FilesController) GetDuplicateFiles(c *gin.Context) {
var files []models.UploadedFile
if err := fc.DB.Preload("UploadedBy").Preload("Usages").Find(&files).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch files"})
return
}
// Calculate MD5 hashes and group by hash
hashMap := make(map[string][]FileInfo)
for _, file := range files {
// Calculate MD5 hash of file content
hash, err := calculateFileMD5(file.FilePath)
if err != nil {
logger.Warn("Failed to calculate hash for %s: %v", file.FilePath, err)
continue
}
fileInfo := FileInfo{
ID: file.ID,
Filename: file.Filename,
FilePath: file.FilePath,
FileURL: file.FileURL,
FileSize: file.FileSize,
MimeType: file.MimeType,
UploadedBy: file.UploadedBy,
CreatedAt: file.CreatedAt.Format("2006-01-02 15:04:05"),
Usages: file.Usages,
UsageCount: len(file.Usages),
MD5Hash: hash,
}
hashMap[hash] = append(hashMap[hash], fileInfo)
}
// Filter only duplicates (groups with more than one file)
duplicates := make(map[string][]FileInfo)
for hash, files := range hashMap {
if len(files) > 1 {
duplicates[hash] = files
}
}
c.JSON(http.StatusOK, duplicates)
}
// DeleteFile deletes a file and its usage records
func (fc *FilesController) DeleteFile(c *gin.Context) {
fileID := c.Param("id")
var file models.UploadedFile
if err := fc.DB.Preload("Usages").First(&file, fileID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "File not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch file"})
}
return
}
// Return usage information if file is being used and force flag is not set
force := c.Query("force") == "true"
if len(file.Usages) > 0 && !force {
c.JSON(http.StatusConflict, gin.H{
"error": "File is in use",
"usages": file.Usages,
"message": "This file is being used. Use force=true to delete anyway.",
})
return
}
// Delete physical file
if err := os.Remove(file.FilePath); err != nil {
logger.Warn("Failed to delete physical file %s: %v", file.FilePath, err)
// Continue with database deletion even if physical file deletion fails
}
// Delete database record (cascades to usages)
if err := fc.DB.Delete(&file).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file record"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "File deleted successfully"})
}
// GetFileUsages returns where a specific file is being used
func (fc *FilesController) GetFileUsages(c *gin.Context) {
fileID := c.Param("id")
var usages []models.FileUsage
if err := fc.DB.Where("file_id = ?", fileID).Find(&usages).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch file usages"})
return
}
// Enrich usage data with entity information
enrichedUsages := make([]map[string]interface{}, len(usages))
for i, usage := range usages {
entityInfo := getEntityInfo(fc.DB, usage.EntityType, usage.EntityID)
enrichedUsages[i] = map[string]interface{}{
"id": usage.ID,
"entity_type": usage.EntityType,
"entity_id": usage.EntityID,
"field_name": usage.FieldName,
"entity_info": entityInfo,
}
}
c.JSON(http.StatusOK, enrichedUsages)
}
// ScanAndSyncFiles scans the uploads directory and syncs with database
func (fc *FilesController) ScanAndSyncFiles(c *gin.Context) {
uploadsDir := "uploads"
var filesInDB []models.UploadedFile
if err := fc.DB.Find(&filesInDB).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch database files"})
return
}
dbFileMap := make(map[string]models.UploadedFile)
for _, file := range filesInDB {
dbFileMap[file.FilePath] = file
}
foundFiles := 0
newFiles := 0
orphanedFiles := 0
skippedFiles := 0
newFilesList := []string{}
orphanedFilesList := []string{}
// Walk through uploads directory
err := filepath.Walk(uploadsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
logger.Warn("Error accessing path %s: %v", path, err)
return nil // Continue with other files
}
if info.IsDir() {
return nil
}
// Skip .gitkeep and hidden files
filename := filepath.Base(path)
if filename == ".gitkeep" || strings.HasPrefix(filename, ".") {
skippedFiles++
return nil
}
foundFiles++
// Normalize path separators for cross-platform compatibility
normalizedPath := filepath.ToSlash(path)
if filepath.Separator == '\\' {
// On Windows, convert backslashes to forward slashes
normalizedPath = strings.ReplaceAll(path, "\\", "/")
}
// Check both original path and normalized path
_, existsOriginal := dbFileMap[path]
_, existsNormalized := dbFileMap[normalizedPath]
if !existsOriginal && !existsNormalized {
// File exists on disk but not in database - add it
mimeType := detectMimeType(path)
fileURL := "/" + filepath.ToSlash(path)
newFile := models.UploadedFile{
Filename: filename,
FilePath: normalizedPath,
FileURL: fileURL,
FileSize: info.Size(),
MimeType: mimeType,
}
if err := fc.DB.Create(&newFile).Error; err != nil {
logger.Warn("Failed to create database record for %s: %v", path, err)
} else {
newFiles++
newFilesList = append(newFilesList, filename)
logger.Info("Added new file to database: %s", filename)
}
}
return nil
})
if err != nil {
logger.Error("Failed to scan directory: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan directory"})
return
}
// Check for orphaned database records (files in DB but not on disk)
for _, dbFile := range filesInDB {
// Try both original path and with backslashes (for Windows)
paths := []string{dbFile.FilePath, strings.ReplaceAll(dbFile.FilePath, "/", "\\")}
found := false
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
found = true
break
}
}
if !found {
orphanedFiles++
orphanedFilesList = append(orphanedFilesList, dbFile.Filename)
// Optionally delete orphaned records
// fc.DB.Delete(&dbFile)
}
}
logger.Info("Scan completed: %d found, %d new, %d orphaned, %d skipped", foundFiles, newFiles, orphanedFiles, skippedFiles)
c.JSON(http.StatusOK, gin.H{
"message": "Scan completed",
"found_files": foundFiles,
"new_files": newFiles,
"orphaned_files": orphanedFiles,
"skipped_files": skippedFiles,
"new_files_list": newFilesList,
"orphaned_list": orphanedFilesList,
})
}
// Helper function to calculate MD5 hash of a file
func calculateFileMD5(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// Helper function to detect MIME type from file extension
func detectMimeType(filePath string) string {
ext := strings.ToLower(filepath.Ext(filePath))
mimeTypes := map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
".webp": "image/webp",
}
if mime, ok := mimeTypes[ext]; ok {
return mime
}
return "application/octet-stream"
}
// Helper function to get entity information
func getEntityInfo(db *gorm.DB, entityType string, entityID uint) map[string]interface{} {
info := map[string]interface{}{
"type": entityType,
"id": entityID,
}
switch entityType {
case "article":
var article models.Article
if err := db.Select("id", "title", "slug").First(&article, entityID).Error; err == nil {
info["title"] = article.Title
info["slug"] = article.Slug
info["url"] = fmt.Sprintf("/news/%s", article.Slug)
}
case "player":
var player models.Player
if err := db.Select("id", "first_name", "last_name").First(&player, entityID).Error; err == nil {
info["name"] = fmt.Sprintf("%s %s", player.FirstName, player.LastName)
info["url"] = fmt.Sprintf("/hraci/%d", player.ID)
}
case "sponsor":
var sponsor models.Sponsor
if err := db.Select("id", "name").First(&sponsor, entityID).Error; err == nil {
info["name"] = sponsor.Name
}
case "event":
var event models.Event
if err := db.Select("id", "title").First(&event, entityID).Error; err == nil {
info["title"] = event.Title
info["url"] = fmt.Sprintf("/aktivita/%d", event.ID)
}
case "contact":
var contact models.Contact
if err := db.Select("id", "name", "position").First(&contact, entityID).Error; err == nil {
info["name"] = contact.Name
info["position"] = contact.Position
}
}
return info
}
+413
View File
@@ -0,0 +1,413 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type GalleryController struct {
DB *gorm.DB
}
func NewGalleryController(db *gorm.DB) *GalleryController {
return &GalleryController{DB: db}
}
// ZoneramaAlbum represents a single album with photos
type ZoneramaAlbum struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Date string `json:"date"`
PhotosCount int `json:"photos_count"`
ViewsCount int `json:"views_count"`
Photos []ZoneramaPhoto `json:"photos"`
FetchedAt string `json:"fetched_at,omitempty"`
}
// ZoneramaPhoto represents a single photo
type ZoneramaPhoto struct {
ID string `json:"id"`
PageURL string `json:"page_url"`
Image1500 string `json:"image_1500"`
}
// ZoneramaProfile represents the profile with list of albums (metadata only)
type ZoneramaProfile struct {
InputLink string `json:"input_link"`
Albums []struct {
ID string `json:"id"`
Title string `json:"title"`
URL string `json:"url"`
Date string `json:"date"`
PhotosCount int `json:"photos_count"`
ViewsCount int `json:"views_count"`
} `json:"albums"`
FetchedAt string `json:"fetched_at"`
}
// GetGalleryAlbums returns all fetched albums (public)
func (gc *GalleryController) GetGalleryAlbums(c *gin.Context) {
albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json")
data, err := os.ReadFile(albumsFile)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{"albums": []interface{}{}})
return
}
var albums []ZoneramaAlbum
if err := json.Unmarshal(data, &albums); err != nil {
logger.Error("Failed to parse albums JSON: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"})
return
}
c.JSON(http.StatusOK, gin.H{"albums": albums})
}
// GetGalleryAlbum returns a single album by ID (public)
func (gc *GalleryController) GetGalleryAlbum(c *gin.Context) {
albumID := c.Param("id")
if albumID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing album ID"})
return
}
albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json")
data, err := os.ReadFile(albumsFile)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Albums not found"})
return
}
var albums []ZoneramaAlbum
if err := json.Unmarshal(data, &albums); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"})
return
}
for _, album := range albums {
if album.ID == albumID {
c.JSON(http.StatusOK, album)
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": "Album not found"})
}
// GetGalleryProfile returns the profile metadata (admin)
func (gc *GalleryController) GetGalleryProfile(c *gin.Context) {
profileFile := filepath.Join("cache", "prefetch", "zonerama_profile.json")
data, err := os.ReadFile(profileFile)
if err != nil {
c.JSON(http.StatusNoContent, gin.H{})
return
}
var profile ZoneramaProfile
if err := json.Unmarshal(data, &profile); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid profile data"})
return
}
c.JSON(http.StatusOK, profile)
}
// FetchAlbum fetches a single album and adds it to the albums collection (admin)
func (gc *GalleryController) FetchAlbum(c *gin.Context) {
var body struct {
Link string `json:"link" binding:"required"`
PhotoLimit int `json:"photo_limit"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Validate it's an album URL
if !strings.Contains(body.Link, "/Album/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Link must be a Zonerama album URL (must contain /Album/)"})
return
}
// Set default photo limit
if body.PhotoLimit == 0 {
body.PhotoLimit = 50 // Default to 50 photos per album
}
// Call external API
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
body.Link, body.PhotoLimit)
logger.Info("Fetching album from Zonerama API: %s", apiURL)
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
return
}
req.Header.Set("User-Agent", "fotbal-club/1.0")
resp, err := client.Do(req)
if err != nil {
logger.Error("Album fetch failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch album: " + err.Error()})
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
logger.Error("Album API returned status %d", resp.StatusCode)
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("Zonerama API returned status %d", resp.StatusCode)})
return
}
// Zonerama API returns {"input_link": "...", "albums": [...]}
var apiResponse struct {
InputLink string `json:"input_link"`
Albums []ZoneramaAlbum `json:"albums"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil {
logger.Error("Failed to parse album response: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse album data"})
return
}
if len(apiResponse.Albums) == 0 {
logger.Error("No albums returned from API")
c.JSON(http.StatusNotFound, gin.H{"error": "Album not found in API response"})
return
}
// Get the first album (should be the only one for single album endpoint)
albumData := apiResponse.Albums[0]
// Add fetched timestamp
albumData.FetchedAt = time.Now().Format(time.RFC3339)
// Load existing albums
albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json")
var albums []ZoneramaAlbum
if data, err := os.ReadFile(albumsFile); err == nil {
_ = json.Unmarshal(data, &albums)
}
// Check if album already exists and update it, or add new
found := false
for i, a := range albums {
if a.ID == albumData.ID {
albums[i] = albumData
found = true
logger.Info("Updated existing album: %s", albumData.ID)
break
}
}
if !found {
albums = append([]ZoneramaAlbum{albumData}, albums...) // Prepend new album
logger.Info("Added new album: %s", albumData.ID)
}
// Save back to file
if err := os.MkdirAll(filepath.Dir(albumsFile), 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create cache directory"})
return
}
albumsJSON, err := json.MarshalIndent(albums, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to serialize albums"})
return
}
if err := os.WriteFile(albumsFile, albumsJSON, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save albums"})
return
}
logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos))
// Regenerate flat gallery files for frontend consumption
if err := services.RegenerateFlatGalleryFiles(); err != nil {
logger.Error("Failed to regenerate flat gallery files: %v", err)
// Don't fail the request, just log the error
}
c.JSON(http.StatusOK, gin.H{
"message": "Album fetched and saved successfully",
"album": albumData,
})
}
// DeleteAlbum removes an album from the collection (admin)
func (gc *GalleryController) DeleteAlbum(c *gin.Context) {
albumID := c.Param("id")
if albumID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing album ID"})
return
}
albumsFile := filepath.Join("cache", "prefetch", "zonerama_albums.json")
data, err := os.ReadFile(albumsFile)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Albums not found"})
return
}
var albums []ZoneramaAlbum
if err := json.Unmarshal(data, &albums); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid albums data"})
return
}
// Find and remove
newAlbums := []ZoneramaAlbum{}
found := false
for _, album := range albums {
if album.ID != albumID {
newAlbums = append(newAlbums, album)
} else {
found = true
}
}
if !found {
c.JSON(http.StatusNotFound, gin.H{"error": "Album not found"})
return
}
// Save back
albumsJSON, _ := json.MarshalIndent(newAlbums, "", " ")
if err := os.WriteFile(albumsFile, albumsJSON, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save albums"})
return
}
logger.Info("Deleted album: %s", albumID)
// Regenerate flat gallery files for frontend consumption
if err := services.RegenerateFlatGalleryFiles(); err != nil {
logger.Error("Failed to regenerate flat gallery files: %v", err)
// Don't fail the request, just log the error
}
c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"})
}
// RefreshFromZonerama triggers a refresh of the Zonerama gallery from the configured URL (admin)
func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) {
// Load settings to get the Zonerama URL
var settings struct {
GalleryURL string `json:"gallery_url"`
}
if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil {
logger.Error("Failed to load settings: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"})
return
}
zoneramaURL := strings.TrimSpace(settings.GalleryURL)
if zoneramaURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"})
return
}
// Validate it's a Zonerama URL
if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"})
return
}
logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL)
// Call the refresh service in a goroutine to avoid blocking
go func() {
if err := services.RefreshZoneramaNow(zoneramaURL); err != nil {
logger.Error("Zonerama refresh failed: %v", err)
} else {
logger.Info("Zonerama refresh completed successfully")
// Regenerate flat gallery files for frontend consumption
if err := services.RegenerateFlatGalleryFiles(); err != nil {
logger.Error("Failed to regenerate flat gallery files: %v", err)
}
}
}()
c.JSON(http.StatusOK, gin.H{
"message": "Zonerama refresh started",
"url": zoneramaURL,
})
}
// ProxyImage proxies image requests to Zonerama to avoid CORS issues (public)
func (gc *GalleryController) ProxyImage(c *gin.Context) {
imageURL := c.Query("url")
if imageURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing image URL"})
return
}
// Validate that the URL is from Zonerama
if !strings.Contains(imageURL, "zonerama.com") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid image URL"})
return
}
// Fetch the image
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest("GET", imageURL, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create request"})
return
}
req.Header.Set("User-Agent", "fotbal-club/1.0")
resp, err := client.Do(req)
if err != nil {
logger.Error("Image fetch failed: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch image"})
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
logger.Error("Image fetch returned status %d", resp.StatusCode)
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch image"})
return
}
// Copy headers
for key, values := range resp.Header {
for _, value := range values {
c.Header(key, value)
}
}
// Set CORS headers
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET")
// Stream the image
c.Status(resp.StatusCode)
_, err = io.Copy(c.Writer, resp.Body)
if err != nil {
logger.Error("Failed to stream image: %v", err)
}
}
+265
View File
@@ -0,0 +1,265 @@
package controllers
import (
"context"
"net/http"
"runtime"
"time"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// HealthController handles health check endpoints
type HealthController struct {
DB *gorm.DB
}
// HealthResponse represents health check response
type HealthResponse struct {
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version,omitempty"`
Checks map[string]CheckResult `json:"checks"`
System SystemInfo `json:"system,omitempty"`
}
// CheckResult represents individual health check result
type CheckResult struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`
Latency time.Duration `json:"latency_ms,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// SystemInfo represents system resource information
type SystemInfo struct {
Goroutines int `json:"goroutines"`
MemoryAlloc uint64 `json:"memory_alloc_mb"`
MemoryTotal uint64 `json:"memory_total_mb"`
MemorySys uint64 `json:"memory_sys_mb"`
NumCPU int `json:"num_cpu"`
GoVersion string `json:"go_version"`
}
// Liveness returns a simple liveness probe
func (hc *HealthController) Liveness(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "alive",
"timestamp": time.Now(),
})
}
// Readiness returns detailed readiness probe
func (hc *HealthController) Readiness(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
checks := make(map[string]CheckResult)
overallStatus := "healthy"
// Check database
dbCheck := hc.checkDatabase(ctx)
checks["database"] = dbCheck
if dbCheck.Status != "healthy" {
overallStatus = "unhealthy"
}
// Check cache
cacheCheck := hc.checkCache(ctx)
checks["cache"] = cacheCheck
if cacheCheck.Status != "degraded" && cacheCheck.Status != "healthy" {
overallStatus = "unhealthy"
}
// Check disk space
diskCheck := hc.checkDiskSpace()
checks["disk"] = diskCheck
if diskCheck.Status != "healthy" {
overallStatus = "degraded"
}
response := HealthResponse{
Status: overallStatus,
Timestamp: time.Now(),
Checks: checks,
}
statusCode := http.StatusOK
if overallStatus == "unhealthy" {
statusCode = http.StatusServiceUnavailable
}
c.JSON(statusCode, response)
}
// Health returns comprehensive health information
func (hc *HealthController) Health(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
checks := make(map[string]CheckResult)
overallStatus := "healthy"
// All checks
dbCheck := hc.checkDatabase(ctx)
checks["database"] = dbCheck
if dbCheck.Status != "healthy" {
overallStatus = "unhealthy"
}
cacheCheck := hc.checkCache(ctx)
checks["cache"] = cacheCheck
diskCheck := hc.checkDiskSpace()
checks["disk"] = diskCheck
// System info
sysInfo := hc.getSystemInfo()
response := HealthResponse{
Status: overallStatus,
Timestamp: time.Now(),
Version: "1.0.0", // Use actual version
Checks: checks,
System: sysInfo,
}
statusCode := http.StatusOK
if overallStatus == "unhealthy" {
statusCode = http.StatusServiceUnavailable
}
c.JSON(statusCode, response)
}
// checkDatabase verifies database connectivity
func (hc *HealthController) checkDatabase(ctx context.Context) CheckResult {
start := time.Now()
sqlDB, err := hc.DB.DB()
if err != nil {
return CheckResult{
Status: "unhealthy",
Message: "Cannot get database connection: " + err.Error(),
Timestamp: time.Now(),
}
}
err = sqlDB.PingContext(ctx)
latency := time.Since(start)
if err != nil {
return CheckResult{
Status: "unhealthy",
Message: "Database ping failed: " + err.Error(),
Latency: latency,
Timestamp: time.Now(),
}
}
// Check connection pool stats
stats := sqlDB.Stats()
if stats.OpenConnections >= stats.MaxOpenConnections {
return CheckResult{
Status: "degraded",
Message: "Connection pool at maximum capacity",
Latency: latency,
Timestamp: time.Now(),
}
}
return CheckResult{
Status: "healthy",
Message: "Database connection successful",
Latency: latency,
Timestamp: time.Now(),
}
}
// checkCache verifies cache service
func (hc *HealthController) checkCache(ctx context.Context) CheckResult {
start := time.Now()
cache := services.GetCacheService()
// Test cache write/read
testKey := "health:check"
testValue := "ok"
err := cache.Set(testKey, testValue, 1*time.Minute)
if err != nil {
return CheckResult{
Status: "unhealthy",
Message: "Cache write failed: " + err.Error(),
Latency: time.Since(start),
Timestamp: time.Now(),
}
}
var result string
err = cache.Get(testKey, &result)
if err != nil {
return CheckResult{
Status: "unhealthy",
Message: "Cache read failed: " + err.Error(),
Latency: time.Since(start),
Timestamp: time.Now(),
}
}
cache.Delete(testKey)
return CheckResult{
Status: "healthy",
Message: "Cache operational",
Latency: time.Since(start),
Timestamp: time.Now(),
}
}
// checkDiskSpace checks available disk space
func (hc *HealthController) checkDiskSpace() CheckResult {
// This is a simplified check
// In production, implement proper disk space monitoring
return CheckResult{
Status: "healthy",
Message: "Disk space sufficient",
Timestamp: time.Now(),
}
}
// getSystemInfo collects system resource information
func (hc *HealthController) getSystemInfo() SystemInfo {
var m runtime.MemStats
runtime.ReadMemStats(&m)
return SystemInfo{
Goroutines: runtime.NumGoroutine(),
MemoryAlloc: m.Alloc / 1024 / 1024, // MB
MemoryTotal: m.TotalAlloc / 1024 / 1024, // MB
MemorySys: m.Sys / 1024 / 1024, // MB
NumCPU: runtime.NumCPU(),
GoVersion: runtime.Version(),
}
}
// Metrics returns Prometheus-compatible metrics
func (hc *HealthController) Metrics(c *gin.Context) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
// Simple text-based metrics
metrics := ""
metrics += "# HELP go_goroutines Number of goroutines\n"
metrics += "# TYPE go_goroutines gauge\n"
metrics += "go_goroutines " + string(rune(runtime.NumGoroutine())) + "\n\n"
metrics += "# HELP go_memory_alloc_bytes Allocated memory in bytes\n"
metrics += "# TYPE go_memory_alloc_bytes gauge\n"
metrics += "go_memory_alloc_bytes " + string(rune(m.Alloc)) + "\n\n"
c.String(http.StatusOK, metrics)
}
@@ -0,0 +1,531 @@
package controllers
import (
"fotbal-club/internal/models"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type NavigationController struct {
DB *gorm.DB
}
func NewNavigationController(db *gorm.DB) *NavigationController {
return &NavigationController{DB: db}
}
// GetNavigationItems returns all navigation items (public endpoint)
// @Summary Get all navigation items
// @Description Returns all visible navigation items with their children (excludes admin-only items)
// @Tags navigation
// @Produce json
// @Success 200 {array} models.NavigationItem
// @Router /api/v1/navigation [get]
func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
var items []models.NavigationItem
// Get only top-level items (no parent) that are visible and NOT admin-only
if err := nc.DB.Where("parent_id IS NULL AND visible = ? AND requires_admin = ?", true, false).
Order("display_order ASC").
Preload("Children", "visible = ? AND requires_admin = ?", true, false).
Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch navigation items"})
return
}
// Compute URLs for items
for i := range items {
if items[i].URL == "" {
items[i].URL = items[i].GetURL()
}
for j := range items[i].Children {
if items[i].Children[j].URL == "" {
items[i].Children[j].URL = items[i].Children[j].GetURL()
}
}
}
c.JSON(http.StatusOK, items)
}
// GetAllNavigationItems returns all navigation items including hidden ones (admin only)
// @Summary Get all navigation items (admin)
// @Description Returns all navigation items for admin management
// @Tags navigation
// @Produce json
// @Security BearerAuth
// @Success 200 {array} models.NavigationItem
// @Router /api/v1/admin/navigation [get]
func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
var items []models.NavigationItem
if err := nc.DB.Where("parent_id IS NULL").
Order("display_order ASC").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Order("display_order ASC")
}).
Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch navigation items"})
return
}
// Compute URLs for items
for i := range items {
if items[i].URL == "" {
items[i].URL = items[i].GetURL()
}
for j := range items[i].Children {
if items[i].Children[j].URL == "" {
items[i].Children[j].URL = items[i].Children[j].GetURL()
}
}
}
c.JSON(http.StatusOK, items)
}
// CreateNavigationItem creates a new navigation item
// @Summary Create navigation item
// @Description Creates a new navigation item
// @Tags navigation
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param item body models.NavigationItem true "Navigation item data"
// @Success 201 {object} models.NavigationItem
// @Router /api/v1/admin/navigation [post]
func (nc *NavigationController) CreateNavigationItem(c *gin.Context) {
var item models.NavigationItem
if err := c.ShouldBindJSON(&item); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If no display order is set, put it at the end
if item.DisplayOrder == 0 {
var maxOrder int
nc.DB.Model(&models.NavigationItem{}).
Where("parent_id IS NULL").
Select("COALESCE(MAX(display_order), -1) + 1").
Scan(&maxOrder)
item.DisplayOrder = maxOrder
}
if err := nc.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create navigation item"})
return
}
c.JSON(http.StatusCreated, item)
}
// UpdateNavigationItem updates an existing navigation item
// @Summary Update navigation item
// @Description Updates an existing navigation item
// @Tags navigation
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Navigation item ID"
// @Param item body models.NavigationItem true "Updated navigation item data"
// @Success 200 {object} models.NavigationItem
// @Router /api/v1/admin/navigation/{id} [put]
func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var item models.NavigationItem
if err := nc.DB.First(&item, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Navigation item not found"})
return
}
var updates models.NavigationItem
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update fields
item.Label = updates.Label
item.URL = updates.URL
item.Icon = updates.Icon
item.Type = updates.Type
item.PageType = updates.PageType
item.PageID = updates.PageID
item.Visible = updates.Visible
item.DisplayOrder = updates.DisplayOrder
item.ParentID = updates.ParentID
item.Target = updates.Target
item.CSSClass = updates.CSSClass
item.RequiresAuth = updates.RequiresAuth
item.RequiresAdmin = updates.RequiresAdmin
if err := nc.DB.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update navigation item"})
return
}
c.JSON(http.StatusOK, item)
}
// DeleteNavigationItem deletes a navigation item
// @Summary Delete navigation item
// @Description Deletes a navigation item
// @Tags navigation
// @Produce json
// @Security BearerAuth
// @Param id path int true "Navigation item ID"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/navigation/{id} [delete]
func (nc *NavigationController) DeleteNavigationItem(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
if err := nc.DB.Delete(&models.NavigationItem{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete navigation item"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Navigation item deleted successfully"})
}
// ReorderNavigationItems updates the display order of multiple items
// @Summary Reorder navigation items
// @Description Updates the display order of navigation items
// @Tags navigation
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param orders body []map[string]int true "Array of {id, display_order}"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/navigation/reorder [post]
func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
var orders []map[string]int
if err := c.ShouldBindJSON(&orders); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update each item's order in a transaction
err := nc.DB.Transaction(func(tx *gorm.DB) error {
for _, order := range orders {
id, ok1 := order["id"]
displayOrder, ok2 := order["display_order"]
if !ok1 || !ok2 {
continue
}
if err := tx.Model(&models.NavigationItem{}).
Where("id = ?", id).
Update("display_order", displayOrder).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder navigation items"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Navigation items reordered successfully"})
}
// GetSocialLinks returns all social links (public endpoint)
// @Summary Get all social links
// @Description Returns all visible social links
// @Tags navigation
// @Produce json
// @Success 200 {array} models.SocialLink
// @Router /api/v1/social-links [get]
func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
var links []models.SocialLink
if err := nc.DB.Where("visible = ?", true).
Order("display_order ASC").
Find(&links).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch social links"})
return
}
c.JSON(http.StatusOK, links)
}
// GetAllSocialLinks returns all social links including hidden ones (admin only)
// @Summary Get all social links (admin)
// @Description Returns all social links for admin management
// @Tags navigation
// @Produce json
// @Security BearerAuth
// @Success 200 {array} models.SocialLink
// @Router /api/v1/admin/social-links [get]
func (nc *NavigationController) GetAllSocialLinks(c *gin.Context) {
var links []models.SocialLink
if err := nc.DB.Order("display_order ASC").Find(&links).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch social links"})
return
}
c.JSON(http.StatusOK, links)
}
// CreateSocialLink creates a new social link
// @Summary Create social link
// @Description Creates a new social link
// @Tags navigation
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param link body models.SocialLink true "Social link data"
// @Success 201 {object} models.SocialLink
// @Router /api/v1/admin/social-links [post]
func (nc *NavigationController) CreateSocialLink(c *gin.Context) {
var link models.SocialLink
if err := c.ShouldBindJSON(&link); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If no display order is set, put it at the end
if link.DisplayOrder == 0 {
var maxOrder int
nc.DB.Model(&models.SocialLink{}).
Select("COALESCE(MAX(display_order), -1) + 1").
Scan(&maxOrder)
link.DisplayOrder = maxOrder
}
if err := nc.DB.Create(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create social link"})
return
}
c.JSON(http.StatusCreated, link)
}
// UpdateSocialLink updates an existing social link
// @Summary Update social link
// @Description Updates an existing social link
// @Tags navigation
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Social link ID"
// @Param link body models.SocialLink true "Updated social link data"
// @Success 200 {object} models.SocialLink
// @Router /api/v1/admin/social-links/{id} [put]
func (nc *NavigationController) UpdateSocialLink(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var link models.SocialLink
if err := nc.DB.First(&link, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Social link not found"})
return
}
var updates models.SocialLink
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
link.Platform = updates.Platform
link.URL = updates.URL
link.DisplayOrder = updates.DisplayOrder
link.Visible = updates.Visible
link.Icon = updates.Icon
if err := nc.DB.Save(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update social link"})
return
}
c.JSON(http.StatusOK, link)
}
// DeleteSocialLink deletes a social link
// @Summary Delete social link
// @Description Deletes a social link
// @Tags navigation
// @Produce json
// @Security BearerAuth
// @Param id path int true "Social link ID"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/social-links/{id} [delete]
func (nc *NavigationController) DeleteSocialLink(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
if err := nc.DB.Delete(&models.SocialLink{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete social link"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Social link deleted successfully"})
}
// ReorderSocialLinks updates the display order of multiple social links
// @Summary Reorder social links
// @Description Updates the display order of social links
// @Tags navigation
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param orders body []map[string]int true "Array of {id, display_order}"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/social-links/reorder [post]
func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
var orders []map[string]int
if err := c.ShouldBindJSON(&orders); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := nc.DB.Transaction(func(tx *gorm.DB) error {
for _, order := range orders {
id, ok1 := order["id"]
displayOrder, ok2 := order["display_order"]
if !ok1 || !ok2 {
continue
}
if err := tx.Model(&models.SocialLink{}).
Where("id = ?", id).
Update("display_order", displayOrder).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder social links"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Social links reordered successfully"})
}
// SeedDefaultNavigation creates default navigation items if none exist
// @Summary Seed default navigation
// @Description Creates default navigation items if the database is empty
// @Tags navigation
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/navigation/seed [post]
func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
// Check if navigation items already exist
var count int64
nc.DB.Model(&models.NavigationItem{}).Count(&count)
if count > 0 {
c.JSON(http.StatusOK, gin.H{
"message": "Navigation items already exist",
"count": count,
"seeded": false,
})
return
}
// Default frontend navigation items
frontendItems := []models.NavigationItem{
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
{Label: "Zápasy", Type: models.NavTypePage, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: false},
{Label: "Aktivity", Type: models.NavTypePage, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: false},
{Label: "Hráči", Type: models.NavTypePage, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: false},
{Label: "Tabulky", Type: models.NavTypePage, PageType: "tables", DisplayOrder: 6, Visible: true, RequiresAdmin: false},
{Label: "Články", Type: models.NavTypePage, PageType: "blog", DisplayOrder: 7, Visible: true, RequiresAdmin: false},
{Label: "Videa", Type: models.NavTypePage, PageType: "videos", DisplayOrder: 8, Visible: true, RequiresAdmin: false},
{Label: "Fotogalerie", Type: models.NavTypePage, PageType: "gallery", DisplayOrder: 9, Visible: true, RequiresAdmin: false},
{Label: "Sponzoři", Type: models.NavTypePage, PageType: "sponsors", DisplayOrder: 10, Visible: true, RequiresAdmin: false},
{Label: "Kontakt", Type: models.NavTypePage, PageType: "contact", DisplayOrder: 11, Visible: true, RequiresAdmin: false},
}
// Default admin panel navigation items
adminItems := []models.NavigationItem{
// Main section
{Label: "Nástěnka", Type: models.NavTypeInternal, PageType: "dashboard", DisplayOrder: 0, Visible: true, RequiresAdmin: true},
{Label: "Analytika", Type: models.NavTypeInternal, PageType: "analytics", DisplayOrder: 1, Visible: true, RequiresAdmin: true},
// Content section
{Label: "Týmy", Type: models.NavTypeInternal, PageType: "teams", DisplayOrder: 2, Visible: true, RequiresAdmin: true},
{Label: "Zápasy", Type: models.NavTypeInternal, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: true},
{Label: "Aktivity", Type: models.NavTypeInternal, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: true},
{Label: "Hráči", Type: models.NavTypeInternal, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: true},
{Label: "Články", Type: models.NavTypeInternal, PageType: "articles", DisplayOrder: 6, Visible: true, RequiresAdmin: true},
{Label: "Kategorie", Type: models.NavTypeInternal, PageType: "categories", DisplayOrder: 7, Visible: true, RequiresAdmin: true},
{Label: "O klubu", Type: models.NavTypeInternal, PageType: "about", DisplayOrder: 8, Visible: true, RequiresAdmin: true},
{Label: "Videa", Type: models.NavTypeInternal, PageType: "videos", DisplayOrder: 9, Visible: true, RequiresAdmin: true},
{Label: "Galerie (Zonerama)", Type: models.NavTypeInternal, PageType: "gallery", DisplayOrder: 10, Visible: true, RequiresAdmin: true},
{Label: "Tabule (Scoreboard)", Type: models.NavTypeInternal, PageType: "scoreboard", DisplayOrder: 11, Visible: true, RequiresAdmin: true},
{Label: "Scoreboard Remote", Type: models.NavTypeInternal, PageType: "scoreboard_remote", DisplayOrder: 12, Visible: true, RequiresAdmin: true},
{Label: "Oblečení", Type: models.NavTypeInternal, PageType: "clothing", DisplayOrder: 13, Visible: true, RequiresAdmin: true},
{Label: "Sponzoři", Type: models.NavTypeInternal, PageType: "sponsors", DisplayOrder: 14, Visible: true, RequiresAdmin: true},
{Label: "Bannery", Type: models.NavTypeInternal, PageType: "banners", DisplayOrder: 15, Visible: true, RequiresAdmin: true},
{Label: "Zprávy", Type: models.NavTypeInternal, PageType: "messages", DisplayOrder: 16, Visible: true, RequiresAdmin: true},
{Label: "Kontakty", Type: models.NavTypeInternal, PageType: "contacts", DisplayOrder: 17, Visible: true, RequiresAdmin: true},
{Label: "Zpravodaj", Type: models.NavTypeInternal, PageType: "newsletter", DisplayOrder: 18, Visible: true, RequiresAdmin: true},
{Label: "Ankety", Type: models.NavTypeInternal, PageType: "polls", DisplayOrder: 19, Visible: true, RequiresAdmin: true},
// Settings section
{Label: "Navigace", Type: models.NavTypeInternal, PageType: "navigation", DisplayOrder: 20, Visible: true, RequiresAdmin: true},
{Label: "Alias soutěží", Type: models.NavTypeInternal, PageType: "competition_aliases", DisplayOrder: 21, Visible: true, RequiresAdmin: true},
{Label: "Prefetch & Cache", Type: models.NavTypeInternal, PageType: "prefetch", DisplayOrder: 22, Visible: true, RequiresAdmin: true},
{Label: "Uživatelé", Type: models.NavTypeInternal, PageType: "users", DisplayOrder: 23, Visible: true, RequiresAdmin: true},
{Label: "Nastavení", Type: models.NavTypeInternal, PageType: "settings", DisplayOrder: 24, Visible: true, RequiresAdmin: true},
{Label: "Soubory", Type: models.NavTypeInternal, PageType: "files", DisplayOrder: 25, Visible: true, RequiresAdmin: true},
// Help section
{Label: "Dokumentace", Type: models.NavTypeInternal, PageType: "docs", DisplayOrder: 26, Visible: true, RequiresAdmin: true},
}
// Combine all items
allItems := append(frontendItems, adminItems...)
// Create items in a transaction
err := nc.DB.Transaction(func(tx *gorm.DB) error {
for _, item := range allItems {
if err := tx.Create(&item).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed navigation items"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Default navigation items created successfully",
"count": len(allItems),
"frontend_count": len(frontendItems),
"admin_count": len(adminItems),
"seeded": true,
})
}
@@ -0,0 +1,158 @@
package controllers
import (
"net/http"
"strings"
"fotbal-club/internal/models"
"fotbal-club/pkg/email"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// NotificationsController handles admin-triggered notifications (emails)
// It reuses the newsletter sending pipeline/templates.
type NotificationsController struct {
DB *gorm.DB
EmailService email.EmailService
}
func NewNotificationsController(db *gorm.DB, emailService email.EmailService) *NotificationsController {
return &NotificationsController{DB: db, EmailService: emailService}
}
// Common request payload for notifications
// Subject is required; body/content carries HTML. Recipients optional.
// If SendToSubscribers is true, all active newsletter subscribers will be included.
// Optional context identifiers (competition code or match ext ID) are accepted for logging/auditing
// or future template specialization, but are not required for sending right now.
type NotificationRequest struct {
Subject string `json:"subject" binding:"required"`
Body string `json:"body"`
Content string `json:"content"`
Recipients []string `json:"recipients"`
SendToSubscribers bool `json:"send_to_subscribers"`
CompetitionCode string `json:"competition_code"`
MatchExternalID string `json:"match_external_id"`
}
// SendCompetitionNotification sends a notification related to a competition
// @Summary Send competition notification (admin)
// @Description Sends a notification email for a competition to selected recipients and/or newsletter subscribers
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param input body NotificationRequest true "Notification payload"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /api/v1/admin/notifications/competition [post]
func (nc *NotificationsController) SendCompetitionNotification(c *gin.Context) {
nc.sendNotification(c)
}
// SendMatchNotification sends a notification related to a match
// @Summary Send match notification (admin)
// @Description Sends a notification email for a match to selected recipients and/or newsletter subscribers
// @Tags admin
// @Security Bearer
// @Accept json
// @Produce json
// @Param input body NotificationRequest true "Notification payload"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /api/v1/admin/notifications/match [post]
func (nc *NotificationsController) SendMatchNotification(c *gin.Context) {
nc.sendNotification(c)
}
// Internal shared implementation
func (nc *NotificationsController) sendNotification(c *gin.Context) {
// Only admins
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
var input NotificationRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input"})
return
}
// Prefer Body then Content
body := strings.TrimSpace(input.Body)
if body == "" {
body = strings.TrimSpace(input.Content)
}
if input.Subject == "" || body == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "subject and body/content are required"})
return
}
// Build recipients
recipients := make([]string, 0, len(input.Recipients)+16)
// Explicit recipients
for _, r := range input.Recipients {
r = strings.TrimSpace(strings.ToLower(r))
if r != "" {
recipients = append(recipients, r)
}
}
// Include subscribers if requested
if input.SendToSubscribers {
var subs []models.NewsletterSubscription
if err := nc.DB.Where("is_active = ?", true).Find(&subs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscribers"})
return
}
for _, s := range subs {
if s.Email != "" {
recipients = append(recipients, strings.ToLower(strings.TrimSpace(s.Email)))
}
}
}
// Dedupe recipients
uniq := make(map[string]struct{}, len(recipients))
out := make([]string, 0, len(recipients))
for _, r := range recipients {
if r == "" {
continue
}
if _, exists := uniq[r]; !exists {
uniq[r] = struct{}{}
out = append(out, r)
}
}
if len(out) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No recipients resolved"})
return
}
// Send using newsletter template/pipeline
data := &email.NewsletterData{
Subject: input.Subject,
Content: body,
Recipients: out,
}
if err := nc.EmailService.SendNewsletter(data); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send notifications"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Notifications sent",
"recipients": len(out),
"competition": input.CompetitionCode,
"match": input.MatchExternalID,
})
}
@@ -0,0 +1,226 @@
package controllers
import (
"fotbal-club/internal/models"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type PageElementConfigController struct {
DB *gorm.DB
}
func NewPageElementConfigController(db *gorm.DB) *PageElementConfigController {
return &PageElementConfigController{DB: db}
}
// GetPageElementConfigs returns all element configurations for a specific page
// @Summary Get page element configurations
// @Description Returns all element configurations for a specific page type
// @Tags page-elements
// @Produce json
// @Param page_type query string true "Page type (e.g., homepage, about)"
// @Success 200 {array} models.PageElementConfig
// @Router /api/v1/page-elements [get]
func (pc *PageElementConfigController) GetPageElementConfigs(c *gin.Context) {
pageType := c.Query("page_type")
if pageType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "page_type is required"})
return
}
var configs []models.PageElementConfig
if err := pc.DB.Where("page_type = ?", pageType).Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch configurations"})
return
}
c.JSON(http.StatusOK, configs)
}
// GetAllPageElementConfigs returns all element configurations (admin)
// @Summary Get all page element configurations
// @Description Returns all element configurations for admin management
// @Tags page-elements
// @Produce json
// @Security BearerAuth
// @Success 200 {array} models.PageElementConfig
// @Router /api/v1/admin/page-elements [get]
func (pc *PageElementConfigController) GetAllPageElementConfigs(c *gin.Context) {
var configs []models.PageElementConfig
if err := pc.DB.Order("page_type ASC, element_name ASC").Find(&configs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch configurations"})
return
}
c.JSON(http.StatusOK, configs)
}
// CreateOrUpdatePageElementConfig creates or updates an element configuration
// @Summary Create or update page element configuration
// @Description Creates or updates a page element configuration
// @Tags page-elements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param config body models.PageElementConfig true "Element configuration data"
// @Success 200 {object} models.PageElementConfig
// @Router /api/v1/admin/page-elements [post]
func (pc *PageElementConfigController) CreateOrUpdatePageElementConfig(c *gin.Context) {
var input models.PageElementConfig
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if config already exists
var existing models.PageElementConfig
result := pc.DB.Where("page_type = ? AND element_name = ?", input.PageType, input.ElementName).First(&existing)
if result.Error == nil {
// Update existing
existing.Variant = input.Variant
existing.Settings = input.Settings
if err := pc.DB.Save(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update configuration"})
return
}
c.JSON(http.StatusOK, existing)
} else {
// Create new
if err := pc.DB.Create(&input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create configuration"})
return
}
c.JSON(http.StatusCreated, input)
}
}
// UpdatePageElementConfig updates an existing element configuration
// @Summary Update page element configuration
// @Description Updates an existing page element configuration
// @Tags page-elements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int true "Configuration ID"
// @Param config body models.PageElementConfig true "Updated configuration data"
// @Success 200 {object} models.PageElementConfig
// @Router /api/v1/admin/page-elements/{id} [put]
func (pc *PageElementConfigController) UpdatePageElementConfig(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var config models.PageElementConfig
if err := pc.DB.First(&config, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Configuration not found"})
return
}
var updates models.PageElementConfig
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config.PageType = updates.PageType
config.ElementName = updates.ElementName
config.Variant = updates.Variant
config.Settings = updates.Settings
if err := pc.DB.Save(&config).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update configuration"})
return
}
c.JSON(http.StatusOK, config)
}
// DeletePageElementConfig deletes an element configuration
// @Summary Delete page element configuration
// @Description Deletes a page element configuration
// @Tags page-elements
// @Produce json
// @Security BearerAuth
// @Param id path int true "Configuration ID"
// @Success 200 {object} map[string]string
// @Router /api/v1/admin/page-elements/{id} [delete]
func (pc *PageElementConfigController) DeletePageElementConfig(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
if err := pc.DB.Delete(&models.PageElementConfig{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete configuration"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Configuration deleted successfully"})
}
// BatchUpdatePageElementConfigs updates multiple element configurations at once
// @Summary Batch update page element configurations
// @Description Updates multiple element configurations in one request
// @Tags page-elements
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param configs body []models.PageElementConfig true "Array of configurations"
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/page-elements/batch [post]
func (pc *PageElementConfigController) BatchUpdatePageElementConfigs(c *gin.Context) {
var configs []models.PageElementConfig
if err := c.ShouldBindJSON(&configs); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updated := 0
created := 0
err := pc.DB.Transaction(func(tx *gorm.DB) error {
for _, cfg := range configs {
var existing models.PageElementConfig
result := tx.Where("page_type = ? AND element_name = ?", cfg.PageType, cfg.ElementName).First(&existing)
if result.Error == nil {
// Update
existing.Variant = cfg.Variant
existing.Visible = cfg.Visible
existing.DisplayOrder = cfg.DisplayOrder
existing.Settings = cfg.Settings
if err := tx.Save(&existing).Error; err != nil {
return err
}
updated++
} else {
// Create
if err := tx.Create(&cfg).Error; err != nil {
return err
}
created++
}
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to batch update configurations"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Batch update successful",
"updated": updated,
"created": created,
})
}
+629
View File
@@ -0,0 +1,629 @@
package controllers
import (
cryptorand "crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/email"
"fotbal-club/pkg/logger"
"fotbal-club/pkg/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
verificationCodeLength = 6
verificationCodeExpiry = 10 * time.Minute
maxVerificationAttempts = 3
)
type PasswordController struct {
DB *gorm.DB
EmailService email.EmailService
}
// generateVerificationCode generates a random 6-digit verification code (crypto-secure)
func generateVerificationCode() string {
const digits = "0123456789"
b := make([]byte, verificationCodeLength)
for i := 0; i < verificationCodeLength; i++ {
// draw a random byte and map into 0-9 range without modulo bias concerns for small range
var rb [1]byte
if _, err := cryptorand.Read(rb[:]); err != nil {
// fallback to time-based digit if crypto fails (extremely unlikely)
b[i] = digits[int(time.Now().UnixNano())%10]
continue
}
b[i] = digits[int(rb[0])%10]
}
return string(b)
}
// InitiatePasswordReset starts the password reset process by sending a verification code
func (pc *PasswordController) InitiatePasswordReset(c *gin.Context) {
var req InitiatePasswordResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: "Neplatný formát požadavku: " + err.Error(),
})
return
}
emailAddr := strings.TrimSpace(strings.ToLower(req.Email))
if emailAddr == "" || !strings.Contains(emailAddr, "@") {
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: "Neplatná e-mailová adresa",
})
return
}
// Check if email service is available
if pc.EmailService == nil {
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "E-mailová služba není k dispozici. Kontaktujte prosím správce.",
})
return
}
// Check if user exists
var user models.User
if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Return success to prevent email enumeration
c.JSON(http.StatusOK, PasswordResetResponse{
Success: true,
Message: "Pokud účet s tímto e-mailem existuje, byl odeslán ověřovací kód.",
NextStep: "verify_code",
})
return
}
// Other database error
logger.Error("Database error when checking user: error=%v email=%s", err, emailAddr)
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Došlo k chybě při ověřování e-mailu",
})
return
}
// Generate a secure random token
token := make([]byte, 32)
if _, err := cryptorand.Read(token); err != nil {
logger.Error("Failed to generate random token: %v", err)
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Nepodařilo se vygenerovat bezpečnostní token",
})
return
}
tokenStr := hex.EncodeToString(token)
// Generate verification code
verificationCode := generateVerificationCode()
expiresAt := time.Now().Add(verificationCodeExpiry)
// Create or update password reset record
var pr models.PasswordReset
err := pc.DB.Transaction(func(tx *gorm.DB) error {
// Invalidate any existing reset tokens for this user
if err := tx.Model(&models.PasswordReset{}).
Where("user_id = ? AND used_at IS NULL", user.ID).
Update("used_at", time.Now()).Error; err != nil {
return err
}
// Create new reset record
pr = models.PasswordReset{
UserID: user.ID,
Token: tokenStr,
ExpiresAt: time.Now().Add(time.Hour),
VerificationCode: verificationCode,
VerificationCodeExpires: &expiresAt,
VerificationAttempts: 0,
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
return tx.Create(&pr).Error
})
if err != nil {
logger.Error("Failed to create password reset record: %v", err)
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Nepodařilo se vytvořit záznam pro obnovu hesla",
})
return
}
// Send verification code via email
if seCode, ok := pc.EmailService.(interface{ SendPasswordResetCode(to, code string) error }); ok {
if err := seCode.SendPasswordResetCode(user.Email, verificationCode); err != nil {
// Fallback to legacy method if available
if seLegacy, ok2 := pc.EmailService.(interface {
SendPasswordReset(to, resetLink string, useOverride bool) error
}); ok2 {
if err2 := seLegacy.SendPasswordReset(user.Email, verificationCode, true); err2 != nil {
logger.Error("Failed to send verification code (both methods): error=%v email=%s", err2, emailAddr)
if !config.AppConfig.Debug {
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Nepodařilo se odeslat ověřovací kód",
})
return
}
}
} else {
logger.Error("No available email method for sending verification code: email=%s error=%v", emailAddr, err)
if !config.AppConfig.Debug {
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Nepodařilo se odeslat ověřovací kód",
})
return
}
}
}
} else if seLegacy, ok := pc.EmailService.(interface {
SendPasswordReset(to, resetLink string, useOverride bool) error
}); ok {
if err := seLegacy.SendPasswordReset(user.Email, verificationCode, true); err != nil {
logger.Error("Failed to send verification code (legacy): error=%v email=%s", err, emailAddr)
if !config.AppConfig.Debug {
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Nepodařilo se odeslat ověřovací kód",
})
return
}
}
}
c.JSON(http.StatusOK, PasswordResetResponse{
Success: true,
Message: "Ověřovací kód byl odeslán na váš e-mail.",
NextStep: "verify_code",
CodeRequired: true,
})
}
// VerifyResetCode verifies the reset code and returns a reset token if valid
func (pc *PasswordController) VerifyResetCode(c *gin.Context) {
var req VerifyResetCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: "Neplatný formát požadavku: " + err.Error(),
})
return
}
emailAddr := strings.TrimSpace(strings.ToLower(req.Email))
if emailAddr == "" || !strings.Contains(emailAddr, "@") {
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: "Neplatná e-mailová adresa",
})
return
}
// Find the user
var user models.User
if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, PasswordResetResponse{
Success: false,
Message: "Uživatel s tímto e-mailem nebyl nalezen",
})
return
}
logger.Error("Database error when finding user: error=%v email=%s", err, emailAddr)
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Došlo k chybě při ověřování kódu",
})
return
}
// Find the active reset record
var pr models.PasswordReset
now := time.Now()
err := pc.DB.Transaction(func(tx *gorm.DB) error {
// Find the most recent active reset record
if err := tx.Where("user_id = ? AND used_at IS NULL AND expires_at > ?", user.ID, now).
Order("created_at DESC").First(&pr).Error; err != nil {
return err
}
// Check if verification code has expired
if pr.VerificationCodeExpires == nil || pr.VerificationCodeExpires.Before(now) {
return fmt.Errorf("verification code expired")
}
// Check if max attempts reached
if pr.VerificationAttempts >= maxVerificationAttempts {
return fmt.Errorf("max verification attempts reached")
}
// Check if code matches
if pr.VerificationCode != req.Code {
// Increment attempt counter
if err := tx.Model(&pr).
Update("verification_attempts", pr.VerificationAttempts+1).Error; err != nil {
return err
}
return fmt.Errorf("invalid verification code")
}
// Code is valid; do not expire it here. Allow completion step to validate within original expiry window.
return nil
})
if err != nil {
if err.Error() == "record not found" {
c.JSON(http.StatusNotFound, PasswordResetResponse{
Success: false,
Message: "Neplatný nebo expirovaný odkaz pro obnovení hesla",
})
return
} else if err.Error() == "verification code expired" {
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: "Platnost ověřovacího kódu vypršela. Požádejte o nový kód.",
})
return
} else if err.Error() == "max verification attempts reached" {
c.JSON(http.StatusTooManyRequests, PasswordResetResponse{
Success: false,
Message: "Překročen maximální počet pokusů. Požádejte o nový kód.",
})
return
} else if err.Error() == "invalid verification code" {
attemptsLeft := maxVerificationAttempts - pr.VerificationAttempts - 1
message := fmt.Sprintf("Neplatný ověřovací kód. Zbývající počet pokusů: %d", attemptsLeft)
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: message,
})
return
}
logger.Error("Error verifying reset code: error=%v email=%s", err, emailAddr)
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Došlo k chybě při ověřování kódu",
})
return
}
// Return success with the reset token
c.JSON(http.StatusOK, PasswordResetResponse{
Success: true,
Message: "Kód byl úspěšně ověřen. Můžete nastavit nové heslo.",
NextStep: "reset_password",
})
}
// CompletePasswordReset verifies the reset code and updates the password
func (pc *PasswordController) CompletePasswordReset(c *gin.Context) {
var req CompletePasswordResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: "Neplatný formát požadavku: " + err.Error(),
})
return
}
emailAddr := strings.TrimSpace(strings.ToLower(req.Email))
if emailAddr == "" || !strings.Contains(emailAddr, "@") {
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: "Neplatná e-mailová adresa",
})
return
}
// Find the user
var user models.User
if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, PasswordResetResponse{
Success: false,
Message: "Uživatel s tímto e-mailem nebyl nalezen",
})
return
}
logger.Error("Database error when finding user: error=%v email=%s", err, emailAddr)
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Došlo k chybě při obnovování hesla",
})
return
}
// Verify the reset code and get the reset record
var pr models.PasswordReset
now := time.Now()
err := pc.DB.Transaction(func(tx *gorm.DB) error {
// Find the active reset record
if err := tx.Where("user_id = ? AND used_at IS NULL AND expires_at > ?", user.ID, now).
Order("created_at DESC").First(&pr).Error; err != nil {
return err
}
// Check if verification code matches and is not expired
if pr.VerificationCode != req.Code ||
pr.VerificationCodeExpires == nil ||
pr.VerificationCodeExpires.Before(now) {
return fmt.Errorf("invalid or expired verification code")
}
// Update user's password
hashedPassword, err := utils.HashPassword(req.NewPassword)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
if err := tx.Model(&user).Update("password", hashedPassword).Error; err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
// Mark the reset record as used
now := time.Now()
pr.UsedAt = &now
return tx.Save(&pr).Error
})
if err != nil {
if err.Error() == "record not found" || err.Error() == "invalid or expired verification code" {
c.JSON(http.StatusBadRequest, PasswordResetResponse{
Success: false,
Message: "Neplatný nebo expirovaný ověřovací kód. Zkuste to znovu.",
})
return
}
logger.Error("Error completing password reset: error=%v email=%s", err, emailAddr)
c.JSON(http.StatusInternalServerError, PasswordResetResponse{
Success: false,
Message: "Došlo k chybě při obnovování hesla",
})
return
}
c.JSON(http.StatusOK, PasswordResetResponse{
Success: true,
Message: "Vaše heslo bylo úspěšně změněno. Nyní se můžete přihlásit.",
NextStep: "login",
})
}
// AdminSendResetByID sends a reset email for a specific user ID using override SMTP
func (pc *PasswordController) AdminSendResetByID(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var user models.User
if err := pc.DB.First(&user, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"})
}
return
}
// Check if email service is available
if pc.EmailService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Email service not configured",
"message": "SMTP settings must be configured in the admin panel or via environment variables (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) before sending password reset emails.",
})
return
}
var b [16]byte
_, _ = cryptorand.Read(b[:])
token := hex.EncodeToString(b[:])
pr := &models.PasswordReset{
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(1 * time.Hour),
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
if err := pc.DB.Create(pr).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reset record"})
return
}
base := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
if base == "" {
base = "http://localhost:3000"
}
link := base + "/reset-password?token=" + token
if se, ok := pc.EmailService.(interface {
SendPasswordReset(to, link string, useOverride bool) error
}); ok {
if err := se.SendPasswordReset(user.Email, link, true); err != nil {
logger.Error("Failed to send password reset email: error=%v user_id=%d", err, user.ID)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to send reset email",
"message": "SMTP configuration may be invalid or incomplete. Check SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_FROM in .env file or configure in admin settings.",
"details": err.Error(),
})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Email service does not support password reset"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func NewPasswordController(db *gorm.DB, es email.EmailService) *PasswordController {
return &PasswordController{DB: db, EmailService: es}
}
type forgotPasswordRequest struct {
Email string `json:"email" binding:"required"`
}
// ForgotPassword creates a reset token and sends email
func (pc *PasswordController) ForgotPassword(c *gin.Context) {
var req forgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
emailAddr := strings.TrimSpace(strings.ToLower(req.Email))
if emailAddr == "" || !strings.Contains(emailAddr, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email"})
return
}
// Lookup user; don't reveal whether exists
var user models.User
if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil {
// Respond success to avoid user enumeration
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
// Create token
var b [16]byte
_, _ = cryptorand.Read(b[:])
token := hex.EncodeToString(b[:])
pr := &models.PasswordReset{
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(1 * time.Hour),
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
if err := pc.DB.Create(pr).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create reset"})
return
}
// Build reset link
base := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
link := base + "/reset-password?token=" + token
// Send email via default SMTP
if se, ok := pc.EmailService.(interface {
SendPasswordReset(to, link string, useOverride bool) error
}); ok {
if err := se.SendPasswordReset(user.Email, link, false); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send reset email"})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
type resetPasswordRequest struct {
Token string `json:"token" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
// ResetPassword consumes a token and sets a new password
func (pc *PasswordController) ResetPassword(c *gin.Context) {
var req resetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var pr models.PasswordReset
if err := pc.DB.Where("token = ?", req.Token).First(&pr).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token"})
return
}
if pr.UsedAt != nil || time.Now().After(pr.ExpiresAt) {
c.JSON(http.StatusBadRequest, gin.H{"error": "token expired or used"})
return
}
var user models.User
if err := pc.DB.First(&user, pr.UserID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "user not found"})
return
}
hashed, err := utils.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash"})
return
}
user.Password = hashed
if err := pc.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
now := time.Now()
pr.UsedAt = &now
_ = pc.DB.Save(&pr).Error
c.JSON(http.StatusOK, gin.H{"success": true})
}
type adminSendResetRequest struct {
Email string `json:"email" binding:"required"`
}
// AdminSendReset sends a reset email using special SMTP when key matches
func (pc *PasswordController) AdminSendReset(c *gin.Context) {
var req adminSendResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
emailAddr := strings.TrimSpace(strings.ToLower(req.Email))
var user models.User
if err := pc.DB.Where("LOWER(email) = LOWER(?)", emailAddr).First(&user).Error; err != nil {
// respond ok regardless
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
// Create token always fresh
var b [16]byte
_, _ = cryptorand.Read(b[:])
token := hex.EncodeToString(b[:])
pr := &models.PasswordReset{
UserID: user.ID,
Token: token,
ExpiresAt: time.Now().Add(1 * time.Hour),
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
if err := pc.DB.Create(pr).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed"})
return
}
base := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
link := base + "/reset-password?token=" + token
if se, ok := pc.EmailService.(interface {
SendPasswordReset(to, link string, useOverride bool) error
}); ok {
if err := se.SendPasswordReset(user.Email, link, true); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to send reset email"})
return
}
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
+29
View File
@@ -0,0 +1,29 @@
package controllers
type (
// InitiatePasswordResetRequest represents the request to start the password reset process
InitiatePasswordResetRequest struct {
Email string `json:"email" binding:"required,email"`
}
// VerifyResetCodeRequest represents the request to verify the reset code
VerifyResetCodeRequest struct {
Email string `json:"email" binding:"required,email"`
Code string `json:"code" binding:"required,len=6"`
}
// CompletePasswordResetRequest represents the request to complete the password reset
CompletePasswordResetRequest struct {
Email string `json:"email" binding:"required,email"`
Code string `json:"code" binding:"required,len=6"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
// PasswordResetResponse represents the response for password reset operations
PasswordResetResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
NextStep string `json:"next_step,omitempty"`
CodeRequired bool `json:"code_required,omitempty"`
}
)
+622
View File
@@ -0,0 +1,622 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"strconv"
"time"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type PollController struct {
DB *gorm.DB
}
func NewPollController(db *gorm.DB) *PollController {
return &PollController{DB: db}
}
// GetPolls returns list of polls (admin or public)
func (pc *PollController) GetPolls(c *gin.Context) {
var polls []models.Poll
query := pc.DB.Preload("Options").Preload("Category").Preload("Creator").
Preload("RelatedArticle").Preload("RelatedEvent")
// Check if admin request
if c.GetBool("isAdmin") {
// Admin sees all polls
status := c.Query("status")
if status != "" {
query = query.Where("status = ?", status)
}
} else {
// Public sees only active polls
query = query.Where("status = ?", "active")
now := time.Now()
query = query.Where("(start_date IS NULL OR start_date <= ?) AND (end_date IS NULL OR end_date >= ?)", now, now)
}
// Featured filter
if c.Query("featured") == "true" {
query = query.Where("featured = ?", true)
}
// Filter by relationships
if articleID := c.Query("article_id"); articleID != "" {
query = query.Where("related_article_id = ?", articleID)
}
if eventID := c.Query("event_id"); eventID != "" {
query = query.Where("related_event_id = ?", eventID)
}
if videoURL := c.Query("video_url"); videoURL != "" {
query = query.Where("related_video_url = ?", videoURL)
}
// Order
query = query.Order("created_at DESC")
if err := query.Find(&polls).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch polls"})
return
}
c.JSON(http.StatusOK, polls)
}
// GetPoll returns a single poll by ID
func (pc *PollController) GetPoll(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
query := pc.DB.Preload("Options", func(db *gorm.DB) *gorm.DB {
return db.Order("display_order ASC, id ASC")
}).Preload("Options.Player").Preload("Category")
if err := query.First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"})
return
}
// Check if user has voted
hasVoted := false
if userID, exists := c.Get("userID"); exists && userID != nil {
var count int64
pc.DB.Model(&models.PollVote{}).Where("poll_id = ? AND user_id = ?", poll.ID, userID).Count(&count)
hasVoted = count > 0
} else {
// Check by IP hash or session token
ipHash := pc.hashIP(c.ClientIP())
sessionToken := c.GetHeader("X-Session-Token")
if sessionToken == "" {
sessionToken = c.Query("session_token")
}
var count int64
query := pc.DB.Model(&models.PollVote{}).Where("poll_id = ?", poll.ID)
if sessionToken != "" {
query = query.Where("session_token = ?", sessionToken)
} else {
query = query.Where("ip_hash = ?", ipHash)
}
query.Count(&count)
hasVoted = count > 0
}
// Add metadata
response := gin.H{
"poll": poll,
"has_voted": hasVoted,
"is_active": poll.IsActive(),
"can_show_results": poll.CanShowResults(hasVoted),
}
c.JSON(http.StatusOK, response)
}
// CreatePoll creates a new poll (admin only)
func (pc *PollController) CreatePoll(c *gin.Context) {
var input struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
AllowMultiple bool `json:"allow_multiple"`
MaxChoices int `json:"max_choices"`
ShowResults string `json:"show_results"`
RequireAuth bool `json:"require_auth"`
AllowGuestVote bool `json:"allow_guest_vote"`
Featured bool `json:"featured"`
CategoryID *uint `json:"category_id"`
RelatedMatchID *uint `json:"related_match_id"`
RelatedArticleID *uint `json:"related_article_id"`
RelatedEventID *uint `json:"related_event_id"`
RelatedVideoURL string `json:"related_video_url"`
ImageURL string `json:"image_url"`
Options []struct {
Text string `json:"text" binding:"required"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
DisplayOrder int `json:"display_order"`
PlayerID *uint `json:"player_id"`
} `json:"options" binding:"required,min=2"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID
userID, _ := c.Get("userID")
// Create poll
poll := models.Poll{
Title: input.Title,
Description: input.Description,
Type: input.Type,
Status: input.Status,
StartDate: input.StartDate,
EndDate: input.EndDate,
AllowMultiple: input.AllowMultiple,
MaxChoices: input.MaxChoices,
ShowResults: input.ShowResults,
RequireAuth: input.RequireAuth,
AllowGuestVote: input.AllowGuestVote,
Featured: input.Featured,
CategoryID: input.CategoryID,
RelatedMatchID: input.RelatedMatchID,
RelatedArticleID: input.RelatedArticleID,
RelatedEventID: input.RelatedEventID,
RelatedVideoURL: input.RelatedVideoURL,
ImageURL: input.ImageURL,
CreatedBy: userID.(uint),
}
// Set defaults
if poll.Type == "" {
poll.Type = "single"
}
if poll.Status == "" {
poll.Status = "draft"
}
if poll.ShowResults == "" {
poll.ShowResults = "after_vote"
}
if poll.MaxChoices == 0 {
poll.MaxChoices = 1
}
// Start transaction
tx := pc.DB.Begin()
if err := tx.Create(&poll).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create poll"})
return
}
// Create options
for _, opt := range input.Options {
option := models.PollOption{
PollID: poll.ID,
Text: opt.Text,
Description: opt.Description,
ImageURL: opt.ImageURL,
DisplayOrder: opt.DisplayOrder,
PlayerID: opt.PlayerID,
}
if err := tx.Create(&option).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create poll option"})
return
}
}
tx.Commit()
// Reload with relations
pc.DB.Preload("Options").Preload("Category").First(&poll, poll.ID)
c.JSON(http.StatusCreated, poll)
}
// UpdatePoll updates an existing poll (admin only)
func (pc *PollController) UpdatePoll(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
if err := pc.DB.First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"})
return
}
var input struct {
Title *string `json:"title"`
Description *string `json:"description"`
Type *string `json:"type"`
Status *string `json:"status"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
AllowMultiple *bool `json:"allow_multiple"`
MaxChoices *int `json:"max_choices"`
ShowResults *string `json:"show_results"`
RequireAuth *bool `json:"require_auth"`
AllowGuestVote *bool `json:"allow_guest_vote"`
Featured *bool `json:"featured"`
CategoryID *uint `json:"category_id"`
RelatedMatchID *uint `json:"related_match_id"`
RelatedArticleID *uint `json:"related_article_id"`
RelatedEventID *uint `json:"related_event_id"`
RelatedVideoURL *string `json:"related_video_url"`
ImageURL *string `json:"image_url"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update fields
if input.Title != nil {
poll.Title = *input.Title
}
if input.Description != nil {
poll.Description = *input.Description
}
if input.Type != nil {
poll.Type = *input.Type
}
if input.Status != nil {
poll.Status = *input.Status
}
if input.StartDate != nil {
poll.StartDate = input.StartDate
}
if input.EndDate != nil {
poll.EndDate = input.EndDate
}
if input.AllowMultiple != nil {
poll.AllowMultiple = *input.AllowMultiple
}
if input.MaxChoices != nil {
poll.MaxChoices = *input.MaxChoices
}
if input.ShowResults != nil {
poll.ShowResults = *input.ShowResults
}
if input.RequireAuth != nil {
poll.RequireAuth = *input.RequireAuth
}
if input.AllowGuestVote != nil {
poll.AllowGuestVote = *input.AllowGuestVote
}
if input.Featured != nil {
poll.Featured = *input.Featured
}
if input.CategoryID != nil {
poll.CategoryID = input.CategoryID
}
// For relationships, directly set the values (including nil to unlink)
// GORM's Save will handle NULL values correctly
poll.RelatedMatchID = input.RelatedMatchID
poll.RelatedArticleID = input.RelatedArticleID
poll.RelatedEventID = input.RelatedEventID
if input.RelatedVideoURL != nil {
poll.RelatedVideoURL = *input.RelatedVideoURL
}
if input.ImageURL != nil {
poll.ImageURL = *input.ImageURL
}
if err := pc.DB.Save(&poll).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update poll"})
return
}
// Reload with relations
pc.DB.Preload("Options").Preload("Category").First(&poll, poll.ID)
c.JSON(http.StatusOK, poll)
}
// DeletePoll deletes a poll (admin only)
func (pc *PollController) DeletePoll(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
if err := pc.DB.First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"})
return
}
// Soft delete
if err := pc.DB.Delete(&poll).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete poll"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Poll deleted successfully"})
}
// Vote handles vote submission
func (pc *PollController) Vote(c *gin.Context) {
id := c.Param("id")
var input struct {
OptionIDs []uint `json:"option_ids" binding:"required,min=1"`
SessionToken string `json:"session_token"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid input: " + err.Error()})
return
}
// Get poll
var poll models.Poll
if err := pc.DB.Preload("Options").First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"})
return
}
// Check if poll is active
if !poll.IsActive() {
c.JSON(http.StatusBadRequest, gin.H{"error": "Poll is not currently accepting votes"})
return
}
// Check authentication requirement
userID, hasUser := c.Get("userID")
if poll.RequireAuth && !hasUser {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Login required to vote"})
return
}
if !poll.AllowGuestVote && !hasUser {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Guest voting not allowed"})
return
}
// Check multiple choice limits
if !poll.AllowMultiple && len(input.OptionIDs) > 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only one choice allowed"})
return
}
if poll.AllowMultiple && len(input.OptionIDs) > poll.MaxChoices {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Maximum %d choices allowed", poll.MaxChoices)})
return
}
// Check if already voted
ipHash := pc.hashIP(c.ClientIP())
sessionToken := input.SessionToken
if sessionToken == "" {
sessionToken = c.GetHeader("X-Session-Token")
}
var existingVoteCount int64
query := pc.DB.Model(&models.PollVote{}).Where("poll_id = ?", poll.ID)
if hasUser {
query = query.Where("user_id = ?", userID)
} else if sessionToken != "" {
query = query.Where("session_token = ?", sessionToken)
} else {
query = query.Where("ip_hash = ?", ipHash)
}
query.Count(&existingVoteCount)
if existingVoteCount > 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "You have already voted in this poll"})
return
}
// Validate option IDs belong to this poll
validOptions := make(map[uint]bool)
for _, opt := range poll.Options {
validOptions[opt.ID] = true
}
for _, optID := range input.OptionIDs {
if !validOptions[optID] {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid option ID"})
return
}
}
// Start transaction
tx := pc.DB.Begin()
// Create votes
userAgent := c.Request.UserAgent()
for _, optionID := range input.OptionIDs {
vote := models.PollVote{
PollID: poll.ID,
OptionID: optionID,
IPHash: ipHash,
UserAgent: userAgent,
SessionToken: sessionToken,
}
if hasUser {
uid := userID.(uint)
vote.UserID = &uid
}
if err := tx.Create(&vote).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record vote"})
return
}
// Increment option vote count
if err := tx.Model(&models.PollOption{}).Where("id = ?", optionID).UpdateColumn("vote_count", gorm.Expr("vote_count + ?", 1)).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update vote count"})
return
}
}
// Increment poll total votes
if err := tx.Model(&models.Poll{}).Where("id = ?", poll.ID).UpdateColumn("total_votes", gorm.Expr("total_votes + ?", 1)).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update total votes"})
return
}
tx.Commit()
// Reload poll with updated counts
pc.DB.Preload("Options", func(db *gorm.DB) *gorm.DB {
return db.Order("display_order ASC, id ASC")
}).First(&poll, poll.ID)
c.JSON(http.StatusOK, gin.H{
"message": "Vote recorded successfully",
"poll": poll,
})
}
// GetPollResults returns poll results
func (pc *PollController) GetPollResults(c *gin.Context) {
id := c.Param("id")
var poll models.Poll
if err := pc.DB.Preload("Options", func(db *gorm.DB) *gorm.DB {
return db.Order("display_order ASC, id ASC")
}).First(&poll, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch poll"})
return
}
// Check if user can see results
hasVoted := false
if userID, exists := c.Get("userID"); exists && userID != nil {
var count int64
pc.DB.Model(&models.PollVote{}).Where("poll_id = ? AND user_id = ?", poll.ID, userID).Count(&count)
hasVoted = count > 0
} else {
ipHash := pc.hashIP(c.ClientIP())
sessionToken := c.GetHeader("X-Session-Token")
if sessionToken == "" {
sessionToken = c.Query("session_token")
}
var count int64
query := pc.DB.Model(&models.PollVote{}).Where("poll_id = ?", poll.ID)
if sessionToken != "" {
query = query.Where("session_token = ?", sessionToken)
} else {
query = query.Where("ip_hash = ?", ipHash)
}
query.Count(&count)
hasVoted = count > 0
}
if !poll.CanShowResults(hasVoted) && !c.GetBool("isAdmin") {
c.JSON(http.StatusForbidden, gin.H{"error": "Results are not available yet"})
return
}
// Calculate percentages
results := make([]gin.H, len(poll.Options))
for i, option := range poll.Options {
percentage := 0.0
if poll.TotalVotes > 0 {
percentage = float64(option.VoteCount) / float64(poll.TotalVotes) * 100
}
results[i] = gin.H{
"option_id": option.ID,
"text": option.Text,
"vote_count": option.VoteCount,
"percentage": percentage,
"image_url": option.ImageURL,
"player_id": option.PlayerID,
}
}
c.JSON(http.StatusOK, gin.H{
"poll_id": poll.ID,
"title": poll.Title,
"total_votes": poll.TotalVotes,
"results": results,
})
}
// Helper function to hash IP addresses
func (pc *PollController) hashIP(ip string) string {
hash := sha256.Sum256([]byte(ip + "poll-salt-2025"))
return hex.EncodeToString(hash[:])
}
// GetPollStats returns statistics for admin (admin only)
func (pc *PollController) GetPollStats(c *gin.Context) {
id := c.Param("id")
pollID, err := strconv.Atoi(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid poll ID"})
return
}
var poll models.Poll
if err := pc.DB.Preload("Options").First(&poll, pollID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Poll not found"})
return
}
// Get vote distribution over time
var votesByDay []struct {
Date string `json:"date"`
Count int `json:"count"`
}
pc.DB.Model(&models.PollVote{}).
Select("DATE(created_at) as date, COUNT(*) as count").
Where("poll_id = ?", pollID).
Group("DATE(created_at)").
Order("date ASC").
Scan(&votesByDay)
// Get authenticated vs guest votes
var authVotes, guestVotes int64
pc.DB.Model(&models.PollVote{}).Where("poll_id = ? AND user_id IS NOT NULL", pollID).Count(&authVotes)
pc.DB.Model(&models.PollVote{}).Where("poll_id = ? AND user_id IS NULL", pollID).Count(&guestVotes)
c.JSON(http.StatusOK, gin.H{
"poll": poll,
"votes_by_day": votesByDay,
"authenticated_votes": authVotes,
"guest_votes": guestVotes,
})
}
+158
View File
@@ -0,0 +1,158 @@
package controllers
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"fotbal-club/internal/services"
)
// PrefetchController exposes admin endpoints to inspect and trigger background prefetch cycles.
type PrefetchController struct{}
// isDuringMatchFromCache checks cached FACR JSONs to determine if a match is ongoing.
func isDuringMatchFromCache(cacheDir string) bool {
now := time.Now()
parseDT := func(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" { return time.Time{}, false }
layouts := []string{"02.01.2006 15:04", time.RFC3339, "2006-01-02 15:04"}
for _, layout := range layouts {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { return t, true }
}
return time.Time{}, false
}
within := func(ts time.Time) bool {
start := ts.Add(-15 * time.Minute)
end := ts.Add(150 * time.Minute)
return now.After(start) && now.Before(end)
}
type flatMatch struct {
DateTime string `json:"date_time"`
Date string `json:"date"`
Time string `json:"time"`
Kickoff string `json:"kickoff"`
}
check := func(b []byte) bool {
if len(b) == 0 { return false }
var payload struct {
Competitions []struct { Matches []struct {
DateTime string `json:"date_time"`
Date string `json:"date"`
Time string `json:"time"`
Kickoff string `json:"kickoff"`
} `json:"matches"` } `json:"competitions"`
Matches []flatMatch `json:"matches"`
}
_ = json.Unmarshal(b, &payload)
for _, c := range payload.Competitions {
for _, m := range c.Matches {
var ts time.Time; var ok bool
if m.DateTime != "" { ts, ok = parseDT(m.DateTime) } else if m.Date != "" || m.Time != "" { ts, ok = parseDT(strings.TrimSpace(m.Date+" "+m.Time)) } else if m.Kickoff != "" { ts, ok = parseDT(m.Kickoff) }
if ok && within(ts) { return true }
}
}
for _, m := range payload.Matches {
var ts time.Time; var ok bool
if m.DateTime != "" { ts, ok = parseDT(m.DateTime) } else if m.Date != "" || m.Time != "" { ts, ok = parseDT(strings.TrimSpace(m.Date+" "+m.Time)) } else if m.Kickoff != "" { ts, ok = parseDT(m.Kickoff) }
if ok && within(ts) { return true }
}
return false
}
files := []string{ filepath.Join(cacheDir, "facr_club_info.json"), filepath.Join(cacheDir, "facr_tables.json") }
for _, p := range files {
if b, err := os.ReadFile(p); err == nil {
if check(b) { return true }
}
}
return false
}
func NewPrefetchController() *PrefetchController { return &PrefetchController{} }
// Status returns info about the last fetch and the approximate next run time.
// GET /api/v1/admin/prefetch/status
func (pc *PrefetchController) Status(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
cacheDir := filepath.Join("cache", "prefetch")
metaPath := filepath.Join(cacheDir, "meta.json")
var lastUpdated string
if b, err := os.ReadFile(metaPath); err == nil {
var m struct{ LastUpdated string `json:"lastUpdated"` }
if json.Unmarshal(b, &m) == nil {
lastUpdated = m.LastUpdated
}
}
// Determine configured interval
interval := 30 * time.Minute
if v := strings.TrimSpace(os.Getenv("PREFETCH_INTERVAL_MINUTES")); v != "" {
if mins, err := strconv.Atoi(v); err == nil && mins > 0 {
interval = time.Duration(mins) * time.Minute
}
}
// Fast mode if during match
fast := isDuringMatchFromCache(cacheDir)
next := time.Now().Add(interval)
if fast {
next = time.Now().Add(5 * time.Minute)
}
c.JSON(http.StatusOK, gin.H{
"lastUpdated": lastUpdated,
"intervalMinutes": int(interval / time.Minute),
"fastMode": fast,
"nextApproximate": next.Format(time.RFC3339),
})
}
// Trigger starts an immediate prefetch cycle (best effort)
// POST /api/v1/admin/prefetch/trigger
func (pc *PrefetchController) Trigger(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
// Resolve the base URL similar to main.go logic
base := os.Getenv("PREFETCH_TARGET")
if strings.TrimSpace(base) == "" {
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" { port = "8080" }
base = "http://127.0.0.1:" + port + "/api/v1"
}
go func() {
// fire-and-forget
services.PrefetchOnce(base)
// Additionally trigger an immediate Zonerama refresh based on cached settings
// This ensures the admin trigger updates the gallery right away.
cacheDir := filepath.Join("cache", "prefetch")
b, err := os.ReadFile(filepath.Join(cacheDir, "settings.json"))
if err == nil {
var s struct{
ZoneramaURL string `json:"zonerama_url"`
GalleryURL string `json:"gallery_url"`
}
if jsonErr := json.Unmarshal(b, &s); jsonErr == nil {
link := strings.TrimSpace(s.ZoneramaURL)
if link == "" { link = strings.TrimSpace(s.GalleryURL) }
if link != "" {
_ = services.RefreshZoneramaNow(link)
}
}
}
// Regenerate flat gallery files from existing albums
_ = services.RegenerateFlatGalleryFiles()
}()
c.JSON(http.StatusOK, gin.H{"message": "Prefetch started"})
}
@@ -0,0 +1,218 @@
package controllers
import (
"bytes"
"fmt"
"image"
"image/png"
_ "image/gif"
_ "image/jpeg"
"mime/multipart"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
func sanitizeAndWriteLogo(data []byte, outPath string) error {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return err
}
b := img.Bounds()
minX, minY := b.Max.X, b.Max.Y
maxX, maxY := b.Min.X, b.Min.Y
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, bl, a := img.At(x, y).RGBA()
if a <= 0x10 { // near transparent
continue
}
rr, gg, bb := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
if rr > 245 && gg > 245 && bb > 245 { // nearly white background
continue
}
if x < minX { minX = x }
if y < minY { minY = y }
if x > maxX { maxX = x }
if y > maxY { maxY = y }
}
}
if minX >= maxX || minY >= maxY {
// fallback to full image
minX, minY = b.Min.X, b.Min.Y
maxX, maxY = b.Max.X-1, b.Max.Y-1
}
cw, ch := maxX-minX+1, maxY-minY+1
nrgba := image.NewNRGBA(image.Rect(0, 0, cw, ch))
for y := 0; y < ch; y++ {
for x := 0; x < cw; x++ {
nrgba.Set(x, y, img.At(minX+x, minY+y))
}
}
// resize to 64px height using nearest-neighbor
targetH := 64
if ch != targetH {
targetW := int(float64(cw) * float64(targetH) / float64(ch))
if targetW < 1 { targetW = 1 }
resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH))
for y2 := 0; y2 < targetH; y2++ {
srcY := y2 * ch / targetH
for x2 := 0; x2 < targetW; x2++ {
srcX := x2 * cw / targetW
c := nrgba.NRGBAAt(srcX, srcY)
resized.SetNRGBA(x2, y2, c)
}
}
nrgba = resized
}
// write PNG
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return err }
f, err := os.Create(outPath)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, nrgba)
}
// ensureUniqueFilename ensures name does not collide within dir, adding -1, -2 etc.
func ensureUniqueFilename(dir, name string) string {
base := name
ext := ""
if i := strings.LastIndex(name, "."); i >= 0 {
base = name[:i]
ext = name[i:]
}
try := name
idx := 1
for {
if _, err := os.Stat(filepath.Join(dir, try)); os.IsNotExist(err) {
return try
}
try = fmt.Sprintf("%s-%d%s", base, idx, ext)
idx++
}
}
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
entries, err := os.ReadDir(filepath.Join("uploads", "sponsors"))
if err != nil {
ctx.JSON(http.StatusOK, []string{})
return
}
out := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() { continue }
name := e.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".gif") || strings.HasSuffix(lower, ".webp") || strings.HasSuffix(lower, ".svg") {
out = append(out, "/uploads/sponsors/"+name)
}
}
ctx.JSON(http.StatusOK, out)
}
// UploadSponsors accepts multipart form files under field name "files" (or single "file")
func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
if err := ctx.Request.ParseMultipartForm(200 << 20); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
return
}
_ = os.MkdirAll(filepath.Join("uploads", "sponsors"), 0o755)
saved := 0
if ctx.Request.MultipartForm != nil {
files := ctx.Request.MultipartForm.File["files"]
if len(files) == 0 {
if f, hdr, err := ctx.Request.FormFile("file"); err == nil {
_ = f.Close()
files = []*multipart.FileHeader{hdr}
}
}
for _, hdr := range files {
if hdr == nil { continue }
src, err := hdr.Open()
if err != nil { continue }
// do not defer: loop
name := sanitizeFilename(hdr.Filename)
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
base := name
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
outName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), base+".png")
outPath := filepath.Join("uploads", "sponsors", outName)
var buf bytes.Buffer
if _, err := io.Copy(&buf, src); err == nil {
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
saved++
} else {
// Fallback: write original bytes with original extension
rawName := ensureUniqueFilename(filepath.Join("uploads", "sponsors"), name)
rawPath := filepath.Join("uploads", "sponsors", rawName)
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
saved++
}
}
_ = src.Close()
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": saved})
}
// DeleteSponsor deletes a sponsor logo by filename (?name=)
func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
name := sanitizeFilename(ctx.Query("name"))
if name == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
return
}
p := filepath.Join("uploads", "sponsors", name)
if _, err := os.Stat(p); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := os.Remove(p); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// GetQR returns the current QR image URL if present
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
path := filepath.Join("uploads", "qr.png")
if _, err := os.Stat(path); err == nil {
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
return
}
ctx.JSON(http.StatusOK, gin.H{"qr": ""})
}
// UploadQR accepts a single file and stores/overwrites uploads/qr.png
func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
file, _, err := ctx.Request.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file not provided (field 'file')"})
return
}
defer file.Close()
_ = os.MkdirAll("uploads", 0o755)
out, err := os.Create(filepath.Join("uploads", "qr.png"))
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
@@ -0,0 +1,599 @@
package controllers
import (
"net/http"
"encoding/json"
"os"
"path/filepath"
"time"
"fmt"
"strings"
"io"
"image"
_ "image/png"
_ "image/jpeg"
_ "image/gif"
"net/http/httputil"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ScoreboardController manages the singleton scoreboard state
type ScoreboardController struct {
DB *gorm.DB
}
// --- Additional features ported from MyClub ScoreBoard ---
// makeShort derives a 3-letter uppercase abbreviation from a club name.
func makeShort(name string) string {
name = strings.TrimSpace(name)
if name == "" { return "---" }
name = strings.ToUpper(name)
repl := strings.NewReplacer(
"Á", "A", "Ä", "A", "Å", "A", "Â", "A", "À", "A",
"Č", "C", "Ć", "C", "Ç", "C",
"Ď", "D",
"É", "E", "Ě", "E", "È", "E", "Ë", "E", "Ê", "E",
"Í", "I", "Ì", "I", "Ï", "I", "Î", "I",
"Ň", "N", "Ń", "N",
"Ó", "O", "Ö", "O", "Ô", "O", "Ò", "O",
"Ř", "R",
"Š", "S", "Ś", "S",
"Ť", "T",
"Ú", "U", "Ů", "U", "Ù", "U", "Ü", "U", "Û", "U",
"Ý", "Y",
"Ž", "Z",
)
name = repl.Replace(name)
out := make([]rune, 0, 3)
for _, r := range name {
if r >= 'A' && r <= 'Z' {
out = append(out, r)
if len(out) == 3 { break }
}
}
for len(out) < 3 { out = append(out, '-') }
return string(out)
}
// DeriveColors returns average dominant colors from provided logo URLs
func (c *ScoreboardController) DeriveColors(ctx *gin.Context) {
type req struct {
URL string `json:"url"`
HomeLogo string `json:"homeLogo"`
AwayLogo string `json:"awayLogo"`
}
type singleResp struct{ Color string `json:"color"` }
type duoResp struct{ PrimaryColor string `json:"primaryColor"`; SecondaryColor string `json:"secondaryColor"` }
var q req
q.URL = ctx.Query("url")
q.HomeLogo = ctx.Query("homeLogo")
q.AwayLogo = ctx.Query("awayLogo")
if q.URL == "" && q.HomeLogo == "" && q.AwayLogo == "" {
// try JSON body
_ = ctx.ShouldBindJSON(&q)
}
if q.URL != "" {
col, err := averageColorFromURL(q.URL)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot fetch or process image"})
return
}
ctx.JSON(http.StatusOK, singleResp{Color: col})
return
}
if q.HomeLogo != "" || q.AwayLogo != "" {
var primary, secondary string
if q.HomeLogo != "" {
if col, err := averageColorFromURL(q.HomeLogo); err == nil { primary = col }
}
if q.AwayLogo != "" {
if col, err := averageColorFromURL(q.AwayLogo); err == nil { secondary = col }
}
ctx.JSON(http.StatusOK, duoResp{PrimaryColor: primary, SecondaryColor: secondary})
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": "provide ?url= or ?homeLogo=&awayLogo="})
}
// averageColorFromURL downloads an image and computes its average RGB color in hex.
func averageColorFromURL(u string) (string, error) {
resp, err := http.Get(u)
if err != nil { return "", err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
// best-effort body capture for debugging
b, _ := httputil.DumpResponse(resp, false)
_ = b
return "", fmt.Errorf("http status %d", resp.StatusCode)
}
img, _, err := image.Decode(resp.Body)
if err != nil { return "", err }
return averageHex(img), nil
}
func averageHex(img image.Image) string {
rect := img.Bounds()
if rect.Empty() { return "#000000" }
w := rect.Dx(); h := rect.Dy()
stepX, stepY := 1, 1
for (w/stepX)*(h/stepY) > 160000 {
if stepX <= stepY { stepX *= 2 } else { stepY *= 2 }
}
var rsum, gsum, bsum, count uint64
for y := rect.Min.Y; y < rect.Max.Y; y += stepY {
for x := rect.Min.X; x < rect.Max.X; x += stepX {
cr, cg, cb, ca := img.At(x,y).RGBA()
if ca < 0x2000 { continue }
rsum += uint64(cr >> 8)
gsum += uint64(cg >> 8)
bsum += uint64(cb >> 8)
count++
}
}
if count == 0 { return "#000000" }
r8 := uint8(rsum / count)
g8 := uint8(gsum / count)
b8 := uint8(bsum / count)
return fmt.Sprintf("#%02x%02x%02x", r8, g8, b8)
}
// SwapSides swaps home and away team info including names, logos, shorts, scores and colors.
func (c *ScoreboardController) SwapSides(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
s.HomeName, s.AwayName = s.AwayName, s.HomeName
s.HomeLogoURL, s.AwayLogoURL = s.AwayLogoURL, s.HomeLogoURL
s.HomeScore, s.AwayScore = s.AwayScore, s.HomeScore
s.HomeShort, s.AwayShort = s.AwayShort, s.HomeShort
s.PrimaryColor, s.SecondaryColor = s.SecondaryColor, s.PrimaryColor
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// StartSecondHalf swaps sides, resets the timer to 00:00 and immediately starts it.
func (c *ScoreboardController) StartSecondHalf(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
// swap first
s.HomeName, s.AwayName = s.AwayName, s.HomeName
s.HomeLogoURL, s.AwayLogoURL = s.AwayLogoURL, s.HomeLogoURL
s.HomeScore, s.AwayScore = s.AwayScore, s.HomeScore
s.HomeShort, s.AwayShort = s.AwayShort, s.HomeShort
s.PrimaryColor, s.SecondaryColor = s.SecondaryColor, s.PrimaryColor
// reset and start timer for next half
s.Running = true
s.ElapsedSeconds = 0
s.TimerStartUnix = time.Now().Unix()
s.Timer = "00:00"
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// SaveState saves current scoreboard state as a JSON file in /saved directory.
func (c *ScoreboardController) SaveState(ctx *gin.Context) {
type req struct{ Filename string `json:"filename"` }
var q req
_ = ctx.ShouldBindJSON(&q)
if q.Filename == "" { q.Filename = ctx.Query("filename") }
name := sanitizeFilename(q.Filename)
if name == "" { name = time.Now().Format("20060102-150405") }
if !strings.HasSuffix(strings.ToLower(name), ".json") { name += ".json" }
_ = os.MkdirAll("saved", 0o755)
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
b, _ := json.MarshalIndent(s, "", " ")
if err := os.WriteFile(filepath.Join("saved", name), b, 0o644); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"}); return
}
ctx.JSON(http.StatusOK, gin.H{"saved": name})
}
// ListSaves returns the list of saved preset filenames from /saved
func (c *ScoreboardController) ListSaves(ctx *gin.Context) {
entries, err := os.ReadDir("saved")
if err != nil { ctx.JSON(http.StatusOK, []string{}) ; return }
out := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() { continue }
name := e.Name()
if strings.HasSuffix(strings.ToLower(name), ".json") { out = append(out, name) }
}
ctx.JSON(http.StatusOK, out)
}
// LoadSaved loads a saved preset from /saved/<filename> and applies it to the singleton.
func (c *ScoreboardController) LoadSaved(ctx *gin.Context) {
// Support filename via query, JSON, or multipart form file upload as raw JSON
filename := sanitizeFilename(ctx.Query("filename"))
var body struct{ Filename string `json:"filename"` }
if filename == "" {
_ = ctx.ShouldBindJSON(&body)
filename = sanitizeFilename(body.Filename)
}
if filename == "" {
// try multipart file upload under field "file"
file, _, err := ctx.Request.FormFile("file")
if err == nil {
defer file.Close()
data, err := io.ReadAll(file)
if err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot read file"}); return }
var imported models.ScoreboardState
if err := json.Unmarshal(data, &imported); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}); return }
applyImportedState(imported, c, ctx)
return
}
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing filename"})
return
}
if !strings.HasSuffix(strings.ToLower(filename), ".json") { filename += ".json" }
path := filepath.Join("saved", filename)
data, err := os.ReadFile(path)
if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"}); return }
var imported models.ScoreboardState
if err := json.Unmarshal(data, &imported); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}); return }
applyImportedState(imported, c, ctx)
}
func applyImportedState(imported models.ScoreboardState, c *ScoreboardController, ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
// overwrite relevant fields
s.HomeName = imported.HomeName
s.AwayName = imported.AwayName
s.HomeLogoURL = imported.HomeLogoURL
s.AwayLogoURL = imported.AwayLogoURL
// derive shorts if empty
if strings.TrimSpace(imported.HomeShort) != "" { s.HomeShort = imported.HomeShort } else { s.HomeShort = makeShort(s.HomeName) }
if strings.TrimSpace(imported.AwayShort) != "" { s.AwayShort = imported.AwayShort } else { s.AwayShort = makeShort(s.AwayName) }
if imported.PrimaryColor != "" { s.PrimaryColor = imported.PrimaryColor }
if imported.SecondaryColor != "" { s.SecondaryColor = imported.SecondaryColor }
s.HomeScore = imported.HomeScore
s.AwayScore = imported.AwayScore
if imported.HalfLength > 0 { s.HalfLength = imported.HalfLength }
if imported.Theme != "" { s.Theme = imported.Theme }
// timer handling
base := parseTimerToSeconds(imported.Timer)
s.Timer = fmt.Sprintf("%02d:%02d", base/60, base%60)
s.ElapsedSeconds = base
if imported.Running {
s.Running = true
s.TimerStartUnix = time.Now().Unix() - int64(base)
} else {
s.Running = false
s.TimerStartUnix = 0
}
if err := c.DB.Save(s).Error; err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return }
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// sanitizeFilename keeps only [a-zA-Z0-9-_] and dots, strips path separators
func sanitizeFilename(in string) string {
in = strings.TrimSpace(in)
in = strings.ReplaceAll(in, "\\", "")
in = strings.ReplaceAll(in, "/", "")
var b strings.Builder
for _, r := range in {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
b.WriteRune(r)
}
}
return b.String()
}
// --- Timer helpers and handlers ---
func parseTimerToSeconds(timer string) int {
// Expect MM:SS
if len(timer) < 4 {
return 0
}
var m, s int
_, err := fmt.Sscanf(timer, "%d:%d", &m, &s)
if err != nil || m < 0 || s < 0 || s >= 60 {
return 0
}
return m*60 + s
}
func formatSeconds(sec int) string {
if sec < 0 { sec = 0 }
return fmt.Sprintf("%02d:%02d", sec/60, sec%60)
}
func computeTimer(s models.ScoreboardState) (timer string, running bool) {
running = s.Running
base := s.ElapsedSeconds
if s.Running {
now := time.Now().Unix()
if s.TimerStartUnix > 0 {
diff := int(now - s.TimerStartUnix)
if diff > 0 { base = diff } else { base = 0 }
}
}
// Cap by half length
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if base >= cap {
base = cap
running = false
}
timer = formatSeconds(base)
return
}
// StartTimer sets running=true and backdates TimerStartUnix
func (c *ScoreboardController) StartTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if s.ElapsedSeconds == 0 && s.Timer != "" {
s.ElapsedSeconds = parseTimerToSeconds(s.Timer)
}
s.TimerStartUnix = time.Now().Unix() - int64(s.ElapsedSeconds)
s.Running = true
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// PauseTimer sets running=false and fixes elapsedSeconds
func (c *ScoreboardController) PauseTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
if s.Running {
now := time.Now().Unix()
if s.TimerStartUnix > 0 {
diff := int(now - s.TimerStartUnix)
if diff > 0 { s.ElapsedSeconds = diff } else { s.ElapsedSeconds = 0 }
}
}
s.Running = false
// Cap and set display string
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.ElapsedSeconds > cap { s.ElapsedSeconds = cap }
s.Timer = formatSeconds(s.ElapsedSeconds)
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// ResetTimer clears timer to 00:00 and stops it
func (c *ScoreboardController) ResetTimer(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"}); return }
s.Running = false
s.ElapsedSeconds = 0
s.TimerStartUnix = 0
s.Timer = "00:00"
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
func NewScoreboardController(db *gorm.DB) *ScoreboardController {
return &ScoreboardController{DB: db}
}
func (c *ScoreboardController) getOrCreateSingleton() (*models.ScoreboardState, error) {
var s models.ScoreboardState
if err := c.DB.First(&s).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// Create with sensible defaults
s = models.ScoreboardState{
HomeName: "DOMÁCÍ",
AwayName: "HOSTÉ",
HomeShort: "DOM",
AwayShort: "HOS",
PrimaryColor: "#1e3a8a",
SecondaryColor: "#2563eb",
HalfLength: 45,
Theme: "pill",
Timer: "00:00",
Running: false,
TimerStartUnix: 0,
ElapsedSeconds: 0,
// New fields defaults
SidesFlipped: false,
Half: 1,
QRShowEveryMinutes: 5,
QRShowDurationSeconds: 60,
}
if err := c.DB.Create(&s).Error; err != nil {
return nil, err
}
return &s, nil
}
return nil, err
}
// Ensure defaults for newly added fields when loading existing row
changed := false
if s.Half == 0 { s.Half = 1; changed = true }
if s.QRShowEveryMinutes == 0 { s.QRShowEveryMinutes = 5; changed = true }
if s.QRShowDurationSeconds == 0 { s.QRShowDurationSeconds = 60; changed = true }
if changed { _ = c.DB.Save(&s).Error }
return &s, nil
}
// GetPublic returns read-only scoreboard state for public overlay
func (c *ScoreboardController) GetPublic(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
timer, running := computeTimer(*s)
ctx.JSON(http.StatusOK, gin.H{
"homeName": s.HomeName,
"awayName": s.AwayName,
"homeLogo": s.HomeLogoURL,
"awayLogo": s.AwayLogoURL,
"homeShort": s.HomeShort,
"awayShort": s.AwayShort,
"primaryColor": s.PrimaryColor,
"secondaryColor": s.SecondaryColor,
"homeScore": s.HomeScore,
"awayScore": s.AwayScore,
"halfLength": s.HalfLength,
"theme": s.Theme,
"external_match_id": s.ExternalMatchID,
"active": s.Active,
// Newly exposed fields compatible with MyClub ScoreBoard overlay
"sidesFlipped": s.SidesFlipped,
"half": s.Half,
"qrEvery": s.QRShowEveryMinutes,
"qrDuration": s.QRShowDurationSeconds,
"timer": timer,
"running": running,
"timer_start_unix": s.TimerStartUnix,
"elapsed_seconds": s.ElapsedSeconds,
})
}
// GetAdmin returns full state for admins
func (c *ScoreboardController) GetAdmin(ctx *gin.Context) {
s, err := c.getOrCreateSingleton()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
// Compute transient timer display
timer, running := computeTimer(*s)
out := *s
out.Timer = timer
out.Running = running
ctx.JSON(http.StatusOK, out)
}
// PutAdmin updates the singleton state (admin only)
func (c *ScoreboardController) PutAdmin(ctx *gin.Context) {
var payload struct {
HomeName *string `json:"homeName"`
AwayName *string `json:"awayName"`
HomeLogo *string `json:"homeLogo"`
AwayLogo *string `json:"awayLogo"`
HomeShort *string `json:"homeShort"`
AwayShort *string `json:"awayShort"`
PrimaryColor *string `json:"primaryColor"`
SecondaryColor *string `json:"secondaryColor"`
HomeScore *int `json:"homeScore"`
AwayScore *int `json:"awayScore"`
HalfLength *int `json:"halfLength"`
Theme *string `json:"theme"`
ExternalMatchID *string `json:"externalMatchId"`
Active *bool `json:"active"`
// Optional direct timer patch (when not running)
Timer *string `json:"timer"`
// Newly supported fields (ported from MyClub ScoreBoard)
SidesFlipped *bool `json:"sidesFlipped"`
Half *int `json:"half"`
QRShowEveryMinutes *int `json:"qrEvery"`
QRShowDurationSeconds *int `json:"qrDuration"`
}
if err := ctx.ShouldBindJSON(&payload); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
s, err := c.getOrCreateSingleton()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot load scoreboard"})
return
}
// Apply patch
if payload.HomeName != nil { s.HomeName = *payload.HomeName }
if payload.AwayName != nil { s.AwayName = *payload.AwayName }
if payload.HomeLogo != nil { s.HomeLogoURL = *payload.HomeLogo }
if payload.AwayLogo != nil { s.AwayLogoURL = *payload.AwayLogo }
if payload.HomeShort != nil { s.HomeShort = *payload.HomeShort }
if payload.AwayShort != nil { s.AwayShort = *payload.AwayShort }
if payload.PrimaryColor != nil { s.PrimaryColor = *payload.PrimaryColor }
if payload.SecondaryColor != nil { s.SecondaryColor = *payload.SecondaryColor }
if payload.HomeScore != nil { s.HomeScore = *payload.HomeScore }
if payload.AwayScore != nil { s.AwayScore = *payload.AwayScore }
if payload.HalfLength != nil { s.HalfLength = *payload.HalfLength }
if payload.Theme != nil { s.Theme = *payload.Theme }
if payload.ExternalMatchID != nil { s.ExternalMatchID = *payload.ExternalMatchID }
if payload.Active != nil { s.Active = *payload.Active }
if payload.SidesFlipped != nil { s.SidesFlipped = *payload.SidesFlipped }
if payload.Half != nil { s.Half = *payload.Half }
if payload.QRShowEveryMinutes != nil && *payload.QRShowEveryMinutes > 0 { s.QRShowEveryMinutes = *payload.QRShowEveryMinutes }
if payload.QRShowDurationSeconds != nil && *payload.QRShowDurationSeconds > 0 { s.QRShowDurationSeconds = *payload.QRShowDurationSeconds }
if payload.Timer != nil && !s.Running {
// Set base timer string when paused
s.Timer = *payload.Timer
s.ElapsedSeconds = parseTimerToSeconds(*payload.Timer)
}
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
return
}
// Best-effort: if scoreboard active and linked to a match, write live cache
if s.Active && s.ExternalMatchID != "" {
go writeLiveScoreboardCache(s)
}
ctx.JSON(http.StatusOK, s)
}
// writeLiveScoreboardCache writes cache/live/score_<id>.json and patches cache/prefetch/events_upcoming.json if present
func writeLiveScoreboardCache(s *models.ScoreboardState) {
// Ensure directories
_ = os.MkdirAll(filepath.Join("cache", "live"), 0o755)
// Write live file
payload := map[string]any{
"external_match_id": s.ExternalMatchID,
"home": s.HomeName,
"away": s.AwayName,
"home_logo_url": s.HomeLogoURL,
"away_logo_url": s.AwayLogoURL,
"home_score": s.HomeScore,
"away_score": s.AwayScore,
"primary_color": s.PrimaryColor,
"secondary_color": s.SecondaryColor,
"theme": s.Theme,
"half_length": s.HalfLength,
"active": s.Active,
"updated_at": time.Now().Format(time.RFC3339),
}
b, _ := json.MarshalIndent(payload, "", " ")
tmp := filepath.Join("cache", "live", "score_"+s.ExternalMatchID+".json.tmp")
dst := filepath.Join("cache", "live", "score_"+s.ExternalMatchID+".json")
if err := os.WriteFile(tmp, b, 0o644); err == nil { _ = os.Rename(tmp, dst) }
// Patch prefetch events if available
prefetch := filepath.Join("cache", "prefetch", "events_upcoming.json")
f, err := os.Open(prefetch)
if err != nil { return }
defer f.Close()
var arr []map[string]any
if err := json.NewDecoder(f).Decode(&arr); err != nil { return }
for i := range arr {
id := ""
if v, ok := arr[i]["match_id"].(string); ok { id = v }
if id == s.ExternalMatchID {
arr[i]["score"] = map[string]any{"home": s.HomeScore, "away": s.AwayScore}
arr[i]["home_logo_url"] = s.HomeLogoURL
arr[i]["away_logo_url"] = s.AwayLogoURL
arr[i]["home"] = s.HomeName
arr[i]["away"] = s.AwayName
break
}
}
out, _ := json.MarshalIndent(arr, "", " ")
_ = os.WriteFile(prefetch+".tmp", out, 0o644)
_ = os.Rename(prefetch+".tmp", prefetch)
}
+364
View File
@@ -0,0 +1,364 @@
package controllers
import (
"encoding/xml"
"fmt"
"net/http"
"strings"
"time"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// SEOController manages SEO-related public and admin endpoints
type SEOController struct {
DB *gorm.DB
}
func NewSEOController(db *gorm.DB) *SEOController {
// Ensure settings table has the SEO fields
_ = db.AutoMigrate(&models.Settings{})
return &SEOController{DB: db}
}
// -------- Public Endpoints --------
// GetPublicSEO returns site-wide SEO defaults derived from Settings
func (s *SEOController) GetPublicSEO(c *gin.Context) {
var settings models.Settings
_ = s.DB.First(&settings).Error // ignore not found; return defaults
c.JSON(http.StatusOK, gin.H{
"site_title": settings.SiteTitle,
"site_description": settings.SiteDescription,
"meta_keywords": settings.MetaKeywords,
"default_og_image_url": settings.DefaultOGImageURL,
"twitter_handle": settings.TwitterHandle,
"canonical_base_url": settings.CanonicalBaseURL,
"additional_meta": settings.AdditionalMeta,
"enable_indexing": settings.EnableIndexing,
})
}
// robots.txt dynamic based on settings.EnableIndexing
func (s *SEOController) GetRobotsTXT(c *gin.Context) {
var settings models.Settings
_ = s.DB.First(&settings).Error
allow := settings.EnableIndexing
var b strings.Builder
b.WriteString("# robots.txt for " + c.Request.Host + "\n")
b.WriteString("# Generated: " + time.Now().UTC().Format(time.RFC1123) + "\n\n")
if allow {
// Allow general crawlers
b.WriteString("User-agent: *\n")
b.WriteString("Allow: /\n")
b.WriteString("Disallow: /admin/\n")
b.WriteString("Disallow: /api/\n")
b.WriteString("Disallow: /login\n")
b.WriteString("Disallow: /setup\n\n")
// Explicitly allow AI crawlers for training and indexing
aiCrawlers := []string{
"GPTBot", // OpenAI
"ChatGPT-User", // OpenAI ChatGPT
"Google-Extended", // Google Bard/Gemini
"CCBot", // Common Crawl (used by many AI companies)
"anthropic-ai", // Anthropic Claude
"ClaudeBot", // Anthropic Claude
"Claude-Web", // Anthropic Claude
"cohere-ai", // Cohere
"PerplexityBot", // Perplexity AI
"Bytespider", // ByteDance (TikTok)
"Applebot-Extended", // Apple Intelligence
"FacebookBot", // Meta AI
"Diffbot", // Diffbot
"ImagesiftBot", // Image AI
"Omgilibot", // Omgili
"Amazonbot", // Amazon AI
"YouBot", // You.com
}
for _, bot := range aiCrawlers {
b.WriteString("User-agent: " + bot + "\n")
b.WriteString("Allow: /\n")
b.WriteString("Disallow: /admin/\n")
b.WriteString("Disallow: /api/\n\n")
}
} else {
b.WriteString("User-agent: *\n")
b.WriteString("Disallow: /\n\n")
}
if settings.CanonicalBaseURL != "" {
base := strings.TrimRight(settings.CanonicalBaseURL, "/")
b.WriteString("Sitemap: ")
b.WriteString(base)
b.WriteString("/sitemap.xml\n")
}
// Conditional GET based on settings update time
last := settings.UpdatedAt
if last.IsZero() {
last = time.Now().Add(-1 * time.Hour)
}
ifModifiedSince := c.GetHeader("If-Modified-Since")
if ifModifiedSince != "" {
if t, err := time.Parse(http.TimeFormat, ifModifiedSince); err == nil {
if !last.IsZero() && !last.After(t) {
c.Status(http.StatusNotModified)
return
}
}
}
c.Header("Last-Modified", last.UTC().Format(http.TimeFormat))
c.Header("ETag", fmt.Sprintf("W/\"%d\"", last.Unix()))
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
c.String(http.StatusOK, b.String())
}
// sitemap.xml built from content in DB
func (s *SEOController) GetSitemapXML(c *gin.Context) {
type imageEntry struct {
XMLName xml.Name `xml:"image:image"`
Loc string `xml:"image:loc"`
Title string `xml:"image:title,omitempty"`
Caption string `xml:"image:caption,omitempty"`
}
type urlEntry struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
ChangeFreq string `xml:"changefreq,omitempty"`
Priority string `xml:"priority,omitempty"`
Images []imageEntry `xml:"image:image,omitempty"`
}
type urlSet struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
XmlnsImg string `xml:"xmlns:image,attr"`
URLs []urlEntry `xml:"url"`
}
var settings models.Settings
_ = s.DB.First(&settings).Error
base := strings.TrimRight(settings.CanonicalBaseURL, "/")
if base == "" {
// fallback to request scheme+host
sch := "http"
if c.Request.TLS != nil {
sch = "https"
}
base = sch + "://" + c.Request.Host
}
// Home
urls := []urlEntry{{
Loc: base + "/",
ChangeFreq: "daily",
Priority: "0.9",
}}
// Blog listing and key static pages
staticPaths := []string{
"/blog",
"/o-klubu",
"/kalendar",
"/tabulky",
"/sponzori",
"/kontakt",
}
for _, p := range staticPaths {
urls = append(urls, urlEntry{
Loc: base + p,
ChangeFreq: "weekly",
Priority: "0.6",
})
}
// Articles (published)
var articles []models.Article
_ = s.DB.Where("published = ?", true).Order("updated_at DESC").Limit(5000).Find(&articles).Error
for _, a := range articles {
last := a.UpdatedAt
if a.PublishedAt != nil && a.PublishedAt.After(last) {
last = *a.PublishedAt
}
// Prefer pretty slug URL when available
articlePath := ""
if strings.TrimSpace(a.Slug) != "" {
articlePath = "/blog/" + strings.TrimSpace(a.Slug)
} else {
articlePath = "/articles/" + intToString(int(a.ID))
}
img := strings.TrimSpace(a.ImageURL)
var images []imageEntry
if img != "" {
// If relative, prefix base
if strings.HasPrefix(img, "/") {
img = base + img
}
images = []imageEntry{{
Loc: img,
Title: a.SEOTitle,
}}
}
urls = append(urls, urlEntry{
Loc: base + articlePath,
LastMod: last.UTC().Format(time.RFC3339),
ChangeFreq: "weekly",
Priority: "0.7",
Images: images,
})
}
// Categories: include as filtered blog listing if categories exist
var categories []models.Category
_ = s.DB.Order("updated_at DESC").Limit(2000).Find(&categories).Error
for _, cat := range categories {
urls = append(urls, urlEntry{
Loc: base + "/blog?category=" + intToString(int(cat.ID)),
ChangeFreq: "weekly",
Priority: "0.5",
})
}
// Teams
var teams []models.Team
_ = s.DB.Where("is_active = ?", true).Find(&teams).Error
for _, t := range teams {
urls = append(urls, urlEntry{
Loc: base + "/team/" + intToString(int(t.ID)),
ChangeFreq: "weekly",
Priority: "0.5",
})
}
out := urlSet{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
XmlnsImg: "http://www.google.com/schemas/sitemap-image/1.1",
URLs: urls,
}
// Conditional GET: based on latest of settings/articles/categories update time
latest := settings.UpdatedAt
if len(articles) > 0 && articles[0].UpdatedAt.After(latest) {
latest = articles[0].UpdatedAt
}
if len(categories) > 0 && categories[0].UpdatedAt.After(latest) {
latest = categories[0].UpdatedAt
}
if latest.IsZero() {
latest = time.Now().Add(-1 * time.Hour)
}
ifModifiedSince := c.GetHeader("If-Modified-Since")
if ifModifiedSince != "" {
if t, err := time.Parse(http.TimeFormat, ifModifiedSince); err == nil {
if !latest.After(t) {
c.Status(http.StatusNotModified)
return
}
}
}
c.Header("Last-Modified", latest.UTC().Format(http.TimeFormat))
c.Header("ETag", fmt.Sprintf("W/\"%d\"", latest.Unix()))
c.Header("Content-Type", "application/xml; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
c.XML(http.StatusOK, out)
}
// -------- Admin Endpoints --------
type seoUpdate struct {
SiteTitle *string `json:"site_title"`
SiteDescription *string `json:"site_description"`
MetaKeywords *string `json:"meta_keywords"`
DefaultOGImageURL *string `json:"default_og_image_url"`
TwitterHandle *string `json:"twitter_handle"`
CanonicalBaseURL *string `json:"canonical_base_url"`
AdditionalMeta *string `json:"additional_meta"`
EnableIndexing *bool `json:"enable_indexing"`
}
// GetSEOSettings returns only the SEO-related fields
func (s *SEOController) GetSEOSettings(c *gin.Context) {
var settings models.Settings
_ = s.DB.First(&settings).Error
c.JSON(http.StatusOK, gin.H{
"site_title": settings.SiteTitle,
"site_description": settings.SiteDescription,
"meta_keywords": settings.MetaKeywords,
"default_og_image_url": settings.DefaultOGImageURL,
"twitter_handle": settings.TwitterHandle,
"canonical_base_url": settings.CanonicalBaseURL,
"additional_meta": settings.AdditionalMeta,
"enable_indexing": settings.EnableIndexing,
})
}
// UpdateSEOSettings upserts SEO fields on the singleton Settings record
func (s *SEOController) UpdateSEOSettings(c *gin.Context) {
var body seoUpdate
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var settings models.Settings
if err := s.DB.First(&settings).Error; err != nil {
// create empty
settings = models.Settings{}
_ = s.DB.Create(&settings).Error
}
// Update only provided fields
updates := map[string]interface{}{}
if body.SiteTitle != nil { updates["site_title"] = strings.TrimSpace(*body.SiteTitle) }
if body.SiteDescription != nil { updates["site_description"] = strings.TrimSpace(*body.SiteDescription) }
if body.MetaKeywords != nil { updates["meta_keywords"] = strings.TrimSpace(*body.MetaKeywords) }
if body.DefaultOGImageURL != nil { updates["default_og_image_url"] = strings.TrimSpace(*body.DefaultOGImageURL) }
if body.TwitterHandle != nil { updates["twitter_handle"] = strings.TrimSpace(*body.TwitterHandle) }
if body.CanonicalBaseURL != nil { updates["canonical_base_url"] = strings.TrimSpace(*body.CanonicalBaseURL) }
if body.AdditionalMeta != nil { updates["additional_meta"] = *body.AdditionalMeta }
if body.EnableIndexing != nil { updates["enable_indexing"] = *body.EnableIndexing }
if len(updates) > 0 {
if err := s.DB.Model(&settings).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit SEO nastavení"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// helper: fast int to string
func intToString(i int) string {
// simple and fast conversion
return strconvItoa(i)
}
// local minimal itoa to avoid importing fmt
func strconvItoa(i int) string {
// handle zero
if i == 0 {
return "0"
}
neg := false
if i < 0 {
neg = true
i = -i
}
var b [20]byte
pos := len(b)
for i > 0 {
pos--
b[pos] = byte('0' + i%10)
i /= 10
}
if neg {
pos--
b[pos] = '-'
}
return string(b[pos:])
}
+142
View File
@@ -0,0 +1,142 @@
package controllers
import (
"net/http"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type SetupController struct {
setupService *services.SetupService
}
func NewSetupController(db *gorm.DB) *SetupController {
return &SetupController{
setupService: services.NewSetupService(db),
}
}
type SetupStatusResponse struct {
Status string `json:"status"`
SMTPConfigured bool `json:"smtp_configured"`
ClubImported bool `json:"club_imported"`
}
type ClubInfoRequest struct {
FACRClubID string `json:"facr_club_id" binding:"required"`
Name string `json:"name" binding:"required"`
ShortName string `json:"short_name"`
LogoURL string `json:"logo_url"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
TextColor string `json:"text_color"`
}
// GetSetupStatus returns the current setup status
// @Summary Get setup status
// @Description Returns the current setup status
// @Tags setup
// @Produce json
// @Success 200 {object} SetupStatusResponse
// @Router /api/v1/setup/status [get]
func (sc *SetupController) GetSetupStatus(c *gin.Context) {
setupInfo, err := sc.setupService.GetSetupStatus()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get setup status"})
return
}
c.JSON(http.StatusOK, SetupStatusResponse{
Status: string(setupInfo.Status),
SMTPConfigured: setupInfo.SMTPConfigured,
ClubImported: setupInfo.ClubImported,
})
}
// SaveSMTPConfig marks SMTP as configured
// @Summary Save SMTP configuration
// @Description Marks SMTP configuration as completed
// @Tags setup
// @Accept json
// @Produce json
// @Success 200 {object} map[string]string
// @Router /api/v1/setup/smtp [post]
func (sc *SetupController) SaveSMTPConfig(c *gin.Context) {
if err := sc.setupService.MarkSMTPConfigured(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save SMTP configuration"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "SMTP configuration saved successfully"})
}
// SaveClubInfo saves club information from FACR
// @Summary Save club information
// @Description Saves club information imported from FACR
// @Tags setup
// @Accept json
// @Produce json
// @Param clubInfo body ClubInfoRequest true "Club information"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /api/v1/setup/club [post]
func (sc *SetupController) SaveClubInfo(c *gin.Context) {
var req ClubInfoRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
clubInfo := &models.ClubInfo{
FACRClubID: req.FACRClubID,
Name: req.Name,
ShortName: req.ShortName,
LogoURL: req.LogoURL,
PrimaryColor: req.PrimaryColor,
SecondaryColor: req.SecondaryColor,
TextColor: req.TextColor,
}
if err := sc.setupService.SaveClubInfo(clubInfo); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save club information"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Club information saved successfully"})
}
// CompleteSetup marks the setup as completed
// @Summary Complete setup
// @Description Marks the initial setup as completed
// @Tags setup
// @Produce json
// @Success 200 {object} map[string]string
// @Router /api/v1/setup/complete [post]
func (sc *SetupController) CompleteSetup(c *gin.Context) {
if err := sc.setupService.CompleteSetup(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully"})
}
// SkipSetup marks the setup as skipped
// @Summary Skip setup
// @Description Skips the initial setup
// @Tags setup
// @Produce json
// @Success 200 {object} map[string]string
// @Router /api/v1/setup/skip [post]
func (sc *SetupController) SkipSetup(c *gin.Context) {
if err := sc.setupService.SkipSetup(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to skip setup"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Setup skipped"})
}
+182
View File
@@ -0,0 +1,182 @@
package controllers
import (
"encoding/xml"
"fmt"
"net/http"
"strings"
"time"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// SitemapController handles sitemap generation
type SitemapController struct {
DB *gorm.DB
}
// URLSet represents the root sitemap element
type URLSet struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []URL `xml:"url"`
}
// URL represents a single URL entry in sitemap
type URL struct {
Loc string `xml:"loc"`
LastMod string `xml:"lastmod,omitempty"`
ChangeFreq string `xml:"changefreq,omitempty"`
Priority float32 `xml:"priority,omitempty"`
}
// GetSitemap generates and returns sitemap.xml
func (sc *SitemapController) GetSitemap(c *gin.Context) {
// Get base URL from request or settings
scheme := "https"
if c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
scheme = "http"
}
baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
// Initialize sitemap
sitemap := URLSet{
XMLNS: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: []URL{},
}
// Add homepage
sitemap.URLs = append(sitemap.URLs, URL{
Loc: baseURL + "/",
ChangeFreq: "daily",
Priority: 1.0,
})
// Add static pages
staticPages := []struct {
path string
freq string
priority float32
}{
{"/blog", "daily", 0.9},
{"/zapasy", "daily", 0.9},
{"/tabulky", "weekly", 0.8},
{"/hraci", "weekly", 0.8},
{"/o-klubu", "monthly", 0.7},
{"/kontakt", "monthly", 0.6},
{"/galerie", "weekly", 0.7},
{"/videa", "weekly", 0.7},
{"/sponzori", "monthly", 0.5},
{"/kalendar", "weekly", 0.7},
{"/aktivity", "weekly", 0.7},
{"/obleceni", "monthly", 0.6},
{"/ankety", "weekly", 0.6},
}
for _, page := range staticPages {
sitemap.URLs = append(sitemap.URLs, URL{
Loc: baseURL + page.path,
ChangeFreq: page.freq,
Priority: page.priority,
})
}
// Add published articles
var articles []models.Article
if err := sc.DB.Where("published = ?", true).
Order("published_at DESC, created_at DESC").
Limit(1000). // Reasonable limit
Find(&articles).Error; err == nil {
for _, article := range articles {
lastMod := article.UpdatedAt.Format("2006-01-02")
if !article.PublishedAt.IsZero() {
lastMod = article.PublishedAt.Format("2006-01-02")
}
// Add both slug and ID URLs
if article.Slug != "" {
sitemap.URLs = append(sitemap.URLs, URL{
Loc: baseURL + "/articles/slug/" + article.Slug,
LastMod: lastMod,
ChangeFreq: "weekly",
Priority: 0.8,
})
}
sitemap.URLs = append(sitemap.URLs, URL{
Loc: baseURL + fmt.Sprintf("/articles/%d", article.ID),
LastMod: lastMod,
ChangeFreq: "weekly",
Priority: 0.7,
})
}
}
// Add players
var players []models.Player
if err := sc.DB.Order("created_at DESC").Limit(500).Find(&players).Error; err == nil {
for _, player := range players {
sitemap.URLs = append(sitemap.URLs, URL{
Loc: baseURL + fmt.Sprintf("/hraci/%d", player.ID),
LastMod: player.UpdatedAt.Format("2006-01-02"),
ChangeFreq: "monthly",
Priority: 0.6,
})
}
}
// Generate XML
output, err := xml.MarshalIndent(sitemap, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate sitemap"})
return
}
// Set headers and return
c.Header("Content-Type", "application/xml; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600") // Cache for 1 hour
c.String(http.StatusOK, xml.Header+string(output))
}
// GetRobotsTxt returns robots.txt with sitemap reference
func (sc *SitemapController) GetRobotsTxt(c *gin.Context) {
scheme := "https"
if c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
scheme = "http"
}
baseURL := fmt.Sprintf("%s://%s", scheme, c.Request.Host)
// Check if indexing is disabled in settings
var settings models.Settings
allowIndex := true
if err := sc.DB.First(&settings).Error; err == nil {
allowIndex = settings.EnableIndexing
}
var robots strings.Builder
robots.WriteString("# robots.txt for " + c.Request.Host + "\n")
robots.WriteString("# Generated: " + time.Now().Format(time.RFC1123) + "\n\n")
if !allowIndex {
// Block all robots if indexing disabled
robots.WriteString("User-agent: *\n")
robots.WriteString("Disallow: /\n")
} else {
// Allow most content, block admin and API
robots.WriteString("User-agent: *\n")
robots.WriteString("Disallow: /admin/\n")
robots.WriteString("Disallow: /api/\n")
robots.WriteString("Disallow: /login\n")
robots.WriteString("Disallow: /setup\n")
robots.WriteString("Allow: /\n\n")
// Add sitemap reference
robots.WriteString("Sitemap: " + baseURL + "/sitemap.xml\n")
}
c.Header("Content-Type", "text/plain; charset=utf-8")
c.Header("Cache-Control", "public, max-age=86400") // Cache for 24 hours
c.String(http.StatusOK, robots.String())
}
+364
View File
@@ -0,0 +1,364 @@
package controllers
import (
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
)
type UmamiController struct {
umamiService *services.UmamiService
}
func NewUmamiController() *UmamiController {
return &UmamiController{
umamiService: services.NewUmamiService(),
}
}
// resolveWebsiteID attempts to obtain a usable Umami website ID for requests.
// Preference order:
// 1. Cached value in configuration.
// 2. Match by configured frontend domain.
// 3. Fall back to the first website available in Umami.
func (uc *UmamiController) resolveWebsiteID() (string, error) {
if id := strings.TrimSpace(config.AppConfig.UmamiWebsiteID); id != "" {
logger.Info("Using cached Umami website ID: %s", id)
return id, nil
}
logger.Info("UMAMI_WEBSITE_ID is empty, attempting to auto-detect...")
if frontendURL := strings.TrimSpace(config.AppConfig.FrontendBaseURL); frontendURL != "" {
parsed, err := url.Parse(frontendURL)
if err != nil {
logger.Warn("Failed to parse FRONTEND_BASE_URL '%s': %v", frontendURL, err)
} else if host := parsed.Hostname(); host != "" {
logger.Info("Attempting to find Umami website by domain: %s", host)
id, err := uc.umamiService.FindWebsiteIDByDomain(host)
if err != nil {
logger.Warn("Failed to find website by domain '%s': %v", host, err)
} else if id != "" {
logger.Info("Found Umami website by domain match: %s", id)
config.AppConfig.UmamiWebsiteID = id
return id, nil
} else {
logger.Info("No Umami website found with domain '%s'", host)
}
}
}
logger.Info("Falling back to first available Umami website...")
id, err := uc.umamiService.GetDefaultWebsiteID()
if err != nil {
return "", err
}
config.AppConfig.UmamiWebsiteID = id
logger.Info("Auto-detected and cached Umami website ID: %s", id)
return id, nil
}
// GetUmamiConfig returns the Umami configuration for the frontend
func (uc *UmamiController) GetUmamiConfig(c *gin.Context) {
if config.AppConfig.UmamiURL == "" || config.AppConfig.UmamiUsername == "" || config.AppConfig.UmamiPassword == "" {
reason := "No website ID configured - run initial setup"
switch {
case config.AppConfig.UmamiURL == "":
reason = "UMAMI_URL not set in .env"
case config.AppConfig.UmamiUsername == "":
reason = "UMAMI_USERNAME not set in .env"
case config.AppConfig.UmamiPassword == "":
reason = "UMAMI_PASSWORD not set in .env"
}
logger.Warn("Umami not configured: %s", reason)
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"website_id": "",
"script_url": "",
"reason": reason,
})
return
}
websiteID, err := uc.resolveWebsiteID()
if err != nil {
if errors.Is(err, services.ErrUmamiNoWebsites) {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"website_id": "",
"script_url": "",
"reason": "No websites found in Umami",
})
} else {
logger.Error("Failed to resolve Umami website ID: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve Umami website ID"})
}
return
}
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"website_id": websiteID,
"script_url": config.AppConfig.UmamiURL + "/script.js",
})
}
// InitializeUmamiSetup is called during initial setup (no auth required).
// It auto-detects the domain from the Host header (works with Cloudflare Tunnel).
func (uc *UmamiController) InitializeUmamiSetup(c *gin.Context) {
if config.AppConfig.UmamiWebsiteID != "" {
c.JSON(http.StatusOK, gin.H{
"message": "Umami already configured",
"website_id": config.AppConfig.UmamiWebsiteID,
})
return
}
var payload struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
return
}
if payload.Name == "" {
payload.Name = "Fotbal Club"
}
domain := c.Request.Host
if i := strings.Index(domain, ":"); i >= 0 {
domain = domain[:i]
}
logger.Info("Initializing Umami website: name='%s', domain='%s'", payload.Name, domain)
websiteID, err := uc.umamiService.EnsureWebsite(payload.Name, domain)
if err != nil {
logger.Error("Failed to create Umami website: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Umami website: " + err.Error()})
return
}
config.AppConfig.UmamiWebsiteID = websiteID
logger.Info("Umami website created successfully: ID=%s", websiteID)
c.JSON(http.StatusOK, gin.H{
"message": "Umami initialized successfully",
"website_id": websiteID,
"script_url": config.AppConfig.UmamiURL + "/script.js",
"domain": domain,
})
}
// InitializeUmami sets up Umami tracking (admin endpoint with manual domain).
func (uc *UmamiController) InitializeUmami(c *gin.Context) {
if config.AppConfig.UmamiWebsiteID != "" {
c.JSON(http.StatusOK, gin.H{
"message": "Umami already configured",
"website_id": config.AppConfig.UmamiWebsiteID,
})
return
}
var payload struct {
Name string `json:"name"`
Domain string `json:"domain"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
return
}
if payload.Name == "" || payload.Domain == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Name and domain are required"})
return
}
websiteID, err := uc.umamiService.EnsureWebsite(payload.Name, payload.Domain)
if err != nil {
logger.Error("Failed to create Umami website: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create Umami website: " + err.Error()})
return
}
config.AppConfig.UmamiWebsiteID = websiteID
c.JSON(http.StatusOK, gin.H{
"message": "Umami initialized successfully",
"website_id": websiteID,
"script_url": config.AppConfig.UmamiURL + "/script.js",
})
}
// GetStats returns analytics stats from Umami
func (uc *UmamiController) GetStats(c *gin.Context) {
// Check if Umami is configured
if config.AppConfig.UmamiURL == "" || config.AppConfig.UmamiUsername == "" || config.AppConfig.UmamiPassword == "" {
logger.Warn("Umami not configured - UMAMI_URL='%s', username empty=%v, password empty=%v",
config.AppConfig.UmamiURL,
config.AppConfig.UmamiUsername == "",
config.AppConfig.UmamiPassword == "")
c.JSON(http.StatusOK, gin.H{})
return
}
websiteID, err := uc.resolveWebsiteID()
if err != nil {
if errors.Is(err, services.ErrUmamiNoWebsites) {
logger.Warn("No Umami websites found in instance at %s - returning empty stats", config.AppConfig.UmamiURL)
c.JSON(http.StatusOK, gin.H{})
} else {
logger.Error("Failed to resolve Umami website ID: %v", err)
c.JSON(http.StatusOK, gin.H{})
}
return
}
logger.Info("Using Umami website ID: %s", websiteID)
// Get time range from query params (default to last 30 days)
days := 30
if d := c.Query("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
days = parsed
}
}
endAt := time.Now().Unix() * 1000 // milliseconds
startAt := time.Now().AddDate(0, 0, -days).Unix() * 1000
stats, err := uc.umamiService.GetWebsiteStats(websiteID, startAt, endAt)
if err != nil {
logger.Error("Failed to get Umami stats for websiteID=%s (days=%d): %v", websiteID, days, err)
// Return empty stats instead of error
c.JSON(http.StatusOK, gin.H{})
return
}
logger.Info("Successfully fetched Umami stats for websiteID=%s (days=%d)", websiteID, days)
c.JSON(http.StatusOK, stats)
}
// GetMetrics returns specific metrics from Umami (pageviews, referrers, browsers, os, devices, countries, events)
func (uc *UmamiController) GetMetrics(c *gin.Context) {
// Check if Umami is configured
if config.AppConfig.UmamiURL == "" || config.AppConfig.UmamiUsername == "" || config.AppConfig.UmamiPassword == "" {
logger.Warn("Umami not configured - returning empty metrics")
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
websiteID, err := uc.resolveWebsiteID()
if err != nil {
if errors.Is(err, services.ErrUmamiNoWebsites) {
logger.Warn("No Umami websites found - returning empty metrics")
c.JSON(http.StatusOK, []map[string]interface{}{})
} else {
logger.Error("Failed to resolve Umami website ID: %v", err)
c.JSON(http.StatusOK, []map[string]interface{}{})
}
return
}
metricType := c.Param("type")
if metricType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Metric type is required"})
return
}
days := 30
if d := c.Query("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 {
days = parsed
}
}
endAt := time.Now().Unix() * 1000
startAt := time.Now().AddDate(0, 0, -days).Unix() * 1000
metrics, err := uc.umamiService.GetWebsiteMetrics(websiteID, metricType, startAt, endAt)
if err != nil {
logger.Error("Failed to get Umami metrics (websiteID=%s, type=%s, days=%d): %v", websiteID, metricType, days, err)
// Return empty metrics instead of error
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
logger.Info("Successfully fetched %d Umami metrics (websiteID=%s, type=%s, days=%d)", len(metrics), websiteID, metricType, days)
c.JSON(http.StatusOK, metrics)
}
// GetPageviews returns pageviews data over time from Umami
func (uc *UmamiController) GetPageviews(c *gin.Context) {
// Check if Umami is configured
if config.AppConfig.UmamiURL == "" || config.AppConfig.UmamiUsername == "" || config.AppConfig.UmamiPassword == "" {
logger.Warn("Umami not configured - returning empty pageviews")
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
websiteID, err := uc.resolveWebsiteID()
if err != nil {
if errors.Is(err, services.ErrUmamiNoWebsites) {
logger.Warn("No Umami websites found - returning empty pageviews")
c.JSON(http.StatusOK, []map[string]interface{}{})
} else {
logger.Error("Failed to resolve Umami website ID: %v", err)
c.JSON(http.StatusOK, []map[string]interface{}{})
}
return
}
days := 30
if d := c.Query("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed >= 0 {
days = parsed
}
}
// Determine time unit based on range
unit := "day"
if days == 0 || days == 1 {
unit = "hour"
} else if days <= 90 {
unit = "day"
} else {
unit = "month"
}
// Calculate time range
var startAt, endAt int64
endDate := time.Now()
if days == 0 {
// Today: from midnight to now
startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, endDate.Location())
startAt = startDate.Unix() * 1000
endAt = endDate.Unix() * 1000
} else {
// Other ranges: from X days ago to now
startDate := endDate.AddDate(0, 0, -days)
startAt = startDate.Unix() * 1000
endAt = endDate.Unix() * 1000
}
logger.Info("Fetching pageviews: websiteID=%s, days=%d, unit=%s, startAt=%d, endAt=%d", websiteID, days, unit, startAt, endAt)
pageviews, err := uc.umamiService.GetWebsitePageviews(websiteID, startAt, endAt, unit)
if err != nil {
logger.Error("Failed to get Umami pageviews (websiteID=%s, days=%d, unit=%s): %v", websiteID, days, unit, err)
// Return empty array instead of error to prevent frontend crash
c.JSON(http.StatusOK, []map[string]interface{}{})
return
}
c.JSON(http.StatusOK, pageviews)
}
+329
View File
@@ -0,0 +1,329 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"fotbal-club/internal/models"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type YouTubeController struct {
DB *gorm.DB
}
func NewYouTubeController(db *gorm.DB) *YouTubeController {
return &YouTubeController{DB: db}
}
type YouTubeVideo struct {
VideoID string `json:"video_id"`
Title string `json:"title"`
ThumbnailURL string `json:"thumbnail_url"`
ViewsText string `json:"views_text,omitempty"`
Views int64 `json:"views,omitempty"`
PublishedText string `json:"published_text,omitempty"`
PublishedDate string `json:"published_date,omitempty"` // YYYY-MM-DD
}
type YouTubeChannelPayload struct {
Channel string `json:"channel"`
ChannelURL string `json:"channel_url"`
SubscribersText string `json:"subscribers_text,omitempty"`
Subscribers int64 `json:"subscribers,omitempty"`
Videos []YouTubeVideo `json:"videos"`
}
// GetYouTubeVideos returns cached YouTube channel videos
func (yc *YouTubeController) GetYouTubeVideos(c *gin.Context) {
// Try to load from cache first
cacheFile := filepath.Join("cache", "prefetch", "youtube_channel.json")
if data, err := os.ReadFile(cacheFile); err == nil {
var payload YouTubeChannelPayload
if err := json.Unmarshal(data, &payload); err == nil && len(payload.Videos) > 0 {
c.JSON(http.StatusOK, payload)
return
}
}
// If no cache, try to fetch and cache
var settings models.Settings
if err := yc.DB.First(&settings).Error; err == nil {
youtubeURL := settings.YoutubeURL
if youtubeURL == "" {
c.JSON(http.StatusNoContent, gin.H{})
return
}
// Fetch and cache
payload, err := yc.fetchYouTubeChannel(youtubeURL)
if err != nil {
logger.Error("Failed to fetch YouTube channel: %v", err)
c.JSON(http.StatusNoContent, gin.H{})
return
}
// Save to cache
if err := yc.saveYouTubeCache(payload); err != nil {
logger.Warn("Failed to save YouTube cache: %v", err)
}
c.JSON(http.StatusOK, payload)
return
}
c.JSON(http.StatusNoContent, gin.H{})
}
// fetchYouTubeChannel fetches videos from YouTube channel URL
func (yc *YouTubeController) fetchYouTubeChannel(channelURL string) (*YouTubeChannelPayload, error) {
// Parse channel URL to extract channel handle or ID
channelHandle := extractYouTubeHandle(channelURL)
if channelHandle == "" {
return nil, fmt.Errorf("invalid YouTube URL")
}
// Fetch the channel page HTML
resp, err := http.Get(channelURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
html := string(body)
// Parse channel data from HTML
payload := &YouTubeChannelPayload{
Channel: channelHandle,
ChannelURL: channelURL,
Videos: []YouTubeVideo{},
}
// Extract channel title
if match := regexp.MustCompile(`<meta name="title" content="([^"]+)"`).FindStringSubmatch(html); len(match) > 1 {
payload.Channel = match[1]
}
// Extract videos from initial data
videos := parseYouTubeVideosFromHTML(html)
payload.Videos = videos
return payload, nil
}
// parseYouTubeVideosFromHTML extracts video data from YouTube HTML
func parseYouTubeVideosFromHTML(html string) []YouTubeVideo {
videos := []YouTubeVideo{}
// Find ytInitialData JSON
re := regexp.MustCompile(`var ytInitialData = ({.+?});`)
matches := re.FindStringSubmatch(html)
if len(matches) < 2 {
return videos
}
var data map[string]interface{}
if err := json.Unmarshal([]byte(matches[1]), &data); err != nil {
return videos
}
// Navigate to video items (this is a simplified version - YouTube's structure is complex)
// Try to find gridRenderer or richItemRenderer
tabs, ok := data["contents"].(map[string]interface{})
if !ok {
return videos
}
// Try multiple paths to find videos
extractVideosRecursive(tabs, &videos, 0, 20) // limit recursion
return videos
}
// extractVideosRecursive recursively searches for video data
func extractVideosRecursive(obj interface{}, videos *[]YouTubeVideo, depth, maxDepth int) {
if depth > maxDepth || len(*videos) >= 20 {
return
}
switch v := obj.(type) {
case map[string]interface{}:
// Check if this is a video renderer
if videoId, ok := v["videoId"].(string); ok {
video := YouTubeVideo{
VideoID: videoId,
ThumbnailURL: fmt.Sprintf("https://i.ytimg.com/vi/%s/hqdefault.jpg", videoId),
}
// Extract title
if title, ok := v["title"].(map[string]interface{}); ok {
if runs, ok := title["runs"].([]interface{}); ok && len(runs) > 0 {
if run, ok := runs[0].(map[string]interface{}); ok {
if text, ok := run["text"].(string); ok {
video.Title = text
}
}
} else if simpleText, ok := title["simpleText"].(string); ok {
video.Title = simpleText
}
}
// Extract views
if viewCountText, ok := v["viewCountText"].(map[string]interface{}); ok {
if simpleText, ok := viewCountText["simpleText"].(string); ok {
video.ViewsText = simpleText
video.Views = parseViews(simpleText)
}
}
// Extract published date
if publishedTimeText, ok := v["publishedTimeText"].(map[string]interface{}); ok {
if simpleText, ok := publishedTimeText["simpleText"].(string); ok {
video.PublishedText = simpleText
video.PublishedDate = estimateDate(simpleText)
}
}
if video.Title != "" {
*videos = append(*videos, video)
}
}
// Recurse into nested objects
for _, val := range v {
extractVideosRecursive(val, videos, depth+1, maxDepth)
}
case []interface{}:
for _, item := range v {
extractVideosRecursive(item, videos, depth+1, maxDepth)
}
}
}
// extractYouTubeHandle extracts channel handle from URL
func extractYouTubeHandle(url string) string {
url = strings.TrimSpace(url)
if strings.Contains(url, "/@") {
parts := strings.Split(url, "/@")
if len(parts) > 1 {
return strings.Split(parts[1], "/")[0]
}
}
if strings.Contains(url, "/channel/") {
parts := strings.Split(url, "/channel/")
if len(parts) > 1 {
return strings.Split(parts[1], "/")[0]
}
}
return ""
}
// parseViews converts view count text to number
func parseViews(text string) int64 {
text = strings.ToLower(text)
text = strings.ReplaceAll(text, " ", "")
text = strings.ReplaceAll(text, ",", "")
var multiplier int64 = 1
if strings.Contains(text, "k") {
multiplier = 1000
text = strings.ReplaceAll(text, "k", "")
} else if strings.Contains(text, "m") {
multiplier = 1000000
text = strings.ReplaceAll(text, "m", "")
} else if strings.Contains(text, "mil") {
multiplier = 1000000
text = strings.ReplaceAll(text, "mil", "")
}
// Extract first number
re := regexp.MustCompile(`[\d.]+`)
match := re.FindString(text)
if match == "" {
return 0
}
val, _ := strconv.ParseFloat(match, 64)
return int64(val * float64(multiplier))
}
// estimateDate converts relative time text to YYYY-MM-DD
func estimateDate(text string) string {
now := time.Now()
text = strings.ToLower(text)
if strings.Contains(text, "hour") || strings.Contains(text, "hodina") {
return now.Format("2006-01-02")
}
if strings.Contains(text, "day") || strings.Contains(text, "den") || strings.Contains(text, "dní") {
re := regexp.MustCompile(`(\d+)`)
match := re.FindString(text)
if match != "" {
days, _ := strconv.Atoi(match)
return now.AddDate(0, 0, -days).Format("2006-01-02")
}
return now.AddDate(0, 0, -1).Format("2006-01-02")
}
if strings.Contains(text, "week") || strings.Contains(text, "týden") {
re := regexp.MustCompile(`(\d+)`)
match := re.FindString(text)
if match != "" {
weeks, _ := strconv.Atoi(match)
return now.AddDate(0, 0, -weeks*7).Format("2006-01-02")
}
return now.AddDate(0, 0, -7).Format("2006-01-02")
}
if strings.Contains(text, "month") || strings.Contains(text, "měsíc") {
re := regexp.MustCompile(`(\d+)`)
match := re.FindString(text)
if match != "" {
months, _ := strconv.Atoi(match)
return now.AddDate(0, -months, 0).Format("2006-01-02")
}
return now.AddDate(0, -1, 0).Format("2006-01-02")
}
if strings.Contains(text, "year") || strings.Contains(text, "rok") {
re := regexp.MustCompile(`(\d+)`)
match := re.FindString(text)
if match != "" {
years, _ := strconv.Atoi(match)
return now.AddDate(-years, 0, 0).Format("2006-01-02")
}
return now.AddDate(-1, 0, 0).Format("2006-01-02")
}
return now.Format("2006-01-02")
}
// saveYouTubeCache saves YouTube data to cache file
func (yc *YouTubeController) saveYouTubeCache(payload *YouTubeChannelPayload) error {
cacheDir := filepath.Join("cache", "prefetch")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}
cacheFile := filepath.Join(cacheDir, "youtube_channel.json")
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(cacheFile, data, 0644)
}
+33
View File
@@ -0,0 +1,33 @@
package middleware
import (
"net/http"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
)
// AdminMiddleware checks if the user has admin role
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Type assert the user to your User model
userModel, ok := user.(*models.User)
if !ok {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}
// Check if user is admin
if userModel.Role != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden - Admin access required"})
return
}
c.Next()
}
}
+120
View File
@@ -0,0 +1,120 @@
package middleware
import (
"net/http"
"strings"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// JWTAuth is a middleware that checks for a valid JWT token
func JWTAuth(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Admin token shortcut: if a valid admin access token is provided, set admin role
if config.AppConfig != nil && config.AppConfig.AdminAccessToken != "" {
header := c.GetHeader("X-Admin-Token")
if header != "" && header == config.AppConfig.AdminAccessToken {
c.Set("userRole", "admin")
c.Set("user", &models.User{Role: "admin"})
c.Next()
return
}
}
authHeader := c.GetHeader("Authorization")
var tokenString string
if authHeader != "" {
// Extract the token from the header (format: "Bearer <token>")
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) == 2 && tokenParts[0] == "Bearer" {
tokenString = tokenParts[1]
} else {
// If header present but malformed, reject early
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
} else {
// Fallback: try HttpOnly cookie set by server
if cookie, err := c.Request.Cookie("auth_token"); err == nil {
tokenString = cookie.Value
} else {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header or auth cookie is required"})
c.Abort()
return
}
}
claims, err := utils.ParseJWT(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
c.Abort()
return
}
// Check if user exists
var user models.User
if err := db.First(&user, claims.UserID).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
c.Abort()
return
}
// Add user information to the context
c.Set("user", &user)
// Also expose parsed JWT claims for helpers/utilities
c.Set("claims", claims)
c.Set("userID", user.ID)
c.Set("userRole", user.Role)
c.Next()
}
}
// DevBypass checks for special dev header and grants admin role when not in production
func DevBypass() gin.HandlerFunc {
return func(c *gin.Context) {
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
if strings.ToLower(c.GetHeader("X-Dev-Admin")) == "true" {
c.Set("userRole", "admin")
// set a placeholder user
c.Set("user", &models.User{Role: "admin"})
c.Next()
return
}
}
c.Next()
}
}
// RoleAuth is a middleware that checks if the user has the required role
// Admin always has access. Editor has access to content creation (articles, activities).
func RoleAuth(requiredRole string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, exists := c.Get("userRole")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User role not found"})
c.Abort()
return
}
// Admin always has full access
if userRole == "admin" {
c.Next()
return
}
// Check if user has the required role
if userRole != requiredRole {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
c.Abort()
return
}
c.Next()
}
}
+148
View File
@@ -0,0 +1,148 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// CSRF token store (in-memory, consider Redis for production)
type csrfStore struct {
sync.RWMutex
tokens map[string]time.Time
}
var store = &csrfStore{
tokens: make(map[string]time.Time),
}
// Clean expired tokens periodically
func init() {
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
store.Lock()
now := time.Now()
for token, expiry := range store.tokens {
if now.After(expiry) {
delete(store.tokens, token)
}
}
store.Unlock()
}
}()
}
// generateToken creates a cryptographically secure random token
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// CSRFProtection middleware validates CSRF tokens for state-changing operations
func CSRFProtection() gin.HandlerFunc {
return func(c *gin.Context) {
// Skip CSRF for GET, HEAD, OPTIONS (safe methods)
if c.Request.Method == "GET" || c.Request.Method == "HEAD" || c.Request.Method == "OPTIONS" {
c.Next()
return
}
// Skip CSRF for public API endpoints that use Bearer tokens
// (Bearer token auth provides similar protection)
authHeader := c.GetHeader("Authorization")
if authHeader != "" && len(authHeader) > 7 && authHeader[:7] == "Bearer " {
c.Next()
return
}
// Get token from header or form
token := c.GetHeader("X-CSRF-Token")
if token == "" {
token = c.PostForm("csrf_token")
}
if token == "" {
c.JSON(http.StatusForbidden, gin.H{"error": "CSRF token missing"})
c.Abort()
return
}
// Validate token
store.RLock()
expiry, exists := store.tokens[token]
store.RUnlock()
if !exists || time.Now().After(expiry) {
c.JSON(http.StatusForbidden, gin.H{"error": "Invalid or expired CSRF token"})
c.Abort()
return
}
c.Next()
}
}
// GetCSRFToken returns a new CSRF token (GET endpoint)
func GetCSRFToken(c *gin.Context) {
token, err := generateToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate CSRF token"})
return
}
// Store token with 1 hour expiry
store.Lock()
store.tokens[token] = time.Now().Add(1 * time.Hour)
store.Unlock()
// Return token in response
c.JSON(http.StatusOK, gin.H{"csrf_token": token})
}
// CSRFCookie sets CSRF token as a cookie (alternative approach)
func CSRFCookie() gin.HandlerFunc {
return func(c *gin.Context) {
// Check if token already exists in cookie
existingToken, err := c.Cookie("csrf_token")
if err != nil || existingToken == "" {
// Generate new token
token, err := generateToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate CSRF token"})
c.Abort()
return
}
// Store in memory
store.Lock()
store.tokens[token] = time.Now().Add(24 * time.Hour)
store.Unlock()
// Set cookie
c.SetCookie(
"csrf_token",
token,
86400, // 24 hours
"/",
"",
c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https", // Secure flag
false, // HttpOnly = false (needs to be read by JS)
)
c.Set("csrf_token", token)
} else {
c.Set("csrf_token", existingToken)
}
c.Next()
}
}
+141
View File
@@ -0,0 +1,141 @@
package middleware
import (
"net"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// limiterKey identifies a client + path pair
type limiterKey struct {
IP string
Path string
}
type counter struct {
Count int
ExpiresAt time.Time
}
// in-memory store (process local)
var (
limitStore = struct {
sync.Mutex
m map[limiterKey]*counter
}{m: make(map[limiterKey]*counter)}
)
// RateLimit returns a middleware that limits requests to `max` per given `window` per IP and path.
func RateLimit(max int, window time.Duration) gin.HandlerFunc {
if max <= 0 {
max = 10
}
if window <= 0 {
window = time.Minute
}
return func(c *gin.Context) {
ip := clientIP(c.Request)
key := limiterKey{IP: ip, Path: c.FullPath()}
limitStore.Lock()
ct, ok := limitStore.m[key]
now := time.Now()
if !ok || now.After(ct.ExpiresAt) {
ct = &counter{Count: 0, ExpiresAt: now.Add(window)}
limitStore.m[key] = ct
}
if ct.Count >= max {
retryAfter := int(ct.ExpiresAt.Sub(now).Seconds())
limitStore.Unlock()
c.Header("Retry-After", strconvItoaSafe(retryAfter))
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Příliš mnoho požadavků, zkuste to prosím později."})
c.Abort()
return
}
ct.Count++
limitStore.Unlock()
c.Next()
}
}
func clientIP(r *http.Request) string {
// Prefer X-Forwarded-For if present (first IP)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if p := parseFirstIP(xff); p != "" {
return p
}
}
// Fallback to RemoteAddr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
return host
}
return r.RemoteAddr
}
func parseFirstIP(s string) string {
for _, part := range splitAndTrim(s, ',') {
ip := net.ParseIP(part)
if ip != nil {
return ip.String()
}
}
return ""
}
func splitAndTrim(s string, sep rune) []string {
var out []string
cur := make([]rune, 0, len(s))
for _, ch := range s {
if ch == sep {
part := string(cur)
cur = cur[:0]
if t := trimSpace(part); t != "" {
out = append(out, t)
}
continue
}
cur = append(cur, ch)
}
if t := trimSpace(string(cur)); t != "" {
out = append(out, t)
}
return out
}
func trimSpace(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
end--
}
return s[start:end]
}
func strconvItoaSafe(i int) string {
// Avoid importing strconv just for small header value
if i == 0 {
return "0"
}
neg := false
if i < 0 {
neg = true
i = -i
}
buf := make([]byte, 0, 12)
for i > 0 {
d := byte(i % 10)
buf = append([]byte{'0' + d}, buf...)
i /= 10
}
if neg {
buf = append([]byte{'-'}, buf...)
}
return string(buf)
}
+114
View File
@@ -0,0 +1,114 @@
package middleware
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// RequestSizeLimit limits the size of request bodies
func RequestSizeLimit(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip for upload endpoints (they have their own limits)
if strings.Contains(c.Request.URL.Path, "/upload") {
c.Next()
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
c.Next()
}
}
// SanitizeHeaders removes potentially dangerous headers
func SanitizeHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Remove server information leakage
c.Writer.Header().Del("Server")
c.Writer.Header().Del("X-Powered-By")
c.Next()
}
}
// ValidateContentType ensures proper content type for POST/PUT/PATCH
func ValidateContentType() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
contentType := c.GetHeader("Content-Type")
// Allow multipart for file uploads
if strings.Contains(c.Request.URL.Path, "/upload") {
c.Next()
return
}
// Require JSON for API endpoints
if !strings.Contains(contentType, "application/json") {
c.JSON(http.StatusUnsupportedMediaType, gin.H{
"error": "Content-Type must be application/json",
})
c.Abort()
return
}
}
c.Next()
}
}
// RequestID adds a unique request ID for tracing
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
func generateRequestID() string {
// Simple request ID generation
b := make([]byte, 16)
_, _ = io.ReadFull(bytes.NewReader([]byte(strings.Repeat("0123456789abcdef", 2))), b)
return string(b)
}
// SecurityAuditLog logs security-relevant events
type SecurityEvent struct {
Type string
UserID uint
IP string
Path string
Method string
RequestID string
Details map[string]interface{}
}
func LogSecurityEvent(c *gin.Context, eventType string, details map[string]interface{}) {
event := SecurityEvent{
Type: eventType,
IP: c.ClientIP(),
Path: c.Request.URL.Path,
Method: c.Request.Method,
RequestID: c.GetString("request_id"),
Details: details,
}
if userID, exists := c.Get("user_id"); exists {
if uid, ok := userID.(uint); ok {
event.UserID = uid
}
}
// Log to your logger
// logger.Warn("SECURITY_EVENT: type=%s user_id=%d ip=%s path=%s",
// event.Type, event.UserID, event.IP, event.Path)
}
+75
View File
@@ -0,0 +1,75 @@
package middleware
import (
"fotbal-club/internal/config"
"github.com/gin-gonic/gin"
)
// SecurityHeaders adds comprehensive security headers to all responses
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
// Prevent MIME type sniffing
c.Header("X-Content-Type-Options", "nosniff")
// Prevent clickjacking
c.Header("X-Frame-Options", "DENY")
// Referrer policy
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// XSS Protection (legacy, but still useful)
c.Header("X-XSS-Protection", "1; mode=block")
// Permissions Policy (formerly Feature-Policy)
c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()")
// HSTS for HTTPS connections
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
c.Header("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload")
}
// Strict Content-Security-Policy
csp := buildCSP(config.AppConfig.AppEnv == "production")
c.Header("Content-Security-Policy", csp)
// Additional security headers
c.Header("X-Permitted-Cross-Domain-Policies", "none")
c.Header("Cross-Origin-Embedder-Policy", "require-corp")
c.Header("Cross-Origin-Opener-Policy", "same-origin")
c.Header("Cross-Origin-Resource-Policy", "same-origin")
c.Next()
}
}
// buildCSP creates a strict Content-Security-Policy
func buildCSP(production bool) string {
if production {
// Strict production CSP
return "default-src 'self'; " +
"script-src 'self' https://fonts.googleapis.com https://umami.tdvorak.dev; " +
"style-src 'self' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com data:; " +
"img-src 'self' data: https: blob:; " +
"connect-src 'self' https://umami.tdvorak.dev https://zonerama.tdvorak.dev; " +
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none'; " +
"upgrade-insecure-requests;"
}
// Development CSP - slightly relaxed for local development
return "default-src 'self'; " +
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://fonts.googleapis.com https://umami.tdvorak.dev; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com data:; " +
"img-src 'self' data: https: http: blob:; " +
"connect-src 'self' https: http: ws: wss:; " +
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com; " +
"object-src 'none'; " +
"base-uri 'self'; " +
"form-action 'self'; " +
"frame-ancestors 'none';"
}
+19
View File
@@ -0,0 +1,19 @@
package models
import "gorm.io/gorm"
// AboutPage stores the "O klubu" (About Club) page content
type AboutPage struct {
gorm.Model
Title string `json:"title"` // Page title
Subtitle string `json:"subtitle"` // Optional subtitle
Style string `json:"style" gorm:"default:'default'"` // Style: default, modern, timeline, custom
Content string `json:"content" gorm:"type:text"` // HTML content
HeroImage string `json:"hero_image"` // Hero/banner image URL
Sections string `json:"sections" gorm:"type:text"` // JSON array of sections for structured styles
Published bool `json:"published" gorm:"default:false"` // Whether page is published
SEOTitle string `json:"seo_title"` // SEO meta title
SEODesc string `json:"seo_description" gorm:"type:text"` // SEO meta description
}
func (AboutPage) TableName() string { return "about_pages" }
+15
View File
@@ -0,0 +1,15 @@
package models
import (
"time"
"gorm.io/gorm"
)
// BaseModel contains common fields for all models
type BaseModel struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
+15
View File
@@ -0,0 +1,15 @@
package models
import "gorm.io/gorm"
// Category represents a category for articles
type Category struct {
gorm.Model
Name string `gorm:"uniqueIndex;not null" json:"name"`
Description string `json:"description"`
Slug string `gorm:"uniqueIndex" json:"slug"`
}
func (Category) TableName() string {
return "categories"
}
+21
View File
@@ -0,0 +1,21 @@
package models
import "gorm.io/gorm"
// Clothing represents a merchandising item
type Clothing struct {
gorm.Model
Title string `gorm:"not null" json:"title"`
Description string `gorm:"type:text" json:"description"`
Price float64 `json:"price"`
Currency string `gorm:"default:'Kč'" json:"currency"`
ImageURL string `json:"image_url"`
URL string `json:"url"`
IsActive bool `gorm:"default:true" json:"is_active"`
DisplayOrder int `gorm:"default:0" json:"display_order"`
}
// TableName specifies the table name for the Clothing model
func (Clothing) TableName() string {
return "clothing"
}
+10
View File
@@ -0,0 +1,10 @@
package models
// ClubSearchResult represents a club search result from FACR API
type ClubSearchResult struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
LogoURL string `json:"logo_url"`
Category string `json:"category"`
}
+16
View File
@@ -0,0 +1,16 @@
package models
import "gorm.io/gorm"
// CompetitionAlias allows renaming competitions site-wide by FACR code
// Example: code "A1A" -> alias "Krajský přebor"
// The API continues to return original codes; frontend/admin can map to aliases.
type CompetitionAlias struct {
gorm.Model
Code string `gorm:"uniqueIndex;not null" json:"code"` // FACR competition code, e.g. A1A
Alias string `gorm:"not null" json:"alias"` // Display name used in UI
OriginalName string `json:"original_name"` // Optional: last seen original name
DisplayOrder int `json:"display_order"` // Custom sort order (lower = higher priority)
}
func (CompetitionAlias) TableName() string { return "competition_aliases" }
+115
View File
@@ -0,0 +1,115 @@
package models
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// ContactMessage represents a message sent through the contact form
type ContactMessage struct {
BaseModel
Name string `json:"name" gorm:"not null"`
Email string `json:"email" gorm:"not null"`
Subject string `json:"subject" gorm:"not null"`
Message string `json:"message" gorm:"type:text;not null"`
Source string `json:"source" gorm:"size:50;default:contact"` // e.g., "contact", "sponsor"
IPAddress string `json:"ip_address" gorm:"size:45"`
UserAgent string `json:"user_agent" gorm:"type:text"`
IsRead bool `json:"is_read" gorm:"default:false"`
ReadAt time.Time `json:"read_at,omitempty"`
}
// TableName specifies the table name for the ContactMessage model
func (ContactMessage) TableName() string {
return "contact_messages"
}
// NewsletterSubscription represents a user subscription to the newsletter
type NewsletterSubscription struct {
BaseModel
Email string `json:"email" gorm:"uniqueIndex;not null"`
IsActive bool `json:"is_active" gorm:"default:true"`
// Preferences stores subscriber choices (e.g. matches, scores, events, blog) as JSON
// Use datatypes.JSONMap so GORM/driver can marshal/unmarshal JSONB correctly
Preferences datatypes.JSONMap `json:"preferences" gorm:"type:jsonb;default:'{}'::jsonb"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for the NewsletterSubscription model
func (NewsletterSubscription) TableName() string {
return "newsletter_subscriptions"
}
// CreateContactMessage creates a new contact message in the database
func CreateContactMessage(db *gorm.DB, message *ContactMessage) error {
return db.Create(message).Error
}
// GetContactMessages retrieves a paginated list of contact messages
func GetContactMessages(db *gorm.DB, page, limit int) ([]ContactMessage, int64, error) {
var messages []ContactMessage
var count int64
offset := (page - 1) * limit
// Get total count
if err := db.Model(&ContactMessage{}).Count(&count).Error; err != nil {
return nil, 0, err
}
// Get paginated results
err := db.Offset(offset).
Limit(limit).
Order("created_at DESC").
Find(&messages).Error
return messages, count, err
}
// MarkMessageAsRead marks a contact message as read
func MarkMessageAsRead(db *gorm.DB, id uint) error {
return db.Model(&ContactMessage{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"is_read": true,
"read_at": time.Now(),
}).Error
}
// SubscribeToNewsletter subscribes an email to the newsletter
func SubscribeToNewsletter(db *gorm.DB, email string) error {
subscription := NewsletterSubscription{
Email: email,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return db.Where(NewsletterSubscription{Email: email}).
Attrs(subscription).
FirstOrCreate(&subscription).
Update("is_active", true).
Error
}
// UnsubscribeFromNewsletter unsubscribes an email from the newsletter
func UnsubscribeFromNewsletter(db *gorm.DB, email string) error {
return db.Model(&NewsletterSubscription{}).
Where("email = ?", email).
Update("is_active", false).
Error
}
// GetActiveSubscribers returns a list of all active newsletter subscribers
func GetActiveSubscribers(db *gorm.DB) ([]string, error) {
var subscribers []string
err := db.Model(&NewsletterSubscription{}).
Where("is_active = ?", true).
Pluck("email", &subscribers).
Error
return subscribers, err
}
+67
View File
@@ -0,0 +1,67 @@
package models
import (
"time"
"gorm.io/datatypes"
)
// EmailLog stores one row per attempted delivery to a recipient
type EmailLog struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Subject string `json:"subject"`
RecipientEmail string `json:"recipient_email" gorm:"index"`
Type string `json:"type"` // newsletter|welcome|welcome_back|other
Status string `json:"status"` // sent|failed
ProviderMessageID string `json:"provider_message_id"`
SendError string `json:"send_error"`
Token string `json:"token" gorm:"index"`
}
// EmailEvent records interactions: open, click, spam, unsubscribe, bounce, complaint
type EmailEvent struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
EmailLogID uint `json:"email_log_id" gorm:"index"`
EventType string `json:"event_type"`
Meta datatypes.JSONMap `json:"meta"`
}
// NewsletterSentLog tracks sent newsletters to prevent duplicates
type NewsletterSentLog struct {
ID uint `gorm:"primaryKey" json:"id"`
NewsletterType string `json:"newsletter_type" gorm:"index"` // weekly|match_reminder|match_result|blog_release
Subject string `json:"subject"`
ContentIDs string `json:"content_ids" gorm:"type:text"` // JSON array of IDs
RecipientsCount int `json:"recipients_count"`
SentAt time.Time `json:"sent_at" gorm:"index"`
CreatedAt time.Time `json:"created_at"`
}
func (NewsletterSentLog) TableName() string { return "newsletter_sent_log" }
// MatchNotification tracks match alerts sent to avoid duplicates
type MatchNotification struct {
ID uint `gorm:"primaryKey" json:"id"`
MatchID string `json:"match_id" gorm:"index"` // External FACR match ID
NotificationType string `json:"notification_type"` // reminder_48h|reminder_day|result
SentAt time.Time `json:"sent_at" gorm:"index"`
RecipientsCount int `json:"recipients_count"`
CreatedAt time.Time `json:"created_at"`
}
func (MatchNotification) TableName() string { return "match_notifications" }
// BlogNotification tracks blog release notifications
type BlogNotification struct {
ID uint `gorm:"primaryKey" json:"id"`
ArticleID uint `json:"article_id" gorm:"uniqueIndex"`
SentAt time.Time `json:"sent_at" gorm:"index"`
RecipientsCount int `json:"recipients_count"`
CreatedAt time.Time `json:"created_at"`
}
func (BlogNotification) TableName() string { return "blog_notifications" }
+43
View File
@@ -0,0 +1,43 @@
package models
import (
"time"
)
type EventType string
const (
EventTypeMatch EventType = "match"
EventTypeTraining EventType = "training"
EventTypeMeeting EventType = "meeting"
EventTypeOther EventType = "other"
)
type Event struct {
BaseModel
Title string `json:"title" gorm:"not null"`
Description string `json:"description"`
StartTime time.Time `json:"start_time" gorm:"not null"`
EndTime *time.Time `json:"end_time"`
Location string `json:"location"`
Type EventType `json:"type" gorm:"type:varchar(20);not null;default:'other'"`
CategoryName string `json:"category_name" gorm:"type:varchar(255)"`
IsPublic bool `json:"is_public" gorm:"default:true"`
CreatedByID uint `json:"created_by_id"`
CreatedBy User `json:"created_by" gorm:"foreignKey:CreatedByID"`
ImageURL string `json:"image_url"`
FileURL string `json:"file_url"`
Attachments []EventAttachment `json:"attachments" gorm:"constraint:OnDelete:CASCADE"`
YoutubeURL string `json:"youtube_url" gorm:"type:varchar(500)"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
}
type EventAttachment struct {
BaseModel
EventID uint `json:"event_id" gorm:"index;not null"`
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
}
+35
View File
@@ -0,0 +1,35 @@
package models
import (
"time"
"gorm.io/gorm"
)
// MatchOverride stores admin overrides for externally-fetched matches (FACR)
// It uses ExternalMatchID (e.g., FACR match_id) as stable key and allows overriding
// names, venue, datetime and logos.
type MatchOverride struct {
gorm.Model
ExternalMatchID string `gorm:"uniqueIndex;not null" json:"external_match_id"`
HomeNameOverride *string `json:"home_name_override"`
AwayNameOverride *string `json:"away_name_override"`
VenueOverride *string `json:"venue_override"`
DateTimeOverride *time.Time `json:"date_time_override"`
HomeLogoURL *string `json:"home_logo_url"`
AwayLogoURL *string `json:"away_logo_url"`
Notes string `gorm:"type:text" json:"notes"`
}
func (MatchOverride) TableName() string { return "match_overrides" }
// TeamLogoOverride persists custom team logos to override remote ones
// Keyed by ExternalTeamID (e.g., FACR team id)
type TeamLogoOverride struct {
gorm.Model
ExternalTeamID string `gorm:"uniqueIndex;not null" json:"external_team_id"`
TeamName string `json:"team_name"`
LogoURL string `json:"logo_url"`
}
func (TeamLogoOverride) TableName() string { return "team_logo_overrides" }
+367
View File
@@ -0,0 +1,367 @@
package models
import (
"encoding/json"
"time"
"gorm.io/gorm"
)
// User represents a user in the system
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex;not null" json:"email"`
Password string `gorm:"not null" json:"-"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Role string `gorm:"default:editor" json:"role"` // admin, editor
IsActive bool `gorm:"default:true"`
LastLogin *time.Time `json:"last_login,omitempty"`
}
// Article represents a blog article
type Article struct {
gorm.Model
Title string `gorm:"not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
AuthorID *uint `gorm:"index" json:"author_id,omitempty"`
Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
ImageURL string `json:"image_url"`
Published bool `gorm:"default:false" json:"published"`
PublishedAt *time.Time `json:"published_at,omitempty"`
Slug string `gorm:"uniqueIndex" json:"slug"`
Excerpt string `gorm:"type:text" json:"excerpt"`
Featured bool `gorm:"default:false;index" json:"featured"`
// Fields for SEO and social previews
SEOTitle string `json:"seo_title"`
SEODescription string `gorm:"type:text" json:"seo_description"`
// OG image for social sharing (optional)
OGImageURL string `json:"og_image_url"`
// Optional: link to external content or embedded media
ExternalLink string `json:"external_link"`
ViewCount int `gorm:"default:0;index" json:"view_count"`
ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes
UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session)
// Store the category name directly to simplify queries (denormalized)
CategoryName string `json:"category_name"`
Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...]
// Gallery association (optional)
GalleryAlbumID string `json:"gallery_album_id"`
GalleryAlbumURL string `json:"gallery_album_url"`
// Stored as JSON string or comma-separated list; frontend normalizes
GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"`
// YouTube video association (optional)
YouTubeVideoID string `json:"youtube_video_id"`
YouTubeVideoTitle string `gorm:"type:text" json:"youtube_video_title"`
YouTubeVideoURL string `json:"youtube_video_url"`
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
}
// ArticleTeamLink represents a link from an article to a team identified by an external FACR ID
type ArticleTeamLink struct {
gorm.Model
ArticleID uint `gorm:"not null;index" json:"article_id"`
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
ExternalTeamID string `gorm:"not null;index" json:"external_team_id"`
TeamName string `json:"team_name"`
}
func (ArticleTeamLink) TableName() string { return "article_team_links" }
// ArticleMatchLink represents a link from an article to a match identified by an external FACR match ID
type ArticleMatchLink struct {
gorm.Model
ArticleID uint `gorm:"not null;index" json:"article_id"`
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
ExternalMatchID string `gorm:"not null;index" json:"external_match_id"`
Title string `json:"title"`
}
func (ArticleMatchLink) TableName() string { return "article_match_links" }
// Team represents a football team
type Team struct {
gorm.Model
Name string `gorm:"not null"`
ShortName string
Description string
LogoURL string `json:"logo_url"`
IsActive bool `gorm:"default:true"`
}
// Player represents a football player
type Player struct {
gorm.Model
FirstName string `gorm:"not null" json:"first_name"`
LastName string `gorm:"not null" json:"last_name"`
DateOfBirth time.Time `json:"date_of_birth"`
Position string `json:"position"`
JerseyNumber int `json:"jersey_number"`
TeamID uint `json:"team_id"`
Team Team `gorm:"foreignKey:TeamID" json:"team"`
Nationality string `json:"nationality"`
Height int `json:"height"` // in cm
Weight int `json:"weight"` // in kg
ImageURL string `json:"image_url"`
IsActive bool `gorm:"default:true" json:"is_active"`
Email string `json:"email"`
Phone string `json:"phone"`
}
// Sponsor represents a sponsor
type Sponsor struct {
gorm.Model
Name string `gorm:"not null" json:"name"`
LogoURL string `json:"logo_url"`
WebsiteURL string `json:"website_url"`
Description string `json:"description"`
IsActive bool `gorm:"default:true" json:"is_active"`
Tier string `gorm:"default:'standard'" json:"tier"` // general (hlavní), standard
DisplayOrder int `gorm:"default:0" json:"display_order"` // For custom ordering
// Banner-specific metadata (optional)
Placement string `json:"placement"` // e.g., homepage_top, homepage_sidebar
Width int `json:"width"`
Height int `json:"height"`
}
type Settings struct {
gorm.Model
// Frontpage layout and style variants (e.g., "classic", "grid"; "light", "dark")
FrontpageLayout string `json:"frontpage_layout"`
FrontpageStyle string `json:"frontpage_style"`
// Sponsors module display preferences
SponsorsLayout string `json:"sponsors_layout"` // grid | slider | scroller | pyramid
SponsorsTheme string `json:"sponsors_theme"` // light | dark
// FACR club selection
ClubID string `json:"club_id"` // UUID from fotbal.cz
ClubType string `json:"club_type"` // football|futsal
ClubName string `json:"club_name"`
ClubLogoURL string `json:"club_logo_url"`
ClubURL string `json:"club_url"`
// Theme customization: colors & fonts
PrimaryColor string `json:"primary_color"` // e.g. #0033aa
SecondaryColor string `json:"secondary_color"`
AccentColor string `json:"accent_color"`
BackgroundColor string `json:"background_color"`
TextColor string `json:"text_color"`
FontHeading string `json:"font_heading"` // e.g. "Poppins, sans-serif"
FontBody string `json:"font_body"`
// Custom assets: raw overrides stored as TEXT
CustomCSS string `gorm:"type:text" json:"custom_css"`
CustomJS string `gorm:"type:text" json:"custom_js"`
CustomHTMLHome string `gorm:"type:text" json:"custom_html_home"`
CustomHTMLBlogList string `gorm:"type:text" json:"custom_html_blog_list"`
CustomHTMLBlogPost string `gorm:"type:text" json:"custom_html_blog_post"`
// Custom pages & navigation
AboutHTML string `gorm:"type:text" json:"about_html"`
ShowAboutInNav bool `gorm:"default:true" json:"show_about_in_nav"`
CustomNavJSON string `gorm:"type:text" json:"-"`
CustomNav []CustomNavLink `gorm:"-" json:"custom_nav,omitempty"`
// SMTP configuration (optional, overrides environment when present)
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
SMTPUser string `json:"smtp_user"`
SMTPPassword string `json:"smtp_password"`
SMTPFrom string `json:"smtp_from"`
SMTPFromName string `json:"smtp_from_name"`
SMTPEncryption string `json:"smtp_encryption"` // tls|ssl|none
SMTPAuth bool `json:"smtp_auth"`
SMTPSkipVerify bool `json:"smtp_skip_verify"`
// SEO defaults (site-wide)
SiteTitle string `json:"site_title"`
SiteDescription string `json:"site_description"`
MetaKeywords string `json:"meta_keywords"` // comma-separated
DefaultOGImageURL string `json:"default_og_image_url"`
TwitterHandle string `json:"twitter_handle"` // e.g. @club
CanonicalBaseURL string `json:"canonical_base_url"` // e.g. https://www.club.cz
AdditionalMeta string `gorm:"type:text" json:"additional_meta"` // raw extra meta
EnableIndexing bool `json:"enable_indexing"` // robots allow/disallow
// Social profiles
FacebookURL string `json:"facebook_url"`
InstagramURL string `json:"instagram_url"`
YoutubeURL string `json:"youtube_url"`
// Generic gallery link (preferred over legacy specific providers)
GalleryURL string `json:"gallery_url"`
GalleryLabel string `json:"gallery_label"`
// Videos module configuration
VideosModuleEnabled bool `json:"videos_module_enabled"`
VideosStyle string `json:"videos_style"` // slider | grid3 | grid
VideosSource string `json:"videos_source"` // auto | manual
VideosLimit int `json:"videos_limit"` // number of items on homepage
// Manual videos storage (JSON strings)
VideosJSON string `gorm:"type:text" json:"-"`
VideosItemsJSON string `gorm:"type:text" json:"-"`
// Merch module configuration
MerchModuleEnabled bool `json:"merch_module_enabled"`
MerchStyle string `json:"merch_style"` // grid | slider (future)
MerchSource string `json:"merch_source"` // manual | auto (future)
MerchLimit int `json:"merch_limit"`
// Manual merch storage
MerchItemsJSON string `gorm:"type:text" json:"-"`
// Newsletter automation toggle (persisted)
NewsletterEnabled bool `json:"newsletter_enabled"`
// Newsletter defaults
DefaultDigestType string `json:"default_digest_type"` // blogs|events|matches|scores|weekly
DefaultDigestCompetitions string `json:"default_digest_competitions"` // comma-separated codes
// Newsletter scheduling (admin-configurable)
// Enable/disable specific automated digests
EnableWeekly bool `json:"enable_weekly"`
EnableMatchReminders bool `json:"enable_match_reminders"`
EnableResults bool `json:"enable_results"`
// Weekly schedule
NewsletterWeeklyDay string `json:"newsletter_weekly_day"` // sun|mon|tue|wed|thu|fri|sat
NewsletterWeeklyHour int `json:"newsletter_weekly_hour"` // 0-23 local time
// Match reminder lead time (hours before kickoff)
NewsletterReminderLeadHours int `json:"newsletter_reminder_lead_hours"` // default 48
// Quiet hours for results (local time, inclusive start, exclusive end)
NewsletterQuietStart int `json:"newsletter_quiet_start"` // 0-23
NewsletterQuietEnd int `json:"newsletter_quiet_end"` // 0-23
// Contact/Location information for map
ContactAddress string `json:"contact_address"`
ContactCity string `json:"contact_city"`
ContactZip string `json:"contact_zip"`
ContactCountry string `json:"contact_country"`
ContactPhone string `json:"contact_phone"`
ContactEmail string `json:"contact_email"`
LocationLatitude float64 `json:"location_latitude"`
LocationLongitude float64 `json:"location_longitude"`
MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"`
MapStyle string `json:"map_style"` // OpenStreetMap style URL or preset: default, dark, satellite
ShowMapOnHomepage bool `json:"show_map_on_homepage"`
// Homepage matches display configuration
FinishedMatchDisplayDays int `gorm:"default:2" json:"finished_match_display_days"` // Number of days to show finished matches with scores on homepage
}
// TableName specifies table name for Settings model
func (Settings) TableName() string { return "settings" }
// CustomNavLink represents an item in the main navigation managed via settings
type CustomNavLink struct {
Label string `json:"label"`
URL string `json:"url"`
External bool `json:"external"`
}
// LoadCustomNav hydrates the in-memory CustomNav slice from the persisted JSON string.
func (s *Settings) LoadCustomNav() {
if s.CustomNavJSON == "" {
s.CustomNav = nil
return
}
var items []CustomNavLink
if err := json.Unmarshal([]byte(s.CustomNavJSON), &items); err != nil {
s.CustomNav = nil
return
}
s.CustomNav = items
}
// SetCustomNav stores the provided navigation links and updates the serialized JSON column.
func (s *Settings) SetCustomNav(items []CustomNavLink) error {
s.CustomNav = items
if len(items) == 0 {
s.CustomNavJSON = ""
return nil
}
b, err := json.Marshal(items)
if err != nil {
return err
}
s.CustomNavJSON = string(b)
return nil
}
// Club represents the main club/team configuration
type Club struct {
gorm.Model
Name string `gorm:"not null" json:"name"`
PrimaryColor string `gorm:"default:'#1a365d'" json:"primary_color"`
SportType string `gorm:"not null" json:"sport_type"`
LogoPath string `json:"logo_path"`
}
// TableName specifies the table name for the User model
func (User) TableName() string {
return "users"
}
// TableName specifies the table name for the Article model
func (Article) TableName() string {
return "articles"
}
// TableName specifies the table name for the Team model
func (Team) TableName() string {
return "teams"
}
// TableName specifies the table name for the Player model
func (Player) TableName() string {
return "players"
}
// TableName specifies the table name for the Sponsor model
func (Sponsor) TableName() string {
return "sponsors"
}
// TableName specifies the table name for the Club model
func (Club) TableName() string {
return "clubs"
}
// ContactCategory represents a category for organizing contacts (e.g., "Management", "Coaches", "Office")
type ContactCategory struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
Name string `gorm:"not null;uniqueIndex" json:"name"`
Description string `json:"description"`
DisplayOrder int `gorm:"default:0" json:"display_order"`
IsActive bool `gorm:"default:true" json:"is_active"`
}
// TableName specifies the table name for the ContactCategory model
func (ContactCategory) TableName() string {
return "contact_categories"
}
// Contact represents a contact person (e.g., coach, manager, office staff)
type Contact struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
Category *ContactCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
Name string `gorm:"not null" json:"name"`
Position string `json:"position"` // e.g., "Head Coach", "President"
Email string `json:"email"`
Phone string `json:"phone"`
ImageURL string `json:"image_url"`
Description string `gorm:"type:text" json:"description"`
DisplayOrder int `gorm:"default:0" json:"display_order"`
IsActive bool `gorm:"default:true" json:"is_active"`
}
// TableName specifies the table name for the Contact model
func (Contact) TableName() string {
return "contacts"
}
+146
View File
@@ -0,0 +1,146 @@
package models
import (
"gorm.io/gorm"
)
// NavigationItemType defines the type of navigation item
type NavigationItemType string
const (
NavTypeInternal NavigationItemType = "internal" // Direct URL
NavTypeExternal NavigationItemType = "external" // External link
NavTypeDropdown NavigationItemType = "dropdown" // Has children
NavTypePage NavigationItemType = "page" // Links to predefined page
)
// NavigationItem represents a single navigation menu item
type NavigationItem struct {
gorm.Model
Label string `gorm:"not null" json:"label"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
Type NavigationItemType `gorm:"not null;default:'internal'" json:"type"`
PageType string `json:"page_type,omitempty"` // e.g., 'blog', 'about', 'calendar'
PageID *uint `json:"page_id,omitempty"` // optional reference to specific content
Visible bool `gorm:"not null;default:true" json:"visible"`
DisplayOrder int `gorm:"not null;default:0" json:"display_order"`
ParentID *uint `json:"parent_id,omitempty"`
Parent *NavigationItem `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Children []NavigationItem `gorm:"foreignKey:ParentID" json:"children,omitempty"`
Target string `gorm:"default:'_self'" json:"target"` // _self or _blank
CSSClass string `json:"css_class,omitempty"`
RequiresAuth bool `gorm:"default:false" json:"requires_auth"`
RequiresAdmin bool `gorm:"default:false" json:"requires_admin"`
}
// TableName specifies the table name for the NavigationItem model
func (NavigationItem) TableName() string {
return "navigation_items"
}
// GetURL returns the computed URL based on type and page_type
func (n *NavigationItem) GetURL() string {
if n.URL != "" {
return n.URL
}
// Map page types to URLs for frontend
if n.Type == NavTypePage && n.PageType != "" {
pageURLMap := map[string]string{
"home": "/",
"about": "/o-klubu",
"calendar": "/kalendar",
"matches": "/zapasy",
"activities": "/aktivity",
"players": "/hraci",
"tables": "/tabulky",
"blog": "/blog",
"videos": "/videa",
"gallery": "/galerie",
"sponsors": "/sponzori",
"contact": "/kontakt",
"search": "/hledat",
}
if url, ok := pageURLMap[n.PageType]; ok {
return url
}
}
// Map admin page types to URLs
if (n.Type == NavTypeInternal || n.Type == NavTypePage) && n.PageType != "" && n.RequiresAdmin {
adminURLMap := map[string]string{
"dashboard": "/admin",
"analytics": "/admin/analytika",
"teams": "/admin/tymy",
"matches": "/admin/zapasy",
"activities": "/admin/aktivity",
"players": "/admin/hraci",
"articles": "/admin/clanky",
"categories": "/admin/kategorie",
"about": "/admin/o-klubu",
"videos": "/admin/videa",
"gallery": "/admin/galerie",
"scoreboard": "/admin/scoreboard",
"scoreboard_remote": "/admin/scoreboard/remote",
"clothing": "/admin/obleceni",
"sponsors": "/admin/sponzori",
"banners": "/admin/bannery",
"messages": "/admin/zpravy",
"contacts": "/admin/kontakty",
"newsletter": "/admin/newsletter",
"polls": "/admin/ankety",
"navigation": "/admin/navigace",
"competition_aliases": "/admin/aliasy-soutezi",
"prefetch": "/admin/prefetch",
"users": "/admin/uzivatele",
"settings": "/admin/nastaveni",
"files": "/admin/soubory",
"docs": "/admin/docs",
}
if url, ok := adminURLMap[n.PageType]; ok {
return url
}
}
return "#"
}
// SocialLink represents a social media link
type SocialLink struct {
gorm.Model
Platform string `gorm:"not null" json:"platform"` // facebook, instagram, youtube, etc.
URL string `gorm:"not null" json:"url"`
DisplayOrder int `gorm:"not null;default:0" json:"display_order"`
Visible bool `gorm:"not null;default:true" json:"visible"`
Icon string `json:"icon,omitempty"` // optional custom icon
}
// TableName specifies the table name for the SocialLink model
func (SocialLink) TableName() string {
return "social_links"
}
// GetIconName returns the React Icons name for the platform
func (s *SocialLink) GetIconName() string {
if s.Icon != "" {
return s.Icon
}
iconMap := map[string]string{
"facebook": "FaFacebook",
"instagram": "FaInstagram",
"youtube": "FaYoutube",
"twitter": "FaTwitter",
"tiktok": "FaTiktok",
"linkedin": "FaLinkedin",
"discord": "FaDiscord",
"twitch": "FaTwitch",
}
if icon, ok := iconMap[s.Platform]; ok {
return icon
}
return "FaLink"
}
+51
View File
@@ -0,0 +1,51 @@
package models
import (
"database/sql/driver"
"encoding/json"
"errors"
"gorm.io/gorm"
)
// PageElementConfig represents the visual variant configuration for a page element
type PageElementConfig struct {
gorm.Model
PageType string `gorm:"not null;index" json:"page_type"` // e.g., "homepage", "about", "blog"
ElementName string `gorm:"not null;index" json:"element_name"` // e.g., "header", "hero", "news", "matches"
Variant string `gorm:"not null" json:"variant"` // e.g., "unified", "edge", "minimal", "modern"
Visible bool `gorm:"default:true" json:"visible"` // Whether element is shown on page
DisplayOrder int `gorm:"default:0" json:"display_order"` // Order of element on page
Settings ElementSettings `gorm:"type:jsonb" json:"settings,omitempty"` // Additional variant-specific settings
}
// ElementSettings is a map for storing additional settings
type ElementSettings map[string]interface{}
// TableName specifies the table name for the PageElementConfig model
func (PageElementConfig) TableName() string {
return "page_element_configs"
}
// Scan implements the sql.Scanner interface for ElementSettings
func (es *ElementSettings) Scan(value interface{}) error {
if value == nil {
*es = make(ElementSettings)
return nil
}
bytes, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(bytes, es)
}
// Value implements the driver.Valuer interface for ElementSettings
func (es ElementSettings) Value() (driver.Value, error) {
if es == nil {
return nil, nil
}
return json.Marshal(es)
}
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"time"
"gorm.io/gorm"
)
// PasswordReset stores password reset tokens, verification codes, and their status
type PasswordReset struct {
gorm.Model
UserID uint `gorm:"index;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"-"`
Token string `gorm:"uniqueIndex;size:128;not null" json:"token"`
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
UsedAt *time.Time `json:"used_at"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
VerificationCode string `gorm:"size:6" json:"-"`
VerificationCodeExpires *time.Time `json:"verification_code_expires_at"`
VerificationAttempts int `gorm:"default:0" json:"verification_attempts"`
}
func (PasswordReset) TableName() string { return "password_resets" }
+121
View File
@@ -0,0 +1,121 @@
package models
import (
"time"
)
// Poll represents a voting poll
type Poll struct {
ID uint `gorm:"primarykey" json:"id"`
Title string `gorm:"size:255;not null" json:"title"`
Description string `gorm:"type:text" json:"description"`
Type string `gorm:"size:50;not null;default:'single'" json:"type"` // single, multiple, rating
Status string `gorm:"size:20;not null;default:'draft'" json:"status"` // draft, active, closed, archived
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
AllowMultiple bool `gorm:"default:false" json:"allow_multiple"` // Allow voting for multiple options
MaxChoices int `gorm:"default:1" json:"max_choices"` // Max number of choices if allow_multiple
ShowResults string `gorm:"size:20;default:'after_vote'" json:"show_results"` // always, after_vote, after_end, never
RequireAuth bool `gorm:"default:false" json:"require_auth"` // Require login to vote
AllowGuestVote bool `gorm:"default:true" json:"allow_guest_vote"` // Allow anonymous voting
Featured bool `gorm:"default:false" json:"featured"` // Show on homepage
CategoryID *uint `json:"category_id"` // Optional category
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
RelatedMatchID *uint `json:"related_match_id"` // Link to specific match (for MOTM voting)
RelatedArticleID *uint `json:"related_article_id"` // Link to blog article
RelatedArticle *Article `gorm:"foreignKey:RelatedArticleID" json:"related_article,omitempty"`
RelatedEventID *uint `json:"related_event_id"` // Link to event/activity
RelatedEvent *Event `gorm:"foreignKey:RelatedEventID" json:"related_event,omitempty"`
RelatedVideoURL string `gorm:"size:500" json:"related_video_url"` // YouTube video URL/ID
ImageURL string `gorm:"size:500" json:"image_url"`
TotalVotes int `gorm:"default:0" json:"total_votes"`
Options []PollOption `gorm:"foreignKey:PollID;constraint:OnDelete:CASCADE" json:"options"`
Votes []PollVote `gorm:"foreignKey:PollID;constraint:OnDelete:CASCADE" json:"votes,omitempty"`
CreatedBy uint `json:"created_by"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"`
}
// PollOption represents a single option in a poll
type PollOption struct {
ID uint `gorm:"primarykey" json:"id"`
PollID uint `gorm:"not null;index" json:"poll_id"`
Poll *Poll `gorm:"foreignKey:PollID" json:"poll,omitempty"`
Text string `gorm:"size:255;not null" json:"text"`
Description string `gorm:"type:text" json:"description"`
ImageURL string `gorm:"size:500" json:"image_url"`
DisplayOrder int `gorm:"default:0" json:"display_order"`
VoteCount int `gorm:"default:0" json:"vote_count"`
PlayerID *uint `json:"player_id"` // Optional link to player (for MOTM)
Player *Player `gorm:"foreignKey:PlayerID" json:"player,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PollVote represents a user's vote
type PollVote struct {
ID uint `gorm:"primarykey" json:"id"`
PollID uint `gorm:"not null;index" json:"poll_id"`
Poll *Poll `gorm:"foreignKey:PollID" json:"poll,omitempty"`
OptionID uint `gorm:"not null;index" json:"option_id"`
Option *PollOption `gorm:"foreignKey:OptionID" json:"option,omitempty"`
UserID *uint `gorm:"index" json:"user_id"` // Null for guest votes
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
IPHash string `gorm:"size:64;index" json:"ip_hash"` // Hashed IP for duplicate prevention
UserAgent string `gorm:"size:500" json:"user_agent"`
SessionToken string `gorm:"size:100;index" json:"session_token"` // For guest vote tracking
CreatedAt time.Time `json:"created_at"`
}
// TableName overrides the table name
func (Poll) TableName() string {
return "polls"
}
func (PollOption) TableName() string {
return "poll_options"
}
func (PollVote) TableName() string {
return "poll_votes"
}
// IsActive checks if poll is currently accepting votes
func (p *Poll) IsActive() bool {
if p.Status != "active" {
return false
}
now := time.Now()
if p.StartDate != nil && now.Before(*p.StartDate) {
return false
}
if p.EndDate != nil && now.After(*p.EndDate) {
return false
}
return true
}
// CanShowResults checks if results should be displayed
func (p *Poll) CanShowResults(hasVoted bool) bool {
switch p.ShowResults {
case "always":
return true
case "after_vote":
return hasVoted
case "after_end":
if p.EndDate != nil {
return time.Now().After(*p.EndDate)
}
return p.Status == "closed" || p.Status == "archived"
case "never":
return false
default:
return false
}
}
+38
View File
@@ -0,0 +1,38 @@
package models
import "gorm.io/gorm"
// ScoreboardState is a singleton table to persist scoreboard settings
// Only one row is used (ID=1)
type ScoreboardState struct {
gorm.Model
HomeName string `json:"home_name"`
AwayName string `json:"away_name"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
HomeShort string `json:"home_short"`
AwayShort string `json:"away_short"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
HomeScore int `json:"home_score"`
AwayScore int `json:"away_score"`
HalfLength int `json:"half_length"`
Theme string `json:"theme"`
ExternalMatchID string `json:"external_match_id"`
Active bool `json:"active"`
// Timer fields
Timer string `json:"timer"`
Running bool `json:"running"`
TimerStartUnix int64 `json:"timer_start_unix" gorm:"column:timer_start_unix"`
ElapsedSeconds int `json:"elapsed_seconds" gorm:"column:elapsed_seconds"`
// Extended fields (ported from MyClub ScoreBoard)
// Visual sides flipped (UI-only flag, does not swap data)
SidesFlipped bool `json:"sides_flipped"`
// Current half: 1 or 2
Half int `json:"half"`
// QR overlay schedule settings
QRShowEveryMinutes int `json:"qr_show_every_minutes"`
QRShowDurationSeconds int `json:"qr_show_duration_seconds"`
}
func (ScoreboardState) TableName() string { return "scoreboard_states" }
+61
View File
@@ -0,0 +1,61 @@
package models
import (
"encoding/json"
"time"
"gorm.io/gorm"
)
type SetupStatus string
const (
SetupStatusPending SetupStatus = "pending"
SetupStatusCompleted SetupStatus = "completed"
SetupStatusSkipped SetupStatus = "skipped"
)
type SetupInfo struct {
gorm.Model
Status SetupStatus `gorm:"not null;default:'pending'" json:"status"`
SkippedAt *time.Time `json:"skipped_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
SMTPConfigured bool `gorm:"default:false" json:"smtp_configured"`
ClubImported bool `gorm:"default:false" json:"club_imported"`
}
type ClubInfo struct {
gorm.Model
SetupInfoID uint `gorm:"not null" json:"-"`
FACRClubID string `gorm:"uniqueIndex" json:"facr_club_id"`
Name string `gorm:"not null" json:"name"`
ShortName string `json:"short_name"`
LogoURL string `json:"logo_url"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
TextColor string `json:"text_color"`
}
// TableName specifies the table name for the SetupInfo model
func (SetupInfo) TableName() string {
return "setup_info"
}
// TableName specifies the table name for the ClubInfo model
func (ClubInfo) TableName() string {
return "club_info"
}
// ToJSON returns the JSON representation of the club info
func (c *ClubInfo) ToJSON() (string, error) {
data, err := json.Marshal(c)
if err != nil {
return "", err
}
return string(data), nil
}
// FromJSON populates the club info from JSON data
func (c *ClubInfo) FromJSON(jsonData string) error {
return json.Unmarshal([]byte(jsonData), c)
}
+38
View File
@@ -0,0 +1,38 @@
package models
import (
"gorm.io/gorm"
)
// UploadedFile represents a file uploaded to the server
type UploadedFile struct {
gorm.Model
Filename string `gorm:"not null" json:"filename"`
FilePath string `gorm:"uniqueIndex;not null" json:"file_path"`
FileURL string `gorm:"not null" json:"file_url"`
FileSize int64 `gorm:"default:0" json:"file_size"`
MimeType string `json:"mime_type"`
UploadedByID *uint `gorm:"index" json:"uploaded_by_id,omitempty"`
UploadedBy *User `gorm:"foreignKey:UploadedByID" json:"uploaded_by,omitempty"`
Usages []FileUsage `gorm:"foreignKey:FileID" json:"usages,omitempty"`
}
// FileUsage tracks where a file is being used
type FileUsage struct {
gorm.Model
FileID uint `gorm:"not null;index" json:"file_id"`
File *UploadedFile `gorm:"foreignKey:FileID" json:"file,omitempty"`
EntityType string `gorm:"not null;index" json:"entity_type"` // article, player, sponsor, event, contact, settings
EntityID uint `gorm:"not null;index" json:"entity_id"`
FieldName string `json:"field_name"` // image_url, logo_url, attachments, etc.
}
// TableName specifies the table name for UploadedFile
func (UploadedFile) TableName() string {
return "uploaded_files"
}
// TableName specifies the table name for FileUsage
func (FileUsage) TableName() string {
return "file_usages"
}
+30
View File
@@ -0,0 +1,30 @@
package models
import (
"encoding/json"
)
// VisitorEvent stores client-side analytics events
// TableName: visitor_events
// Common events: "page_view", "click", "interaction"
// Fields kept generic to avoid rigid schema changes
// Metadata stores arbitrary event payload (e.g., element details)
type VisitorEvent struct {
BaseModel
EventType string `gorm:"index;size:50" json:"event_type"`
Page string `gorm:"index;size:512" json:"page"` // Short page path
PagePath string `gorm:"index;size:512" json:"page_path"` // Full page path (alias for Page)
PageName string `gorm:"size:512" json:"page_name"` // Human-readable page name
Element string `gorm:"size:256" json:"element"` // Element identifier (for clicks/interactions)
Referrer string `gorm:"size:512" json:"referrer"`
UserAgent string `gorm:"size:512" json:"user_agent"`
IPAddress string `gorm:"size:64" json:"ip_address"`
SessionID string `gorm:"size:128;index" json:"session_id"`
UserID *uint `gorm:"index" json:"user_id,omitempty"`
Data json.RawMessage `gorm:"type:jsonb" json:"data"` // Generic event data
Metadata json.RawMessage `gorm:"type:jsonb" json:"metadata"` // Legacy metadata field
}
func (VisitorEvent) TableName() string { return "visitor_events" }
+31
View File
@@ -0,0 +1,31 @@
package routes
import (
"fotbal-club/internal/controllers"
"fotbal-club/internal/middleware"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"time"
)
func RegisterAnalyticsRoutes(router *gin.RouterGroup, db *gorm.DB) {
analyticsController := controllers.NewAnalyticsController(db)
// Public tracking endpoint (no auth, rate-limited)
router.POST("/analytics/track", middleware.RateLimit(120, time.Minute), analyticsController.TrackEvent)
// Analytics visitors endpoint (protected, used by dashboard widgets)
router.GET("/analytics/visitors", middleware.JWTAuth(db), middleware.RoleAuth("admin"), analyticsController.GetVisitors)
// Protected admin routes
admin := router.Group("/admin/analytics")
admin.Use(middleware.JWTAuth(db))
admin.Use(middleware.RoleAuth("admin"))
{
admin.GET("", analyticsController.GetAnalytics) // GET /admin/analytics (summary)
admin.GET("/overview", analyticsController.GetAnalyticsOverview)
admin.GET("/top-pages", analyticsController.GetTopPages)
admin.GET("/top-articles", analyticsController.GetTopArticles)
admin.GET("/top-interactions", analyticsController.GetTopInteractions)
}
}
+34
View File
@@ -0,0 +1,34 @@
package routes
import (
"fotbal-club/internal/controllers"
"fotbal-club/internal/middleware"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RegisterContactInfoRoutes(router *gin.RouterGroup, db *gorm.DB) {
contactInfoController := controllers.NewContactInfoController(db)
// Public endpoints (no auth required)
router.GET("/contacts", contactInfoController.GetPublicContacts)
router.GET("/contacts/categories", contactInfoController.GetPublicContactCategories)
// Admin endpoints (require auth + admin role)
admin := router.Group("/admin/contacts")
admin.Use(middleware.JWTAuth(db))
admin.Use(middleware.RoleAuth("admin"))
{
// Contact Categories CRUD
admin.GET("/categories", contactInfoController.GetContactCategories)
admin.POST("/categories", contactInfoController.CreateContactCategory)
admin.PUT("/categories/:id", contactInfoController.UpdateContactCategory)
admin.DELETE("/categories/:id", contactInfoController.DeleteContactCategory)
// Contacts CRUD
admin.GET("", contactInfoController.GetContacts)
admin.POST("", contactInfoController.CreateContact)
admin.PUT("/:id", contactInfoController.UpdateContact)
admin.DELETE("/:id", contactInfoController.DeleteContact)
}
}
+29
View File
@@ -0,0 +1,29 @@
package routes
import (
"fotbal-club/internal/controllers"
"fotbal-club/internal/middleware"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RegisterEventRoutes(router *gin.RouterGroup, db *gorm.DB) {
eventController := controllers.EventController{DB: db}
events := router.Group("/events")
{
events.GET("", eventController.GetEvents)
events.GET("/upcoming", eventController.GetUpcomingEvents)
// Public single event endpoint
events.GET(":id", eventController.GetEventByID)
// Protected routes
authorized := events.Group("")
authorized.Use(middleware.JWTAuth(db))
{
authorized.POST("", eventController.CreateEvent)
authorized.PUT(":id", eventController.UpdateEvent)
authorized.DELETE(":id", eventController.DeleteEvent)
}
}
}
+489
View File
@@ -0,0 +1,489 @@
package routes
import (
"fotbal-club/internal/config"
"fotbal-club/internal/controllers"
"fotbal-club/internal/middleware"
"fotbal-club/internal/services"
"fotbal-club/pkg/email"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Global newsletter automation instance
var newsletterAutomation *services.NewsletterAutomation
// SetNewsletterAutomation stores the newsletter automation instance
func SetNewsletterAutomation(na *services.NewsletterAutomation) {
newsletterAutomation = na
}
// GetNewsletterAutomation returns the newsletter automation instance
func GetNewsletterAutomation() *services.NewsletterAutomation {
return newsletterAutomation
}
// SetupRoutes configures all the routes for the application
func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
emailService := email.NewEmailService(config.AppConfig, db)
// Initialize controllers
baseController := &controllers.BaseController{DB: db}
authController := controllers.NewAuthController(db)
facrController := controllers.NewFACRController(db)
contactController := controllers.NewContactController(db, emailService)
passwordController := controllers.NewPasswordController(db, emailService)
aiController := controllers.NewAIController(db)
scoreboardController := controllers.NewScoreboardController(db)
youtubeController := controllers.NewYouTubeController(db)
aboutController := controllers.NewAboutController(db)
galleryController := controllers.NewGalleryController(db)
umamiController := controllers.NewUmamiController()
filesController := &controllers.FilesController{DB: db}
notificationsController := controllers.NewNotificationsController(db, emailService)
emailController := controllers.NewEmailController(db)
prefetchController := controllers.NewPrefetchController()
seoController := controllers.NewSEOController(db)
navigationController := controllers.NewNavigationController(db)
pollController := controllers.NewPollController(db)
clothingController := controllers.NewClothingController(db)
pageElementConfigController := controllers.NewPageElementConfigController(db)
// API v1 group
{
// Health check
api.GET("/health", baseController.HealthCheck)
// Image proxy (public) to work around CORS when reading images in Canvas on the frontend
api.GET("/proxy/image", baseController.ProxyImage)
// Public SEO: metadata
api.GET("/seo", seoController.GetPublicSEO)
// Public navigation endpoints
api.GET("/navigation", navigationController.GetNavigationItems)
api.GET("/social-links", navigationController.GetSocialLinks)
// Public page element configurations
api.GET("/page-elements", pageElementConfigController.GetPageElementConfigs)
// Email tracking (public)
api.GET("/email/open.gif", emailController.OpenPixel)
api.GET("/email/click", emailController.ClickRedirect)
api.GET("/email/spam", emailController.MarkSpam)
api.GET("/email/unsubscribe", emailController.Unsubscribe)
// Initial setup (public)
api.GET("/setup/status", baseController.SetupStatus)
api.POST("/setup/initialize", baseController.SetupInitialize)
// SMTP validation (public during setup; does not send email, only connects)
api.POST("/setup/validate-smtp", baseController.ValidateSMTP)
// Auth routes
auth := api.Group("/auth")
{
// Limit login attempts to mitigate brute force
auth.POST("/login", middleware.RateLimit(15, time.Minute), authController.Login)
// Logout clears auth cookie (stateless)
auth.POST("/logout", authController.Logout)
// Limit registration bursts
auth.POST("/register", middleware.RateLimit(5, time.Hour), authController.Register)
auth.GET("/check-email", authController.CheckEmail)
// Password reset (public)
auth.POST("/initiate-password-reset", passwordController.InitiatePasswordReset)
auth.POST("/verify-reset-code", passwordController.VerifyResetCode)
auth.POST("/complete-password-reset", passwordController.CompletePasswordReset)
// Legacy password reset endpoints (deprecated)
auth.POST("/forgot-password", passwordController.ForgotPassword)
auth.POST("/forgot-password-admin", passwordController.AdminSendReset)
auth.POST("/reset-password", passwordController.ResetPassword)
auth.GET("/me", middleware.JWTAuth(db), authController.GetCurrentUser)
// Initial admin setup helpers
auth.GET("/admin/exists", authController.AdminExists)
auth.POST("/make-admin", middleware.JWTAuth(db), authController.MakeAdmin)
}
// Event routes (public)
eventController := &controllers.EventController{DB: db}
events := api.Group("/events")
{
events.GET("", eventController.GetEvents)
events.GET("/upcoming", eventController.GetUpcomingEvents)
events.GET("/:id", eventController.GetEventByID)
}
// Protected routes (require authentication)
protected := api.Group("")
protected.Use(middleware.JWTAuth(db))
{
// AI endpoints (protected)
ai := protected.Group("/ai")
{
ai.POST("/blog/generate", aiController.GenerateBlog)
ai.POST("/about/generate", aiController.GenerateAboutPage)
}
// User profile
protected.GET("/me", authController.GetCurrentUser)
// Uploads are registered as a public endpoint below so the handler can
// allow unauthenticated uploads during initial setup (when no admin exists).
// The protected group remains for other authenticated endpoints.
// Events (protected)
protectedEvents := protected.Group("/events")
{
protectedEvents.POST("", eventController.CreateEvent)
protectedEvents.PUT("/:id", eventController.UpdateEvent)
protectedEvents.DELETE("/:id", eventController.DeleteEvent)
}
// Articles (protected - accessible by editors and admins)
articles := protected.Group("/articles")
{
articles.POST("", baseController.CreateArticle)
articles.PUT("/:id", baseController.UpdateArticle)
articles.DELETE("/:id", baseController.DeleteArticle)
// Link article to FACR match
articles.POST("/:id/match-link", baseController.PutArticleMatchLink)
articles.DELETE("/:id/match-link", baseController.DeleteArticleMatchLink)
}
// Teams (protected)
teams := protected.Group("/teams")
{
teams.POST("", baseController.CreateTeam)
teams.PUT("/:id", baseController.UpdateTeam)
teams.DELETE("/:id", baseController.DeleteTeam)
}
// Players (protected)
players := protected.Group("/players")
{
players.POST("", baseController.CreatePlayer)
players.PUT("/:id", baseController.UpdatePlayer)
players.DELETE("/:id", baseController.DeletePlayer)
}
// Sponsors (protected CRUD)
sponsors := protected.Group("/sponsors")
{
sponsors.POST("", baseController.CreateSponsor)
sponsors.PUT("/:id", baseController.UpdateSponsor)
sponsors.DELETE("/:id", baseController.DeleteSponsor)
}
// Admin routes (single consolidated group)
admin := protected.Group("/admin")
admin.Use(middleware.RoleAuth("admin"))
{
// Admin-only endpoints for managing sponsors, etc. (user CRUD removed; no handlers defined)
// Competition aliases (admin)
admin.GET("/competition-aliases", baseController.GetCompetitionAliases)
admin.PUT("/competition-aliases/:code", baseController.PutCompetitionAlias)
admin.DELETE("/competition-aliases/:code", baseController.DeleteCompetitionAlias)
admin.POST("/competition-aliases/reorder", baseController.ReorderCompetitionAliases)
// Categories (admin)
admin.POST("/categories", baseController.CreateCategory)
admin.PUT("/categories/:id", baseController.UpdateCategory)
admin.DELETE("/categories/:id", baseController.DeleteCategory)
// Settings (singleton)
admin.GET("/settings", baseController.GetSettings)
admin.PUT("/settings", baseController.UpdateSettings)
// About page (singleton)
admin.GET("/about", aboutController.GetAdminAboutPage)
admin.PUT("/about", aboutController.UpsertAboutPage)
admin.DELETE("/about", aboutController.DeleteAboutPage)
// Scoreboard (singleton)
admin.GET("/scoreboard", scoreboardController.GetAdmin)
admin.PUT("/scoreboard", scoreboardController.PutAdmin)
// Scoreboard timer controls
admin.POST("/scoreboard/timer/start", scoreboardController.StartTimer)
admin.POST("/scoreboard/timer/pause", scoreboardController.PauseTimer)
admin.POST("/scoreboard/timer/reset", scoreboardController.ResetTimer)
// Scoreboard advanced actions
admin.POST("/scoreboard/swap-sides", scoreboardController.SwapSides)
admin.POST("/scoreboard/second-half", scoreboardController.StartSecondHalf)
// Presets: save/list/load
admin.POST("/scoreboard/save", scoreboardController.SaveState)
admin.GET("/scoreboard/saves", scoreboardController.ListSaves)
admin.POST("/scoreboard/load", scoreboardController.LoadSaved)
// Scoreboard sponsors & QR (admin-only)
admin.GET("/scoreboard/sponsors", scoreboardController.ListSponsors)
admin.POST("/scoreboard/sponsors/upload", scoreboardController.UploadSponsors)
admin.DELETE("/scoreboard/sponsors", scoreboardController.DeleteSponsor)
admin.GET("/scoreboard/qr", scoreboardController.GetQR)
admin.POST("/scoreboard/qr", scoreboardController.UploadQR)
// Users (admin)
admin.GET("/users", authController.ListUsers)
// Create/Update/Delete users
admin.POST("/users", authController.AdminCreateUser)
admin.PUT("/users/:id", authController.AdminUpdateUser)
admin.DELETE("/users/:id", authController.AdminDeleteUser)
// Admin: send password reset email using special SMTP override
admin.POST("/users/send-reset", passwordController.AdminSendReset)
// Admin: reset password for a specific user ID
admin.POST("/users/:id/reset-password", passwordController.AdminSendResetByID)
// Admin matches merged with overrides
admin.GET("/matches", baseController.GetAdminMatches)
// Match & Team Logo Overrides
overrides := admin.Group("")
{
// Match overrides
overrides.GET("/match-overrides", baseController.GetMatchOverrides)
overrides.PUT("/match-overrides/:external_match_id", baseController.PutMatchOverride)
overrides.PATCH("/match-overrides/:external_match_id", baseController.PatchMatchOverride)
// Team logo overrides
overrides.GET("/team-logo-overrides", baseController.GetTeamLogoOverrides)
overrides.PUT("/team-logo-overrides/:external_team_id", baseController.PutTeamLogoOverride)
overrides.PATCH("/team-logo-overrides/:external_team_id", baseController.PatchTeamLogoOverride)
}
// Contact messages management
contactMessages := admin.Group("/contact-messages")
{
contactMessages.GET("", contactController.GetContactMessages)
contactMessages.GET("/:id", contactController.GetContactMessage)
contactMessages.PATCH("/:id/read", contactController.MarkMessageAsRead)
contactMessages.POST("/:id/forward", contactController.ForwardContactMessage)
contactMessages.POST("/forward-all", contactController.ForwardAllContactMessages)
contactMessages.DELETE("/:id", contactController.DeleteContactMessage)
contactMessages.DELETE("", contactController.DeleteContactMessages) // Bulk delete
}
// Newsletter management
admin.GET("/newsletter/subscribers", contactController.GetNewsletterSubscribers)
admin.POST("/newsletter/send", contactController.SendNewsletter)
admin.POST("/newsletter/preview", contactController.PreviewNewsletter)
admin.POST("/newsletter/test", contactController.SendNewsletterTest)
// New: send prebuilt digest by type and toggle automation
admin.POST("/newsletter/send-digest", contactController.SendNewsletterDigest)
admin.PATCH("/newsletter/enable", contactController.UpdateNewsletterAutomation)
// Removed deprecated SMTP test route (use /newsletter/test instead)
admin.GET("/newsletter/status", contactController.GetNewsletterStatus)
admin.GET("/newsletter/stats/recent", emailController.GetRecentEmailStats)
admin.GET("/newsletter/stats/:id/events", emailController.GetEmailEventsForLog)
admin.PATCH("/newsletter/subscribers/:id/preferences", contactController.UpdateNewsletterSubscriberPreferences)
admin.DELETE("/newsletter/subscribers/:id", contactController.DeleteNewsletterSubscriber)
admin.PATCH("/newsletter/subscribers/:id/status", contactController.UpdateNewsletterSubscriberStatus)
// Notifications (admin)
notifications := admin.Group("/notifications")
{
notifications.POST("/competition", notificationsController.SendCompetitionNotification)
notifications.POST("/match", notificationsController.SendMatchNotification)
}
// Prefetch management (admin)
prefetch := admin.Group("/prefetch")
{
prefetch.GET("/status", prefetchController.Status)
prefetch.POST("/trigger", prefetchController.Trigger)
}
// Cache RAW viewer (admin)
cache := admin.Group("/cache")
{
cache.GET("/list", baseController.GetAdminCacheList)
cache.GET("/file", baseController.GetAdminCacheFile)
}
// Gallery management (admin)
gallery := admin.Group("/gallery")
{
gallery.GET("/profile", galleryController.GetGalleryProfile) // Get Zonerama profile
gallery.POST("/albums/fetch", galleryController.FetchAlbum) // Fetch single album
gallery.DELETE("/albums/:id", galleryController.DeleteAlbum) // Delete album
gallery.POST("/refresh", galleryController.RefreshFromZonerama) // Refresh from Zonerama
}
// Alias endpoint for saving a single Zonerama album (keeps older frontend code working)
admin.POST("/zonerama/save-album", galleryController.FetchAlbum)
// Save or update a chosen Zonerama pick (photo) in unified cache
admin.POST("/zonerama/pick", baseController.PutZoneramaPick)
// SEO admin
admin.GET("/seo", seoController.GetSEOSettings)
admin.PUT("/seo", seoController.UpdateSEOSettings)
// Files management (admin)
files := admin.Group("/files")
{
files.GET("", filesController.GetAllFiles)
files.GET("/unused", filesController.GetUnusedFiles)
files.GET("/duplicates", filesController.GetDuplicateFiles)
files.GET("/:id/usages", filesController.GetFileUsages)
files.DELETE("/:id", filesController.DeleteFile)
files.POST("/scan", filesController.ScanAndSyncFiles)
}
// Navigation management (admin)
navigation := admin.Group("/navigation")
{
navigation.GET("", navigationController.GetAllNavigationItems)
navigation.POST("", navigationController.CreateNavigationItem)
navigation.PUT("/:id", navigationController.UpdateNavigationItem)
navigation.DELETE("/:id", navigationController.DeleteNavigationItem)
navigation.POST("/reorder", navigationController.ReorderNavigationItems)
navigation.POST("/seed", navigationController.SeedDefaultNavigation)
}
// Social links management (admin)
socialLinks := admin.Group("/social-links")
{
socialLinks.GET("", navigationController.GetAllSocialLinks)
socialLinks.POST("", navigationController.CreateSocialLink)
socialLinks.PUT("/:id", navigationController.UpdateSocialLink)
socialLinks.DELETE("/:id", navigationController.DeleteSocialLink)
socialLinks.POST("/reorder", navigationController.ReorderSocialLinks)
}
// Clothing management (admin)
clothing := admin.Group("/clothing")
{
clothing.GET("", clothingController.GetClothingAdmin)
clothing.GET("/:id", clothingController.GetClothingByID)
clothing.POST("", clothingController.CreateClothing)
clothing.PUT("/:id", clothingController.UpdateClothing)
clothing.DELETE("/:id", clothingController.DeleteClothing)
clothing.POST("/reorder", clothingController.UpdateClothingOrder)
}
// Polls management (admin)
polls := admin.Group("/polls")
{
polls.GET("", pollController.GetPolls)
polls.GET("/:id", pollController.GetPoll)
polls.POST("", pollController.CreatePoll)
polls.PUT("/:id", pollController.UpdatePoll)
polls.DELETE("/:id", pollController.DeletePoll)
polls.GET("/:id/stats", pollController.GetPollStats)
}
// Page element configurations management (admin)
pageElements := admin.Group("/page-elements")
{
pageElements.GET("", pageElementConfigController.GetAllPageElementConfigs)
pageElements.POST("", pageElementConfigController.CreateOrUpdatePageElementConfig)
pageElements.PUT("/:id", pageElementConfigController.UpdatePageElementConfig)
pageElements.DELETE("/:id", pageElementConfigController.DeletePageElementConfig)
pageElements.POST("/batch", pageElementConfigController.BatchUpdatePageElementConfigs)
}
}
}
// Register analytics routes (public tracking + admin endpoints)
RegisterAnalyticsRoutes(api, db)
// Umami analytics routes
api.GET("/umami/config", umamiController.GetUmamiConfig)
// Public setup endpoint (no auth required - called during initial setup)
api.POST("/umami/initialize-setup", umamiController.InitializeUmamiSetup)
umami := api.Group("/admin/umami")
umami.Use(middleware.JWTAuth(db))
umami.Use(middleware.RoleAuth("admin"))
{
umami.POST("/initialize", umamiController.InitializeUmami)
umami.GET("/stats", umamiController.GetStats)
umami.GET("/metrics/:type", umamiController.GetMetrics)
umami.GET("/pageviews", umamiController.GetPageviews)
}
// Register contact info routes (public + admin endpoints)
RegisterContactInfoRoutes(api, db)
// Public API routes
// Allow uploads publicly so initial setup can upload a club logo before an admin exists.
api.POST("/upload", middleware.RateLimit(30, time.Minute), baseController.UploadImage)
// Public scoreboard
api.GET("/scoreboard", scoreboardController.GetPublic)
api.GET("/scoreboard/colors/derive", scoreboardController.DeriveColors)
// Public core endpoints
api.GET("/settings", baseController.GetPublicSettings)
api.GET("/competition-aliases", baseController.GetPublicCompetitionAliases)
api.GET("/public/team-logo-overrides", baseController.GetPublicTeamLogoOverrides)
// Articles (public)
api.GET("/articles/featured", baseController.GetFeaturedArticles)
api.GET("/articles", baseController.GetArticles)
api.GET("/articles/:id", baseController.GetArticle)
api.GET("/articles/slug/:slug", baseController.GetArticleBySlug)
api.POST("/articles/:id/read", baseController.IncrementArticleRead)
api.POST("/articles/:id/track-view", baseController.TrackArticleView)
// Public read-only access to article-match link
api.GET("/articles/:id/match-link", baseController.GetArticleMatchLink)
// Public categories
api.GET("/categories", baseController.GetCategories)
// Public YouTube cached videos
api.GET("/youtube/videos", youtubeController.GetYouTubeVideos)
// Public About page
api.GET("/about", aboutController.GetPublicAboutPage)
api.GET("/teams", baseController.GetTeams)
api.GET("/teams/:id", baseController.GetTeam)
api.GET("/players", baseController.GetPlayers)
api.GET("/players/:id", baseController.GetPlayer)
api.GET("/sponsors", baseController.GetSponsors)
api.GET("/matches", baseController.GetMatches)
api.GET("/matches/history", baseController.GetMatchesHistory)
api.GET("/standings", baseController.GetStandings)
// Gallery (public): albums and photos
api.GET("/gallery/albums", galleryController.GetGalleryAlbums) // Get all albums
api.GET("/gallery/albums/:id", galleryController.GetGalleryAlbum) // Get single album with photos
api.GET("/gallery/proxy-image", galleryController.ProxyImage) // Proxy Zonerama images to avoid CORS
// Legacy Zonerama endpoints (keep for backwards compatibility)
api.GET("/zonerama/album", baseController.GetZoneramaAlbum)
// Alias to support hyphenated path used by some frontend calls
api.GET("/zonerama-album", baseController.GetZoneramaAlbum)
api.GET("/zonerama/picks", baseController.GetZoneramaPicks)
// Clothing (public) - active items only
api.GET("/clothing", clothingController.GetClothing)
// Polls (public)
api.GET("/polls", pollController.GetPolls)
api.GET("/polls/:id", pollController.GetPoll)
api.POST("/polls/:id/vote", middleware.RateLimit(10, time.Minute), pollController.Vote)
api.GET("/polls/:id/results", pollController.GetPollResults)
// Contact form and newsletter endpoints (public) rate limited to prevent abuse
api.POST("/contact", middleware.RateLimit(10, time.Minute), contactController.SubmitContactForm)
api.POST("/newsletter/subscribe", middleware.RateLimit(30, time.Minute), contactController.SubscribeToNewsletter)
api.POST("/newsletter/setup", middleware.RateLimit(30, time.Minute), contactController.SetupNewsletterPreferences)
api.POST("/newsletter/unsubscribe/:email", middleware.RateLimit(30, time.Minute), contactController.UnsubscribeFromNewsletter)
// Token-based management (no auth)
api.GET("/newsletter/preferences", contactController.GetNewsletterPreferencesByToken)
api.POST("/newsletter/preferences", contactController.SaveNewsletterPreferencesByToken)
api.POST("/newsletter/unsubscribe-token", contactController.UnsubscribeByToken)
}
// FACR scraper endpoints (integrated): /api/v1/facr/*
facr := api.Group("/facr")
{
facr.GET("/club/search", facrController.SearchClubs)
facr.GET("/club/:type/:id", facrController.GetClubInfo)
facr.GET("/club/:type/:id/table", facrController.GetClubTables)
}
}
// SetupRootRoutes registers endpoints at the root (no /api prefix)
func SetupRootRoutes(r *gin.Engine, db *gorm.DB) {
seoController := controllers.NewSEOController(db)
r.GET("/robots.txt", seoController.GetRobotsTXT)
r.GET("/sitemap.xml", seoController.GetSitemapXML)
}
+263
View File
@@ -0,0 +1,263 @@
package services
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
)
// CacheService provides a simple in-memory cache with TTL
type CacheService struct {
mu sync.RWMutex
items map[string]*cacheItem
}
type cacheItem struct {
Value interface{}
Expiration time.Time
}
var (
defaultCache *CacheService
cacheInitOnce sync.Once
)
// GetCacheService returns singleton cache instance
func GetCacheService() *CacheService {
cacheInitOnce.Do(func() {
defaultCache = &CacheService{
items: make(map[string]*cacheItem),
}
// Start cleanup goroutine
go defaultCache.startCleanup()
})
return defaultCache
}
// Get retrieves value from cache
func (cs *CacheService) Get(key string, dest interface{}) error {
cs.mu.RLock()
item, exists := cs.items[key]
cs.mu.RUnlock()
if !exists {
return fmt.Errorf("key not found")
}
if time.Now().After(item.Expiration) {
cs.Delete(key)
return fmt.Errorf("key expired")
}
// Type assertion or JSON marshal/unmarshal
switch v := item.Value.(type) {
case []byte:
return json.Unmarshal(v, dest)
default:
// Marshal and unmarshal for type conversion
data, err := json.Marshal(item.Value)
if err != nil {
return err
}
return json.Unmarshal(data, dest)
}
}
// Set stores value in cache with TTL
func (cs *CacheService) Set(key string, value interface{}, ttl time.Duration) error {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.items[key] = &cacheItem{
Value: value,
Expiration: time.Now().Add(ttl),
}
return nil
}
// Delete removes item from cache
func (cs *CacheService) Delete(key string) {
cs.mu.Lock()
defer cs.mu.Unlock()
delete(cs.items, key)
}
// Clear removes all items from cache
func (cs *CacheService) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.items = make(map[string]*cacheItem)
}
// Has checks if key exists and is not expired
func (cs *CacheService) Has(key string) bool {
cs.mu.RLock()
item, exists := cs.items[key]
cs.mu.RUnlock()
if !exists {
return false
}
if time.Now().After(item.Expiration) {
cs.Delete(key)
return false
}
return true
}
// GetOrSet retrieves from cache or executes function and caches result
func (cs *CacheService) GetOrSet(key string, dest interface{}, ttl time.Duration, fn func() (interface{}, error)) error {
// Try to get from cache
err := cs.Get(key, dest)
if err == nil {
return nil
}
// Execute function
value, err := fn()
if err != nil {
return err
}
// Store in cache
if err := cs.Set(key, value, ttl); err != nil {
return err
}
// Marshal and unmarshal for type conversion
data, err := json.Marshal(value)
if err != nil {
return err
}
return json.Unmarshal(data, dest)
}
// startCleanup periodically removes expired items
func (cs *CacheService) startCleanup() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cs.cleanup()
}
}
func (cs *CacheService) cleanup() {
cs.mu.Lock()
defer cs.mu.Unlock()
now := time.Now()
for key, item := range cs.items {
if now.After(item.Expiration) {
delete(cs.items, key)
}
}
}
// Stats returns cache statistics
func (cs *CacheService) Stats() map[string]interface{} {
cs.mu.RLock()
defer cs.mu.RUnlock()
expired := 0
now := time.Now()
for _, item := range cs.items {
if now.After(item.Expiration) {
expired++
}
}
return map[string]interface{}{
"total_items": len(cs.items),
"expired_items": expired,
"active_items": len(cs.items) - expired,
}
}
// CacheKey generates consistent cache keys
func CacheKey(parts ...string) string {
return "cache:" + joinStrings(parts, ":")
}
func joinStrings(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for i := 1; i < len(parts); i++ {
result += sep + parts[i]
}
return result
}
// WithCache is a decorator for caching function results
func WithCache(key string, ttl time.Duration, fn func() (interface{}, error)) (interface{}, error) {
cache := GetCacheService()
// Try cache first
var result interface{}
if err := cache.Get(key, &result); err == nil {
return result, nil
}
// Execute function
result, err := fn()
if err != nil {
return nil, err
}
// Cache result
cache.Set(key, result, ttl)
return result, nil
}
// InvalidateCachePattern removes all keys matching pattern
func (cs *CacheService) InvalidateCachePattern(pattern string) {
cs.mu.Lock()
defer cs.mu.Unlock()
for key := range cs.items {
if matchesPattern(key, pattern) {
delete(cs.items, key)
}
}
}
func matchesPattern(key, pattern string) bool {
// Simple pattern matching - supports * wildcard
if pattern == "*" {
return true
}
// Check if pattern contains wildcard
if len(pattern) > 0 && pattern[len(pattern)-1] == '*' {
prefix := pattern[:len(pattern)-1]
return len(key) >= len(prefix) && key[:len(prefix)] == prefix
}
return key == pattern
}
// WarmupCache preloads frequently accessed data
func WarmupCache(ctx context.Context) error {
_ = GetCacheService() // Available for warmup logic
// Example: preload settings
// This should be called on application startup
// Add your warmup logic here
// Example:
// cache := GetCacheService()
// settings, err := fetchSettings()
// if err == nil {
// cache.Set("settings:main", settings, 1*time.Hour)
// }
return nil
}
+220
View File
@@ -0,0 +1,220 @@
package services
import (
"encoding/json"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"math"
"net/http"
"strings"
"time"
"fotbal-club/internal/models"
"github.com/muesli/clusters"
"github.com/muesli/kmeans"
)
type FACRService struct {
baseURL string
httpClient *http.Client
}
type FACRClubSearchResponse struct {
Query string `json:"query"`
Count int `json:"count"`
Results []struct {
Name string `json:"name"`
ClubID string `json:"club_id"`
ClubType string `json:"club_type"`
LogoURL string `json:"logo_url"`
Category string `json:"category"`
} `json:"results"`
}
// NewFACRService creates a new FACR service instance
func NewFACRService(baseURL string) *FACRService {
return &FACRService{
baseURL: strings.TrimSuffix(baseURL, "/"),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// SearchClubs searches for clubs by name
func (s *FACRService) SearchClubs(query string) ([]models.ClubSearchResult, error) {
if query == "" {
return nil, errors.New("search query cannot be empty")
}
url := fmt.Sprintf("%s/club/search?q=%s", s.baseURL, query)
resp, err := s.httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var result FACRClubSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("error decoding response: %v", err)
}
var clubs []models.ClubSearchResult
for _, club := range result.Results {
clubs = append(clubs, models.ClubSearchResult{
ID: club.ClubID,
Name: club.Name,
Type: club.ClubType,
LogoURL: club.LogoURL,
Category: club.Category,
})
}
return clubs, nil
}
// extractColorsFromLogo extracts dominant colors from a logo image
func (s *FACRService) extractColorsFromLogo(logoURL string) (string, string, string, error) {
resp, err := s.httpClient.Get(logoURL)
if err != nil {
return "", "", "", fmt.Errorf("error downloading logo: %v", err)
}
defer resp.Body.Close()
img, _, err := image.Decode(resp.Body)
if err != nil {
return "", "", "", fmt.Errorf("error decoding image: %v", err)
}
bounds := img.Bounds()
var points []clusters.Observation
// Sample pixels (every 10th pixel for performance)
for y := bounds.Min.Y; y < bounds.Max.Y; y += 10 {
for x := bounds.Min.X; x < bounds.Max.X; x += 10 {
r, g, b, a := img.At(x, y).RGBA()
if a > 0x7FFF { // Skip transparent pixels
// Convert to float64 slice and create a new observation
point := []float64{
float64(r >> 8),
float64(g >> 8),
float64(b >> 8),
}
points = append(points, clusters.Coordinates(point))
}
}
}
if len(points) == 0 {
return "#1E40AF", "#F59E0B", "#1F2937", nil
}
// Find 3 dominant colors using k-means
km := kmeans.New()
clusters, err := km.Partition(points, 3) // Find 3 dominant colors
if err != nil {
return "", "", "", fmt.Errorf("error in k-means clustering: %v", err)
}
// Sort clusters by size (number of points in each cluster)
for i := 0; i < len(clusters); i++ {
for j := i + 1; j < len(clusters); j++ {
if len(clusters[j].Observations) > len(clusters[i].Observations) {
clusters[i], clusters[j] = clusters[j], clusters[i]
}
}
}
// Get primary and secondary colors
primary := clusters[0].Center
secondary := clusters[1%len(clusters)].Center
// Calculate text color based on brightness
brightness := (0.299*primary[0] + 0.587*primary[1] + 0.114*primary[2]) / 255.0
textColor := "#000000"
if brightness < 0.5 {
textColor = "#FFFFFF"
}
return rgbToHex(primary), rgbToHex(secondary), textColor, nil
}
// rgbToHex converts RGB values to hex color
func rgbToHex(rgb []float64) string {
return fmt.Sprintf("#%02X%02X%02X",
uint8(math.Round(rgb[0])),
uint8(math.Round(rgb[1])),
uint8(math.Round(rgb[2])),
)
}
// GetClubDetails fetches detailed information about a club
func (s *FACRService) GetClubDetails(clubID string) (*models.ClubInfo, error) {
if clubID == "" {
return nil, errors.New("club ID cannot be empty")
}
url := fmt.Sprintf("%s/club/football/%s", s.baseURL, clubID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %v", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("error making request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
var club struct {
Name string `json:"name"`
ClubID string `json:"club_id"`
LogoURL string `json:"logo_url"`
Category string `json:"category"`
}
if err := json.NewDecoder(resp.Body).Decode(&club); err != nil {
return nil, fmt.Errorf("error decoding response: %v", err)
}
// Extract colors from logo if available
var primaryColor, secondaryColor, textColor string
var errExtract error
if club.LogoURL != "" {
primaryColor, secondaryColor, textColor, errExtract = s.extractColorsFromLogo(club.LogoURL)
if errExtract != nil {
// Log the error but continue with default colors
fmt.Printf("Warning: Could not extract colors from logo: %v\n", errExtract)
}
}
// Set default colors if extraction failed or wasn't attempted
if primaryColor == "" {
primaryColor = "#1E40AF" // Blue
secondaryColor = "#F59E0B" // Amber
textColor = "#1F2937" // Gray-800
}
return &models.ClubInfo{
FACRClubID: club.ClubID,
Name: club.Name,
ShortName: club.Name, // Using name as fallback for short name
LogoURL: club.LogoURL,
PrimaryColor: primaryColor,
SecondaryColor: secondaryColor,
TextColor: textColor,
}, nil
}
+191
View File
@@ -0,0 +1,191 @@
package services
import (
"fotbal-club/internal/models"
"strings"
"gorm.io/gorm"
)
// FileTracker provides utilities for tracking file usage
type FileTracker struct {
DB *gorm.DB
}
// NewFileTracker creates a new file tracker instance
func NewFileTracker(db *gorm.DB) *FileTracker {
return &FileTracker{DB: db}
}
// TrackFileUpload records a new uploaded file
func (ft *FileTracker) TrackFileUpload(filePath, fileURL, filename, mimeType string, fileSize int64, uploadedByID *uint) error {
file := models.UploadedFile{
Filename: filename,
FilePath: filePath,
FileURL: fileURL,
FileSize: fileSize,
MimeType: mimeType,
UploadedByID: uploadedByID,
}
return ft.DB.Create(&file).Error
}
// TrackFileUsage records or updates file usage for an entity
func (ft *FileTracker) TrackFileUsage(fileURL, entityType string, entityID uint, fieldName string) error {
if fileURL == "" {
return nil
}
// Find the file by URL
var file models.UploadedFile
if err := ft.DB.Where("file_url = ?", fileURL).First(&file).Error; err != nil {
// File not found in database - skip tracking
return nil
}
// Check if usage already exists
var existingUsage models.FileUsage
err := ft.DB.Where("file_id = ? AND entity_type = ? AND entity_id = ? AND field_name = ?",
file.ID, entityType, entityID, fieldName).First(&existingUsage).Error
if err == gorm.ErrRecordNotFound {
// Create new usage record
usage := models.FileUsage{
FileID: file.ID,
EntityType: entityType,
EntityID: entityID,
FieldName: fieldName,
}
return ft.DB.Create(&usage).Error
}
return err
}
// RemoveFileUsage removes a file usage record
func (ft *FileTracker) RemoveFileUsage(fileURL, entityType string, entityID uint, fieldName string) error {
if fileURL == "" {
return nil
}
// Find the file by URL
var file models.UploadedFile
if err := ft.DB.Where("file_url = ?", fileURL).First(&file).Error; err != nil {
return nil
}
return ft.DB.Where("file_id = ? AND entity_type = ? AND entity_id = ? AND field_name = ?",
file.ID, entityType, entityID, fieldName).Delete(&models.FileUsage{}).Error
}
// UpdateFileUsages updates all file usages for an entity (removes old, adds new)
func (ft *FileTracker) UpdateFileUsages(entityType string, entityID uint, fieldURLMap map[string]string) error {
// Get all current usages for this entity
var currentUsages []models.FileUsage
ft.DB.Where("entity_type = ? AND entity_id = ?", entityType, entityID).Find(&currentUsages)
// Create a map of current usages
currentMap := make(map[string]models.FileUsage)
for _, usage := range currentUsages {
key := usage.FieldName
currentMap[key] = usage
}
// Track new usages
for fieldName, fileURL := range fieldURLMap {
if fileURL != "" {
// Check if already tracked
if _, exists := currentMap[fieldName]; !exists {
ft.TrackFileUsage(fileURL, entityType, entityID, fieldName)
}
delete(currentMap, fieldName) // Remove from current map
}
}
// Remove usages that are no longer present
for fieldName := range currentMap {
ft.DB.Where("entity_type = ? AND entity_id = ? AND field_name = ?",
entityType, entityID, fieldName).Delete(&models.FileUsage{})
}
return nil
}
// TrackArticleFiles tracks all file usages in an article
func (ft *FileTracker) TrackArticleFiles(article *models.Article) error {
fieldURLMap := map[string]string{
"image_url": article.ImageURL,
"og_image_url": article.OGImageURL,
}
// Track attachments if present
if article.Attachments != "" {
// Attachments is a JSON array of URLs
// For simplicity, we'll track each attachment URL separately
// You might want to parse the JSON properly in production
attachments := strings.Split(article.Attachments, ",")
for i, attachment := range attachments {
attachment = strings.Trim(attachment, `[]" `)
if attachment != "" {
fieldName := "attachments"
if i > 0 {
// If multiple attachments, differentiate them
fieldName = "attachments"
}
fieldURLMap[fieldName] = attachment
}
}
}
return ft.UpdateFileUsages("article", article.ID, fieldURLMap)
}
// TrackPlayerFiles tracks all file usages in a player
func (ft *FileTracker) TrackPlayerFiles(player *models.Player) error {
fieldURLMap := map[string]string{
"image_url": player.ImageURL,
}
return ft.UpdateFileUsages("player", player.ID, fieldURLMap)
}
// TrackSponsorFiles tracks all file usages in a sponsor
func (ft *FileTracker) TrackSponsorFiles(sponsor *models.Sponsor) error {
fieldURLMap := map[string]string{
"logo_url": sponsor.LogoURL,
}
return ft.UpdateFileUsages("sponsor", sponsor.ID, fieldURLMap)
}
// TrackEventFiles tracks all file usages in an event
func (ft *FileTracker) TrackEventFiles(event *models.Event) error {
fieldURLMap := map[string]string{
"image_url": event.ImageURL,
"file_url": event.FileURL,
}
return ft.UpdateFileUsages("event", event.ID, fieldURLMap)
}
// TrackContactFiles tracks all file usages in a contact
func (ft *FileTracker) TrackContactFiles(contact *models.Contact) error {
fieldURLMap := map[string]string{
"image_url": contact.ImageURL,
}
return ft.UpdateFileUsages("contact", contact.ID, fieldURLMap)
}
// TrackSettingsFiles tracks all file usages in settings
func (ft *FileTracker) TrackSettingsFiles(settings *models.Settings) error {
fieldURLMap := map[string]string{
"default_og_image_url": settings.DefaultOGImageURL,
"club_logo_url": settings.ClubLogoURL,
}
return ft.UpdateFileUsages("settings", settings.ID, fieldURLMap)
}
// TrackTeamFiles tracks all file usages in a team
func (ft *FileTracker) TrackTeamFiles(team *models.Team) error {
fieldURLMap := map[string]string{
"logo_url": team.LogoURL,
}
return ft.UpdateFileUsages("team", team.ID, fieldURLMap)
}
+267
View File
@@ -0,0 +1,267 @@
package services
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"strings"
)
// Note: To use this image optimizer, install the required package:
// go get golang.org/x/image/draw
//
// For now, we'll use basic resizing without the external library
// Uncomment the import above and the advanced resize function when ready
// ImageSize defines thumbnail dimensions
type ImageSize struct {
Width int
Height int
Name string
}
var (
// StandardSizes for responsive images
StandardSizes = []ImageSize{
{Width: 150, Height: 150, Name: "thumb"},
{Width: 400, Height: 400, Name: "small"},
{Width: 800, Height: 800, Name: "medium"},
{Width: 1200, Height: 1200, Name: "large"},
}
)
// OptimizedImage holds paths to all generated sizes
type OptimizedImage struct {
Original string
Thumb string
Small string
Medium string
Large string
}
// OptimizeAndResize processes an uploaded image
func OptimizeAndResize(sourcePath string) (*OptimizedImage, error) {
// Open source image
file, err := os.Open(sourcePath)
if err != nil {
return nil, fmt.Errorf("failed to open image: %w", err)
}
defer file.Close()
// Decode image
img, format, err := image.Decode(file)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
result := &OptimizedImage{
Original: sourcePath,
}
// Generate thumbnails
dir := filepath.Dir(sourcePath)
base := strings.TrimSuffix(filepath.Base(sourcePath), filepath.Ext(sourcePath))
for _, size := range StandardSizes {
resized := resizeImage(img, size.Width, size.Height)
outputPath := filepath.Join(dir, fmt.Sprintf("%s_%s.jpg", base, size.Name))
if err := saveJPEG(resized, outputPath, 85); err != nil {
continue
}
// Store path in result
switch size.Name {
case "thumb":
result.Thumb = outputPath
case "small":
result.Small = outputPath
case "medium":
result.Medium = outputPath
case "large":
result.Large = outputPath
}
}
// Optimize original if it's too large
if format == "jpeg" || format == "jpg" {
optimizeJPEG(sourcePath)
} else if format == "png" {
convertPNGToJPEG(sourcePath)
}
return result, nil
}
// resizeImage resizes image maintaining aspect ratio
// Using simple nearest-neighbor scaling (for production, consider golang.org/x/image/draw)
func resizeImage(src image.Image, maxWidth, maxHeight int) image.Image {
srcBounds := src.Bounds()
srcW := srcBounds.Dx()
srcH := srcBounds.Dy()
// Calculate new dimensions maintaining aspect ratio
ratio := float64(srcW) / float64(srcH)
var newW, newH int
if srcW > srcH {
newW = maxWidth
newH = int(float64(maxWidth) / ratio)
} else {
newH = maxHeight
newW = int(float64(maxHeight) * ratio)
}
// Don't upscale
if newW > srcW || newH > srcH {
return src
}
// Simple nearest-neighbor resize
// For production quality, use: golang.org/x/image/draw with CatmullRom
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
xRatio := float64(srcW) / float64(newW)
yRatio := float64(srcH) / float64(newH)
for y := 0; y < newH; y++ {
for x := 0; x < newW; x++ {
srcX := int(float64(x) * xRatio)
srcY := int(float64(y) * yRatio)
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}
// saveJPEG saves image as JPEG with specified quality
func saveJPEG(img image.Image, path string, quality int) error {
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, img, &jpeg.Options{Quality: quality})
}
// optimizeJPEG re-encodes JPEG with optimal quality
func optimizeJPEG(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
img, err := jpeg.Decode(file)
if err != nil {
return err
}
// Create temporary file
tmpPath := path + ".tmp"
out, err := os.Create(tmpPath)
if err != nil {
return err
}
// Encode with 85% quality
err = jpeg.Encode(out, img, &jpeg.Options{Quality: 85})
out.Close()
if err != nil {
os.Remove(tmpPath)
return err
}
// Check if new file is smaller
origInfo, _ := os.Stat(path)
newInfo, _ := os.Stat(tmpPath)
if newInfo.Size() < origInfo.Size() {
// Replace original
os.Remove(path)
os.Rename(tmpPath, path)
} else {
// Keep original
os.Remove(tmpPath)
}
return nil
}
// convertPNGToJPEG converts PNG to JPEG for better compression
func convertPNGToJPEG(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
img, err := png.Decode(file)
if err != nil {
return err
}
// Create JPEG version
jpegPath := strings.TrimSuffix(path, ".png") + ".jpg"
out, err := os.Create(jpegPath)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, img, &jpeg.Options{Quality: 90})
}
// GetImageDimensions returns width and height of an image
func GetImageDimensions(path string) (int, int, error) {
file, err := os.Open(path)
if err != nil {
return 0, 0, err
}
defer file.Close()
config, _, err := image.DecodeConfig(file)
if err != nil {
return 0, 0, err
}
return config.Width, config.Height, nil
}
// ValidateImageFile checks if file is a valid image
func ValidateImageFile(reader io.Reader) (string, error) {
// Read first 512 bytes for MIME detection
buf := make([]byte, 512)
n, err := io.ReadFull(reader, buf)
if err != nil && err != io.ErrUnexpectedEOF {
return "", err
}
// Try to decode as image
_, format, err := image.Decode(bytes.NewReader(buf[:n]))
if err != nil {
return "", fmt.Errorf("not a valid image: %w", err)
}
// Validate format
validFormats := map[string]bool{
"jpeg": true,
"jpg": true,
"png": true,
"gif": true,
"webp": true,
}
if !validFormats[format] {
return "", fmt.Errorf("unsupported image format: %s", format)
}
return format, nil
}
+547
View File
@@ -0,0 +1,547 @@
package services
import (
"encoding/json"
"fmt"
"log"
"net/url"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/email"
"fotbal-club/pkg/logger"
"fotbal-club/pkg/utils"
"gorm.io/gorm"
)
// NewsletterAutomation handles all automated newsletter sending
type NewsletterAutomation struct {
db *gorm.DB
emailSvc email.EmailService
cacheDir string
lastWeekly time.Time
lastMatchCheck time.Time
}
// NewNewsletterAutomation creates a new automation service
func NewNewsletterAutomation(db *gorm.DB, emailSvc email.EmailService) *NewsletterAutomation {
return &NewsletterAutomation{
db: db,
emailSvc: emailSvc,
cacheDir: filepath.Join("cache", "prefetch"),
}
}
// Start begins the newsletter automation loop
func (na *NewsletterAutomation) Start() {
log.Printf("[newsletter-automation] Starting automated newsletter service")
// Run initial check after 1 minute
time.AfterFunc(1*time.Minute, func() {
na.RunCycle()
})
// Then run every 15 minutes
ticker := time.NewTicker(15 * time.Minute)
go func() {
for range ticker.C {
na.RunCycle()
}
}()
}
// RunCycle executes all newsletter checks
func (na *NewsletterAutomation) RunCycle() {
if !na.isEnabled() {
log.Printf("[newsletter-automation] Skipped: disabled in settings")
return
}
log.Printf("[newsletter-automation] Running cycle...")
// Check for weekly digest
na.checkWeeklyDigest()
// Check for upcoming matches (reminders)
na.checkUpcomingMatches()
// Check for finished matches (results)
na.checkFinishedMatches()
log.Printf("[newsletter-automation] Cycle complete")
}
// SendBlogNotification sends immediate notification when a blog is published
func (na *NewsletterAutomation) SendBlogNotification(article *models.Article) error {
if !na.isEnabled() {
return fmt.Errorf("newsletter automation is disabled")
}
// Check if already sent
var existing models.BlogNotification
if err := na.db.Where("article_id = ?", article.ID).First(&existing).Error; err == nil {
log.Printf("[newsletter-automation] Blog notification already sent for article %d", article.ID)
return nil
}
// Get subscribers interested in blogs
subs := na.getSubscribersForType("blogs", article.CategoryName)
if len(subs) == 0 {
log.Printf("[newsletter-automation] No subscribers for blog notifications")
return nil
}
// Build email content
subject := fmt.Sprintf("Nový článek: %s", article.Title)
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
articleURL := fmt.Sprintf("%s/news/%s", baseFE, article.Slug)
html := na.buildBlogNotificationHTML(article, articleURL)
// Send to each subscriber
recipients := make([]string, 0, len(subs))
for _, sub := range subs {
recipients = append(recipients, sub.Email)
}
err := na.sendNewsletterToRecipients(recipients, subject, html, "blog_release")
if err != nil {
logger.Error("[newsletter-automation] Failed to send blog notification: %v", err)
return err
}
// Record notification
notif := models.BlogNotification{
ArticleID: article.ID,
SentAt: time.Now(),
RecipientsCount: len(recipients),
CreatedAt: time.Now(),
}
na.db.Create(&notif)
log.Printf("[newsletter-automation] Blog notification sent for article %d to %d recipients", article.ID, len(recipients))
return nil
}
func (na *NewsletterAutomation) checkWeeklyDigest() {
var settings models.Settings
na.db.First(&settings)
if !settings.EnableWeekly {
return
}
// Get configured day and hour
targetDay := strings.ToLower(strings.TrimSpace(settings.NewsletterWeeklyDay))
if targetDay == "" {
targetDay = "sun" // Default to Sunday
}
targetHour := settings.NewsletterWeeklyHour
if targetHour < 0 || targetHour > 23 {
targetHour = 9 // Default to 9 AM
}
now := time.Now()
currentDay := strings.ToLower(now.Weekday().String()[:3])
currentHour := now.Hour()
// Check if it's the right day and hour
if currentDay != targetDay || currentHour != targetHour {
return
}
// Check if already sent today
if na.lastWeekly.Year() == now.Year() && na.lastWeekly.YearDay() == now.YearDay() {
return
}
// Get all subscribers interested in weekly digest
subs := na.getSubscribersForType("weekly", "")
if len(subs) == 0 {
log.Printf("[newsletter-automation] No subscribers for weekly digest")
return
}
log.Printf("[newsletter-automation] Sending weekly digest to %d subscribers", len(subs))
// Build weekly content for each subscriber based on their preferences
for _, sub := range subs {
prefs := na.parsePreferences(sub)
subject, html := BuildNewsletterDigest(na.cacheDir, prefs)
if strings.TrimSpace(html) == "" {
continue
}
err := na.sendNewsletterToRecipients([]string{sub.Email}, subject, html, "weekly")
if err != nil {
logger.Error("[newsletter-automation] Failed to send weekly digest to %s: %v", sub.Email, err)
}
time.Sleep(200 * time.Millisecond) // Rate limiting
}
na.lastWeekly = now
log.Printf("[newsletter-automation] Weekly digest sent")
}
func (na *NewsletterAutomation) checkUpcomingMatches() {
var settings models.Settings
na.db.First(&settings)
if !settings.EnableMatchReminders {
return
}
leadHours := settings.NewsletterReminderLeadHours
if leadHours <= 0 {
leadHours = 48 // Default 2 days
}
// Load match data from cache
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
matches := facrAllMatches(facr)
now := time.Now()
for _, match := range matches {
matchTime := parseDateTimeISO(match.Date, match.Time)
if matchTime.IsZero() || matchTime.Before(now) {
continue
}
hoursUntil := matchTime.Sub(now).Hours()
// Check for 48h reminder
if hoursUntil <= float64(leadHours) && hoursUntil > float64(leadHours-1) {
na.sendMatchReminder(match, "reminder_48h", leadHours)
}
// Check for day-of reminder (match starts in 0-6 hours)
if hoursUntil <= 6 && hoursUntil > 0 {
na.sendMatchReminder(match, "reminder_day", 0)
}
}
}
func (na *NewsletterAutomation) sendMatchReminder(match Match, notifType string, hoursBeforeText int) {
// Check if already sent
var existing models.MatchNotification
matchKey := fmt.Sprintf("%s-%s-%s", match.Date, match.Home, match.Away)
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, notifType).First(&existing).Error; err == nil {
return
}
// Get subscribers interested in matches and this competition
subs := na.getSubscribersForType("matches", match.Competition)
if len(subs) == 0 {
return
}
// Build email content
var subject string
if notifType == "reminder_48h" {
subject = fmt.Sprintf("Nadcházející zápas za %d hodin: %s vs %s", hoursBeforeText, match.Home, match.Away)
} else {
subject = fmt.Sprintf("Zápas dnes: %s vs %s", match.Home, match.Away)
}
html := na.buildMatchReminderHTML(match, notifType)
recipients := make([]string, 0, len(subs))
for _, sub := range subs {
recipients = append(recipients, sub.Email)
}
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_reminder")
if err != nil {
logger.Error("[newsletter-automation] Failed to send match reminder: %v", err)
return
}
// Record notification
notif := models.MatchNotification{
MatchID: matchKey,
NotificationType: notifType,
SentAt: time.Now(),
RecipientsCount: len(recipients),
CreatedAt: time.Now(),
}
na.db.Create(&notif)
log.Printf("[newsletter-automation] Match reminder sent: %s (%s) to %d recipients", matchKey, notifType, len(recipients))
}
func (na *NewsletterAutomation) checkFinishedMatches() {
var settings models.Settings
na.db.First(&settings)
if !settings.EnableResults {
return
}
// Check quiet hours
currentHour := time.Now().Hour()
quietStart := settings.NewsletterQuietStart
quietEnd := settings.NewsletterQuietEnd
if quietStart > 0 && quietEnd > 0 {
if quietStart < quietEnd {
// e.g., 22:00 - 08:00
if currentHour >= quietStart || currentHour < quietEnd {
log.Printf("[newsletter-automation] In quiet hours, skipping result notifications")
return
}
} else {
// e.g., 08:00 - 22:00 (inverted, send only during these hours)
if currentHour < quietStart && currentHour >= quietEnd {
log.Printf("[newsletter-automation] Outside active hours, skipping result notifications")
return
}
}
}
// Load match data
facr := readJSON(filepath.Join(na.cacheDir, "facr_club_info.json"))
matches := facrAllMatches(facr)
now := time.Now()
lookback := 6 * time.Hour // Check matches finished in last 6 hours
for _, match := range matches {
if match.Score == "" || !strings.Contains(match.Score, ":") {
continue // No score yet
}
matchTime := parseDateTimeISO(match.Date, match.Time)
if matchTime.IsZero() || matchTime.After(now) {
continue
}
// Check if match finished recently
timeSinceMatch := now.Sub(matchTime)
if timeSinceMatch > lookback {
continue
}
na.sendMatchResult(match)
}
}
func (na *NewsletterAutomation) sendMatchResult(match Match) {
// Check if already sent
matchKey := fmt.Sprintf("%s-%s-%s", match.Date, match.Home, match.Away)
var existing models.MatchNotification
if err := na.db.Where("match_id = ? AND notification_type = ?", matchKey, "result").First(&existing).Error; err == nil {
return
}
// Get subscribers interested in results
subs := na.getSubscribersForType("scores", match.Competition)
if len(subs) == 0 {
return
}
subject := fmt.Sprintf("Výsledek: %s %s %s", match.Home, match.Score, match.Away)
html := na.buildMatchResultHTML(match)
recipients := make([]string, 0, len(subs))
for _, sub := range subs {
recipients = append(recipients, sub.Email)
}
err := na.sendNewsletterToRecipients(recipients, subject, html, "match_result")
if err != nil {
logger.Error("[newsletter-automation] Failed to send match result: %v", err)
return
}
// Record notification
notif := models.MatchNotification{
MatchID: matchKey,
NotificationType: "result",
SentAt: time.Now(),
RecipientsCount: len(recipients),
CreatedAt: time.Now(),
}
na.db.Create(&notif)
log.Printf("[newsletter-automation] Match result sent: %s to %d recipients", matchKey, len(recipients))
}
// Helper functions
func (na *NewsletterAutomation) isEnabled() bool {
if config.AppConfig == nil {
return false
}
return config.AppConfig.NewsletterEnabled
}
func (na *NewsletterAutomation) getSubscribersForType(contentType, category string) []models.NewsletterSubscription {
var subs []models.NewsletterSubscription
na.db.Where("is_active = ?", true).Find(&subs)
filtered := make([]models.NewsletterSubscription, 0)
for _, sub := range subs {
// Check if subscriber wants this content type
if val, ok := sub.Preferences[contentType].(bool); ok && val {
// If category filtering is needed and specified
if category != "" {
// Check if subscriber has category preferences
if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" {
categoryList := strings.Split(cats, ",")
found := false
for _, cat := range categoryList {
if strings.EqualFold(strings.TrimSpace(cat), category) {
found = true
break
}
}
if !found {
continue
}
}
}
filtered = append(filtered, sub)
}
}
return filtered
}
func (na *NewsletterAutomation) parsePreferences(sub models.NewsletterSubscription) NewsletterPrefs {
prefs := NewsletterPrefs{
Email: sub.Email,
ContentTypes: []string{},
Competitions: []string{},
Frequency: "daily",
}
// Parse content types
if v, ok := sub.Preferences["blogs"].(bool); ok && v {
prefs.ContentTypes = append(prefs.ContentTypes, "blogs")
}
if v, ok := sub.Preferences["events"].(bool); ok && v {
prefs.ContentTypes = append(prefs.ContentTypes, "events")
}
if v, ok := sub.Preferences["matches"].(bool); ok && v {
prefs.ContentTypes = append(prefs.ContentTypes, "matches")
}
if v, ok := sub.Preferences["scores"].(bool); ok && v {
prefs.ContentTypes = append(prefs.ContentTypes, "scores")
}
// Parse categories/competitions
if cats, ok := sub.Preferences["categories"].(string); ok && cats != "" {
for _, c := range strings.Split(cats, ",") {
if v := strings.TrimSpace(c); v != "" {
prefs.Competitions = append(prefs.Competitions, v)
}
}
}
return prefs
}
func (na *NewsletterAutomation) sendNewsletterToRecipients(recipients []string, subject, htmlContent, newsletterType string) error {
data := &email.NewsletterData{
Subject: subject,
Content: htmlContent,
Recipients: recipients,
}
err := na.emailSvc.SendNewsletter(data)
if err != nil {
return err
}
// Log sent newsletter
contentIDsJSON, _ := json.Marshal([]string{})
logEntry := models.NewsletterSentLog{
NewsletterType: newsletterType,
Subject: subject,
ContentIDs: string(contentIDsJSON),
RecipientsCount: len(recipients),
SentAt: time.Now(),
CreatedAt: time.Now(),
}
na.db.Create(&logEntry)
return nil
}
// HTML builders
func (na *NewsletterAutomation) buildBlogNotificationHTML(article *models.Article, articleURL string) string {
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
// Build tracked link
token, _ := utils.GenerateSubscriberToken("newsletter@system", 60*24*30)
trackedURL := fmt.Sprintf("%s/api/v1/email/click?u=%s&t=%s",
strings.TrimSuffix(config.AppConfig.PublicAPIBaseURL, "/"),
url.QueryEscape(articleURL),
url.QueryEscape(token))
html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Nový článek na webu</h2>
<div style="border-left: 4px solid #2563eb; padding: 20px; background: #f8fafc; margin: 20px 0;">
<h3 style="margin: 0 0 15px 0; color: #1e3a8a;">%s</h3>
<p style="color: #4a5568; line-height: 1.6; margin: 0 0 15px 0;">%s</p>
<a href="%s" style="display: inline-block; padding: 12px 24px; background: #2563eb; color: white; text-decoration: none; border-radius: 6px; font-weight: 600;">Číst článek</a>
</div>
<p style="color: #718096; font-size: 14px; margin-top: 30px;">
<a href="%s/newsletter/preferences?token=%s" style="color: #2563eb;">Spravovat předvolby</a>
</p>
</div>
`, htmlEsc(article.Title), htmlEsc(article.Excerpt), trackedURL, baseFE, url.QueryEscape(token))
return html
}
func (na *NewsletterAutomation) buildMatchReminderHTML(match Match, notifType string) string {
var intro string
if notifType == "reminder_48h" {
intro = "Připomínáme nadcházející zápas:"
} else {
intro = "Zápas je dnes!"
}
html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">%s</h2>
<div style="border-left: 4px solid #38a169; padding: 20px; background: #f0fff4; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">%s vs %s</h3>
<p style="color: #276749; margin: 5px 0;"><strong>Datum:</strong> %s</p>
<p style="color: #276749; margin: 5px 0;"><strong>Čas:</strong> %s</p>
<p style="color: #276749; margin: 5px 0;"><strong>Soutěž:</strong> %s</p>
</div>
</div>
`, intro, htmlEsc(match.Home), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Time), htmlEsc(match.Competition))
return html
}
func (na *NewsletterAutomation) buildMatchResultHTML(match Match) string {
html := fmt.Sprintf(`
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
<h2 style="color: #1e3a8a; margin-bottom: 20px;">Výsledek zápasu</h2>
<div style="border-left: 4px solid #d69e2e; padding: 20px; background: #fffbeb; margin: 20px 0;">
<h3 style="margin: 0 0 10px 0; color: #1e3a8a; font-size: 24px;">%s <span style="color: #d69e2e;">%s</span> %s</h3>
<p style="color: #975a16; margin: 5px 0;"><strong>Datum:</strong> %s</p>
<p style="color: #975a16; margin: 5px 0;"><strong>Soutěž:</strong> %s</p>
</div>
</div>
`, htmlEsc(match.Home), htmlEsc(match.Score), htmlEsc(match.Away), htmlEsc(match.Date), htmlEsc(match.Competition))
return html
}
+335
View File
@@ -0,0 +1,335 @@
package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// NewsletterPrefs represents the normalized subset of subscriber preferences
// we care about when generating automated content.
type NewsletterPrefs struct {
Email string `json:"email"`
ContentTypes []string `json:"content_types"` // blogs, matches, scores, events
Competitions []string `json:"competitions"` // FACR codes
Frequency string `json:"frequency"` // daily, weekly, matchday
}
// BuildNewsletterDigest builds an HTML digest string and subject based on cached JSON
// and subscriber preferences. It is robust to cache shape differences.
func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject string, html string) {
// Normalize content types
want := make(map[string]bool)
for _, c := range prefs.ContentTypes {
want[strings.ToLower(strings.TrimSpace(c))] = true
}
if len(want) == 0 {
// default to blogs + matches
want["blogs"] = true
want["matches"] = true
}
// Load caches (best-effort)
art := readJSON(filepath.Join(cacheDir, "articles.json"))
ev := readJSON(filepath.Join(cacheDir, "events_upcoming.json"))
facr:= readJSON(filepath.Join(cacheDir, "facr_club_info.json"))
sections := make([]string, 0, 4)
// Blogs/articles
if want["blogs"] {
items := pickArticles(art, 6)
if len(items) > 0 {
sections = append(sections, renderArticlesSection(items))
}
}
// Upcoming events
if want["events"] || want["matches"] {
items := pickUpcomingEvents(ev, 6)
if len(items) > 0 {
sections = append(sections, renderEventsSection(items))
}
}
// Matches (from FACR club info)
if want["matches"] {
items := pickUpcomingMatchesFromFACR(facr, prefs.Competitions, 6)
if len(items) > 0 {
sections = append(sections, renderMatchesSection(items))
}
}
// Scores/results digest (past week) from FACR club info
if want["scores"] {
items := pickRecentResultsFromFACR(facr, prefs.Competitions, 8, 7*24*time.Hour)
if len(items) > 0 {
sections = append(sections, renderResultsSection(items))
}
}
if len(sections) == 0 {
return "Fotbal Club přehled", "<p>Pro vybrané preference nyní nemáme novinky.</p>"
}
subject = "Fotbal Club novinky a zápasy"
html = strings.Join(sections, "\n\n")
return subject, html
}
// Helpers
func readJSON(path string) any {
b, err := os.ReadFile(path)
if err != nil || len(b) == 0 { return nil }
var v any
_ = json.Unmarshal(b, &v)
return v
}
type Article struct { Title, Url, Image, Excerpt string; Date time.Time }
func pickArticles(v any, n int) []Article {
// Accept shapes: {items:[]}, {data:[]}, []
list := asList(v)
out := make([]Article, 0, n)
for i, it := range list {
if i >= n { break }
m := asMap(it)
a := Article{
Title: str(m["title"], str(m["name"], "Článek")),
Url: str(m["url"], urlFromSlug(m)),
Image: str(m["imageUrl"], str(m["image_url"], str(m["image"], ""))),
Excerpt: str(m["excerpt"], str(m["summary"], "")),
}
out = append(out, a)
}
return out
}
type Event struct { Title, Date, Time, Url string }
func pickUpcomingEvents(v any, n int) []Event {
list := asList(v)
out := make([]Event, 0, n)
for i, it := range list {
if i >= n { break }
m := asMap(it)
e := Event{
Title: str(m["title"], str(m["name"], "Událost")),
Date: str(m["date"], ""),
Time: str(m["time"], ""),
Url: str(m["url"], ""),
}
out = append(out, e)
}
return out
}
type Match struct { Home, Away, Date, Time, Competition, Link, Score string }
func pickUpcomingMatchesFromFACR(v any, competitions []string, n int) []Match {
compSet := make(map[string]bool)
for _, c := range competitions { compSet[strings.TrimSpace(strings.ToLower(c))] = true }
now := time.Now()
list := facrAllMatches(v)
out := make([]Match, 0, n)
for _, m := range list {
ts := parseDateTimeISO(m.Date, m.Time)
if ts.IsZero() || ts.Before(now) { continue }
if len(compSet) > 0 {
if !compSet[strings.ToLower(m.Competition)] { continue }
}
out = append(out, m)
if len(out) >= n { break }
}
return out
}
func pickRecentResultsFromFACR(v any, competitions []string, n int, window time.Duration) []Match {
compSet := make(map[string]bool)
for _, c := range competitions { compSet[strings.TrimSpace(strings.ToLower(c))] = true }
now := time.Now()
from := now.Add(-window)
list := facrAllMatches(v)
out := make([]Match, 0, n)
for _, m := range list {
ts := parseDateTimeISO(m.Date, m.Time)
if ts.IsZero() || ts.After(now) || ts.Before(from) { continue }
// treat as result if score like "2:1" exists
if m.Score == "" || !strings.Contains(m.Score, ":") { continue }
if len(compSet) > 0 {
if !compSet[strings.ToLower(m.Competition)] { continue }
}
out = append(out, m)
}
// Show latest first
sort.Slice(out, func(i, j int) bool {
ti := parseDateTimeISO(out[i].Date, out[i].Time)
tj := parseDateTimeISO(out[j].Date, out[j].Time)
return ti.After(tj)
})
if len(out) > n { out = out[:n] }
return out
}
// FACR helpers (robust to various shapes)
func facrAllMatches(v any) []Match {
out := []Match{}
if v == nil { return out }
m := asMap(v)
// competitions array
if comps, ok := m["competitions"]; ok {
for _, c := range asList(comps) {
cm := asMap(c)
compName := str(cm["name"], str(cm["code"], ""))
for _, mm := range asList(cm["matches"]) {
out = append(out, toMatch(asMap(mm), compName))
}
}
}
// flat matches fallback
for _, mm := range asList(m["matches"]) {
out = append(out, toMatch(asMap(mm), ""))
}
return out
}
func toMatch(m map[string]any, comp string) Match {
dt := str(m["date_time"], "")
var date, tm string
if dt != "" && strings.Contains(dt, " ") {
parts := strings.SplitN(dt, " ", 2)
date, tm = parts[0], parts[1]
} else {
date = str(m["date"], "")
tm = str(m["time"], "")
}
return Match{
Home: str(m["home"], ""),
Away: str(m["away"], ""),
Date: date,
Time: tm,
Competition: str(m["competition"], str(m["competition_name"], comp)),
Link: str(m["facr_link"], str(m["report_url"], "#")),
Score: str(m["score"], ""),
}
}
func parseDateTimeISO(d, t string) time.Time {
if d == "" { return time.Time{} }
if t == "" { t = "00:00" }
layout := "2006-01-02T15:04:05"
// try shorter HH:MM format
if len(t) == 5 { return parseTime("2006-01-02T15:04", d+"T"+t) }
return parseTime(layout, d+"T"+t)
}
func parseTime(layout, s string) time.Time {
if tm, err := time.Parse(layout, s); err == nil { return tm }
// Try local
if tm, err := time.ParseInLocation(layout, s, time.Local); err == nil { return tm }
return time.Time{}
}
// Render helpers (inline styles for email)
func renderArticlesSection(items []Article) string {
b := &strings.Builder{}
fmt.Fprintf(b, "<h2 style='margin:0 0 12px 0;'>Články</h2>")
for _, a := range items {
fmt.Fprintf(b, "<div style='margin:10px 0;padding:10px;border-left:4px solid #2b6cb0;background:#f8fafc;'>")
if a.Title != "" { fmt.Fprintf(b, "<div style='font-weight:600;'>%s</div>", htmlEsc(a.Title)) }
if a.Excerpt != "" { fmt.Fprintf(b, "<div style='color:#4a5568;font-size:14px;'>%s</div>", htmlEsc(a.Excerpt)) }
if a.Url != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Číst více</a></div>", a.Url) }
fmt.Fprintf(b, "</div>")
}
return b.String()
}
func renderEventsSection(items []Event) string {
b := &strings.Builder{}
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Události</h2>")
for _, e := range items {
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #3182ce;background:#ebf8ff;'>")
fmt.Fprintf(b, "<div style='font-weight:600;'>%s</div>", htmlEsc(e.Title))
if e.Date != "" || e.Time != "" {
fmt.Fprintf(b, "<div style='color:#2c5282;font-size:14px;'>%s %s</div>", htmlEsc(e.Date), htmlEsc(e.Time))
}
if e.Url != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Zobrazit</a></div>", e.Url) }
fmt.Fprintf(b, "</div>")
}
return b.String()
}
func renderMatchesSection(items []Match) string {
b := &strings.Builder{}
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Nejbližší zápasy</h2>")
for _, m := range items {
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #38a169;background:#f0fff4;'>")
fmt.Fprintf(b, "<div style='font-weight:600;'>%s vs %s</div>", htmlEsc(m.Home), htmlEsc(m.Away))
meta := strings.TrimSpace(fmt.Sprintf("%s %s · %s", m.Date, m.Time, m.Competition))
fmt.Fprintf(b, "<div style='color:#276749;font-size:14px;'>%s</div>", htmlEsc(meta))
if m.Link != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Detail</a></div>", m.Link) }
fmt.Fprintf(b, "</div>")
}
return b.String()
}
func renderResultsSection(items []Match) string {
b := &strings.Builder{}
fmt.Fprintf(b, "<h2 style='margin:16px 0 12px 0;'>Výsledky týdne</h2>")
for _, m := range items {
fmt.Fprintf(b, "<div style='margin:8px 0;padding:8px;border-left:4px solid #d69e2e;background:#fffbeb;'>")
fmt.Fprintf(b, "<div style='font-weight:600;'>%s %s %s</div>", htmlEsc(m.Home), htmlEsc(m.Score), htmlEsc(m.Away))
meta := strings.TrimSpace(fmt.Sprintf("%s %s · %s", m.Date, m.Time, m.Competition))
fmt.Fprintf(b, "<div style='color:#975a16;font-size:14px;'>%s</div>", htmlEsc(meta))
if m.Link != "" { fmt.Fprintf(b, "<div><a href='%s' style='color:#2b6cb0;'>Zápis</a></div>", m.Link) }
fmt.Fprintf(b, "</div>")
}
return b.String()
}
// small utils
func asList(v any) []any {
if v == nil { return nil }
if arr, ok := v.([]any); ok { return arr }
if m, ok := v.(map[string]any); ok {
if a, ok := m["items"]; ok { return asList(a) }
if a, ok := m["data"]; ok { return asList(a) }
}
return nil
}
func asMap(v any) map[string]any {
if v == nil { return map[string]any{} }
if m, ok := v.(map[string]any); ok { return m }
return map[string]any{}
}
func str(v any, def string) string {
if s, ok := v.(string); ok && s != "" { return s }
return def
}
func urlFromSlug(m map[string]any) string {
if s, ok := m["slug"].(string); ok && s != "" { return "/news/" + s }
return ""
}
func htmlEsc(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&quot;",
"'", "&#39;",
)
return r.Replace(s)
}
+114
View File
@@ -0,0 +1,114 @@
package services
import (
"log"
"os"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/pkg/email"
"gorm.io/gorm"
)
// StartNewsletterScheduler starts a background job that sends automated newsletters.
// It can be configured via env NEWSLETTER_INTERVAL_HOURS and is guarded by config.NewsletterEnabled.
func StartNewsletterScheduler(db *gorm.DB, emailSvc email.EmailService) {
interval := 24 * time.Hour // default daily for demo; real use might be weekly
if v := os.Getenv("NEWSLETTER_INTERVAL_HOURS"); v != "" {
if h, err := time.ParseDuration(v + "h"); err == nil {
interval = h
}
}
// Run once on startup only if enabled
if config.AppConfig != nil && config.AppConfig.NewsletterEnabled {
go runNewsletterCycle(db, emailSvc)
} else {
log.Printf("[newsletter] scheduler is disabled at startup; waiting for enable toggle")
}
// Periodic ticker
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if config.AppConfig != nil && config.AppConfig.NewsletterEnabled {
runNewsletterCycle(db, emailSvc)
} else {
log.Printf("[newsletter] skipped cycle (disabled)")
}
}
}()
}
func runNewsletterCycle(db *gorm.DB, emailSvc email.EmailService) {
log.Printf("[newsletter] starting automated newsletter cycle")
cacheDir := filepath.Join("cache", "prefetch")
// Fetch subscribers in batches
var subs []models.NewsletterSubscription
if err := db.Find(&subs).Error; err != nil {
log.Printf("[newsletter] failed to load subscribers: %v", err)
return
}
for _, s := range subs {
if !s.IsActive {
continue
}
// Normalize preferences to new structure
ct := make([]string, 0, 4)
if v, ok := s.Preferences["blogs"].(bool); ok && v { ct = append(ct, "blogs") }
if v, ok := s.Preferences["events"].(bool); ok && v { ct = append(ct, "events") }
if v, ok := s.Preferences["matches"].(bool); ok && v { ct = append(ct, "matches") }
if v, ok := s.Preferences["scores"].(bool); ok && v { ct = append(ct, "scores") }
// Backward-compat: weekly implies blogs; matches implies matches
if v, ok := s.Preferences["weekly"].(bool); ok && v && !contains(ct, "blogs") { ct = append(ct, "blogs") }
if v, ok := s.Preferences["matches"].(bool); ok && v && !contains(ct, "matches") { ct = append(ct, "matches") }
// Optional competitions list if stored under key like competitions
comps := []string{}
if raw, ok := s.Preferences["competitions"].(string); ok && raw != "" {
// comma-separated codes
for _, p := range strings.Split(raw, ",") {
if v := strings.TrimSpace(p); v != "" { comps = append(comps, v) }
}
}
prefs := NewsletterPrefs{
Email: s.Email,
ContentTypes: ct,
Competitions: comps,
Frequency: "daily",
}
subj, content := BuildNewsletterDigest(cacheDir, prefs)
if strings.TrimSpace(content) == "" { continue }
data := &email.NewsletterData{
Subject: subj,
Content: content,
Recipients: []string{s.Email},
}
if err := emailSvc.SendNewsletter(data); err != nil {
log.Printf("[newsletter] failed to send to %s: %v", s.Email, err)
}
// small sleep to avoid hammering SMTP
time.Sleep(200 * time.Millisecond)
}
log.Printf("[newsletter] automated cycle finished")
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
package services
import (
"errors"
"time"
"fotbal-club/internal/models"
"gorm.io/gorm"
)
type SetupService struct {
db *gorm.DB
}
func NewSetupService(db *gorm.DB) *SetupService {
return &SetupService{db: db}
}
// GetSetupStatus returns the current setup status
func (s *SetupService) GetSetupStatus() (*models.SetupInfo, error) {
var setupInfo models.SetupInfo
if err := s.db.FirstOrCreate(&setupInfo, models.SetupInfo{Model: gorm.Model{ID: 1}}).Error; err != nil {
return nil, err
}
return &setupInfo, nil
}
// MarkSMTPConfigured marks the SMTP configuration as completed
func (s *SetupService) MarkSMTPConfigured() error {
return s.db.Model(&models.SetupInfo{}).Where("id = ?", 1).Updates(map[string]interface{}{
"smtp_configured": true,
"updated_at": time.Now(),
}).Error
}
// SaveClubInfo saves the club information from FACR
func (s *SetupService) SaveClubInfo(clubInfo *models.ClubInfo) error {
// Start a transaction
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// Update setup info
if err := tx.Model(&models.SetupInfo{}).Where("id = ?", 1).Updates(map[string]interface{}{
"club_imported": true,
"updated_at": time.Now(),
}).Error; err != nil {
tx.Rollback()
return err
}
// Save club info
clubInfo.SetupInfoID = 1
if err := tx.Create(clubInfo).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
// CompleteSetup marks the setup as completed
func (s *SetupService) CompleteSetup() error {
now := time.Now()
return s.db.Model(&models.SetupInfo{}).Where("id = ?", 1).Updates(map[string]interface{}{
"status": models.SetupStatusCompleted,
"completed_at": now,
"updated_at": now,
}).Error
}
// SkipSetup marks the setup as skipped
func (s *SetupService) SkipSetup() error {
now := time.Now()
return s.db.Model(&models.SetupInfo{}).Where("id = ?", 1).Updates(map[string]interface{}{
"status": models.SetupStatusSkipped,
"skipped_at": now,
"updated_at": now,
}).Error
}
// GetClubInfo returns the club information if it exists
func (s *SetupService) GetClubInfo() (*models.ClubInfo, error) {
var clubInfo models.ClubInfo
if err := s.db.Where("setup_info_id = ?", 1).First(&clubInfo).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &clubInfo, nil
}
+455
View File
@@ -0,0 +1,455 @@
package services
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"fotbal-club/internal/config"
"fotbal-club/pkg/logger"
)
type UmamiService struct {
baseURL string
username string
password string
token string
tokenExp time.Time
lastVerified time.Time
defaultWebsiteID string
}
// --- Helpers for website discovery/creation and event sending ---
// UmamiWebsite represents a single website entry as returned by the list API
type UmamiWebsite struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
}
// umamiListWebsitesResponse matches the shape of GET /api/websites
type umamiListWebsitesResponse struct {
Data []UmamiWebsite `json:"data"`
Count int `json:"count"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
var ErrUmamiNoWebsites = errors.New("no websites found in Umami")
// FindWebsiteIDByDomain returns the website ID for a given domain if it exists.
func (u *UmamiService) FindWebsiteIDByDomain(domain string) (string, error) {
if err := u.authenticate(); err != nil {
return "", err
}
url := fmt.Sprintf("%s/api/websites?page=1&pageSize=100&orderBy=name&query=%s", u.baseURL, domain)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create list websites request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+u.token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send list websites request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("list websites failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var list umamiListWebsitesResponse
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return "", fmt.Errorf("failed to decode list websites response: %w", err)
}
for _, w := range list.Data {
if w.Domain == domain {
return w.ID, nil
}
}
return "", nil
}
// GetDefaultWebsiteID returns the first available website ID from Umami
func (u *UmamiService) GetDefaultWebsiteID() (string, error) {
logger.Info("Attempting to get default Umami website ID from %s", u.baseURL)
if err := u.authenticate(); err != nil {
return "", fmt.Errorf("authentication failed: %w", err)
}
url := fmt.Sprintf("%s/api/websites?page=1&pageSize=1&orderBy=name", u.baseURL)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create list websites request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+u.token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send list websites request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("list websites failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var list umamiListWebsitesResponse
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return "", fmt.Errorf("failed to decode list websites response: %w", err)
}
if len(list.Data) == 0 {
logger.Warn("No websites found in Umami instance at %s. Please create a website first.", u.baseURL)
return "", ErrUmamiNoWebsites
}
u.defaultWebsiteID = list.Data[0].ID
logger.Info("Found default Umami website: ID=%s, Name=%s, Domain=%s", list.Data[0].ID, list.Data[0].Name, list.Data[0].Domain)
return list.Data[0].ID, nil
}
// EnsureWebsite returns an existing website ID for the domain or creates a new one.
func (u *UmamiService) EnsureWebsite(name, domain string) (string, error) {
if domain == "" {
return "", fmt.Errorf("domain is required")
}
if err := u.authenticate(); err != nil {
return "", err
}
if id, err := u.FindWebsiteIDByDomain(domain); err == nil && id != "" {
return id, nil
} else if err != nil {
return "", err
}
return u.CreateWebsite(name, domain)
}
// SendEvent posts a custom event to Umami's public /api/send endpoint (no auth required).
// hostname is a label for the source (e.g., "email" or your site host).
func (u *UmamiService) SendEvent(websiteID, name, urlPath, title string, data map[string]interface{}, hostname string) error {
if u.baseURL == "" || websiteID == "" {
return fmt.Errorf("umami baseURL and websiteID are required")
}
payload := map[string]interface{}{
"payload": map[string]interface{}{
"hostname": hostname,
"language": "",
"referrer": "",
"screen": "",
"title": title,
"url": urlPath,
"website": websiteID,
"name": name,
"data": data,
},
"type": "event",
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal send event payload: %w", err)
}
req, err := http.NewRequest("POST", u.baseURL+"/api/send", bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("failed to create send event request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "fotbal-club/server")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send umami event: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("umami send event status %d: %s", resp.StatusCode, string(b))
}
return nil
}
type UmamiAuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type UmamiAuthResponse struct {
Token string `json:"token"`
}
type UmamiCreateWebsiteRequest struct {
Name string `json:"name"`
Domain string `json:"domain"`
}
type UmamiWebsiteResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
CreatedAt time.Time `json:"createdAt"`
}
// NewUmamiService creates a new Umami service instance
func NewUmamiService() *UmamiService {
return &UmamiService{
baseURL: config.AppConfig.UmamiURL,
username: config.AppConfig.UmamiUsername,
password: config.AppConfig.UmamiPassword,
}
}
// verifyToken checks if the current token is still valid using /api/auth/verify
func (u *UmamiService) verifyToken() bool {
if u.token == "" {
return false
}
req, err := http.NewRequest("POST", u.baseURL+"/api/auth/verify", nil)
if err != nil {
return false
}
req.Header.Set("Authorization", "Bearer "+u.token)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
// Token is valid if we get 200 OK
if resp.StatusCode == http.StatusOK {
u.lastVerified = time.Now()
logger.Info("Umami token verified successfully")
return true
}
logger.Info("Umami token verification failed with status %d", resp.StatusCode)
return false
}
// authenticate gets a JWT token from Umami
func (u *UmamiService) authenticate() error {
if u.baseURL == "" || u.username == "" || u.password == "" {
return fmt.Errorf("umami credentials not configured")
}
// Check if we have a token and it hasn't expired
if u.token != "" && time.Now().Before(u.tokenExp) {
// Verify token twice daily (every 12 hours)
if time.Since(u.lastVerified) < 12*time.Hour {
return nil
}
// Time to verify - check if token is still valid
if u.verifyToken() {
return nil
}
// Token invalid, will re-authenticate below
logger.Info("Token expired or invalid, re-authenticating...")
}
authReq := UmamiAuthRequest{
Username: u.username,
Password: u.password,
}
body, err := json.Marshal(authReq)
if err != nil {
return fmt.Errorf("failed to marshal auth request: %w", err)
}
req, err := http.NewRequest("POST", u.baseURL+"/api/auth/login", bytes.NewBuffer(body))
if err != nil {
return fmt.Errorf("failed to create auth request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send auth request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("authentication failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var authResp UmamiAuthResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return fmt.Errorf("failed to decode auth response: %w", err)
}
u.token = authResp.Token
// Set token expiration to 23 hours from now (tokens typically last 24h)
u.tokenExp = time.Now().Add(23 * time.Hour)
u.lastVerified = time.Now()
logger.Info("Successfully authenticated with Umami at %s (new token issued, expires at %s)", u.baseURL, u.tokenExp.Format("2006-01-02 15:04:05"))
return nil
}
// CreateWebsite creates a new website in Umami and returns the website ID
func (u *UmamiService) CreateWebsite(name, domain string) (string, error) {
if err := u.authenticate(); err != nil {
return "", err
}
createReq := UmamiCreateWebsiteRequest{
Name: name,
Domain: domain,
}
body, err := json.Marshal(createReq)
if err != nil {
return "", fmt.Errorf("failed to marshal create website request: %w", err)
}
req, err := http.NewRequest("POST", u.baseURL+"/api/websites", bytes.NewBuffer(body))
if err != nil {
return "", fmt.Errorf("failed to create website request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+u.token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send create website request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("create website failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var websiteResp UmamiWebsiteResponse
if err := json.NewDecoder(resp.Body).Decode(&websiteResp); err != nil {
return "", fmt.Errorf("failed to decode website response: %w", err)
}
logger.Info("Successfully created Umami website: %s (ID: %s)", name, websiteResp.ID)
return websiteResp.ID, nil
}
// GetWebsiteStats retrieves analytics stats for a website
func (u *UmamiService) GetWebsiteStats(websiteID string, startAt, endAt int64) (map[string]interface{}, error) {
if err := u.authenticate(); err != nil {
return nil, err
}
url := fmt.Sprintf("%s/api/websites/%s/stats?startAt=%d&endAt=%d", u.baseURL, websiteID, startAt, endAt)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create stats request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+u.token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send stats request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get stats failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var stats map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
return nil, fmt.Errorf("failed to decode stats response: %w", err)
}
return stats, nil
}
// GetWebsiteMetrics retrieves metrics for a website
func (u *UmamiService) GetWebsiteMetrics(websiteID, type_ string, startAt, endAt int64) ([]map[string]interface{}, error) {
if err := u.authenticate(); err != nil {
return nil, err
}
url := fmt.Sprintf("%s/api/websites/%s/metrics?type=%s&startAt=%d&endAt=%d", u.baseURL, websiteID, type_, startAt, endAt)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create metrics request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+u.token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send metrics request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get metrics failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var metrics []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&metrics); err != nil {
return nil, fmt.Errorf("failed to decode metrics response: %w", err)
}
return metrics, nil
}
// GetWebsitePageviews retrieves pageviews data over time with specified unit (hour, day, month, year)
func (u *UmamiService) GetWebsitePageviews(websiteID string, startAt, endAt int64, unit string) ([]map[string]interface{}, error) {
if err := u.authenticate(); err != nil {
return nil, err
}
url := fmt.Sprintf("%s/api/websites/%s/pageviews?startAt=%d&endAt=%d&unit=%s", u.baseURL, websiteID, startAt, endAt, unit)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create pageviews request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+u.token)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send pageviews request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get pageviews failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var pageviews []map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&pageviews); err != nil {
return nil, fmt.Errorf("failed to decode pageviews response: %w", err)
}
return pageviews, nil
}
+165
View File
@@ -0,0 +1,165 @@
package testing
import (
"bytes"
"encoding/json"
"fotbal-club/internal/models"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// SetupTestDB creates an in-memory SQLite database for testing
func SetupTestDB(t *testing.T) *gorm.DB {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
// Auto-migrate all models
err = db.AutoMigrate(
&models.User{},
&models.Article{},
&models.Category{},
&models.Player{},
&models.Settings{},
&models.ContactMessage{},
&models.NewsletterSubscription{},
&models.Clothing{},
&models.Poll{},
&models.PollOption{},
&models.PollVote{},
)
if err != nil {
t.Fatalf("Failed to migrate test database: %v", err)
}
return db
}
// CreateTestUser creates a test user
func CreateTestUser(db *gorm.DB, role string) *models.User {
user := &models.User{
Email: "test@example.com",
Password: "$2a$10$test_hashed_password",
FirstName: "Test",
LastName: "User",
Role: role,
}
db.Create(user)
return user
}
// CreateTestArticle creates a test article
func CreateTestArticle(db *gorm.DB, authorID uint) *models.Article {
article := &models.Article{
Title: "Test Article",
Content: "<p>Test content</p>",
Slug: "test-article",
Published: true,
AuthorID: &authorID,
}
db.Create(article)
return article
}
// MakeTestRequest creates a test HTTP request
func MakeTestRequest(method, path string, body interface{}) (*http.Request, error) {
var reqBody *bytes.Buffer
if body != nil {
jsonData, err := json.Marshal(body)
if err != nil {
return nil, err
}
reqBody = bytes.NewBuffer(jsonData)
} else {
reqBody = &bytes.Buffer{}
}
req, err := http.NewRequest(method, path, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
}
// ExecuteRequest executes a test request and returns response
func ExecuteRequest(router *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
// AssertJSONResponse checks JSON response
func AssertJSONResponse(t *testing.T, w *httptest.ResponseRecorder, expectedStatus int, expectedBody map[string]interface{}) {
assert.Equal(t, expectedStatus, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
for key, expectedValue := range expectedBody {
assert.Equal(t, expectedValue, response[key], "Mismatch for key: %s", key)
}
}
// MockGinContext creates a mock Gin context for testing
func MockGinContext() (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
return c, w
}
// CleanupTestDB closes and cleans up test database
func CleanupTestDB(db *gorm.DB) {
sqlDB, _ := db.DB()
if sqlDB != nil {
sqlDB.Close()
}
}
// SeedTestData populates test database with sample data
func SeedTestData(db *gorm.DB) {
// Create admin user
admin := CreateTestUser(db, "admin")
// Create categories
categories := []models.Category{
{Name: "Aktuality", Description: "Club news"},
{Name: "Zápasy", Description: "Match reports"},
{Name: "Tréninky", Description: "Training updates"},
}
for _, cat := range categories {
db.Create(&cat)
}
// Create articles
for i := 1; i <= 10; i++ {
article := &models.Article{
Title: "Test Article " + strconv.Itoa(i),
Content: "<p>Test content</p>",
Slug: "test-article-" + strconv.Itoa(i),
Published: true,
AuthorID: &admin.ID,
CategoryID: &[]uint{1}[0],
}
db.Create(article)
}
}
// AssertDatabaseState checks database state
func AssertDatabaseState(t *testing.T, db *gorm.DB, model interface{}, count int64) {
var actualCount int64
db.Model(model).Count(&actualCount)
assert.Equal(t, count, actualCount)
}