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
+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)
}