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" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/internal/services" "fotbal-club/pkg/email" "fotbal-club/pkg/logger" "golang.org/x/crypto/bcrypt" "golang.org/x/text/transform" "golang.org/x/text/unicode/norm" "unicode" "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 } func makeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) if s == "" { return "" } t := transform.Chain(norm.NFD, transform.RemoveFunc(func(r rune) bool { return unicode.Is(unicode.Mn, r) }), norm.NFC) s, _, _ = transform.String(t, s) reSpace := regexp.MustCompile(`\s+`) s = reSpace.ReplaceAllString(s, "-") reInvalid := regexp.MustCompile(`[^a-z0-9-]`) s = reInvalid.ReplaceAllString(s, "") reHyphens := regexp.MustCompile(`-+`) s = reHyphens.ReplaceAllString(s, "-") s = strings.Trim(s, "-") return s } func normalizePhone(raw, country string) string { s := strings.TrimSpace(raw) if s == "" { return "" } re := regexp.MustCompile(`[\s\-\.\(\)]`) s = re.ReplaceAllString(s, "") if strings.HasPrefix(s, "00") { s = "+" + s[2:] } if strings.HasPrefix(s, "+") { return s } if matched, _ := regexp.MatchString(`^420\d{9}$`, s); matched { return "+" + s } if matched, _ := regexp.MatchString(`^\d{9}$`, s); matched { c := strings.ToLower(country) if strings.Contains(c, "česk") || strings.Contains(c, "czech") { return "+420" + s } } return s } func getPrefetchBaseURL() string { base := strings.TrimSpace(os.Getenv("PREFETCH_TARGET")) if base == "" { port := strings.TrimSpace(os.Getenv("PORT")) if port == "" { port = "8080" } base = "http://127.0.0.1:" + port + "/api/v1" } return base } // 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 { if tlo.LogoURL != "" { m["home_logo_url"] = tlo.LogoURL } if strings.TrimSpace(tlo.TeamName) != "" { m["home"] = tlo.TeamName m["home_team"] = tlo.TeamName } } } if awayTeamID, ok := m["away_team_id"].(string); ok { if tlo, found := tloByTeam[awayTeamID]; found { if tlo.LogoURL != "" { m["away_logo_url"] = tlo.LogoURL } if strings.TrimSpace(tlo.TeamName) != "" { m["away"] = tlo.TeamName m["away_team"] = tlo.TeamName } } } } } } // 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) } func (bc *BaseController) GetMatches(c *gin.Context) { p := filepath.Join("cache", "prefetch", "matches.json") f, err := os.Open(p) if err != nil { p2 := filepath.Join("cache", "prefetch", "events_upcoming.json") f, err = os.Open(p2) if err != nil { c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"}) return } } defer f.Close() var matches []map[string]any if err := json.NewDecoder(f).Decode(&matches); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached matches"}) return } 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 homeID, ok := m["home_id"].(string); ok { if tlo, found := tloByTeam[homeID]; found { if tlo.LogoURL != "" { m["home_logo_url"] = tlo.LogoURL } if strings.TrimSpace(tlo.TeamName) != "" { m["home"] = tlo.TeamName m["home_team"] = tlo.TeamName } } } else if homeTeamID, ok2 := m["home_team_id"].(string); ok2 { if tlo, found := tloByTeam[homeTeamID]; found { if tlo.LogoURL != "" { m["home_logo_url"] = tlo.LogoURL } if strings.TrimSpace(tlo.TeamName) != "" { m["home"] = tlo.TeamName m["home_team"] = tlo.TeamName } } } if awayID, ok := m["away_id"].(string); ok { if tlo, found := tloByTeam[awayID]; found { if tlo.LogoURL != "" { m["away_logo_url"] = tlo.LogoURL } if strings.TrimSpace(tlo.TeamName) != "" { m["away"] = tlo.TeamName m["away_team"] = tlo.TeamName } } } else if awayTeamID, ok2 := m["away_team_id"].(string); ok2 { if tlo, found := tloByTeam[awayTeamID]; found { if tlo.LogoURL != "" { m["away_logo_url"] = tlo.LogoURL } if strings.TrimSpace(tlo.TeamName) != "" { m["away"] = tlo.TeamName m["away_team"] = tlo.TeamName } } } } } } c.Header("Cache-Control", "public, max-age=60") c.JSON(http.StatusOK, matches) } func (bc *BaseController) GetStandings(c *gin.Context) { p := filepath.Join("cache", "prefetch", "facr_tables.json") f, err := os.Open(p) if err != nil { c.JSON(http.StatusNoContent, gin.H{"message": "No cached standings"}) return } defer f.Close() var payload struct { Competitions []struct { Name string `json:"name"` Code string `json:"code"` Table struct { Overall []struct { Rank string `json:"rank"` Team string `json:"team"` TeamID string `json:"team_id"` TeamLogoURL string `json:"team_logo_url"` Played string `json:"played"` Wins string `json:"wins"` Draws string `json:"draws"` Losses string `json:"losses"` Score string `json:"score"` Points string `json:"points"` } `json:"overall"` } `json:"table"` } `json:"competitions"` } if err := json.NewDecoder(f).Decode(&payload); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached standings"}) return } var rows []map[string]any if len(payload.Competitions) > 0 { comp := payload.Competitions[0] for _, r := range comp.Table.Overall { rows = append(rows, map[string]any{ "rank": r.Rank, "team": r.Team, "team_id": r.TeamID, "team_logo_url": r.TeamLogoURL, "played": r.Played, "wins": r.Wins, "draws": r.Draws, "losses": r.Losses, "score": r.Score, "points": r.Points, }) } } // Apply team overrides (name/logo) if present if len(rows) > 0 { var tlovs []models.TeamLogoOverride if err := bc.DB.Find(&tlovs).Error; err == nil { tloByID := map[string]models.TeamLogoOverride{} for _, it := range tlovs { tloByID[it.ExternalTeamID] = it } for i := range rows { id, _ := rows[i]["team_id"].(string) if id == "" { continue } if tlo, ok := tloByID[id]; ok { if strings.TrimSpace(tlo.TeamName) != "" { rows[i]["team"] = tlo.TeamName } if strings.TrimSpace(tlo.LogoURL) != "" { rows[i]["team_logo_url"] = tlo.LogoURL } } } } } c.Header("Cache-Control", "public, max-age=120") c.JSON(http.StatusOK, rows) } // 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) } // Load match link if exists var matchLink models.ArticleMatchLink if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil { art.MatchLink = &matchLink } 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), } // Ensure category slug is set and unique s := makeSlug(cat.Name) if s == "" { s = "category" } orig := s for i := 0; i < 50; i++ { var cnt int64 if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"}) return } if cnt == 0 { break } s = fmt.Sprintf("%s-%d", orig, i+1) } cat.Slug = s 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) } // Load match link if exists var matchLink models.ArticleMatchLink if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil { art.MatchLink = &matchLink } 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 } // GetArticles returns a paginated list of articles (public by default, admin can request all with published=false) func (bc *BaseController) GetArticles(c *gin.Context) { pageStr := strings.TrimSpace(c.DefaultQuery("page", "1")) sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "10")) page, _ := strconv.Atoi(pageStr) if page < 1 { page = 1 } size, _ := strconv.Atoi(sizeStr) if size <= 0 { size = 10 } if size > 100 { size = 100 } pParam := strings.ToLower(strings.TrimSpace(c.Query("published"))) featuredParam := strings.ToLower(strings.TrimSpace(c.Query("featured"))) == "true" q := strings.TrimSpace(c.Query("q")) slug := strings.TrimSpace(c.Query("slug")) catRaw := strings.TrimSpace(c.Query("category_id")) matchID := strings.TrimSpace(c.Query("match_id")) monthStr := strings.TrimSpace(c.Query("month")) if monthStr == "" { monthStr = strings.TrimSpace(c.Query("date")) } catID := 0 if catRaw != "" { if v, err := strconv.Atoi(catRaw); err == nil { catID = v } } skipCache := false if pParam == "false" { skipCache = true } if q != "" || slug != "" || catID > 0 || matchID != "" || monthStr != "" { skipCache = true } if !skipCache { if bc.respondArticlesFromCache(c, page, size) { return } } var items []models.Article qb := bc.DB.Model(&models.Article{}) if pParam == "" || pParam == "true" { qb = qb.Where("published = ?", true) } if featuredParam { qb = qb.Where("featured = ?", true) } if catID > 0 { qb = qb.Where("category_id = ?", catID) } if slug != "" { qb = qb.Where("slug = ?", slug) } if q != "" { like := "%" + strings.ToLower(q) + "%" qb = qb.Where("LOWER(title) LIKE ? OR LOWER(content) LIKE ? OR LOWER(category_name) LIKE ?", like, like, like) } if matchID != "" { qb = qb.Joins("JOIN article_match_links aml ON aml.article_id = articles.id").Where("aml.external_match_id = ?", matchID) } if monthStr != "" { var y, m int if len(monthStr) >= 7 { if yy, err := strconv.Atoi(monthStr[0:4]); err == nil { y = yy } if mm, err := strconv.Atoi(monthStr[5:7]); err == nil { m = mm } } if y > 0 && m >= 1 && m <= 12 { start := time.Date(y, time.Month(m), 1, 0, 0, 0, 0, time.UTC) nm := m + 1 ny := y if nm == 13 { nm = 1 ny = y + 1 } end := time.Date(ny, time.Month(nm), 1, 0, 0, 0, 0, time.UTC) qb = qb.Where("COALESCE(published_at, created_at) >= ? AND COALESCE(published_at, created_at) < ?", start, end) } } var total int64 if err := qb.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } if err := qb.Preload("Author").Preload("Category"). Order("COALESCE(published_at, created_at) DESC, created_at DESC"). Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil { 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" } if items[i].ReadTime == 0 { items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content) } } 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 := strings.TrimSpace(c.DefaultQuery("page", "1")) sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "6")) page, _ := strconv.Atoi(pageStr) if page < 1 { page = 1 } size, _ := strconv.Atoi(sizeStr) if size <= 0 { size = 6 } if size > 100 { size = 100 } var items []models.Article qb := bc.DB.Model(&models.Article{}). Where("published = ?", true). Where("featured = ?", true) var total int64 if err := qb.Count(&total).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } if err := qb.Preload("Author").Preload("Category"). Order("COALESCE(published_at, created_at) DESC, created_at DESC"). Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil { 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" } if items[i].ReadTime == 0 { items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content) } } c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size}) } // UpdateArticle updates an existing article (protected) func (bc *BaseController) UpdateArticle(c *gin.Context) { 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 } 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"` Featured *bool `json:"featured"` Slug *string `json:"slug"` SeoTitle *string `json:"seo_title"` SeoDescription *string `json:"seo_description"` OgImageURL *string `json:"og_image_url"` 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": "Neplatná data", "detail": err.Error()}) return } oldPublished := art.Published if body.Title != nil { art.Title = strings.TrimSpace(*body.Title) } if body.Content != nil { art.Content = *body.Content } if body.CategoryID != nil { if *body.CategoryID == 0 { art.CategoryID = nil } else { art.CategoryID = body.CategoryID } } else if body.CategoryName != nil { name := strings.TrimSpace(*body.CategoryName) if name == "" { art.CategoryID = nil } else { 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 při hledání kategorie"}) return } } art.CategoryID = &cat.ID } } if body.ImageURL != nil { art.ImageURL = strings.TrimSpace(*body.ImageURL) } if body.Featured != nil { art.Featured = *body.Featured } if body.Published != nil { art.Published = *body.Published } if body.PublishedAt != nil { t := strings.TrimSpace(*body.PublishedAt) if t == "" { art.PublishedAt = nil } else if tt, err := time.Parse(time.RFC3339, t); err == nil { art.PublishedAt = &tt } } if art.Published && art.PublishedAt == nil { now := time.Now() art.PublishedAt = &now } if art.Published && strings.TrimSpace(art.Content) == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Obsah je povinný pro publikovaný článek"}) return } if body.Slug != nil { s := strings.TrimSpace(*body.Slug) if s == "" { s = makeSlug(art.Title) } else { s = makeSlug(s) } orig := s for i := 0; i < 50; i++ { var cnt int64 if err := bc.DB.Model(&models.Article{}).Where("slug = ? AND id != ?", s, art.ID).Count(&cnt).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"}) return } if cnt == 0 { break } s = fmt.Sprintf("%s-%d", orig, i+1) } art.Slug = s } if body.SeoTitle != nil { v := strings.TrimSpace(*body.SeoTitle) if v == "" { v = strings.TrimSpace(art.Title) } art.SEOTitle = v } if body.SeoDescription != nil { v := strings.TrimSpace(*body.SeoDescription) if v == "" { v = deriveSeoDescription(art.Content) } art.SEODescription = v } if body.OgImageURL != nil { art.OGImageURL = strings.TrimSpace(*body.OgImageURL) } if body.GalleryAlbumID != nil { art.GalleryAlbumID = strings.TrimSpace(*body.GalleryAlbumID) } if body.GalleryAlbumURL != nil { art.GalleryAlbumURL = strings.TrimSpace(*body.GalleryAlbumURL) } if body.GalleryPhotoIDs != nil { ids := *body.GalleryPhotoIDs if len(ids) > 0 { art.GalleryPhotoIDs = strings.Join(ids, ",") } else { art.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) } if err := bc.DB.Save(&art).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek"}) return } go func(a models.Article) { ft := services.NewFileTracker(bc.DB) ft.TrackArticleFiles(&a) }(art) if art.Published && !oldPublished { go bc.triggerBlogNotification(&art) go func() { services.PrefetchOnce(getBaseURL()) }() } bc.DB.Preload("Author").Preload("Category").First(&art, art.ID) 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) } // DeleteArticle deletes an article (protected) func (bc *BaseController) DeleteArticle(c *gin.Context) { 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 } _ = bc.DB.Where("article_id = ?", art.ID).Delete(&models.ArticleMatchLink{}).Error _ = bc.DB.Where("article_id = ?", art.ID).Delete(&models.ArticleTeamLink{}).Error if err := bc.DB.Delete(&art).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat článek"}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // 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" }, // "by_id": { "": { "name": "Team Name", "logo_url": "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)) byID := make(map[string]any, len(items)) for _, it := range items { if it.TeamName != "" && it.LogoURL != "" { m[it.TeamName] = it.LogoURL } if it.ExternalTeamID != "" { byID[it.ExternalTeamID] = map[string]string{ "name": it.TeamName, "logo_url": it.LogoURL, } } } // Public cacheable response c.Header("Cache-Control", "public, max-age=120") c.JSON(http.StatusOK, gin.H{"by_name": m, "by_id": byID}) } // writeTeamLogoOverridesCache writes a JSON snapshot of team-logo overrides to cache/prefetch/team_logo_overrides.json // Shape: { // "by_name": { "Team Name": "https://..." }, // "by_id": { "": { "name": "Team Name", "logo_url": "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)) byID := make(map[string]any, len(items)) for _, it := range items { if it.TeamName != "" && it.LogoURL != "" { m[it.TeamName] = it.LogoURL } if it.ExternalTeamID != "" { byID[it.ExternalTeamID] = map[string]string{ "name": it.TeamName, "logo_url": it.LogoURL, } } } payload := map[string]any{"by_name": m, "by_id": byID} 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) } 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 } // Use realistic browser headers - some CDNs block unknown clients req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36") req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8") // Set a benign referer tied to the target host to satisfy anti-hotlink checks if u.Host != "" { ref := u.Scheme + "://" + u.Host + "/" req.Header.Set("Referer", ref) } 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"` // Optional bases FrontendBaseURL string `json:"frontend_base_url"` APIBaseURL string `json:"api_base_url"` // Social profiles (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 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 } // Gallery if body.GalleryURL != "" { s.GalleryURL = strings.TrimSpace(body.GalleryURL) } if body.GalleryLabel != "" { s.GalleryLabel = strings.TrimSpace(body.GalleryLabel) } // Frontpage style if body.FrontpageStyle != "" { s.FrontpageStyle = body.FrontpageStyle } // Detect and persist base URLs if v := strings.TrimSpace(body.APIBaseURL); v != "" { s.APIBaseURL = v } if v := strings.TrimSpace(body.FrontendBaseURL); v != "" { s.FrontendBaseURL = v } // If not provided, infer from current request and proxy headers { scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { parts := strings.Split(xf, ",") if len(parts) > 0 { if h := strings.TrimSpace(parts[0]); h != "" { host = h } } } if !strings.Contains(host, ":") { if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" { if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") { host = host + ":" + xfp } } } if strings.TrimSpace(s.APIBaseURL) == "" { s.APIBaseURL = scheme + "://" + host + "/api/v1" } if strings.TrimSpace(s.FrontendBaseURL) == "" { if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" { s.FrontendBaseURL = origin } else { s.FrontendBaseURL = scheme + "://" + host } } } // 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 } } // Best-effort: cache club logo locally from logoapi and update settings if strings.TrimSpace(s.ClubID) != "" { if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(s.ClubID)); err == nil && strings.TrimSpace(url) != "" { _ = bc.DB.Model(&models.Settings{}).Where("id = ?", s.ID).Update("club_logo_url", url).Error } } // Trigger background prefetch and YouTube cache refresh when settings are updated post-setup go func(snap models.Settings) { defer func() { _ = recover() }() snap.LoadCustomNav() var pubVids []string if snap.VideosJSON != "" { _ = json.Unmarshal([]byte(snap.VideosJSON), &pubVids) } var pubVidsItems any if snap.VideosItemsJSON != "" { _ = json.Unmarshal([]byte(snap.VideosItemsJSON), &pubVidsItems) } var pubMerchItems any if snap.MerchItemsJSON != "" { _ = json.Unmarshal([]byte(snap.MerchItemsJSON), &pubMerchItems) } resp := map[string]any{ "club_id": snap.ClubID, "club_type": snap.ClubType, "club_name": snap.ClubName, "club_logo_url": snap.ClubLogoURL, "club_url": snap.ClubURL, "primary_color": snap.PrimaryColor, "secondary_color": snap.SecondaryColor, "accent_color": snap.AccentColor, "background_color": snap.BackgroundColor, "text_color": snap.TextColor, "font_heading": snap.FontHeading, "font_body": snap.FontBody, "sponsors_layout": snap.SponsorsLayout, "sponsors_theme": snap.SponsorsTheme, "facebook_url": snap.FacebookURL, "instagram_url": snap.InstagramURL, "youtube_url": snap.YoutubeURL, "gallery_url": snap.GalleryURL, "gallery_label": snap.GalleryLabel, "videos_module_enabled": snap.VideosModuleEnabled, "videos_style": snap.VideosStyle, "videos_source": snap.VideosSource, "videos_limit": snap.VideosLimit, "videos": pubVids, "videos_items": pubVidsItems, "merch_module_enabled": snap.MerchModuleEnabled, "merch_style": snap.MerchStyle, "merch_source": snap.MerchSource, "merch_limit": snap.MerchLimit, "merch_items": pubMerchItems, "about_html": snap.AboutHTML, "show_about_in_nav": snap.ShowAboutInNav, "custom_nav": snap.CustomNav, "contact_address": snap.ContactAddress, "contact_city": snap.ContactCity, "contact_zip": snap.ContactZip, "contact_country": snap.ContactCountry, "contact_phone": snap.ContactPhone, "contact_email": snap.ContactEmail, "location_latitude": snap.LocationLatitude, "location_longitude": snap.LocationLongitude, "map_zoom_level": snap.MapZoomLevel, "map_style": snap.MapStyle, "show_map_on_homepage": snap.ShowMapOnHomepage, } b, _ := json.MarshalIndent(resp, "", " ") outPath := filepath.Join("cache", "prefetch", "settings.json") _ = os.MkdirAll(filepath.Dir(outPath), 0o755) tmp := outPath + ".tmp" _ = os.WriteFile(tmp, b, 0o644) _ = os.Rename(tmp, outPath) }(s) { base := strings.TrimSpace(s.APIBaseURL) if base == "" { base = getPrefetchBaseURL() } go services.PrefetchOnce(strings.TrimRight(base, "/")) } 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", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL}) 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"` // Optional bases FrontendBaseURL string `json:"frontend_base_url"` APIBaseURL string `json:"api_base_url"` } 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 = normalizePhone(v, body.ContactCountry) } 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 } // Persist base URLs (prefer request body, otherwise infer) if v := strings.TrimSpace(body.APIBaseURL); v != "" { s.APIBaseURL = v } if v := strings.TrimSpace(body.FrontendBaseURL); v != "" { s.FrontendBaseURL = v } { scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { parts := strings.Split(xf, ",") if len(parts) > 0 { if h := strings.TrimSpace(parts[0]); h != "" { host = h } } } if !strings.Contains(host, ":") { if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" { if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") { host = host + ":" + xfp } } } if strings.TrimSpace(s.APIBaseURL) == "" { s.APIBaseURL = scheme + "://" + host + "/api/v1" } if strings.TrimSpace(s.FrontendBaseURL) == "" { if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" { s.FrontendBaseURL = origin } else { s.FrontendBaseURL = scheme + "://" + host } } } // 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 } } // Best-effort: cache club logo locally from logoapi and update settings if strings.TrimSpace(s.ClubID) != "" { go func(id uint, clubID string) { if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(clubID)); err == nil && strings.TrimSpace(url) != "" { _ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_logo_url", url).Error } }(s.ID, s.ClubID) } 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) // Immediately write public settings cache from current Settings snapshot go func() { defer func() { _ = recover() }() s.LoadCustomNav() 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 := map[string]any{ "club_id": s.ClubID, "club_type": s.ClubType, "club_name": s.ClubName, "club_logo_url": s.ClubLogoURL, "club_url": s.ClubURL, "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_layout": s.SponsorsLayout, "sponsors_theme": s.SponsorsTheme, "facebook_url": s.FacebookURL, "instagram_url": s.InstagramURL, "youtube_url": s.YoutubeURL, "gallery_url": s.GalleryURL, "gallery_label": s.GalleryLabel, "videos_module_enabled": s.VideosModuleEnabled, "videos_style": s.VideosStyle, "videos_source": s.VideosSource, "videos_limit": s.VideosLimit, "videos": pubVids, "videos_items": pubVidsItems, "merch_module_enabled": s.MerchModuleEnabled, "merch_style": s.MerchStyle, "merch_source": s.MerchSource, "merch_limit": s.MerchLimit, "merch_items": pubMerchItems, "about_html": s.AboutHTML, "show_about_in_nav": s.ShowAboutInNav, "custom_nav": s.CustomNav, "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, } b, _ := json.MarshalIndent(resp, "", " ") outPath := filepath.Join("cache", "prefetch", "settings.json") _ = os.MkdirAll(filepath.Dir(outPath), 0o755) tmp := outPath + ".tmp" _ = os.WriteFile(tmp, b, 0o644) _ = os.Rename(tmp, outPath) }() // Seed default homepage page elements with all available sections bc.seedDefaultHomePageElements() logger.Info("Default homepage page elements seeded") // Run all setup operations asynchronously in background to provide immediate response 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 string, apiBase string) { defer func() { _ = recover() }() // 1. Trigger prefetch (matches, standings, etc.) baseURL := strings.TrimSpace(apiBase) if baseURL == "" { baseURL = getPrefetchBaseURL() } services.PrefetchOnce(strings.TrimRight(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, s.APIBaseURL) logger.Info("SetupInitialize finished successfully - background operations running") c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL}) } // UpdateSettings updates 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"` // Homepage matches display configuration FinishedMatchDisplayDays *int `json:"finished_match_display_days"` // Deployment base URLs (optional, for domain/IP change) FrontendBaseURL *string `json:"frontend_base_url"` APIBaseURL *string `json:"api_base_url"` } var body reqBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": 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 { v := strings.TrimSpace(*body.ContactPhone) country := s.ContactCountry if body.ContactCountry != nil { country = strings.TrimSpace(*body.ContactCountry) } s.ContactPhone = normalizePhone(v, country) } 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 body.FinishedMatchDisplayDays != nil { s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays } // Deployment base URLs if body.FrontendBaseURL != nil { s.FrontendBaseURL = strings.TrimSpace(*body.FrontendBaseURL) } if body.APIBaseURL != nil { s.APIBaseURL = strings.TrimSpace(*body.APIBaseURL) } 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 } } // Best-effort: cache club logo locally from logoapi and update settings if ClubID is set if strings.TrimSpace(s.ClubID) != "" && (strings.HasPrefix(strings.ToLower(strings.TrimSpace(s.ClubLogoURL)), "http://") || strings.HasPrefix(strings.ToLower(strings.TrimSpace(s.ClubLogoURL)), "https://") || strings.TrimSpace(s.ClubLogoURL) == "") { go func(id uint, clubID string) { if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(clubID)); err == nil && strings.TrimSpace(url) != "" { _ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_logo_url", url).Error } }(s.ID, s.ClubID) } logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel) // Best-effort: trigger prefetch so cached settings.json and dependent files update immediately go func(urlFromSettings string) { base := strings.TrimSpace(urlFromSettings) if base == "" { base = getPrefetchBaseURL() } services.PrefetchOnce(strings.TrimRight(base, "/")) }(s.APIBaseURL) // 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, // Deployment base URLs (hints for frontend tooling) "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL, } logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel) c.JSON(http.StatusOK, resp) } func (bc *BaseController) GetSettings(c *gin.Context) { _ = bc.DB.AutoMigrate(&models.Settings{}) var s models.Settings if err := bc.DB.First(&s).Error; err != nil { if err == gorm.ErrRecordNotFound { 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 } } s.LoadCustomNav() c.JSON(http.StatusOK, s) } func (bc *BaseController) GetTeams(c *gin.Context) { var items []models.Team q := bc.DB.Model(&models.Team{}) activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false" if activeOnly { q = q.Where("is_active = ?", true) } if err := q.Order("name ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } func (bc *BaseController) GetTeam(c *gin.Context) { id := c.Param("id") var item models.Team if err := bc.DB.First(&item, 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, item) } func (bc *BaseController) CreateTeam(c *gin.Context) { 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": "Neplatná data", "detail": err.Error()}) return } name := strings.TrimSpace(body.Name) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název týmu je povinný"}) return } item := models.Team{ Name: name, ShortName: strings.TrimSpace(body.ShortName), Description: strings.TrimSpace(body.Description), LogoURL: strings.TrimSpace(body.LogoURL), IsActive: true, } if body.IsActive != nil { item.IsActive = *body.IsActive } if err := bc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit tým"}) return } c.JSON(http.StatusCreated, item) } func (bc *BaseController) UpdateTeam(c *gin.Context) { id := c.Param("id") var item models.Team if err := bc.DB.First(&item, 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": "Neplatná data", "detail": err.Error()}) return } if body.Name != nil { v := strings.TrimSpace(*body.Name) if v == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název týmu nemůže být prázdný"}) return } item.Name = v } if body.ShortName != nil { item.ShortName = strings.TrimSpace(*body.ShortName) } if body.Description != nil { item.Description = strings.TrimSpace(*body.Description) } if body.LogoURL != nil { item.LogoURL = strings.TrimSpace(*body.LogoURL) } if body.IsActive != nil { item.IsActive = *body.IsActive } if err := bc.DB.Save(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat tým"}) return } c.JSON(http.StatusOK, item) } func (bc *BaseController) DeleteTeam(c *gin.Context) { id := c.Param("id") var item models.Team if err := bc.DB.First(&item, 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 cnt int64 if err := bc.DB.Model(&models.Player{}).Where("team_id = ?", item.ID).Count(&cnt).Error; err == nil && cnt > 0 { c.JSON(http.StatusConflict, gin.H{"chyba": "Nelze smazat tým s přiřazenými hráči", "detail": cnt}) return } if err := bc.DB.Delete(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat tým"}) return } c.JSON(http.StatusOK, gin.H{"zprava": "Tým byl smazán"}) } func (bc *BaseController) GetPlayers(c *gin.Context) { var items []models.Player q := bc.DB.Model(&models.Player{}) if v := strings.TrimSpace(c.Query("team_id")); v != "" { q = q.Where("team_id = ?", v) } activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false" if activeOnly { q = q.Where("is_active = ?", true) } if err := q.Order("last_name ASC, first_name ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } func (bc *BaseController) GetPlayer(c *gin.Context) { id := c.Param("id") var item models.Player if err := bc.DB.Preload("Team").First(&item, 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, item) } func (bc *BaseController) CreatePlayer(c *gin.Context) { 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"` ImageURL string `json:"image_url"` IsActive *bool `json:"is_active"` Email string `json:"email"` Phone string `json:"phone"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()}) return } item := models.Player{ FirstName: strings.TrimSpace(body.FirstName), LastName: strings.TrimSpace(body.LastName), Position: strings.TrimSpace(body.Position), Nationality: strings.TrimSpace(body.Nationality), ImageURL: strings.TrimSpace(body.ImageURL), IsActive: true, Email: strings.TrimSpace(body.Email), } if body.JerseyNumber != nil { item.JerseyNumber = *body.JerseyNumber } if body.TeamID != nil { item.TeamID = *body.TeamID } if body.Height != nil { item.Height = *body.Height } if body.Weight != nil { item.Weight = *body.Weight } if body.IsActive != nil { item.IsActive = *body.IsActive } if v := strings.TrimSpace(body.Phone); v != "" { item.Phone = normalizePhone(v, "") } if dob := strings.TrimSpace(body.DateOfBirth); dob != "" { if t, err := time.Parse("2006-01-02", dob); err == nil { item.DateOfBirth = t } else if t2, err2 := time.Parse(time.RFC3339, dob); err2 == nil { item.DateOfBirth = t2 } else { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatné datum narození"}) return } } if err := bc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit hráče"}) return } c.JSON(http.StatusCreated, item) } func (bc *BaseController) UpdatePlayer(c *gin.Context) { id := c.Param("id") var item models.Player if err := bc.DB.First(&item, 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"` ImageURL *string `json:"image_url"` IsActive *bool `json:"is_active"` Email *string `json:"email"` Phone *string `json:"phone"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()}) return } if body.FirstName != nil { item.FirstName = strings.TrimSpace(*body.FirstName) } if body.LastName != nil { item.LastName = strings.TrimSpace(*body.LastName) } if body.Position != nil { item.Position = strings.TrimSpace(*body.Position) } if body.JerseyNumber != nil { item.JerseyNumber = *body.JerseyNumber } if body.TeamID != nil { item.TeamID = *body.TeamID } if body.Nationality != nil { item.Nationality = strings.TrimSpace(*body.Nationality) } if body.Height != nil { item.Height = *body.Height } if body.Weight != nil { item.Weight = *body.Weight } if body.ImageURL != nil { item.ImageURL = strings.TrimSpace(*body.ImageURL) } if body.IsActive != nil { item.IsActive = *body.IsActive } if body.Email != nil { item.Email = strings.TrimSpace(*body.Email) } if body.Phone != nil { item.Phone = normalizePhone(strings.TrimSpace(*body.Phone), "") } if body.DateOfBirth != nil { v := strings.TrimSpace(*body.DateOfBirth) if v == "" { // leave as-is } else if t, err := time.Parse("2006-01-02", v); err == nil { item.DateOfBirth = t } else if t2, err2 := time.Parse(time.RFC3339, v); err2 == nil { item.DateOfBirth = t2 } else { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatné datum narození"}) return } } if err := bc.DB.Save(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat hráče"}) return } c.JSON(http.StatusOK, item) } func (bc *BaseController) DeletePlayer(c *gin.Context) { id := c.Param("id") var item models.Player if err := bc.DB.First(&item, 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 } if err := bc.DB.Delete(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat hráče"}) return } c.JSON(http.StatusOK, gin.H{"zprava": "Hráč byl smazán"}) } func (bc *BaseController) GetSponsors(c *gin.Context) { var items []models.Sponsor q := bc.DB.Model(&models.Sponsor{}) activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false" if activeOnly { q = q.Where("is_active = ?", true) } if err := q.Order("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) } func (bc *BaseController) CreateSponsor(c *gin.Context) { var body struct { Name string `json:"name" binding:"required"` LogoURL string `json:"logo_url"` WebsiteURL string `json:"website_url"` Description string `json:"description"` 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": "Neplatná data", "detail": err.Error()}) return } name := strings.TrimSpace(body.Name) if name == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název sponzora je povinný"}) return } item := models.Sponsor{ Name: name, LogoURL: strings.TrimSpace(body.LogoURL), WebsiteURL: strings.TrimSpace(body.WebsiteURL), Description: strings.TrimSpace(body.Description), IsActive: true, Tier: strings.TrimSpace(body.Tier), Placement: strings.TrimSpace(body.Placement), } if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder } if body.Width != nil { item.Width = *body.Width } if body.Height != nil { item.Height = *body.Height } if body.IsActive != nil { item.IsActive = *body.IsActive } if err := bc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit sponzora"}) return } c.JSON(http.StatusCreated, item) } func (bc *BaseController) UpdateSponsor(c *gin.Context) { id := c.Param("id") var item models.Sponsor if err := bc.DB.First(&item, 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"` Description *string `json:"description"` 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": "Neplatná data", "detail": err.Error()}) return } if body.Name != nil { v := strings.TrimSpace(*body.Name) if v == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název sponzora nemůže být prázdný"}) return } item.Name = v } if body.LogoURL != nil { item.LogoURL = strings.TrimSpace(*body.LogoURL) } if body.WebsiteURL != nil { item.WebsiteURL = strings.TrimSpace(*body.WebsiteURL) } if body.Description != nil { item.Description = strings.TrimSpace(*body.Description) } if body.IsActive != nil { item.IsActive = *body.IsActive } if body.Tier != nil { item.Tier = strings.TrimSpace(*body.Tier) } if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder } if body.Placement != nil { item.Placement = strings.TrimSpace(*body.Placement) } if body.Width != nil { item.Width = *body.Width } if body.Height != nil { item.Height = *body.Height } if err := bc.DB.Save(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat sponzora"}) return } c.JSON(http.StatusOK, item) } func (bc *BaseController) DeleteSponsor(c *gin.Context) { id := c.Param("id") var item models.Sponsor if err := bc.DB.First(&item, 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 } if err := bc.DB.Delete(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat sponzora"}) return } c.JSON(http.StatusOK, gin.H{"zprava": "Sponzor byl smazán"}) } // Banners (separate from sponsors) func (bc *BaseController) GetBanners(c *gin.Context) { var items []models.Banner q := bc.DB.Model(&models.Banner{}) activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false" if activeOnly { q = q.Where("is_active = ?", true) } if p := strings.TrimSpace(c.Query("placement")); p != "" { q = q.Where("placement = ?", p) } if err := q.Order("display_order ASC, created_at ASC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } c.JSON(http.StatusOK, items) } func (bc *BaseController) CreateBanner(c *gin.Context) { var body struct { Name string `json:"name" binding:"required"` ImageURL string `json:"image_url"` ClickURL string `json:"click_url"` Placement string `json:"placement"` Width *int `json:"width"` Height *int `json:"height"` IsActive *bool `json:"is_active"` DisplayOrder *int `json:"display_order"` } 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 banneru je povinný"}) return } item := models.Banner{ Name: name, ImageURL: strings.TrimSpace(body.ImageURL), ClickURL: strings.TrimSpace(body.ClickURL), Placement: strings.TrimSpace(body.Placement), IsActive: true, } if body.Width != nil { item.Width = *body.Width } if body.Height != nil { item.Height = *body.Height } if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder } if body.IsActive != nil { item.IsActive = *body.IsActive } if err := bc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit banner"}) return } c.JSON(http.StatusCreated, item) } func (bc *BaseController) UpdateBanner(c *gin.Context) { id := c.Param("id") var item models.Banner if err := bc.DB.First(&item, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } var body struct { Name *string `json:"name"` ImageURL *string `json:"image_url"` ClickURL *string `json:"click_url"` Placement *string `json:"placement"` Width *int `json:"width"` Height *int `json:"height"` IsActive *bool `json:"is_active"` DisplayOrder *int `json:"display_order"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()}) return } if body.Name != nil { v := strings.TrimSpace(*body.Name) if v == "" { c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru nemůže být prázdný"}) return } item.Name = v } if body.ImageURL != nil { item.ImageURL = strings.TrimSpace(*body.ImageURL) } if body.ClickURL != nil { item.ClickURL = strings.TrimSpace(*body.ClickURL) } if body.Placement != nil { item.Placement = strings.TrimSpace(*body.Placement) } if body.Width != nil { item.Width = *body.Width } if body.Height != nil { item.Height = *body.Height } if body.IsActive != nil { item.IsActive = *body.IsActive } if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder } if err := bc.DB.Save(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat banner"}) return } c.JSON(http.StatusOK, item) } func (bc *BaseController) DeleteBanner(c *gin.Context) { id := c.Param("id") var item models.Banner if err := bc.DB.First(&item, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"}) return } c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } if err := bc.DB.Delete(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat banner"}) return } c.JSON(http.StatusOK, gin.H{"zprava": "Banner byl smazán"}) } func (bc *BaseController) UploadImage(c *gin.Context) { f, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) return } // Enforce maximum upload size (bytes) if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"}) return } name := strings.TrimSpace(f.Filename) ext := strings.ToLower(filepath.Ext(name)) allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, ".pdf": true} if !allowed[ext] { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"}) return } // Light content sniffing to ensure the uploaded payload matches the declared extension // (helps mitigate mislabelled uploads). We only read the first few bytes. if src, err := f.Open(); err == nil { buf := make([]byte, 512) n, _ := src.Read(buf) _ = src.Close() detected := http.DetectContentType(buf[:n]) validCT := false switch ext { case ".pdf": validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream" case ".svg": // Many servers label SVGs inconsistently; allow svg+xml, xml, or text/plain dl := strings.ToLower(detected) validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/") default: // Common images should report image/* validCT = strings.HasPrefix(detected, "image/") } if !validCT { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"}) return } } dir := config.AppConfig.UploadDir if strings.TrimSpace(dir) == "" { dir = "./uploads" } _ = os.MkdirAll(dir, 0o755) b := make([]byte, 8) _, _ = rand.Read(b) randHex := hex.EncodeToString(b) outName := fmt.Sprintf("upload_%d_%s%s", time.Now().Unix(), randHex, ext) outPath := filepath.Join(dir, outName) if err := c.SaveUploadedFile(f, outPath); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}) return } urlPath := "/uploads/" + outName ft := services.NewFileTracker(bc.DB) mimeType := f.Header.Get("Content-Type") _ = ft.TrackFileUpload(outPath, urlPath, outName, mimeType, f.Size, nil) // Build absolute URL from request (supports proxies) scheme := "http" if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") { scheme = "https" } host := c.Request.Host if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" { // Take the first value if comma-separated parts := strings.Split(xf, ",") if len(parts) > 0 { h := strings.TrimSpace(parts[0]) if h != "" { host = h } } } // Append forwarded port when host has no explicit port and it's non-default if !strings.Contains(host, ":") { if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" { if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") { host = host + ":" + xfp } } } absolute := scheme + "://" + host + urlPath c.JSON(http.StatusOK, gin.H{ // Always return a backend-relative path for storage "url": urlPath, // Convenience absolute URL for immediate usage in UIs "absolute_url": absolute, // Basic metadata (best-effort) "name": outName, "type": mimeType, "size": f.Size, }) } // 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) } }