mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
upload
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
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})
|
||||
}
|
||||
@@ -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
@@ -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"})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
)
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user