package controllers import ( "crypto/rand" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "time" "image/jpeg" "image/png" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/internal/services" "fotbal-club/pkg/email" "fotbal-club/pkg/logger" "fotbal-club/pkg/utils" "golang.org/x/crypto/bcrypt" "github.com/gin-gonic/gin" "gopkg.in/mail.v2" "gorm.io/gorm" ) // BaseController handles all the base API endpoints type BaseController struct { DB *gorm.DB } // GetMatchesHistory returns cached past matches with overrides applied (public) // Optional query: q= filters by home/away/venue/competition func (bc *BaseController) GetMatchesHistory(c *gin.Context) { p := filepath.Join("cache", "prefetch", "events_past.json") f, err := os.Open(p) if err != nil { c.JSON(http.StatusNoContent, gin.H{"message": "No cached past matches"}) return } defer f.Close() var matches []map[string]interface{} if err := json.NewDecoder(f).Decode(&matches); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached past matches"}) return } // Apply overrides (same as in GetMatches) var movs []models.MatchOverride if err := bc.DB.Find(&movs).Error; err == nil { movByID := map[string]models.MatchOverride{} for _, m := range movs { movByID[m.ExternalMatchID] = m } var tlovs []models.TeamLogoOverride if err := bc.DB.Find(&tlovs).Error; err == nil { tloByTeam := map[string]models.TeamLogoOverride{} for _, t := range tlovs { tloByTeam[t.ExternalTeamID] = t } for _, m := range matches { var matchID string if v, ok := m["match_id"].(string); ok { matchID = v } else if v2, ok2 := m["id"].(string); ok2 { matchID = v2 } if ov, ok := movByID[matchID]; ok { if ov.HomeNameOverride != nil { m["home"] = *ov.HomeNameOverride m["home_team"] = *ov.HomeNameOverride } if ov.AwayNameOverride != nil { m["away"] = *ov.AwayNameOverride m["away_team"] = *ov.AwayNameOverride } if ov.VenueOverride != nil { m["venue"] = *ov.VenueOverride } if ov.DateTimeOverride != nil { m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339) m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04") } if ov.HomeLogoURL != nil { m["home_logo_url"] = *ov.HomeLogoURL } if ov.AwayLogoURL != nil { m["away_logo_url"] = *ov.AwayLogoURL } } if homeTeamID, ok := m["home_team_id"].(string); ok { if tlo, found := tloByTeam[homeTeamID]; found && tlo.LogoURL != "" { m["home_logo_url"] = tlo.LogoURL } } if awayTeamID, ok := m["away_team_id"].(string); ok { if tlo, found := tloByTeam[awayTeamID]; found && tlo.LogoURL != "" { m["away_logo_url"] = tlo.LogoURL } } } } } // Optional search filter if s := strings.ToLower(strings.TrimSpace(c.Query("q"))); s != "" { filtered := make([]map[string]interface{}, 0, len(matches)) for _, m := range matches { get := func(k string) string { if v, ok := m[k]; ok { if vs, ok2 := v.(string); ok2 { return vs } } return "" } fields := []string{get("home"), get("away"), get("venue"), get("competition"), get("competition_name"), get("league")} matched := false for _, f := range fields { if f == "" { continue } if strings.Contains(strings.ToLower(f), s) { matched = true break } } if matched { filtered = append(filtered, m) } } matches = filtered } c.Header("Cache-Control", "public, max-age=60") c.JSON(http.StatusOK, matches) } // writeCompetitionAliasesCache writes a JSON snapshot of competition aliases to cache/prefetch/competition_aliases.json // This keeps the on-disk cache in sync immediately after admin changes, without waiting for the prefetcher cycle. func (bc *BaseController) writeCompetitionAliasesCache() { // Load all aliases ordered by display_order (same as public API) var items []models.CompetitionAlias if err := bc.DB.Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil { return } // Marshal pretty JSON for easier inspection b, err := json.MarshalIndent(items, "", " ") if err != nil { return } // Ensure destination directory exists dir := filepath.Join("cache", "prefetch") _ = os.MkdirAll(dir, 0o755) // Atomic write via temporary file then rename tmp := filepath.Join(dir, "competition_aliases.json.tmp") dst := filepath.Join(dir, "competition_aliases.json") if err := os.WriteFile(tmp, b, 0o644); err == nil { _ = os.Rename(tmp, dst) // Sidecar header with minimal metadata, similar to prefetch pattern hdr := map[string]string{ "fetched_at": time.Now().Format(time.RFC3339), } if hb, err := json.Marshal(hdr); err == nil { _ = os.WriteFile(dst+".hdr", hb, 0o644) } } } // --- Zonerama integration (public proxy + unified picks cache) --- // GetZoneramaAlbum proxies a single Zonerama album request without caching. // Query: link=ZONERAMA_ALBUM_URL (&photo_limit=10) (&rendered=true|false) func (bc *BaseController) GetZoneramaAlbum(c *gin.Context) { link := strings.TrimSpace(c.Query("link")) if link == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing link"}) return } photoLimit := strings.TrimSpace(c.DefaultQuery("photo_limit", "24")) rendered := strings.TrimSpace(c.DefaultQuery("rendered", "true")) // Build external URL api := "https://zonerama.tdvorak.dev/zonerama-album?link=" + url.QueryEscape(link) if photoLimit != "" { api += "&photo_limit=" + url.QueryEscape(photoLimit) } if rendered != "" { api += "&rendered=" + url.QueryEscape(rendered) } req, err := http.NewRequest("GET", api, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } req.Header.Set("User-Agent", "fotbal-club/zonerama-proxy") client := &http.Client{Timeout: 25 * time.Second} resp, err := client.Do(req) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("zonerama status %d", resp.StatusCode)}) return } c.Header("Cache-Control", "no-store") c.Header("Content-Type", "application/json; charset=utf-8") if _, err := io.Copy(c.Writer, resp.Body); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "stream failed"}) return } } type zoneramaPick struct { ID string `json:"id"` AlbumID string `json:"album_id"` AlbumURL string `json:"album_url"` PageURL string `json:"page_url"` ImageURL string `json:"image_url"` // prefer cached local URL if available Title string `json:"title"` PickedAt string `json:"picked_at"` } func picksPath() string { return filepath.Join("cache", "prefetch", "zonerama", "picks.json") } // GetZoneramaPicks returns the unified picks JSON (public) func (bc *BaseController) GetZoneramaPicks(c *gin.Context) { p := picksPath() f, err := os.Open(p) if err != nil { // no content yet c.JSON(http.StatusOK, []zoneramaPick{}) return } defer f.Close() var items []zoneramaPick if err := json.NewDecoder(f).Decode(&items); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid picks cache"}) return } c.JSON(http.StatusOK, items) } // PutZoneramaPick saves or updates a chosen image + album link to unified cache (admin) // Body: { id, album_id, album_url, page_url, image_url, title? } func (bc *BaseController) PutZoneramaPick(c *gin.Context) { var body zoneramaPick if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } body.ID = strings.TrimSpace(body.ID) body.AlbumID = strings.TrimSpace(body.AlbumID) body.AlbumURL = strings.TrimSpace(body.AlbumURL) body.PageURL = strings.TrimSpace(body.PageURL) body.ImageURL = strings.TrimSpace(body.ImageURL) if body.ID == "" || body.ImageURL == "" || body.AlbumURL == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "id, image_url and album_url are required"}) return } if body.PickedAt == "" { body.PickedAt = time.Now().Format(time.RFC3339) } // Load existing list (if any) path := picksPath() _ = os.MkdirAll(filepath.Dir(path), 0o755) var items []zoneramaPick if b, err := os.ReadFile(path); err == nil { _ = json.Unmarshal(b, &items) } // Upsert by ID (photo id); if missing, append at the front updated := false for i := range items { if items[i].ID == body.ID { items[i] = body updated = true break } } if !updated { // Prepend new picks to keep the latest at the top items = append([]zoneramaPick{body}, items...) // Trim to a reasonable size to avoid unbounded growth if len(items) > 500 { items = items[:500] } } // Write atomically tmp := path + ".tmp" if b, err := json.MarshalIndent(items, "", " "); err == nil { if err := os.WriteFile(tmp, b, 0o644); err == nil { _ = os.Rename(tmp, path) c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(items)}) return } } c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot write picks"}) } // --- Admin: Cache RAW viewer --- // GetAdminCacheList lists available JSON cache files from known cache roots. // Returns: [{ label, path, size_bytes, mod_time }] func (bc *BaseController) GetAdminCacheList(c *gin.Context) { type item struct { Label string `json:"label"` Path string `json:"path"` Size int64 `json:"size_bytes"` ModTime time.Time `json:"mod_time"` } var out []item // Helper to scan a directory for .json files scan := func(root, labelPrefix string) { _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { if err != nil { return nil } if info.IsDir() { return nil } if strings.HasSuffix(strings.ToLower(info.Name()), ".json") { rel := strings.TrimPrefix(filepath.ToSlash(p), "./") rel = "/" + rel out = append(out, item{ Label: labelPrefix + info.Name(), Path: rel, Size: info.Size(), ModTime: info.ModTime(), }) } return nil }) } scan(filepath.Join("cache", "prefetch"), "Prefetch: ") scan(filepath.Join("cache", "facr"), "FACR: ") // Stable order by label if len(out) > 1 { // simple insertion sort to avoid adding sort import for i := 1; i < len(out); i++ { j := i for j > 0 && strings.ToLower(out[j-1].Label) > strings.ToLower(out[j].Label) { out[j-1], out[j] = out[j], out[j-1] j-- } } } c.JSON(http.StatusOK, gin.H{"files": out}) } // GetAdminCacheFile streams a cache file as raw JSON. For FACR cache entries which // are stored as a wrapper {"data": , "stored_at": ...}, it unwraps and returns only the JSON in data. // Query: path=/cache/... relative to server root; only allowed under /cache/prefetch and /cache/facr func (bc *BaseController) GetAdminCacheFile(c *gin.Context) { raw := strings.TrimSpace(c.Query("path")) if raw == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"}) return } // Security: allow only specific roots if !(strings.HasPrefix(raw, "/cache/prefetch/") || strings.HasPrefix(raw, "/cache/facr/")) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } // Map URL path to filesystem path fsPath := strings.TrimPrefix(raw, "/") fsPath = filepath.FromSlash(fsPath) // Read file b, err := os.ReadFile(fsPath) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } // If FACR cache: attempt to unwrap cachedItem if strings.HasPrefix(raw, "/cache/facr/") { var wrap struct { Data json.RawMessage `json:"data"` StoredAt any `json:"stored_at"` } if json.Unmarshal(b, &wrap) == nil && len(wrap.Data) > 0 { c.Header("Content-Type", "application/json; charset=utf-8") c.Status(http.StatusOK) _, _ = c.Writer.Write(wrap.Data) return } // If unwrap failed, fall back to raw bytes } // Default: stream as-is c.Header("Content-Type", "application/json; charset=utf-8") c.Status(http.StatusOK) _, _ = c.Writer.Write(b) } // --- Article ⇄ Match Link (FACR external match id) --- // GetArticleMatchLink returns the linked external match id for a given article, if any (protected) func (bc *BaseController) GetArticleMatchLink(c *gin.Context) { id := c.Param("id") var art models.Article if err := bc.DB.Select("id").First(&art, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Článek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } var link models.ArticleMatchLink if err := bc.DB.Where("article_id = ?", art.ID).First(&link).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusOK, gin.H{"article_id": art.ID}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } c.JSON(http.StatusOK, gin.H{"article_id": art.ID, "external_match_id": link.ExternalMatchID, "title": link.Title}) } // PutArticleMatchLink creates or replaces the link (protected) func (bc *BaseController) PutArticleMatchLink(c *gin.Context) { id := c.Param("id") var art models.Article if err := bc.DB.Select("id").First(&art, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Článek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } var body struct { ExternalMatchID string `json:"external_match_id"` Title string `json:"title"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } ext := strings.TrimSpace(body.ExternalMatchID) if ext == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "external_match_id je povinné"}) return } var link models.ArticleMatchLink if err := bc.DB.Where("article_id = ?", art.ID).First(&link).Error; err != nil { if err == gorm.ErrRecordNotFound { link = models.ArticleMatchLink{ArticleID: art.ID, ExternalMatchID: ext, Title: strings.TrimSpace(body.Title)} if err := bc.DB.Create(&link).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit odkaz"}) return } } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } } else { link.ExternalMatchID = ext link.Title = strings.TrimSpace(body.Title) if err := bc.DB.Save(&link).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit odkaz"}) return } } c.JSON(http.StatusOK, gin.H{"article_id": art.ID, "external_match_id": link.ExternalMatchID, "title": link.Title}) } // DeleteArticleMatchLink removes the link (protected) func (bc *BaseController) DeleteArticleMatchLink(c *gin.Context) { id := c.Param("id") var art models.Article if err := bc.DB.Select("id").First(&art, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Článek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } if err := bc.DB.Where("article_id = ?", art.ID).Delete(&models.ArticleMatchLink{}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze odstranit odkaz"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // deriveSeoDescription generates a short SEO description from HTML content (max ~160 chars) func deriveSeoDescription(html string) string { // Remove HTML tags reTags := regexp.MustCompile("<[^>]+>") text := reTags.ReplaceAllString(html, " ") // Normalize whitespace text = strings.TrimSpace(strings.Join(strings.Fields(text), " ")) if text == "" { return "" } // Limit to ~160 characters, avoid cutting words abruptly when possible if len([]rune(text)) > 160 { runes := []rune(text) cut := 160 // try to cut at last space before 160 for i := cut; i >= 120; i-- { if runes[i] == ' ' { cut = i break } } text = strings.TrimSpace(string(runes[:cut])) + "..." } return text } // GetArticle returns a single article by ID (public) func (bc *BaseController) GetArticle(c *gin.Context) { id := c.Param("id") var art models.Article if err := bc.DB.Preload("Author").Preload("Category").First(&art, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" } if art.ReadTime == 0 { art.ReadTime = computeEstimatedReadMinutes(art.Content) } c.JSON(http.StatusOK, art) } // GetCategories returns a list of all categories (public) func (bc *BaseController) GetCategories(c *gin.Context) { var items []models.Category if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } // CreateCategory creates a new category (admin only) func (bc *BaseController) CreateCategory(c *gin.Context) { var body struct { Name string `json:"name" binding:"required"` Description string `json:"description"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()}) return } name := strings.TrimSpace(body.Name) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie je povinný"}) return } // Check if category with same name already exists var existing models.Category if err := bc.DB.Where("name = ?", name).First(&existing).Error; err == nil { c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"}) return } cat := models.Category{ Name: name, Description: strings.TrimSpace(body.Description), } if err := bc.DB.Create(&cat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"}) return } c.JSON(http.StatusCreated, cat) } // UpdateCategory updates an existing category (admin only) func (bc *BaseController) UpdateCategory(c *gin.Context) { id := c.Param("id") var cat models.Category if err := bc.DB.First(&cat, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } var body struct { Name *string `json:"name"` Description *string `json:"description"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()}) return } if body.Name != nil { name := strings.TrimSpace(*body.Name) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie nemůže být prázdný"}) return } // Check if another category with same name exists var existing models.Category if err := bc.DB.Where("name = ? AND id != ?", name, id).First(&existing).Error; err == nil { c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"}) return } cat.Name = name } if body.Description != nil { cat.Description = strings.TrimSpace(*body.Description) } if err := bc.DB.Save(&cat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"}) return } c.JSON(http.StatusOK, cat) } // DeleteCategory deletes a category (admin only) func (bc *BaseController) DeleteCategory(c *gin.Context) { id := c.Param("id") var cat models.Category if err := bc.DB.First(&cat, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } // Check if any articles are using this category var articleCount int64 if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", id).Count(&articleCount).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole článků"}) return } if articleCount > 0 { c.JSON(http.StatusConflict, gin.H{ "chyba": "Nelze smazat kategorii, která obsahuje články", "detail": fmt.Sprintf("Kategorie obsahuje %d článků", articleCount), }) return } if err := bc.DB.Delete(&cat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"}) return } c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"}) } // GetArticleBySlug returns a single article by slug (public) func (bc *BaseController) GetArticleBySlug(c *gin.Context) { slug := strings.TrimSpace(c.Param("slug")) if slug == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"}) return } var art models.Article if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" } if art.ReadTime == 0 { art.ReadTime = computeEstimatedReadMinutes(art.Content) } c.JSON(http.StatusOK, art) } // writeArticlesCache writes a JSON snapshot of PUBLISHED articles to cache/blogs/articles.json // Shape: { "items": [Article], "total": N, "page": 1, "page_size": N } func (bc *BaseController) writeArticlesCache() { // Load only published articles ordered by published_at desc, created_at desc var items []models.Article if err := bc.DB.Where("published = ?", true).Order("published_at DESC, created_at DESC").Find(&items).Error; err != nil { return } // Ensure image fallback for i := range items { if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" } } payload := map[string]any{ "items": items, "total": len(items), "page": 1, "page_size": len(items), } b, err := json.MarshalIndent(payload, "", " ") if err != nil { return } dir := filepath.Join("cache", "blogs") _ = os.MkdirAll(dir, 0o755) tmp := filepath.Join(dir, "articles.json.tmp") dst := filepath.Join(dir, "articles.json") if err := os.WriteFile(tmp, b, 0o644); err == nil { _ = os.Rename(tmp, dst) } } // respondArticlesFromCache attempts to read cache/blogs/articles.json (or cache/prefetch/articles.json) and respond. // Returns true if a response was written. func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool { // Helper to page slice safely pageSlice := func(arr []map[string]any, page, size int) []map[string]any { if size <= 0 { size = len(arr) } if page <= 0 { page = 1 } start := (page - 1) * size if start >= len(arr) { return []map[string]any{} } end := start + size if end > len(arr) { end = len(arr) } return arr[start:end] } readAndRespond := func(p string) bool { f, err := os.Open(p) if err != nil { return false } defer f.Close() var raw map[string]any if err := json.NewDecoder(f).Decode(&raw); err != nil { return false } // Normalize items array var arr []map[string]any if its, ok := raw["items"].([]any); ok { for _, it := range its { if m, ok := it.(map[string]any); ok { arr = append(arr, m) } } } else if its, ok := raw["data"].([]any); ok { for _, it := range its { if m, ok := it.(map[string]any); ok { arr = append(arr, m) } } } if len(arr) == 0 { return false } // Optional filters from query publishedOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("published", "false"))) == "true" featuredOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("featured", "false"))) == "true" if publishedOnly || featuredOnly { filtered := make([]map[string]any, 0, len(arr)) for _, m := range arr { if publishedOnly { if v, ok := m["published"].(bool); !ok || !v { continue } } if featuredOnly { if v, ok := m["featured"].(bool); !ok || !v { continue } } filtered = append(filtered, m) } arr = filtered } if len(arr) == 0 { return false } total := len(arr) paged := pageSlice(arr, page, size) c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size}) return true } // Try blogs cache first if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) { return true } // Fallback to prefetch cache if available if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) { return true } return false } // ValidateSMTP attempts to connect to an SMTP server using provided settings. // It does NOT send an email; it only tries to establish a connection (and authenticate if username/password provided). // POST /api/v1/setup/validate-smtp { host, port, username?, password?, from?, use_tls? } func (bc *BaseController) ValidateSMTP(c *gin.Context) { type req struct { Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` From string `json:"from"` UseTLS *bool `json:"use_tls"` } var body req if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()}) return } host := strings.TrimSpace(body.Host) if host == "" || body.Port <= 0 { c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "host and port are required"}) return } d := mail.NewDialer(host, body.Port, strings.TrimSpace(body.Username), body.Password) // Determine encryption: implicit SSL for 465, STARTTLS otherwise (as supported by server) if body.UseTLS != nil { // If explicitly requested TLS and port is 465, use implicit SSL if *body.UseTLS && body.Port == 465 { d.SSL = true } else { d.SSL = false } } else { d.SSL = body.Port == 465 } d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: host} d.Timeout = 20 * time.Second // Try to open the connection (auth is attempted automatically if username/password provided) sc, err := d.Dial() if err != nil { errorMsg := err.Error() // Provide more helpful error messages for common authentication issues if strings.Contains(errorMsg, "535") || strings.Contains(strings.ToLower(errorMsg), "authentication failed") { errorMsg = "Chyba autentizace (535): Zkontrolujte prosím uživatelské jméno a heslo. " + errorMsg } else if strings.Contains(errorMsg, "530") { errorMsg = "Vyžadována autentizace (530): Server vyžaduje uživatelské jméno a heslo. " + errorMsg } else if strings.Contains(errorMsg, "connection refused") || strings.Contains(errorMsg, "no route to host") { errorMsg = "Nelze se připojit k serveru: Zkontrolujte host a port. " + errorMsg } else if strings.Contains(errorMsg, "certificate") || strings.Contains(errorMsg, "tls") { errorMsg = "Chyba TLS/SSL: Zkontrolujte nastavení šifrování a port. " + errorMsg } c.JSON(http.StatusOK, gin.H{"ok": false, "error": errorMsg}) return } _ = sc.Close() c.JSON(http.StatusOK, gin.H{"ok": true, "message": "SMTP připojení a autentizace proběhly úspěšně"}) } // GetYouTubeVideos returns cached YouTube channel JSON from prefetch cache (public) // It reads cache/prefetch/youtube_channel.json and streams the JSON payload as-is. func (bc *BaseController) GetYouTubeVideos(c *gin.Context) { p := filepath.Join("cache", "prefetch", "youtube_channel.json") f, err := os.Open(p) if err != nil { c.JSON(http.StatusNoContent, gin.H{"message": "No cached YouTube data"}) return } defer f.Close() c.Header("Cache-Control", "public, max-age=600") c.Header("Content-Type", "application/json; charset=utf-8") if _, err := io.Copy(c.Writer, f); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"}) return } } // HealthCheck returns a simple status and verifies database connectivity func (bc *BaseController) HealthCheck(c *gin.Context) { // Default status status := gin.H{"status": "ok"} if bc.DB != nil { if sqlDB, err := bc.DB.DB(); err == nil { if err := sqlDB.Ping(); err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "db": "down"}) return } } else { c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "db": "unavailable"}) return } } c.JSON(http.StatusOK, status) } // --- Competition Aliases --- // Admin: list all competition aliases func (bc *BaseController) GetCompetitionAliases(c *gin.Context) { var items []models.CompetitionAlias // Order by display_order first (0 values go last), then by code if err := bc.DB.Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } // Admin: create or replace alias by code (idempotent) func (bc *BaseController) PutCompetitionAlias(c *gin.Context) { code := strings.TrimSpace(c.Param("code")) if code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba code"}) return } var body struct { Alias string `json:"alias"` OriginalName string `json:"original_name"` DisplayOrder *int `json:"display_order,omitempty"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if strings.TrimSpace(body.Alias) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "alias je povinný"}) return } var item models.CompetitionAlias if err := bc.DB.Where("code = ?", code).First(&item).Error; err != nil { if err == gorm.ErrRecordNotFound { item = models.CompetitionAlias{Code: code} } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } } item.Alias = body.Alias item.OriginalName = body.OriginalName if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder } if item.ID == 0 { if err := bc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit alias"}) return } } else { if err := bc.DB.Save(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit alias"}) return } } // Update cache snapshot so /cache/prefetch/competition_aliases.json reflects changes immediately bc.writeCompetitionAliasesCache() c.JSON(http.StatusOK, item) } // Admin: delete alias by code func (bc *BaseController) DeleteCompetitionAlias(c *gin.Context) { code := strings.TrimSpace(c.Param("code")) if code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba code"}) return } if err := bc.DB.Where("code = ?", code).Delete(&models.CompetitionAlias{}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze smazat alias"}) return } // Update cache snapshot after deletion bc.writeCompetitionAliasesCache() c.JSON(http.StatusOK, gin.H{"ok": true}) } // Admin: bulk reorder competition aliases func (bc *BaseController) ReorderCompetitionAliases(c *gin.Context) { var body struct { Items []struct { Code string `json:"code"` DisplayOrder int `json:"display_order"` } `json:"items"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Update each item's display_order in a transaction tx := bc.DB.Begin() for _, item := range body.Items { if err := tx.Model(&models.CompetitionAlias{}). Where("code = ?", item.Code). Update("display_order", item.DisplayOrder).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze aktualizovat pořadí"}) return } } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"}) return } // Update cache snapshot after reordering bc.writeCompetitionAliasesCache() c.JSON(http.StatusOK, gin.H{"ok": true, "updated": len(body.Items)}) } // autoPopulateCompetitionAliases reads FACR cache and creates missing competition aliases func (bc *BaseController) autoPopulateCompetitionAliases() { defer func() { if r := recover(); r != nil { logger.Error("Panic in autoPopulateCompetitionAliases: panic=%v", r) } }() // Read FACR club info cache cacheFile := filepath.Join("cache", "prefetch", "facr_club_info.json") data, err := os.ReadFile(cacheFile) if err != nil { logger.Warn("Cannot read FACR cache for alias auto-population: %v", err) return } var facrData struct { Competitions []struct { ID string `json:"id"` Code string `json:"code"` Name string `json:"name"` } `json:"competitions"` } if err := json.Unmarshal(data, &facrData); err != nil { logger.Warn("Cannot parse FACR cache: %v", err) return } // Get existing aliases var existing []models.CompetitionAlias if err := bc.DB.Find(&existing).Error; err != nil { logger.Warn("Cannot fetch existing competition aliases: %v", err) return } existingCodes := make(map[string]bool) for _, alias := range existing { existingCodes[alias.Code] = true } // Create missing aliases created := 0 for _, comp := range facrData.Competitions { // Use code if available, otherwise fall back to ID code := strings.TrimSpace(comp.Code) if code == "" { code = strings.TrimSpace(comp.ID) } name := strings.TrimSpace(comp.Name) if code == "" || existingCodes[code] { continue } newAlias := models.CompetitionAlias{ Code: code, Alias: name, // Default alias to original name OriginalName: name, } if err := bc.DB.Create(&newAlias).Error; err != nil { logger.Warn("Failed to create competition alias: code=%s error=%v", code, err) continue } created++ existingCodes[code] = true } if created > 0 { logger.Info("Auto-populated competition aliases: count=%d", created) } } // autoPopulateYouTubeVideos reads YouTube cache and auto-selects 5 most recent videos for homepage func (bc *BaseController) autoPopulateYouTubeVideos(settings *models.Settings) { defer func() { if r := recover(); r != nil { logger.Error("Panic in autoPopulateYouTubeVideos: panic=%v", r) } }() // Read YouTube cache cacheFile := filepath.Join("cache", "prefetch", "youtube_channel.json") data, err := os.ReadFile(cacheFile) if err != nil { logger.Warn("Cannot read YouTube cache for auto-population: %v", err) return } var ytData struct { Videos []struct { VideoID string `json:"video_id"` Title string `json:"title"` ThumbnailURL string `json:"thumbnail_url"` PublishedText string `json:"published_text"` PublishedDate string `json:"published_date"` } `json:"videos"` } if err := json.Unmarshal(data, &ytData); err != nil { logger.Warn("Cannot parse YouTube cache: %v", err) return } if len(ytData.Videos) == 0 { logger.Info("No YouTube videos to auto-populate") return } // Take first 5 videos (they're already sorted by most recent from API) limit := 5 if len(ytData.Videos) < limit { limit = len(ytData.Videos) } type VideoItem struct { URL string `json:"url"` Title string `json:"title"` Length string `json:"length"` UploadedAt string `json:"uploaded_at"` ThumbnailURL string `json:"thumbnail_url"` } videoItems := make([]VideoItem, 0, limit) for i := 0; i < limit; i++ { v := ytData.Videos[i] videoItems = append(videoItems, VideoItem{ URL: "https://www.youtube.com/watch?v=" + v.VideoID, Title: v.Title, Length: "", UploadedAt: v.PublishedDate, ThumbnailURL: v.ThumbnailURL, }) } // Save to settings VideosItemsJSON itemsJSON, err := json.Marshal(videoItems) if err != nil { logger.Warn("Failed to marshal video items: %v", err) return } settings.VideosItemsJSON = string(itemsJSON) settings.VideosModuleEnabled = true settings.VideosSource = "auto" // Auto source from YouTube channel settings.VideosLimit = limit settings.VideosStyle = "slider" // Default to slider display if err := bc.DB.Save(settings).Error; err != nil { logger.Warn("Failed to save auto-populated YouTube videos: %v", err) return } logger.Info("Auto-populated %d YouTube videos to homepage", limit) } // Public: list aliases for frontend mapping func (bc *BaseController) GetPublicCompetitionAliases(c *gin.Context) { var items []models.CompetitionAlias // Order by display_order first (0 values go last), then by code if err := bc.DB.Select("code", "alias", "original_name", "display_order").Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } // TrackArticleView increments the view counter for an article (public) func (bc *BaseController) TrackArticleView(c *gin.Context) { id := c.Param("id") // Use atomic SQL update to increment view_count result := bc.DB.Model(&models.Article{}). Where("id = ?", id). UpdateColumn("view_count", gorm.Expr("view_count + ?", 1)) if result.Error != nil { logger.Error("Failed to track article view: id=%s error=%v", id, result.Error) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to track view"}) return } if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // IncrementArticleRead increments the read counter for an article (public) func (bc *BaseController) IncrementArticleRead(c *gin.Context) { id := c.Param("id") // Use atomic SQL update if err := bc.DB.Model(&models.Article{}). Where("id = ?", id). UpdateColumn("read_count", gorm.Expr("read_count + ?", 1)).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Clanek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat ctenost"}) return } var art models.Article if err := bc.DB.Preload("Author").First(&art, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" } c.JSON(http.StatusOK, art) } // computeEstimatedReadMinutes estimates reading time by stripping HTML and counting words. func computeEstimatedReadMinutes(html string) int { // remove tags reTags := regexp.MustCompile("<[^>]+>") text := reTags.ReplaceAllString(html, " ") // collapse whitespace text = strings.TrimSpace(strings.Join(strings.Fields(text), " ")) if text == "" { return 1 } words := len(strings.Fields(text)) // assume 200 wpm minutes := (words + 199) / 200 if minutes < 1 { minutes = 1 } if minutes > 999 { minutes = 999 } return minutes } // GetAdminMatches returns cached matches merged with DB overrides (admin only) func (bc *BaseController) GetAdminMatches(c *gin.Context) { // Read cached events p := filepath.Join("cache", "prefetch", "events_upcoming.json") f, err := os.Open(p) if err != nil { c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"}) return } defer f.Close() var matches []map[string]interface{} if err := json.NewDecoder(f).Decode(&matches); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"}) return } // Load overrides var movs []models.MatchOverride if err := bc.DB.Find(&movs).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"}) return } movByID := map[string]models.MatchOverride{} for _, m := range movs { movByID[m.ExternalMatchID] = m } var tlovs []models.TeamLogoOverride if err := bc.DB.Find(&tlovs).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"}) return } tloByTeam := map[string]models.TeamLogoOverride{} for _, t := range tlovs { tloByTeam[t.ExternalTeamID] = t } // Apply overrides in-place for _, m := range matches { // External match ID var matchID string if v, ok := m["match_id"].(string); ok { matchID = v } else if v2, ok2 := m["id"].(string); ok2 { matchID = v2 } if ov, ok := movByID[matchID]; ok { if ov.HomeNameOverride != nil { m["home"] = *ov.HomeNameOverride m["home_team"] = *ov.HomeNameOverride } if ov.AwayNameOverride != nil { m["away"] = *ov.AwayNameOverride m["away_team"] = *ov.AwayNameOverride } if ov.VenueOverride != nil { m["venue"] = *ov.VenueOverride } if ov.DateTimeOverride != nil { m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339) m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04") } if ov.HomeLogoURL != nil { m["home_logo_url"] = *ov.HomeLogoURL } if ov.AwayLogoURL != nil { m["away_logo_url"] = *ov.AwayLogoURL } } // Team-logo overrides by team id if homeID, ok := m["home_id"].(string); ok { if tlo, ok := tloByTeam[homeID]; ok { if tlo.LogoURL != "" { m["home_logo_url"] = tlo.LogoURL } if tlo.TeamName != "" { m["home"] = tlo.TeamName } } } if awayID, ok := m["away_id"].(string); ok { if tlo, ok := tloByTeam[awayID]; ok { if tlo.LogoURL != "" { m["away_logo_url"] = tlo.LogoURL } if tlo.TeamName != "" { m["away"] = tlo.TeamName } } } } c.JSON(http.StatusOK, matches) } // --- Admin: Match & Team Logo Overrides --- // GetMatchOverrides lists all match overrides func (bc *BaseController) GetMatchOverrides(c *gin.Context) { var items []models.MatchOverride if err := bc.DB.Order("updated_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } // PutMatchOverride creates or replaces an override by external_match_id func (bc *BaseController) PutMatchOverride(c *gin.Context) { extID := c.Param("external_match_id") if extID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Chybí external_match_id"}) return } var body struct { HomeNameOverride *string `json:"home_name_override"` AwayNameOverride *string `json:"away_name_override"` VenueOverride *string `json:"venue_override"` DateTimeOverride *time.Time `json:"date_time_override"` HomeLogoURL *string `json:"home_logo_url"` AwayLogoURL *string `json:"away_logo_url"` Notes *string `json:"notes"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var item models.MatchOverride if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil { if err == gorm.ErrRecordNotFound { // create new item = models.MatchOverride{ExternalMatchID: extID} } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databaze"}) return } } item.HomeNameOverride = body.HomeNameOverride item.AwayNameOverride = body.AwayNameOverride item.VenueOverride = body.VenueOverride item.DateTimeOverride = body.DateTimeOverride item.HomeLogoURL = body.HomeLogoURL item.AwayLogoURL = body.AwayLogoURL if body.Notes != nil { item.Notes = *body.Notes } if item.ID == 0 { if err := bc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvorit zaznam"}) return } } else { if err := bc.DB.Save(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit zmeny"}) return } } c.JSON(http.StatusOK, item) } // PatchMatchOverride partially updates fields of an override by external_match_id func (bc *BaseController) PatchMatchOverride(c *gin.Context) { extID := c.Param("external_match_id") if extID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_match_id"}) return } var item models.MatchOverride if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } var body map[string]interface{} if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Prevent changing the key delete(body, "external_match_id") if err := bc.DB.Model(&item).Updates(body).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"}) return } // Best-effort: write JSON snapshot to cache go bc.writeTeamLogoOverridesCache() c.JSON(http.StatusOK, item) } // GetTeamLogoOverrides lists all team logo overrides func (bc *BaseController) GetTeamLogoOverrides(c *gin.Context) { var items []models.TeamLogoOverride if err := bc.DB.Order("updated_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } // GetPublicTeamLogoOverrides returns a simple mapping usable by widgets without auth // Shape: { "by_name": { "Team Name": "https://.../logo.png" } } func (bc *BaseController) GetPublicTeamLogoOverrides(c *gin.Context) { var items []models.TeamLogoOverride if err := bc.DB.Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } m := make(map[string]string, len(items)) for _, it := range items { if it.TeamName != "" && it.LogoURL != "" { m[it.TeamName] = it.LogoURL } } // Public cacheable response c.Header("Cache-Control", "public, max-age=120") c.JSON(http.StatusOK, gin.H{"by_name": m}) } // writeTeamLogoOverridesCache writes a JSON snapshot of team-logo overrides to cache/prefetch/team_logo_overrides.json // Shape: { "by_name": { "Team Name": "https://..." } } func (bc *BaseController) writeTeamLogoOverridesCache() { var items []models.TeamLogoOverride if err := bc.DB.Find(&items).Error; err != nil { return } m := make(map[string]string, len(items)) for _, it := range items { if it.TeamName != "" && it.LogoURL != "" { m[it.TeamName] = it.LogoURL } } payload := map[string]any{"by_name": m} b, err := json.MarshalIndent(payload, "", " ") if err != nil { return } dir := filepath.Join("cache", "prefetch") _ = os.MkdirAll(dir, 0o755) tmp := filepath.Join(dir, "team_logo_overrides.json.tmp") dst := filepath.Join(dir, "team_logo_overrides.json") if err := os.WriteFile(tmp, b, 0o644); err == nil { _ = os.Rename(tmp, dst) } } // PutTeamLogoOverride creates or replaces a team logo override by external_team_id func (bc *BaseController) PutTeamLogoOverride(c *gin.Context) { extID := c.Param("external_team_id") if extID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_team_id"}) return } var body struct { TeamName string `json:"team_name"` LogoURL string `json:"logo_url"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var item models.TeamLogoOverride if err := bc.DB.Where("external_team_id = ?", extID).First(&item).Error; err != nil { if err == gorm.ErrRecordNotFound { item = models.TeamLogoOverride{ExternalTeamID: extID} } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } } item.TeamName = body.TeamName item.LogoURL = body.LogoURL if item.ID == 0 { if err := bc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit záznam"}) return } } else if err := bc.DB.Save(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"}) return } // Best-effort: write JSON snapshot to cache go bc.writeTeamLogoOverridesCache() c.JSON(http.StatusOK, item) } // PatchTeamLogoOverride partially updates a team logo override by external_team_id func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) { extID := c.Param("external_team_id") if extID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_team_id"}) return } var item models.TeamLogoOverride if err := bc.DB.Where("external_team_id = ?", extID).First(&item).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } var body map[string]interface{} if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } delete(body, "external_team_id") if err := bc.DB.Model(&item).Updates(body).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"}) return } c.JSON(http.StatusOK, item) } // ProxyImage streams a remote image to the client to avoid browser CORS restrictions for Canvas operations // GET /api/v1/proxy/image?url= func (bc *BaseController) ProxyImage(c *gin.Context) { raw := c.Query("url") if raw == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing url parameter"}) return } u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"}) return } // Fetch with a short timeout client := &http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest("GET", u.String(), nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request init failed"}) return } // Some CDNs require a UA req.Header.Set("User-Agent", "fotbal-club/1.0 (+https://localhost)") resp, err := client.Do(req) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"}) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { c.JSON(http.StatusBadGateway, gin.H{"error": "remote returned status", "status": resp.StatusCode}) return } ct := resp.Header.Get("Content-Type") // Allow only images for safety if ct == "" || (ct != "image/jpeg" && ct != "image/png" && ct != "image/gif" && ct != "image/webp" && ct != "image/svg+xml") { // try to infer from URL ext := filepath.Ext(u.Path) switch ext { case ".jpg", ".jpeg": ct = "image/jpeg" case ".png": ct = "image/png" case ".gif": ct = "image/gif" case ".webp": ct = "image/webp" case ".svg": ct = "image/svg+xml" default: c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported content type"}) return } } // Stream response c.Header("Access-Control-Allow-Origin", "*") c.Header("Cache-Control", "public, max-age=86400") c.DataFromReader(http.StatusOK, resp.ContentLength, ct, resp.Body, nil) } // SetupStatus reports whether initial setup is required // requires_setup is true if no admin user exists OR settings lack a ClubID func (bc *BaseController) SetupStatus(c *gin.Context) { var adminCount int64 if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } // Ensure settings table exists to avoid noisy errors on first run _ = bc.DB.AutoMigrate(&models.Settings{}) var s models.Settings _ = bc.DB.First(&s).Error // ignore not found requires := adminCount == 0 || s.ClubID == "" c.JSON(http.StatusOK, gin.H{"requires_setup": requires}) } // It is allowed only if no admin exists yet. SMTP is optional. func (bc *BaseController) SetupInitialize(c *gin.Context) { // Ensure required tables exist even if global migrations were skipped _ = bc.DB.AutoMigrate(&models.User{}, &models.Settings{}) // Check if an admin already exists var adminCount int64 if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } if adminCount > 0 { // Allow idempotent updates to Settings (club basics if missing, and SMTP at any time) type setupBody struct { ClubID string `json:"club_id"` ClubType string `json:"club_type"` ClubName string `json:"club_name"` ClubLogoURL string `json:"club_logo_url"` ClubURL string `json:"club_url"` // Social profiles (optional) FacebookURL string `json:"facebook_url"` InstagramURL string `json:"instagram_url"` YoutubeURL string `json:"youtube_url"` PrimaryColor string `json:"primary_color"` SecondaryColor string `json:"secondary_color"` AccentColor string `json:"accent_color"` BackgroundColor string `json:"background_color"` TextColor string `json:"text_color"` FontHeading string `json:"font_heading"` FontBody string `json:"font_body"` SMTP *struct { Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` From string `json:"from"` UseTLS *bool `json:"use_tls"` } `json:"smtp"` } var body setupBody _ = c.ShouldBindJSON(&body) // best-effort; all fields optional here var s models.Settings _ = bc.DB.First(&s).Error // ignore not found if s.ID == 0 { s = models.Settings{} } // Only write club basics if not set yet and payload contains values if s.ClubID == "" && body.ClubID != "" { s.ClubID = body.ClubID s.ClubType = body.ClubType s.ClubName = body.ClubName s.ClubLogoURL = body.ClubLogoURL s.ClubURL = body.ClubURL if body.PrimaryColor != "" { s.PrimaryColor = body.PrimaryColor } if body.SecondaryColor != "" { s.SecondaryColor = body.SecondaryColor } if body.AccentColor != "" { s.AccentColor = body.AccentColor } if body.BackgroundColor != "" { s.BackgroundColor = body.BackgroundColor } if body.TextColor != "" { s.TextColor = body.TextColor } if body.FontHeading != "" { s.FontHeading = body.FontHeading } if body.FontBody != "" { s.FontBody = body.FontBody } } // Always allow updating social profiles idempotently if provided if v := strings.TrimSpace(body.FacebookURL); v != "" { s.FacebookURL = v } if v := strings.TrimSpace(body.InstagramURL); v != "" { s.InstagramURL = v } if v := strings.TrimSpace(body.YoutubeURL); v != "" { // Normalize: allow @handle, full URLs, or www.* without scheme if strings.HasPrefix(strings.ToLower(v), "www.") { v = "https://" + v } s.YoutubeURL = v } // Always allow updating SMTP via setup when admin already exists (idempotent) if body.SMTP != nil { if host := strings.TrimSpace(body.SMTP.Host); host != "" { s.SMTPHost = host } if body.SMTP.Port > 0 { s.SMTPPort = body.SMTP.Port } if u := strings.TrimSpace(body.SMTP.Username); u != "" { s.SMTPUser = u s.SMTPAuth = true } if p := body.SMTP.Password; p != "" { s.SMTPPassword = p } if from := strings.TrimSpace(body.SMTP.From); from != "" { s.SMTPFrom = from } if s.SMTPFromName == "" { s.SMTPFromName = "Fotbal Club" } if body.SMTP.UseTLS != nil { if *body.SMTP.UseTLS { if body.SMTP.Port == 465 { s.SMTPEncryption = "ssl" } else { s.SMTPEncryption = "tls" } } else { s.SMTPEncryption = "none" } } } if s.ID == 0 { if err := bc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"}) return } } else { if err := bc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"}) return } } // Trigger background prefetch and YouTube cache refresh when settings are updated post-setup scheme := "http" if c.Request.TLS != nil { scheme = "https" } host := c.Request.Host if host != "" { baseURL := scheme + "://" + host + "/api/v1" go services.PrefetchOnce(baseURL) } if strings.TrimSpace(s.YoutubeURL) != "" { go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL) } // If gallery_url is a Zonerama link, refresh Zonerama cache immediately if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") { go func(link string) { _ = services.RefreshZoneramaNow(link) }(g) } c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena"}) return } // No admin exists yet: run full initial setup type reqBody struct { // Admin user AdminEmail string `json:"admin_email" binding:"required,email"` AdminPassword string `json:"admin_password" binding:"required,min=8"` FirstName string `json:"first_name"` LastName string `json:"last_name"` // JWT JWTSecret string `json:"jwt_secret"` // Club (FACR) ClubID string `json:"club_id"` ClubType string `json:"club_type"` ClubName string `json:"club_name"` ClubLogoURL string `json:"club_logo_url"` ClubURL string `json:"club_url"` // Social (optional) FacebookURL string `json:"facebook_url"` InstagramURL string `json:"instagram_url"` YoutubeURL string `json:"youtube_url"` // Gallery (optional) GalleryURL string `json:"gallery_url"` GalleryLabel string `json:"gallery_label"` // Location/Contact (optional) ContactAddress string `json:"contact_address"` ContactCity string `json:"contact_city"` ContactZip string `json:"contact_zip"` ContactCountry string `json:"contact_country"` ContactPhone string `json:"contact_phone"` ContactEmail string `json:"contact_email"` LocationLatitude float64 `json:"location_latitude"` LocationLongitude float64 `json:"location_longitude"` MapStyle string `json:"map_style"` // Frontpage style (optional) FrontpageStyle string `json:"frontpage_style"` // Theme (optional, can set later) PrimaryColor string `json:"primary_color"` SecondaryColor string `json:"secondary_color"` AccentColor string `json:"accent_color"` BackgroundColor string `json:"background_color"` TextColor string `json:"text_color"` FontHeading string `json:"font_heading"` FontBody string `json:"font_body"` // SMTP optional SMTP *struct { Host string `json:"host"` Port int `json:"port"` Username string `json:"username"` Password string `json:"password"` From string `json:"from"` UseTLS *bool `json:"use_tls"` } `json:"smtp"` } var body reqBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } logger.Info("SetupInitialize payload received: admin_email=%s club_id=%s club_type=%s club_name=%s gallery_url=%s gallery_label=%s", body.AdminEmail, body.ClubID, body.ClubType, body.ClubName, body.GalleryURL, body.GalleryLabel) // Optionally persist JWT secret to environment if empty in config (best set via env in deployment) if body.JWTSecret != "" && config.AppConfig.JWTSecret == "" { // Not writing to disk; just warn that server restart needed to take effect } // Create admin user hashed, err := bcrypt.GenerateFromPassword([]byte(body.AdminPassword), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze zpracovat heslo"}) return } admin := models.User{ Email: body.AdminEmail, Password: string(hashed), FirstName: body.FirstName, LastName: body.LastName, Role: "admin", } if err := bc.DB.Create(&admin).Error; err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { c.JSON(http.StatusBadRequest, gin.H{"error": "Email již existuje"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit admina"}) return } logger.Info("Admin user created: email=%s id=%d", admin.Email, admin.ID) // Upsert settings var s models.Settings if err := bc.DB.First(&s).Error; err != nil { if err == gorm.ErrRecordNotFound { s = models.Settings{} } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } } if s.ID == 0 { s = models.Settings{} } if body.ClubID != "" { s.ClubID = body.ClubID } if body.ClubType != "" { s.ClubType = body.ClubType } if body.ClubName != "" { s.ClubName = body.ClubName } if body.ClubLogoURL != "" { s.ClubLogoURL = body.ClubLogoURL } if body.ClubURL != "" { s.ClubURL = body.ClubURL } // Social profiles if v := strings.TrimSpace(body.FacebookURL); v != "" { s.FacebookURL = v } if v := strings.TrimSpace(body.InstagramURL); v != "" { s.InstagramURL = v } if v := strings.TrimSpace(body.YoutubeURL); v != "" { if strings.HasPrefix(strings.ToLower(v), "www.") { v = "https://" + v } s.YoutubeURL = v } if body.PrimaryColor != "" { s.PrimaryColor = body.PrimaryColor } if body.SecondaryColor != "" { s.SecondaryColor = body.SecondaryColor } if body.AccentColor != "" { s.AccentColor = body.AccentColor } if body.BackgroundColor != "" { s.BackgroundColor = body.BackgroundColor } if body.TextColor != "" { s.TextColor = body.TextColor } if body.FontHeading != "" { s.FontHeading = body.FontHeading } if body.FontBody != "" { s.FontBody = body.FontBody } // Gallery if body.GalleryURL != "" { s.GalleryURL = strings.TrimSpace(body.GalleryURL) } if body.GalleryLabel != "" { s.GalleryLabel = strings.TrimSpace(body.GalleryLabel) } // Location/Contact if v := strings.TrimSpace(body.ContactAddress); v != "" { s.ContactAddress = v } if v := strings.TrimSpace(body.ContactCity); v != "" { s.ContactCity = v } if v := strings.TrimSpace(body.ContactZip); v != "" { s.ContactZip = v } if v := strings.TrimSpace(body.ContactCountry); v != "" { s.ContactCountry = v } if v := strings.TrimSpace(body.ContactPhone); v != "" { s.ContactPhone = v } if v := strings.TrimSpace(body.ContactEmail); v != "" { s.ContactEmail = v } if body.LocationLatitude != 0 { s.LocationLatitude = body.LocationLatitude } if body.LocationLongitude != 0 { s.LocationLongitude = body.LocationLongitude } if v := strings.TrimSpace(body.MapStyle); v != "" { s.MapStyle = v } if body.GalleryLabel != "" { s.GalleryLabel = strings.TrimSpace(body.GalleryLabel) } // Frontpage style if body.FrontpageStyle != "" { s.FrontpageStyle = body.FrontpageStyle } // SMTP overrides from initial setup if body.SMTP != nil { if v := strings.TrimSpace(body.SMTP.Host); v != "" { s.SMTPHost = v } if body.SMTP.Port > 0 { s.SMTPPort = body.SMTP.Port } if v := strings.TrimSpace(body.SMTP.Username); v != "" { s.SMTPUser = v s.SMTPAuth = true } if v := body.SMTP.Password; v != "" { s.SMTPPassword = v } if v := strings.TrimSpace(body.SMTP.From); v != "" { s.SMTPFrom = v } // Default FromName if empty if s.SMTPFromName == "" { s.SMTPFromName = "Fotbal Club" } if body.SMTP.UseTLS != nil { if *body.SMTP.UseTLS { if body.SMTP.Port == 465 { s.SMTPEncryption = "ssl" } else { s.SMTPEncryption = "tls" } } else { s.SMTPEncryption = "none" } } } if s.ID == 0 { if err := bc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"}) return } } else { if err := bc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"}) return } } logger.Info("Initial settings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel) // Run all setup operations asynchronously in background to provide immediate response scheme := "http" if c.Request.TLS != nil { scheme = "https" } host := c.Request.Host logger.Info("Starting initial data prefetch and setup operations in background...") // Run all setup operations in a single background goroutine go func(settingsID uint, youtubeURL, galleryURL, adminEmail, baseHost string) { defer func() { _ = recover() }() // 1. Trigger prefetch (matches, standings, etc.) if baseHost != "" { baseURL := scheme + "://" + baseHost + "/api/v1" services.PrefetchOnce(baseURL) logger.Info("Background prefetch completed") // Auto-populate competition aliases from FACR data bc.autoPopulateCompetitionAliases() logger.Info("Background competition aliases populated") } // 2. If YouTube channel is configured, refresh its cache if strings.TrimSpace(youtubeURL) != "" { if err := services.RefreshYouTubeChannelNow(youtubeURL); err != nil { logger.Warn("YouTube cache refresh failed during setup: %v", err) } else { logger.Info("Background YouTube cache refreshed") // Auto-populate 5 most recent videos into settings var settings models.Settings if err := bc.DB.First(&settings, settingsID).Error; err == nil { bc.autoPopulateYouTubeVideos(&settings) } } } // 3. If gallery_url is a Zonerama link, refresh Zonerama cache if g := strings.TrimSpace(galleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") { if err := services.RefreshZoneramaNow(g); err != nil { logger.Warn("Zonerama cache refresh failed during setup: %v", err) } else { logger.Info("Background Zonerama cache refreshed") } } // 4. Send welcome email es := email.NewEmailService(config.AppConfig, bc.DB) if err := es.SendAdminWelcome(adminEmail); err != nil { logger.Error("Failed to send admin welcome email: error=%v email=%s", err, adminEmail) } else { logger.Info("Welcome email sent to %s", adminEmail) } logger.Info("All background setup operations completed") }(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email, host) logger.Info("SetupInitialize finished successfully - background operations running") c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully"}) } // UploadImage handles image/file uploads func (bc *BaseController) UploadImage(c *gin.Context) { // Auth required in normal operation. However during initial setup there is no admin user yet // so allow unauthenticated uploads only when no admin exists. Otherwise require authenticated user. if _, ok := c.Get("user"); !ok { // Try to parse Authorization bearer token to identify user when called on public route authHeader := c.GetHeader("Authorization") if authHeader != "" { parts := strings.Split(authHeader, " ") if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { if claims, err := utils.ParseJWT(parts[1]); err == nil { var u models.User if err := bc.DB.First(&u, claims.UserID).Error; err == nil { c.Set("user", &u) c.Set("userRole", u.Role) } } } } if _, ok2 := c.Get("user"); !ok2 { var adminCount int64 if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err == nil { if adminCount > 0 { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uzivatel neni prihlasen"}) return } } } } file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Soubor nebyl nalezen v požadavku (pole 'file')"}) return } defer file.Close() // Read first 512 bytes for MIME detection var sniff [512]byte n, _ := io.ReadFull(file, sniff[:]) mimeType := http.DetectContentType(sniff[:n]) // Validate MIME type allowed := false for _, mt := range config.AppConfig.AllowedMimeTypes { if mt == mimeType { allowed = true break } } if !allowed { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Nepodporovany typ souboru"}) return } // Reset reader to include sniffed bytes reader := io.MultiReader(bytesReader(sniff[:n]), file) // preserve_quality form flag: when true, store file as-is without re-encoding (max quality). preserveFlag := strings.ToLower(strings.TrimSpace(c.PostForm("preserve_quality"))) preserve := preserveFlag == "1" || preserveFlag == "true" || preserveFlag == "yes" // Ensure upload directory exists (e.g., /uploads/2025/08) subdir := time.Now().Format("2006/01") destDir := filepath.Join(config.AppConfig.UploadDir, subdir) if err := os.MkdirAll(destDir, 0o755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvorit adresar pro upload"}) return } // Generate unique filename preserving extension ext := filepath.Ext(header.Filename) if ext == "" { // derive from MIME switch mimeType { case "image/jpeg": ext = ".jpg" case "image/png": ext = ".png" case "image/gif": ext = ".gif" case "image/webp": ext = ".webp" case "image/svg+xml": ext = ".svg" case "application/pdf": ext = ".pdf" case "application/msword": ext = ".doc" case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ext = ".docx" case "application/vnd.ms-excel": ext = ".xls" case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ext = ".xlsx" case "application/vnd.ms-powerpoint": ext = ".ppt" case "application/vnd.openxmlformats-officedocument.presentationml.presentation": ext = ".pptx" case "text/plain": ext = ".txt" case "application/zip", "application/x-zip-compressed": ext = ".zip" case "application/x-rar-compressed", "application/vnd.rar": ext = ".rar" default: ext = "" } } randBytes := make([]byte, 16) if _, err := rand.Read(randBytes); err != nil { randBytes = []byte(time.Now().Format("150405.000")) } fname := time.Now().Format("20060102-150405") + "-" + hex.EncodeToString(randBytes) + ext destPath := filepath.Join(destDir, fname) // Save file with optional re-encoding/compression out, err := os.Create(destPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze ulozit soubor"}) return } defer out.Close() var written int64 if preserve { // Store original bytes without decoding/re-encoding to preserve maximal quality n64, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nepovedlo se ulozit soubor"}) return } written = n64 if written >= config.AppConfig.MaxUploadSize { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je prilis velky"}) return } } else { switch mimeType { case "image/jpeg": // Decode and re-encode JPEG with quality reduction to reduce size while keeping good quality img, err := jpeg.Decode(io.LimitReader(reader, config.AppConfig.MaxUploadSize)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatny JPEG soubor"}) return } if err := jpeg.Encode(out, img, &jpeg.Options{Quality: 82}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Komprese JPEG selhala"}) return } fi, _ := out.Stat() written = fi.Size() case "image/png": // Decode and re-encode PNG with best compression (lossless) img, err := png.Decode(io.LimitReader(reader, config.AppConfig.MaxUploadSize)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatný PNG soubor"}) return } enc := png.Encoder{CompressionLevel: png.BestCompression} if err := enc.Encode(out, img); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Komprese PNG selhala"}) return } fi, _ := out.Stat() written = fi.Size() case "image/svg+xml": // SVG is text-based; keep as-is without modification n, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při ukládání souboru"}) return } written = n if written >= config.AppConfig.MaxUploadSize { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je příliš velký"}) return } default: // Fallback: store as-is (for allowed types only) n, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při ukládání souboru"}) return } written = n if written >= config.AppConfig.MaxUploadSize { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je příliš velký"}) return } } } // Build public URL and respond // Files are served under /uploads in main.go, so construct the URL accordingly publicURL := "/uploads/" + filepath.ToSlash(filepath.Join(subdir, fname)) // Also include a relative filesystem path for convenience relPath := filepath.Join("uploads", subdir, fname) // Track uploaded file in database var uploadedByID *uint if userID, exists := c.Get("user_id"); exists { if uid, ok := userID.(uint); ok { uploadedByID = &uid } } uploadedFile := models.UploadedFile{ Filename: fname, FilePath: destPath, FileURL: publicURL, FileSize: written, MimeType: mimeType, UploadedByID: uploadedByID, } // Best effort - don't fail if tracking fails if err := bc.DB.Create(&uploadedFile).Error; err != nil { logger.Warn("Failed to track uploaded file: %v", err) } c.JSON(http.StatusOK, gin.H{ "url": publicURL, "path": relPath, "size": written, "mime": mimeType, "filename": fname, }) } // helper to wrap bytes as io.Reader without extra allocs func bytesReader(b []byte) io.Reader { return &sliceReader{b: b} } type sliceReader struct{ b []byte } func (r *sliceReader) Read(p []byte) (int, error) { if len(r.b) == 0 { return 0, io.EOF } n := copy(p, r.b) r.b = r.b[n:] return n, nil } func makeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) if s == "" { return "" } // Map of Czech/Slovak diacritics to ASCII equivalents diacriticsMap := map[rune]rune{ 'á': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'ě': 'e', 'í': 'i', 'ň': 'n', 'ó': 'o', 'ř': 'r', 'š': 's', 'ť': 't', 'ú': 'u', 'ů': 'u', 'ý': 'y', 'ž': 'z', } // Replace diacritics with ASCII equivalents var result []rune for _, char := range s { if replacement, ok := diacriticsMap[char]; ok { result = append(result, replacement) } else { result = append(result, char) } } s = string(result) // replace non-alphanumeric with hyphens re := regexp.MustCompile(`[^a-z0-9]+`) s = re.ReplaceAllString(s, "-") s = strings.Trim(s, "-") if s == "" { s = "article" } if len(s) > 120 { s = s[:120] } return s } // CreateArticle creates a new article (protected) func (bc *BaseController) CreateArticle(c *gin.Context) { // Require authenticated user uVal, ok := c.Get("user") if !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } user := uVal.(*models.User) type reqBody struct { Title string `json:"title" binding:"required"` Content string `json:"content" binding:"required"` CategoryID uint `json:"category_id"` CategoryName string `json:"category_name"` ImageURL string `json:"image_url"` Published *bool `json:"published"` PublishedAt *string `json:"published_at"` Featured *bool `json:"featured"` Slug string `json:"slug"` SeoTitle string `json:"seo_title"` SeoDescription string `json:"seo_description"` OgImageURL string `json:"og_image_url"` // Gallery fields (optional) GalleryAlbumID string `json:"gallery_album_id"` GalleryAlbumURL string `json:"gallery_album_url"` GalleryPhotoIDs []string `json:"gallery_photo_ids"` YouTubeVideoID string `json:"youtube_video_id"` YouTubeVideoTitle string `json:"youtube_video_title"` YouTubeVideoURL string `json:"youtube_video_url"` YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"` } var body reqBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } slug := strings.TrimSpace(body.Slug) if slug == "" { slug = makeSlug(body.Title) } // Fallback if slug is still empty (shouldn't happen with makeSlug, but be safe) if slug == "" { slug = fmt.Sprintf("article-%d", time.Now().Unix()) } // ensure unique slug; if exists, append -n orig := slug for i := 0; i < 50; i++ { var cnt int64 if err := bc.DB.Model(&models.Article{}).Where("slug = ?", slug).Count(&cnt).Error; err != nil { logger.Error("Failed to check slug uniqueness: error=%v slug=%s", err, slug) c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"}) return } if cnt == 0 { break } slug = fmt.Sprintf("%s-%d", orig, i+1) } logger.Info("Generated unique slug for article: original=%s final=%s", orig, slug) // Resolve category by name if provided and CategoryID not set if body.CategoryID == 0 && strings.TrimSpace(body.CategoryName) != "" { name := strings.TrimSpace(body.CategoryName) var cat models.Category if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil { if err == gorm.ErrRecordNotFound { cat = models.Category{Name: name} if err := bc.DB.Create(&cat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"}) return } } else { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze (kategorie)"}) return } } body.CategoryID = cat.ID } published := false if body.Published != nil { published = *body.Published } featured := false if body.Featured != nil { featured = *body.Featured } var pubAt time.Time if body.PublishedAt != nil && strings.TrimSpace(*body.PublishedAt) != "" { if t, err := time.Parse(time.RFC3339, *body.PublishedAt); err == nil { pubAt = t } } if published && pubAt.IsZero() { pubAt = time.Now() } est := computeEstimatedReadMinutes(body.Content) // Prepare SEO fallbacks seoTitle := strings.TrimSpace(body.SeoTitle) if seoTitle == "" { seoTitle = strings.TrimSpace(body.Title) } seoDesc := strings.TrimSpace(body.SeoDescription) if seoDesc == "" { seoDesc = deriveSeoDescription(body.Content) } authorID := user.ID var pubAtPtr *time.Time if !pubAt.IsZero() { pubAtPtr = &pubAt } var categoryIDPtr *uint if body.CategoryID > 0 { categoryIDPtr = &body.CategoryID } art := models.Article{ Title: strings.TrimSpace(body.Title), Content: body.Content, AuthorID: &authorID, CategoryID: categoryIDPtr, Published: published, PublishedAt: pubAtPtr, ImageURL: strings.TrimSpace(body.ImageURL), ReadTime: est, Slug: slug, SEOTitle: seoTitle, SEODescription: seoDesc, Featured: featured, } // Optional OG image if trimmed := strings.TrimSpace(body.OgImageURL); trimmed != "" { art.OGImageURL = trimmed } // Gallery fields if trimmed := strings.TrimSpace(body.GalleryAlbumID); trimmed != "" { art.GalleryAlbumID = trimmed } if trimmed := strings.TrimSpace(body.GalleryAlbumURL); trimmed != "" { art.GalleryAlbumURL = trimmed } if len(body.GalleryPhotoIDs) > 0 { art.GalleryPhotoIDs = strings.Join(body.GalleryPhotoIDs, ",") } if trimmed := strings.TrimSpace(body.YouTubeVideoID); trimmed != "" { art.YouTubeVideoID = trimmed } if trimmed := strings.TrimSpace(body.YouTubeVideoTitle); trimmed != "" { art.YouTubeVideoTitle = trimmed } if trimmed := strings.TrimSpace(body.YouTubeVideoURL); trimmed != "" { art.YouTubeVideoURL = trimmed } if trimmed := strings.TrimSpace(body.YouTubeVideoThumbnail); trimmed != "" { art.YouTubeVideoThumbnail = trimmed } if err := bc.DB.Create(&art).Error; err != nil { logger.Error("Failed to create article: error=%v title=%s slug=%s", err, body.Title, slug) c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit článek", "error": err.Error()}) return } if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" } // Track file usage fileTracker := services.NewFileTracker(bc.DB) go fileTracker.TrackArticleFiles(&art) // Send newsletter notification if article is published if art.Published { go bc.triggerBlogNotification(&art) } // Best-effort: refresh published articles cache go bc.writeArticlesCache() c.JSON(http.StatusCreated, art) } // UpdateArticle updates an existing article (protected; author or admin) func (bc *BaseController) UpdateArticle(c *gin.Context) { // Require authenticated user uVal, ok := c.Get("user") if !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } user := uVal.(*models.User) type reqBody struct { Title *string `json:"title"` Content *string `json:"content"` CategoryID *uint `json:"category_id"` CategoryName *string `json:"category_name"` ImageURL *string `json:"image_url"` Published *bool `json:"published"` PublishedAt *string `json:"published_at"` Slug *string `json:"slug"` SeoTitle *string `json:"seo_title"` SeoDescription *string `json:"seo_description"` OgImageURL *string `json:"og_image_url"` Featured *bool `json:"featured"` // Gallery fields (optional) GalleryAlbumID *string `json:"gallery_album_id"` GalleryAlbumURL *string `json:"gallery_album_url"` GalleryPhotoIDs []string `json:"gallery_photo_ids"` YouTubeVideoID *string `json:"youtube_video_id"` YouTubeVideoTitle *string `json:"youtube_video_title"` YouTubeVideoURL *string `json:"youtube_video_url"` YouTubeVideoThumbnail *string `json:"youtube_video_thumbnail"` } var body reqBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } id := c.Param("id") var art models.Article if err := bc.DB.First(&art, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } // Permission: admin or author if user.Role != "admin" && (art.AuthorID == nil || *art.AuthorID != user.ID) { c.JSON(http.StatusForbidden, gin.H{"chyba": "Nemáte oprávnění upravit tento článek"}) return } // Track if article was published before update wasPublished := art.Published if body.Title != nil { art.Title = strings.TrimSpace(*body.Title) } if body.Content != nil { art.Content = *body.Content // Recalculate read time when content changes art.ReadTime = computeEstimatedReadMinutes(*body.Content) } if body.CategoryID != nil { art.CategoryID = body.CategoryID } if body.CategoryName != nil { name := strings.TrimSpace(*body.CategoryName) if name != "" { var cat models.Category if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil { if err == gorm.ErrRecordNotFound { cat = models.Category{Name: name} if err := bc.DB.Create(&cat).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"}) return } } else { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze (kategorie)"}) return } } catID := cat.ID art.CategoryID = &catID } } if body.ImageURL != nil { art.ImageURL = strings.TrimSpace(*body.ImageURL) } if body.Published != nil { art.Published = *body.Published } if body.PublishedAt != nil && strings.TrimSpace(*body.PublishedAt) != "" { if t, err := time.Parse(time.RFC3339, *body.PublishedAt); err == nil { art.PublishedAt = &t } } if body.Featured != nil { art.Featured = *body.Featured } if body.Slug != nil { art.Slug = strings.TrimSpace(*body.Slug) } if body.SeoTitle != nil { art.SEOTitle = strings.TrimSpace(*body.SeoTitle) } if body.SeoDescription != nil { art.SEODescription = strings.TrimSpace(*body.SeoDescription) } if body.OgImageURL != nil { art.OGImageURL = strings.TrimSpace(*body.OgImageURL) } // Gallery fields if body.GalleryAlbumID != nil { art.GalleryAlbumID = strings.TrimSpace(*body.GalleryAlbumID) } if body.GalleryAlbumURL != nil { art.GalleryAlbumURL = strings.TrimSpace(*body.GalleryAlbumURL) } if len(body.GalleryPhotoIDs) > 0 { art.GalleryPhotoIDs = strings.Join(body.GalleryPhotoIDs, ",") } if body.YouTubeVideoID != nil { art.YouTubeVideoID = strings.TrimSpace(*body.YouTubeVideoID) } if body.YouTubeVideoTitle != nil { art.YouTubeVideoTitle = strings.TrimSpace(*body.YouTubeVideoTitle) } if body.YouTubeVideoURL != nil { art.YouTubeVideoURL = strings.TrimSpace(*body.YouTubeVideoURL) } if body.YouTubeVideoThumbnail != nil { art.YouTubeVideoThumbnail = strings.TrimSpace(*body.YouTubeVideoThumbnail) } // Auto-fill SEO if still empty after updates if strings.TrimSpace(art.SEOTitle) == "" && strings.TrimSpace(art.Title) != "" { art.SEOTitle = strings.TrimSpace(art.Title) } if strings.TrimSpace(art.SEODescription) == "" && strings.TrimSpace(art.Content) != "" { art.SEODescription = deriveSeoDescription(art.Content) } if err := bc.DB.Save(&art).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"}) return } if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" } // Track file usage fileTracker := services.NewFileTracker(bc.DB) go fileTracker.TrackArticleFiles(&art) // Send newsletter notification if article was just published if !wasPublished && art.Published { go bc.triggerBlogNotification(&art) } // Best-effort: refresh published articles cache go bc.writeArticlesCache() c.JSON(http.StatusOK, art) } // GetArticles returns a paginated list of articles (public) func (bc *BaseController) GetArticles(c *gin.Context) { // Filters pageStr := c.DefaultQuery("page", "1") sizeStr := c.DefaultQuery("page_size", "10") publishedOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("published", "false"))) == "true" featuredOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("featured", "false"))) == "true" categoryIDStr := strings.TrimSpace(c.Query("category_id")) page, _ := strconv.Atoi(pageStr) size, _ := strconv.Atoi(sizeStr) if page < 1 { page = 1 } if size < 1 || size > 100 { size = 10 } q := bc.DB.Model(&models.Article{}).Preload("Author").Preload("Category").Order("published_at DESC, created_at DESC") if publishedOnly { q = q.Where("published = ?", true) } if featuredOnly { q = q.Where("featured = ?", true) } if categoryIDStr != "" { if cid, err := strconv.Atoi(categoryIDStr); err == nil && cid > 0 { q = q.Where("category_id = ?", cid) } } // Optional full-text like search across title/content/slug if search := strings.TrimSpace(c.Query("q")); search != "" { like := "%" + search + "%" q = q.Where("title ILIKE ? OR content ILIKE ? OR slug ILIKE ?", like, like, like) } var total int64 if err := q.Count(&total).Error; err != nil { // Fallback to cache on DB error if bc.respondArticlesFromCache(c, page, size) { return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } var items []models.Article if err := q.Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil { // Fallback to cache on DB error if bc.respondArticlesFromCache(c, page, size) { return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } // If requesting only published and none found, attempt cache as soft-fallback if (publishedOnly || featuredOnly) && len(items) == 0 { if bc.respondArticlesFromCache(c, page, size) { return } } for i := range items { if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" } } c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size}) } // GetFeaturedArticles returns a paginated list of featured, published articles (public) func (bc *BaseController) GetFeaturedArticles(c *gin.Context) { pageStr := c.DefaultQuery("page", "1") sizeStr := c.DefaultQuery("page_size", "10") page, _ := strconv.Atoi(pageStr) size, _ := strconv.Atoi(sizeStr) if page < 1 { page = 1 } if size < 1 || size > 100 { size = 10 } q := bc.DB.Model(&models.Article{}).Preload("Author").Preload("Category"). Where("published = ? AND featured = ?", true, true). Order("published_at DESC, created_at DESC") var total int64 if err := q.Count(&total).Error; err != nil { if bc.respondArticlesFromCache(c, page, size) { return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } var items []models.Article if err := q.Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil { if bc.respondArticlesFromCache(c, page, size) { return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } for i := range items { if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" } } c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size}) } // DeleteArticle deletes an article by ID func (bc *BaseController) DeleteArticle(c *gin.Context) { id := c.Param("id") // Auth user uVal, ok := c.Get("user") if !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } user := uVal.(*models.User) var art models.Article if err := bc.DB.First(&art, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } // Permission: admin or author if user.Role != "admin" && (art.AuthorID == nil || *art.AuthorID != user.ID) { c.JSON(http.StatusForbidden, gin.H{"chyba": "Nemáte oprávnění smazat tento článek"}) return } if err := bc.DB.Delete(&art).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Smazání článku selhalo"}) return } // Best-effort: refresh published articles cache go bc.writeArticlesCache() c.JSON(http.StatusOK, gin.H{"success": true, "message": "Článek byl smazán"}) } // (Removed duplicate/broken helper implementations here; helpers are defined once above.) // Public: get one player func (bc *BaseController) GetPlayer(c *gin.Context) { id := c.Param("id") var p models.Player if err := bc.DB.First(&p, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Hráč nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, p) } // GetPlayers returns a paginated, filterable list of players (public) // Query params: page, page_size, team_id, active, q (search), position, nationality func (bc *BaseController) GetPlayers(c *gin.Context) { // Pagination pageStr := c.DefaultQuery("page", "1") sizeStr := c.DefaultQuery("page_size", "20") page, _ := strconv.Atoi(pageStr) size, _ := strconv.Atoi(sizeStr) if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } q := bc.DB.Model(&models.Player{}) // Filters if teamIDStr := strings.TrimSpace(c.Query("team_id")); teamIDStr != "" { if tid, err := strconv.Atoi(teamIDStr); err == nil && tid > 0 { q = q.Where("team_id = ?", tid) } } if activeStr := strings.ToLower(strings.TrimSpace(c.Query("active"))); activeStr != "" { if activeStr == "true" || activeStr == "1" { q = q.Where("is_active = ?", true) } else if activeStr == "false" || activeStr == "0" { q = q.Where("is_active = ?", false) } } if pos := strings.TrimSpace(c.Query("position")); pos != "" { q = q.Where("position ILIKE ?", "%"+pos+"%") } if nat := strings.TrimSpace(c.Query("nationality")); nat != "" { q = q.Where("nationality ILIKE ?", "%"+nat+"%") } if search := strings.TrimSpace(c.Query("q")); search != "" { like := "%" + search + "%" q = q.Where("first_name ILIKE ? OR last_name ILIKE ?", like, like) } // Count total var total int64 if err := q.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } // Fetch page var items []models.Player if err := q.Order("last_name ASC, first_name ASC").Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size}) } // CreatePlayer creates a new player (protected) func (bc *BaseController) CreatePlayer(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } var body struct { FirstName string `json:"first_name" binding:"required"` LastName string `json:"last_name" binding:"required"` DateOfBirth *string `json:"date_of_birth"` Position string `json:"position"` JerseyNumber *int `json:"jersey_number"` TeamID *uint `json:"team_id"` Nationality string `json:"nationality"` Height *int `json:"height"` Weight *int `json:"weight"` IsActive *bool `json:"is_active"` Email string `json:"email"` Phone string `json:"phone"` ImageURL string `json:"image_url"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } p := models.Player{ FirstName: strings.TrimSpace(body.FirstName), LastName: strings.TrimSpace(body.LastName), Position: strings.TrimSpace(body.Position), Nationality: strings.TrimSpace(body.Nationality), Email: strings.TrimSpace(body.Email), Phone: strings.TrimSpace(body.Phone), ImageURL: strings.TrimSpace(body.ImageURL), } if body.TeamID != nil { // Validate team exists var t models.Team if err := bc.DB.First(&t, *body.TeamID).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatný team_id"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } p.TeamID = *body.TeamID } if body.JerseyNumber != nil { p.JerseyNumber = *body.JerseyNumber } if body.Height != nil { p.Height = *body.Height } if body.Weight != nil { p.Weight = *body.Weight } if body.IsActive != nil { p.IsActive = *body.IsActive } else { p.IsActive = true } if body.DateOfBirth != nil && strings.TrimSpace(*body.DateOfBirth) != "" { if t, err := time.Parse(time.RFC3339, *body.DateOfBirth); err == nil { p.DateOfBirth = t } else if t2, err2 := time.Parse("2006-01-02", *body.DateOfBirth); err2 == nil { p.DateOfBirth = t2 } } if err := bc.DB.Create(&p).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit hráče"}) return } // Track file usage fileTracker := services.NewFileTracker(bc.DB) go fileTracker.TrackPlayerFiles(&p) c.JSON(http.StatusCreated, p) } // UpdatePlayer updates an existing player (protected) func (bc *BaseController) UpdatePlayer(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } id := c.Param("id") var p models.Player if err := bc.DB.First(&p, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Hráč nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } var body struct { FirstName *string `json:"first_name"` LastName *string `json:"last_name"` DateOfBirth *string `json:"date_of_birth"` Position *string `json:"position"` JerseyNumber *int `json:"jersey_number"` TeamID *uint `json:"team_id"` Nationality *string `json:"nationality"` Height *int `json:"height"` Weight *int `json:"weight"` IsActive *bool `json:"is_active"` Email *string `json:"email"` Phone *string `json:"phone"` ImageURL *string `json:"image_url"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } if body.FirstName != nil { p.FirstName = strings.TrimSpace(*body.FirstName) } if body.LastName != nil { p.LastName = strings.TrimSpace(*body.LastName) } if body.Position != nil { p.Position = strings.TrimSpace(*body.Position) } if body.TeamID != nil { var t models.Team if err := bc.DB.First(&t, *body.TeamID).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatný team_id"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } p.TeamID = *body.TeamID } if body.JerseyNumber != nil { p.JerseyNumber = *body.JerseyNumber } if body.Nationality != nil { p.Nationality = strings.TrimSpace(*body.Nationality) } if body.Height != nil { p.Height = *body.Height } if body.Weight != nil { p.Weight = *body.Weight } if body.IsActive != nil { p.IsActive = *body.IsActive } if body.Email != nil { p.Email = strings.TrimSpace(*body.Email) } if body.Phone != nil { p.Phone = strings.TrimSpace(*body.Phone) } if body.ImageURL != nil { p.ImageURL = strings.TrimSpace(*body.ImageURL) } if body.DateOfBirth != nil { s := strings.TrimSpace(*body.DateOfBirth) if s == "" { /* ignore */ } else if t, err := time.Parse(time.RFC3339, s); err == nil { p.DateOfBirth = t } else if t2, err2 := time.Parse("2006-01-02", s); err2 == nil { p.DateOfBirth = t2 } } if err := bc.DB.Save(&p).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"}) return } // Track file usage fileTracker := services.NewFileTracker(bc.DB) go fileTracker.TrackPlayerFiles(&p) c.JSON(http.StatusOK, p) } // DeletePlayer deletes a player (protected) func (bc *BaseController) DeletePlayer(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } id := c.Param("id") if err := bc.DB.Delete(&models.Player{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Smazání hráče selhalo"}) return } c.JSON(http.StatusOK, gin.H{"zprava": "Hráč byl smazán"}) } // CreateTeam creates a new team (protected) func (bc *BaseController) CreateTeam(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } var body struct { Name string `json:"name" binding:"required"` ShortName string `json:"short_name"` Description string `json:"description"` LogoURL string `json:"logo_url"` IsActive *bool `json:"is_active"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } active := true if body.IsActive != nil { active = *body.IsActive } t := models.Team{ Name: strings.TrimSpace(body.Name), ShortName: strings.TrimSpace(body.ShortName), Description: strings.TrimSpace(body.Description), LogoURL: strings.TrimSpace(body.LogoURL), IsActive: active, } if err := bc.DB.Create(&t).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit tým"}) return } c.JSON(http.StatusCreated, t) } // UpdateTeam updates an existing team (protected) func (bc *BaseController) UpdateTeam(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } id := c.Param("id") var t models.Team if err := bc.DB.First(&t, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Tým nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } var body struct { Name *string `json:"name"` ShortName *string `json:"short_name"` Description *string `json:"description"` LogoURL *string `json:"logo_url"` IsActive *bool `json:"is_active"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } if body.Name != nil { t.Name = strings.TrimSpace(*body.Name) } if body.ShortName != nil { t.ShortName = strings.TrimSpace(*body.ShortName) } if body.Description != nil { t.Description = strings.TrimSpace(*body.Description) } if body.LogoURL != nil { t.LogoURL = strings.TrimSpace(*body.LogoURL) } if body.IsActive != nil { t.IsActive = *body.IsActive } if err := bc.DB.Save(&t).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"}) return } c.JSON(http.StatusOK, t) } // DeleteTeam deletes a team (protected) func (bc *BaseController) DeleteTeam(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } id := c.Param("id") if err := bc.DB.Delete(&models.Team{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Smazání týmu selhalo"}) return } c.JSON(http.StatusOK, gin.H{"zprava": "Tým byl smazán"}) } // Public: list teams func (bc *BaseController) GetTeams(c *gin.Context) { var items []models.Team if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } // Public: get one team func (bc *BaseController) GetTeam(c *gin.Context) { id := c.Param("id") var t models.Team if err := bc.DB.First(&t, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Tým nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, t) } // Public: list sponsors func (bc *BaseController) GetSponsors(c *gin.Context) { var items []models.Sponsor // Order by tier (general first), then by display_order, then by name if err := bc.DB.Order("CASE WHEN tier = 'general' THEN 0 ELSE 1 END, display_order ASC, name ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } // CreateSponsor creates a sponsor (protected) func (bc *BaseController) CreateSponsor(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } var body struct { Name string `json:"name" binding:"required"` LogoURL string `json:"logo_url"` WebsiteURL string `json:"website_url"` IsActive *bool `json:"is_active"` Tier string `json:"tier"` DisplayOrder *int `json:"display_order"` Placement string `json:"placement"` Width *int `json:"width"` Height *int `json:"height"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } active := true if body.IsActive != nil { active = *body.IsActive } tier := strings.TrimSpace(body.Tier) if tier == "" { tier = "standard" } displayOrder := 0 if body.DisplayOrder != nil { displayOrder = *body.DisplayOrder } s := models.Sponsor{ Name: strings.TrimSpace(body.Name), LogoURL: strings.TrimSpace(body.LogoURL), WebsiteURL: strings.TrimSpace(body.WebsiteURL), IsActive: active, Tier: tier, DisplayOrder: displayOrder, Placement: strings.TrimSpace(body.Placement), } if body.Width != nil { s.Width = *body.Width } if body.Height != nil { s.Height = *body.Height } // Defaults by placement if width/height not provided if (s.Width == 0 || s.Height == 0) && s.Placement != "" { switch s.Placement { case "homepage_top", "homepage_footer": if s.Width == 0 { s.Width = 1200 } if s.Height == 0 { s.Height = 200 } case "homepage_middle": if s.Width == 0 { s.Width = 970 } if s.Height == 0 { s.Height = 250 } case "homepage_sidebar": if s.Width == 0 { s.Width = 300 } if s.Height == 0 { s.Height = 250 } } } if err := bc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit sponzora"}) return } // Track file usage fileTracker := services.NewFileTracker(bc.DB) go fileTracker.TrackSponsorFiles(&s) c.JSON(http.StatusCreated, s) } // UpdateSponsor updates a sponsor (protected) func (bc *BaseController) UpdateSponsor(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } id := c.Param("id") var s models.Sponsor if err := bc.DB.First(&s, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Sponzor nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } var body struct { Name *string `json:"name"` LogoURL *string `json:"logo_url"` WebsiteURL *string `json:"website_url"` IsActive *bool `json:"is_active"` Tier *string `json:"tier"` DisplayOrder *int `json:"display_order"` Placement *string `json:"placement"` Width *int `json:"width"` Height *int `json:"height"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } if body.Name != nil { s.Name = strings.TrimSpace(*body.Name) } if body.LogoURL != nil { s.LogoURL = strings.TrimSpace(*body.LogoURL) } if body.WebsiteURL != nil { s.WebsiteURL = strings.TrimSpace(*body.WebsiteURL) } if body.IsActive != nil { s.IsActive = *body.IsActive } if body.Tier != nil { tier := strings.TrimSpace(*body.Tier) if tier != "" { s.Tier = tier } } if body.DisplayOrder != nil { s.DisplayOrder = *body.DisplayOrder } if body.Placement != nil { s.Placement = strings.TrimSpace(*body.Placement) } if body.Width != nil { s.Width = *body.Width } if body.Height != nil { s.Height = *body.Height } // If placement changed and no explicit dimensions provided, apply defaults if (body.Width == nil || *body.Width == 0) && (body.Height == nil || *body.Height == 0) && s.Placement != "" { switch s.Placement { case "homepage_top", "homepage_footer": s.Width = 1200 s.Height = 200 case "homepage_middle": s.Width = 970 s.Height = 250 case "homepage_sidebar": s.Width = 300 s.Height = 250 } } if err := bc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit změny"}) return } // Track file usage fileTracker := services.NewFileTracker(bc.DB) go fileTracker.TrackSponsorFiles(&s) // Return updated sponsor c.JSON(http.StatusOK, s) } // DeleteSponsor deletes a sponsor (protected) func (bc *BaseController) DeleteSponsor(c *gin.Context) { if _, ok := c.Get("user"); !ok { c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uživatel není přihlášen"}) return } id := c.Param("id") if err := bc.DB.Delete(&models.Sponsor{}, id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Smazání sponzora selhalo"}) return } c.JSON(http.StatusOK, gin.H{"zprava": "Sponzor byl smazán"}) } // --- Public: Matches and Standings (from prefetch cache) --- // GetMatches returns cached upcoming matches with overrides applied (public) func (bc *BaseController) GetMatches(c *gin.Context) { p := filepath.Join("cache", "prefetch", "events_upcoming.json") f, err := os.Open(p) if err != nil { c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"}) return } defer f.Close() var matches []map[string]interface{} if err := json.NewDecoder(f).Decode(&matches); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached matches"}) return } // Load and apply overrides (same logic as admin endpoint) var movs []models.MatchOverride if err := bc.DB.Find(&movs).Error; err == nil { movByID := map[string]models.MatchOverride{} for _, m := range movs { movByID[m.ExternalMatchID] = m } var tlovs []models.TeamLogoOverride if err := bc.DB.Find(&tlovs).Error; err == nil { tloByTeam := map[string]models.TeamLogoOverride{} for _, t := range tlovs { tloByTeam[t.ExternalTeamID] = t } // Apply overrides for _, m := range matches { var matchID string if v, ok := m["match_id"].(string); ok { matchID = v } else if v2, ok2 := m["id"].(string); ok2 { matchID = v2 } if ov, ok := movByID[matchID]; ok { if ov.HomeNameOverride != nil { m["home"] = *ov.HomeNameOverride m["home_team"] = *ov.HomeNameOverride } if ov.AwayNameOverride != nil { m["away"] = *ov.AwayNameOverride m["away_team"] = *ov.AwayNameOverride } if ov.VenueOverride != nil { m["venue"] = *ov.VenueOverride } if ov.DateTimeOverride != nil { m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339) m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04") } if ov.HomeLogoURL != nil { m["home_logo_url"] = *ov.HomeLogoURL } if ov.AwayLogoURL != nil { m["away_logo_url"] = *ov.AwayLogoURL } } // Apply team logo overrides if homeTeamID, ok := m["home_team_id"].(string); ok { if tlo, found := tloByTeam[homeTeamID]; found && tlo.LogoURL != "" { m["home_logo_url"] = tlo.LogoURL } } if awayTeamID, ok := m["away_team_id"].(string); ok { if tlo, found := tloByTeam[awayTeamID]; found && tlo.LogoURL != "" { m["away_logo_url"] = tlo.LogoURL } } } } } // Optional search filter: match home/away/venue/competition fields if s := strings.ToLower(strings.TrimSpace(c.Query("q"))); s != "" { filtered := make([]map[string]interface{}, 0, len(matches)) for _, m := range matches { get := func(k string) string { if v, ok := m[k]; ok { if vs, ok2 := v.(string); ok2 { return vs } } return "" } fields := []string{ get("home"), get("away"), get("venue"), get("competition"), get("competition_name"), get("league"), } matched := false for _, f := range fields { if f == "" { continue } if strings.Contains(strings.ToLower(f), s) { matched = true break } } if matched { filtered = append(filtered, m) } } matches = filtered } c.Header("Cache-Control", "public, max-age=60") c.JSON(http.StatusOK, matches) } // GetStandings returns cached FACR tables func (bc *BaseController) GetStandings(c *gin.Context) { p := filepath.Join("cache", "prefetch", "facr_tables.json") b, err := os.ReadFile(p) if err != nil { c.JSON(http.StatusNoContent, gin.H{"message": "No cached standings"}) return } c.Header("Cache-Control", "public, max-age=300") c.Data(http.StatusOK, "application/json", b) } // Admin: Users list func (bc *BaseController) GetUsers(c *gin.Context) { var users []models.User if err := bc.DB.Order("created_at DESC").Find(&users).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } resp := make([]gin.H, 0, len(users)) for _, u := range users { name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName)) resp = append(resp, gin.H{ "id": u.ID, "email": u.Email, "name": name, "role": u.Role, // Model nemá příznak aktivity; pro teď vždy true "isActive": true, "createdAt": u.CreatedAt, }) } c.JSON(http.StatusOK, resp) } // Admin: Create user func (bc *BaseController) CreateUser(c *gin.Context) { var body struct { Name string `json:"name"` Email string `json:"email" binding:"required"` Password string `json:"password" binding:"required,min=8"` Role string `json:"role"` IsActive *bool `json:"isActive"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } body.Email = strings.TrimSpace(strings.ToLower(body.Email)) if body.Email == "" || !strings.Contains(body.Email, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "Neplatný email"}) return } parts := strings.Fields(strings.TrimSpace(body.Name)) first, last := "", "" if len(parts) > 0 { first = parts[0] } if len(parts) > 1 { last = strings.Join(parts[1:], " ") } role := strings.ToLower(strings.TrimSpace(body.Role)) if role != "admin" { role = "user" } var existing models.User if err := bc.DB.Where("LOWER(email) = LOWER(?)", body.Email).First(&existing).Error; err == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Email již existuje"}) return } hashed, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze zpracovat heslo"}) return } u := models.User{ Email: body.Email, Password: string(hashed), FirstName: first, LastName: last, Role: role, } if err := bc.DB.Create(&u).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit uživatele"}) return } name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName)) c.JSON(http.StatusCreated, gin.H{ "id": u.ID, "email": u.Email, "name": name, "role": u.Role, "isActive": true, "createdAt": u.CreatedAt, }) } // Admin: Update user func (bc *BaseController) UpdateUser(c *gin.Context) { id := c.Param("id") var u models.User if err := bc.DB.First(&u, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Uživatel nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } var body struct { Name *string `json:"name"` Email *string `json:"email"` Role *string `json:"role"` IsActive *bool `json:"isActive"` Password *string `json:"password"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if body.Name != nil { parts := strings.Fields(strings.TrimSpace(*body.Name)) u.FirstName, u.LastName = "", "" if len(parts) > 0 { u.FirstName = parts[0] } if len(parts) > 1 { u.LastName = strings.Join(parts[1:], " ") } } if body.Email != nil { email := strings.TrimSpace(strings.ToLower(*body.Email)) if email == "" || !strings.Contains(email, "@") { c.JSON(http.StatusBadRequest, gin.H{"error": "Neplatný email"}) return } var cnt int64 if err := bc.DB.Model(&models.User{}).Where("LOWER(email) = LOWER(?) AND id <> ?", email, u.ID).Count(&cnt).Error; err == nil && cnt > 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "Email již existuje"}) return } u.Email = email } if body.Role != nil { r := strings.ToLower(strings.TrimSpace(*body.Role)) if r != "admin" && r != "user" { c.JSON(http.StatusBadRequest, gin.H{"error": "Neplatná role"}) return } u.Role = r } if body.Password != nil && *body.Password != "" { hashed, err := bcrypt.GenerateFromPassword([]byte(*body.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze zpracovat heslo"}) return } u.Password = string(hashed) } if err := bc.DB.Save(&u).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"}) return } name := strings.TrimSpace(strings.TrimSpace(u.FirstName) + " " + strings.TrimSpace(u.LastName)) c.JSON(http.StatusOK, gin.H{ "id": u.ID, "email": u.Email, "name": name, "role": u.Role, "isActive": true, "createdAt": u.CreatedAt, }) } // Admin: Delete user func (bc *BaseController) DeleteUser(c *gin.Context) { id := c.Param("id") var u models.User if err := bc.DB.First(&u, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Uživatel nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } // Disallow deleting any admin user if u.Role == "admin" { c.JSON(http.StatusBadRequest, gin.H{"error": "Nelze smazat uživatele s rolí admin"}) return } if curVal, ok := c.Get("user"); ok { cur := curVal.(*models.User) if cur.ID == u.ID { c.JSON(http.StatusBadRequest, gin.H{"error": "Nelze smazat sám sebe"}) return } } if err := bc.DB.Delete(&u).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Smazání uživatele selhalo"}) return } } // Admin: Settings - get settings (singleton) func (bc *BaseController) GetSettings(c *gin.Context) { var s models.Settings if err := bc.DB.First(&s).Error; err != nil { if err == gorm.ErrRecordNotFound { // Return sensible defaults rather than 404 s = models.Settings{ FrontpageLayout: "classic", FrontpageStyle: "light", PrimaryColor: "#1a365d", SecondaryColor: "#2b6cb0", AccentColor: "#e53e3e", BackgroundColor: "#ffffff", TextColor: "#1a202c", FontHeading: "Poppins, sans-serif", FontBody: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif", VideosModuleEnabled: true, VideosSource: "auto", VideosStyle: "slider", VideosLimit: 5, ShowAboutInNav: true, } c.JSON(http.StatusOK, s) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } s.LoadCustomNav() c.JSON(http.StatusOK, s) } // Admin: Settings - update settings (upsert singleton) func (bc *BaseController) UpdateSettings(c *gin.Context) { // Ensure latest schema for settings (adds new columns if needed) _ = bc.DB.AutoMigrate(&models.Settings{}) type reqBody struct { FrontpageLayout *string `json:"frontpage_layout"` FrontpageStyle *string `json:"frontpage_style"` // Sponsors module prefs SponsorsLayout *string `json:"sponsors_layout"` SponsorsTheme *string `json:"sponsors_theme"` ClubID *string `json:"club_id"` ClubType *string `json:"club_type"` ClubName *string `json:"club_name"` ClubLogoURL *string `json:"club_logo_url"` ClubURL *string `json:"club_url"` // Theme customization PrimaryColor *string `json:"primary_color"` SecondaryColor *string `json:"secondary_color"` AccentColor *string `json:"accent_color"` BackgroundColor *string `json:"background_color"` TextColor *string `json:"text_color"` FontHeading *string `json:"font_heading"` FontBody *string `json:"font_body"` // Custom overrides CustomCSS *string `json:"custom_css"` CustomJS *string `json:"custom_js"` CustomHTMLHome *string `json:"custom_html_home"` CustomHTMLBlogList *string `json:"custom_html_blog_list"` CustomHTMLBlogPost *string `json:"custom_html_blog_post"` // Custom pages & navigation AboutHTML *string `json:"about_html"` ShowAboutInNav *bool `json:"show_about_in_nav"` CustomNav *[]models.CustomNavLink `json:"custom_nav"` // Gallery GalleryURL *string `json:"gallery_url"` GalleryLabel *string `json:"gallery_label"` // SMTP (optional, dynamic) SMTPHost *string `json:"smtp_host"` SMTPPort *int `json:"smtp_port"` SMTPUser *string `json:"smtp_user"` SMTPPassword *string `json:"smtp_password"` SMTPFrom *string `json:"smtp_from"` SMTPFromName *string `json:"smtp_from_name"` SMTPEncryption *string `json:"smtp_encryption"` // tls|ssl|none SMTPAuth *bool `json:"smtp_auth"` SMTPSkipVerify *bool `json:"smtp_skip_verify"` // Social profiles FacebookURL *string `json:"facebook_url"` InstagramURL *string `json:"instagram_url"` YoutubeURL *string `json:"youtube_url"` // Videos module VideosModuleEnabled *bool `json:"videos_module_enabled"` VideosStyle *string `json:"videos_style"` VideosSource *string `json:"videos_source"` VideosLimit *int `json:"videos_limit"` // Manual videos Videos *[]string `json:"videos"` VideosItems *[]struct { URL string `json:"url"` Title *string `json:"title"` Length *string `json:"length"` UploadedAt *string `json:"uploaded_at"` ThumbnailURL *string `json:"thumbnail_url"` } `json:"videos_items"` // Merch module MerchModuleEnabled *bool `json:"merch_module_enabled"` MerchStyle *string `json:"merch_style"` MerchSource *string `json:"merch_source"` MerchLimit *int `json:"merch_limit"` // Manual merch MerchItems *[]struct { Title *string `json:"title"` ImageURL string `json:"image_url"` URL *string `json:"url"` } `json:"merch_items"` // Newsletter defaults DefaultDigestType *string `json:"default_digest_type"` DefaultDigestCompetitions *string `json:"default_digest_competitions"` // Newsletter scheduling EnableWeekly *bool `json:"enable_weekly"` EnableMatchReminders *bool `json:"enable_match_reminders"` EnableResults *bool `json:"enable_results"` NewsletterWeeklyDay *string `json:"newsletter_weekly_day"` NewsletterWeeklyHour *int `json:"newsletter_weekly_hour"` NewsletterReminderLeadHours *int `json:"newsletter_reminder_lead_hours"` NewsletterQuietStart *int `json:"newsletter_quiet_start"` NewsletterQuietEnd *int `json:"newsletter_quiet_end"` // Contact/Location information ContactAddress *string `json:"contact_address"` ContactCity *string `json:"contact_city"` ContactZip *string `json:"contact_zip"` ContactCountry *string `json:"contact_country"` ContactPhone *string `json:"contact_phone"` ContactEmail *string `json:"contact_email"` LocationLatitude *float64 `json:"location_latitude"` LocationLongitude *float64 `json:"location_longitude"` MapZoomLevel *int `json:"map_zoom_level"` MapStyle *string `json:"map_style"` ShowMapOnHomepage *bool `json:"show_map_on_homepage"` } var body reqBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": err.Error()}) return } var s models.Settings if err := bc.DB.First(&s).Error; err != nil { if err == gorm.ErrRecordNotFound { s = models.Settings{} } else { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } } // Ensure navigation cache is hydrated before modifications s.LoadCustomNav() if body.FrontpageLayout != nil { s.FrontpageLayout = *body.FrontpageLayout } if body.FrontpageStyle != nil { s.FrontpageStyle = *body.FrontpageStyle } // Sponsors module if body.SponsorsLayout != nil { v := strings.TrimSpace(*body.SponsorsLayout) switch v { case "grid", "slider", "scroller", "pyramid": s.SponsorsLayout = v default: // keep previous or default to grid if s.SponsorsLayout == "" { s.SponsorsLayout = "grid" } } } if body.SponsorsTheme != nil { v := strings.TrimSpace(*body.SponsorsTheme) if v == "dark" || v == "light" { s.SponsorsTheme = v } else { if s.SponsorsTheme == "" { s.SponsorsTheme = "light" } } } if body.ClubID != nil { s.ClubID = *body.ClubID } if body.ClubType != nil { s.ClubType = *body.ClubType } if body.ClubName != nil { s.ClubName = *body.ClubName } if body.ClubLogoURL != nil { s.ClubLogoURL = *body.ClubLogoURL } if body.ClubURL != nil { s.ClubURL = *body.ClubURL } if body.PrimaryColor != nil { s.PrimaryColor = *body.PrimaryColor } if body.SecondaryColor != nil { s.SecondaryColor = *body.SecondaryColor } if body.AccentColor != nil { s.AccentColor = *body.AccentColor } if body.BackgroundColor != nil { s.BackgroundColor = *body.BackgroundColor } if body.TextColor != nil { s.TextColor = *body.TextColor } if body.FontHeading != nil { s.FontHeading = *body.FontHeading } if body.FontBody != nil { s.FontBody = *body.FontBody } if body.CustomCSS != nil { s.CustomCSS = *body.CustomCSS } // Newsletter defaults if body.DefaultDigestType != nil { s.DefaultDigestType = *body.DefaultDigestType } if body.DefaultDigestCompetitions != nil { s.DefaultDigestCompetitions = *body.DefaultDigestCompetitions } // Newsletter scheduling if body.EnableWeekly != nil { s.EnableWeekly = *body.EnableWeekly } if body.EnableMatchReminders != nil { s.EnableMatchReminders = *body.EnableMatchReminders } if body.EnableResults != nil { s.EnableResults = *body.EnableResults } if body.NewsletterWeeklyDay != nil { s.NewsletterWeeklyDay = strings.ToLower(strings.TrimSpace(*body.NewsletterWeeklyDay)) } if body.NewsletterWeeklyHour != nil { s.NewsletterWeeklyHour = *body.NewsletterWeeklyHour } if body.NewsletterReminderLeadHours != nil { s.NewsletterReminderLeadHours = *body.NewsletterReminderLeadHours } if body.NewsletterQuietStart != nil { s.NewsletterQuietStart = *body.NewsletterQuietStart } if body.NewsletterQuietEnd != nil { s.NewsletterQuietEnd = *body.NewsletterQuietEnd } if body.CustomJS != nil { s.CustomJS = *body.CustomJS } if body.CustomHTMLHome != nil { s.CustomHTMLHome = *body.CustomHTMLHome } if body.CustomHTMLBlogList != nil { s.CustomHTMLBlogList = *body.CustomHTMLBlogList } if body.CustomHTMLBlogPost != nil { s.CustomHTMLBlogPost = *body.CustomHTMLBlogPost } if body.AboutHTML != nil { s.AboutHTML = *body.AboutHTML } if body.ShowAboutInNav != nil { s.ShowAboutInNav = *body.ShowAboutInNav } if body.CustomNav != nil { if err := s.SetCustomNav(*body.CustomNav); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná navigace"}) return } } // Gallery if body.GalleryURL != nil { s.GalleryURL = *body.GalleryURL } if body.GalleryLabel != nil { s.GalleryLabel = strings.TrimSpace(*body.GalleryLabel) } // Social profiles if body.FacebookURL != nil { s.FacebookURL = *body.FacebookURL } if body.InstagramURL != nil { s.InstagramURL = *body.InstagramURL } if body.YoutubeURL != nil { s.YoutubeURL = *body.YoutubeURL // Auto-enable videos module when YouTube URL is provided and videos_source is auto if strings.TrimSpace(*body.YoutubeURL) != "" { // Only auto-enable if not explicitly disabled by user if body.VideosModuleEnabled == nil { // Check if videos_source is auto (default) or explicitly set to auto if s.VideosSource == "" || s.VideosSource == "auto" || (body.VideosSource != nil && *body.VideosSource == "auto") { s.VideosModuleEnabled = true } } } } // Videos module if body.VideosModuleEnabled != nil { s.VideosModuleEnabled = *body.VideosModuleEnabled } if body.VideosStyle != nil { s.VideosStyle = strings.TrimSpace(*body.VideosStyle) } if body.VideosSource != nil { s.VideosSource = strings.TrimSpace(*body.VideosSource) } else if s.VideosSource == "" { // Default to auto source s.VideosSource = "auto" } if body.VideosLimit != nil { s.VideosLimit = *body.VideosLimit } else if s.VideosLimit == 0 { // Default to 6 videos on homepage s.VideosLimit = 6 } // Manual videos if body.Videos != nil { if b, err := json.Marshal(body.Videos); err == nil { s.VideosJSON = string(b) } } if body.VideosItems != nil { if b, err := json.Marshal(body.VideosItems); err == nil { s.VideosItemsJSON = string(b) } } // Merch module if body.MerchModuleEnabled != nil { s.MerchModuleEnabled = *body.MerchModuleEnabled } if body.MerchStyle != nil { s.MerchStyle = strings.TrimSpace(*body.MerchStyle) } if body.MerchSource != nil { s.MerchSource = strings.TrimSpace(*body.MerchSource) } if body.MerchLimit != nil { s.MerchLimit = *body.MerchLimit } if body.MerchItems != nil { if b, err := json.Marshal(body.MerchItems); err == nil { s.MerchItemsJSON = string(b) } } // SMTP dynamic settings (if provided) if body.SMTPHost != nil { s.SMTPHost = strings.TrimSpace(*body.SMTPHost) } if body.SMTPPort != nil { s.SMTPPort = *body.SMTPPort } if body.SMTPUser != nil { s.SMTPUser = strings.TrimSpace(*body.SMTPUser) } // Only update password if a new one is provided (not empty) if body.SMTPPassword != nil && *body.SMTPPassword != "" { s.SMTPPassword = *body.SMTPPassword } if body.SMTPFrom != nil { s.SMTPFrom = strings.TrimSpace(*body.SMTPFrom) } if body.SMTPFromName != nil { s.SMTPFromName = strings.TrimSpace(*body.SMTPFromName) } if body.SMTPEncryption != nil { s.SMTPEncryption = strings.ToLower(strings.TrimSpace(*body.SMTPEncryption)) } if body.SMTPAuth != nil { s.SMTPAuth = *body.SMTPAuth } if body.SMTPSkipVerify != nil { s.SMTPSkipVerify = *body.SMTPSkipVerify } // Contact/Location information if body.ContactAddress != nil { s.ContactAddress = strings.TrimSpace(*body.ContactAddress) } if body.ContactCity != nil { s.ContactCity = strings.TrimSpace(*body.ContactCity) } if body.ContactZip != nil { s.ContactZip = strings.TrimSpace(*body.ContactZip) } if body.ContactCountry != nil { s.ContactCountry = strings.TrimSpace(*body.ContactCountry) } if body.ContactPhone != nil { s.ContactPhone = strings.TrimSpace(*body.ContactPhone) } if body.ContactEmail != nil { s.ContactEmail = strings.TrimSpace(*body.ContactEmail) } if body.LocationLatitude != nil { s.LocationLatitude = *body.LocationLatitude } if body.LocationLongitude != nil { s.LocationLongitude = *body.LocationLongitude } if body.MapZoomLevel != nil { s.MapZoomLevel = *body.MapZoomLevel } if body.MapStyle != nil { s.MapStyle = strings.TrimSpace(*body.MapStyle) } if body.ShowMapOnHomepage != nil { s.ShowMapOnHomepage = *body.ShowMapOnHomepage } if s.ID == 0 { if err := bc.DB.Create(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"}) return } } else { if err := bc.DB.Save(&s).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"}) return } } logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel) // If gallery_url is a Zonerama link, refresh Zonerama cache immediately if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") { go func(link string) { _ = services.RefreshZoneramaNow(link) }(g) } // If YouTube URL updated, trigger immediate refresh if body.YoutubeURL != nil { v := strings.TrimSpace(*body.YoutubeURL) if v != "" { go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(v) } } c.JSON(http.StatusOK, s) } // NewBaseController creates a new instance of BaseController func (bc *BaseController) GetPublicSettings(c *gin.Context) { var s models.Settings if err := bc.DB.First(&s).Error; err != nil { if err == gorm.ErrRecordNotFound { // mirror defaults from GetSettings s = models.Settings{ FrontpageLayout: "classic", FrontpageStyle: "light", PrimaryColor: "#1a365d", SecondaryColor: "#2b6cb0", AccentColor: "#e53e3e", BackgroundColor: "#ffffff", TextColor: "#1a202c", FontHeading: "Poppins, sans-serif", FontBody: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif", VideosModuleEnabled: false, VideosSource: "auto", VideosStyle: "slider", VideosLimit: 6, } } else { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } } // Apply defaults for videos settings if not set if s.VideosSource == "" { s.VideosSource = "auto" } if s.VideosLimit == 0 { s.VideosLimit = 6 } if s.VideosStyle == "" { s.VideosStyle = "slider" } // Auto-enable videos module if YouTube URL is provided and source is auto if s.YoutubeURL != "" && s.VideosSource == "auto" && !s.VideosModuleEnabled { s.VideosModuleEnabled = true } // Build a whitelist response // Conditional GET based on settings timestamp last := s.UpdatedAt if last.IsZero() { last = time.Now().Add(-1 * time.Hour) } if ims := c.GetHeader("If-Modified-Since"); ims != "" { if t, err := time.Parse(http.TimeFormat, ims); err == nil { if !last.After(t) { c.Status(http.StatusNotModified) return } } } // Decode manual videos for public payload var pubVids []string if s.VideosJSON != "" { _ = json.Unmarshal([]byte(s.VideosJSON), &pubVids) } var pubVidsItems any if s.VideosItemsJSON != "" { _ = json.Unmarshal([]byte(s.VideosItemsJSON), &pubVidsItems) } var pubMerchItems any if s.MerchItemsJSON != "" { _ = json.Unmarshal([]byte(s.MerchItemsJSON), &pubMerchItems) } resp := gin.H{ // Core site identity (needed for prefetch to derive FACR endpoints) "club_id": s.ClubID, "club_type": s.ClubType, "club_name": s.ClubName, "club_logo_url": s.ClubLogoURL, "club_url": s.ClubURL, // Theme "primary_color": s.PrimaryColor, "secondary_color": s.SecondaryColor, "accent_color": s.AccentColor, "background_color": s.BackgroundColor, "text_color": s.TextColor, "font_heading": s.FontHeading, "font_body": s.FontBody, // Sponsors module prefs "sponsors_layout": s.SponsorsLayout, "sponsors_theme": s.SponsorsTheme, // Social / media "facebook_url": s.FacebookURL, "instagram_url": s.InstagramURL, "youtube_url": s.YoutubeURL, "gallery_url": s.GalleryURL, "gallery_label": s.GalleryLabel, // Videos module "videos_module_enabled": s.VideosModuleEnabled, "videos_style": s.VideosStyle, "videos_source": s.VideosSource, "videos_limit": s.VideosLimit, // Manual videos (public so homepage can render) "videos": pubVids, "videos_items": pubVidsItems, // Merch config + items "merch_module_enabled": s.MerchModuleEnabled, "merch_style": s.MerchStyle, "merch_source": s.MerchSource, "merch_limit": s.MerchLimit, "merch_items": pubMerchItems, // Custom club page & navigation "about_html": s.AboutHTML, "show_about_in_nav": s.ShowAboutInNav, "custom_nav": s.CustomNav, // Contact/Map information (public for displaying on contact page and homepage) "contact_address": s.ContactAddress, "contact_city": s.ContactCity, "contact_zip": s.ContactZip, "contact_country": s.ContactCountry, "contact_phone": s.ContactPhone, "contact_email": s.ContactEmail, "location_latitude": s.LocationLatitude, "location_longitude": s.LocationLongitude, "map_zoom_level": s.MapZoomLevel, "map_style": s.MapStyle, "show_map_on_homepage": s.ShowMapOnHomepage, } logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel) c.JSON(http.StatusOK, resp) } // Global newsletter automation instance (set from main) var globalNewsletterAutomation *services.NewsletterAutomation // SetNewsletterAutomation sets the global newsletter automation instance func SetNewsletterAutomation(na *services.NewsletterAutomation) { globalNewsletterAutomation = na } // triggerBlogNotification sends newsletter notification when blog is published func (bc *BaseController) triggerBlogNotification(article *models.Article) { if globalNewsletterAutomation == nil { logger.Warn("Newsletter automation not initialized, skipping blog notification") return } if err := globalNewsletterAutomation.SendBlogNotification(article); err != nil { logger.Error("Failed to send blog notification: %v", err) } }