package controllers import ( "crypto/rand" "crypto/tls" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "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" "unicode/utf8" "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 } // --- Helper functions for team-name aliasing --- // generateTeamNameAliases returns alternative keys for a team name to improve matching on the frontend. // Examples: // // "FK Hrtus & Partner Staré Město, z.s." -> ["FK Hrtus & Partner Staré Město", "FK H&P Staré Město"] func generateTeamNameAliases(name string) []string { base := strings.TrimSpace(name) if base == "" { return nil } out := make([]string, 0, 5) seen := map[string]struct{}{} add := func(v string) { v = strings.TrimSpace(v) if v == "" || v == base { return } if _, ok := seen[v]; ok { return } seen[v] = struct{}{} out = append(out, v) } // Alias 1: trim common legal suffixes at the end (z.s., o.s.) and trailing comma/space t := trimLegalSuffixes(base) t = strings.TrimSpace(t) if t != "" && t != base { add(t) } // Alias 2: sponsor initials around '&' (e.g., "Hrtus & Partner" -> "H&P") s := abbreviateAmpersand(t) if s != "" && s != base && s != t { add(s) } e := expandPNAbbrev(t) if e != "" && e != base && e != t { add(e) } es := abbreviateAmpersand(e) if es != "" && es != base && es != t && es != e { add(es) } // Generate PN-abbreviated variants like "... n. X." / "... p. X." from full forms (nad/pod) makePNAbbrevs := func(s string) []string { if strings.TrimSpace(s) == "" { return nil } // Build variants for "nad " / "pod " -> // n. W., n.W., n. W, n.W (and p. analogs) mk := func(in string, re *regexp.Regexp, repPrefix string, withFinalDot bool, withSpace bool) string { return re.ReplaceAllStringFunc(in, func(m string) string { sub := re.FindStringSubmatch(m) if len(sub) < 2 { return m } letter := firstRuneUpper(sub[1]) if letter == "" { return m } if withFinalDot { if withSpace { return repPrefix + " " + letter + "." } return repPrefix + letter + "." } if withSpace { return repPrefix + " " + letter } return repPrefix + letter }) } // spaced + with dot a := mk(s, rePNNadWord, "n.", true, true) a = mk(a, rePNPodWord, "p.", true, true) // no space + with dot b := mk(s, rePNNadWord, "n.", true, false) b = mk(b, rePNPodWord, "p.", true, false) // spaced + without final dot c := mk(s, rePNNadWord, "n.", false, true) c = mk(c, rePNPodWord, "p.", false, true) // no space + without final dot d := mk(s, rePNNadWord, "n.", false, false) d = mk(d, rePNPodWord, "p.", false, false) // collect distinct, non-empty, changed variants seen := map[string]struct{}{} out := []string{} addv := func(x string) { x = strings.TrimSpace(x) if x == "" || x == s { return } if _, ok := seen[x]; ok { return } seen[x] = struct{}{} out = append(out, x) } addv(a) addv(b) addv(c) addv(d) return out } for _, v := range []string{t, e} { for _, p := range makePNAbbrevs(v) { add(p) } } // Also generate and add versions with common club prefixes stripped (SK, FK, MFK, TJ, 1.BFK, ...) st := stripOrgPrefixes(t) se := stripOrgPrefixes(e) if st != "" && st != t { add(st) } if se != "" && se != e { add(se) } // PN abbreviations for stripped versions as well for _, v := range []string{st, se} { for _, p := range makePNAbbrevs(v) { add(p) } } variants := []string{t, s, e, es, st, se} for _, v := range variants { if strings.TrimSpace(v) == "" { continue } nd := strings.ReplaceAll(v, ".", "") nd = strings.TrimSpace(reMultiSpace.ReplaceAllString(nd, " ")) if nd != "" && nd != base { add(nd) } fa := foldAccents(v) if fa != "" && fa != base { add(fa) } } return out } var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)[\s]*$`) func trimLegalSuffixes(s string) string { return strings.TrimSpace(reLegalSuffix.ReplaceAllString(s, "")) } var ( reAbbrevP = regexp.MustCompile(`(?i)\bp\s*\.\s*`) reAbbrevN = regexp.MustCompile(`(?i)\bn\s*\.\s*`) reMultiSpace = regexp.MustCompile(`\s+`) ) var ( rePNNadWord = regexp.MustCompile(`(?i)\bnad\s+([\p{L}-]+)`) rePNPodWord = regexp.MustCompile(`(?i)\bpod\s+([\p{L}-]+)`) ) // Remove leading organization tokens like "1.BFK", "FK", "SK", "TJ", "MFK", "SFC", ... var reLeadingOrg = regexp.MustCompile(`(?i)^(?:\d+\.)?\s*(?:sfc|afc|fc|fk|mfk|tj|sk|afk|bfk|hfk)\.?\s+`) func stripOrgPrefixes(s string) string { x := strings.TrimSpace(s) if x == "" { return x } for { nx := reLeadingOrg.ReplaceAllString(x, "") nx = strings.TrimSpace(nx) if nx == x || nx == "" { return nx } x = nx } } func expandPNAbbrev(s string) string { if s == "" { return s } x := reAbbrevP.ReplaceAllString(s, "pod ") x = reAbbrevN.ReplaceAllString(x, "nad ") x = strings.TrimSpace(reMultiSpace.ReplaceAllString(x, " ")) return x } // abbreviateAmpersand finds the first "Word1 & Word2" pattern and replaces it with "W1&W2" initials. func abbreviateAmpersand(s string) string { idx := strings.Index(s, "&") if idx < 0 { return s } // Find word to the left left := s[:idx] right := s[idx+1:] // Trim neighborhood spaces left = strings.TrimSpace(left) right = strings.TrimSpace(right) if left == "" || right == "" { return s } // Extract last word from left and first word from right lw := lastWord(left) rw := firstWord(right) if lw == "" || rw == "" { return s } // Build initials li := firstRuneUpper(lw) ri := firstRuneUpper(rw) if li == "" || ri == "" { return s } abbr := li + "&" + ri // Replace the "lw & rw" segment while keeping any surrounding context intact // Identify the segment boundaries to replace precisely // We replace the last occurrence of lw before &, and the first occurrence of rw after & // For safety, operate on tokens leftCut := strings.TrimSuffix(left, lw) rightCut := strings.TrimPrefix(right, rw) return strings.TrimSpace(leftCut + abbr + rightCut) } func lastWord(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } parts := strings.Fields(s) if len(parts) == 0 { return "" } return parts[len(parts)-1] } func firstWord(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } parts := strings.Fields(s) if len(parts) == 0 { return "" } return parts[0] } func firstRuneUpper(s string) string { if s == "" { return "" } r, _ := utf8.DecodeRuneInString(s) if r == utf8.RuneError { return "" } return string(unicode.ToUpper(r)) } // 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 { // Fallback: try to locate the article in cached JSON so article pages work without DB seed lookup := func(path string) (*models.Article, bool) { b, e := os.ReadFile(path) if e != nil { return nil, false } // Try wrapper {items: []} var wrap struct { Items []models.Article `json:"items"` } if json.Unmarshal(b, &wrap) == nil && len(wrap.Items) > 0 { for i := range wrap.Items { if strings.TrimSpace(strings.ToLower(wrap.Items[i].Slug)) == strings.ToLower(slug) { return &wrap.Items[i], true } } } // Fallback to raw array var arr []models.Article if json.Unmarshal(b, &arr) == nil && len(arr) > 0 { for i := range arr { if strings.TrimSpace(strings.ToLower(arr[i].Slug)) == strings.ToLower(slug) { return &arr[i], true } } } return nil, false } if a, ok := lookup(filepath.Join("cache", "blogs", "articles.json")); ok { art = *a } else if a2, ok2 := lookup(filepath.Join("cache", "prefetch", "articles.json")); ok2 { art = *a2 } else { c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"}) return } } else { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"}) return } } // Restrict unpublished article visibility if !art.Published { roleVal, hasRole := c.Get("userRole") role, _ := roleVal.(string) uidVal, hasUID := c.Get("userID") var uid uint if hasUID { if u, ok := uidVal.(uint); ok { uid = u } } isOwner := (art.AuthorID != nil && uid != 0 && *art.AuthorID == uid) if !hasRole || (role != "admin" && role != "editor" && !isOwner) { c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"}) return } } if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" } if art.ReadTime == 0 { art.ReadTime = computeEstimatedReadMinutes(art.Content) } var matchLink models.ArticleMatchLink if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil { art.MatchLink = &matchLink } var aliases []models.CompetitionAlias _ = bc.DB.Find(&aliases).Error bc.addArticleComputedFields(&art, aliases) c.JSON(http.StatusOK, art) } // respondArticlesFromCache tries to serve articles from on-disk cache and returns true if it did. func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool { // Helper: read JSON file and respond with pagination readAndRespond := func(path string) bool { b, err := os.ReadFile(path) if err != nil { return false } // Try payload {items: [...]} first var wrap struct { Items []models.Article `json:"items"` } if json.Unmarshal(b, &wrap) == nil && len(wrap.Items) > 0 { items := wrap.Items total := len(items) start := (page - 1) * size if start < 0 { start = 0 } if start > total { start = total } end := start + size if end > total { end = total } paged := items[start:end] c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size}) return true } // Fallback: raw array of articles var arr []models.Article if json.Unmarshal(b, &arr) == nil && len(arr) > 0 { total := len(arr) start := (page - 1) * size if start < 0 { start = 0 } if start > total { start = total } end := start + size if end > total { end = total } paged := arr[start:end] c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size}) return true } return false } // Try blogs cache first, then prefetch if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) { return true } if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) { return true } return false } 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 } func foldAccents(s string) string { s = strings.ToLower(strings.TrimSpace(s)) t := transform.Chain(norm.NFD, transform.RemoveFunc(func(r rune) bool { return unicode.Is(unicode.Mn, r) }), norm.NFC) out, _, _ := transform.String(t, s) return out } // 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.ScoreOverride != nil { m["score"] = strings.TrimSpace(*ov.ScoreOverride) } 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 != "" { sq := foldAccents(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(foldAccents(f), sq) { matched = true break } } if matched { filtered = append(filtered, m) } } matches = filtered } // Respond with filtered/processed past matches 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.ScoreOverride != nil { m["score"] = strings.TrimSpace(*ov.ScoreOverride) } 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 } } } } } } if raw := strings.TrimSpace(c.Query("q")); raw != "" { sq := foldAccents(raw) filtered := make([]map[string]any, 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(foldAccents(f), sq) { 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) GetStandings(c *gin.Context) { p := filepath.Join("cache", "prefetch", "facr_tables.json") f, err := os.Open(p) // ... (rest of the code remains the same) 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 { if it.ExternalTeamID == "" { continue } tloByID[strings.ToLower(it.ExternalTeamID)] = it } for i := range rows { id, _ := rows[i]["team_id"].(string) if id == "" { continue } if tlo, ok := tloByID[strings.ToLower(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 base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/") api := base + "/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 } // Restrict unpublished article visibility if !art.Published { roleVal, hasRole := c.Get("userRole") role, _ := roleVal.(string) uidVal, hasUID := c.Get("userID") var uid uint if hasUID { if u, ok := uidVal.(uint); ok { uid = u } } isOwner := (art.AuthorID != nil && uid != 0 && *art.AuthorID == uid) if !hasRole || (role != "admin" && role != "editor" && !isOwner) { c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"}) 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 } // Compute helper fields (category_slug, competition_alias, normalized_category, url) var aliases []models.CompetitionAlias _ = bc.DB.Find(&aliases).Error bc.addArticleComputedFields(&art, aliases) c.JSON(http.StatusOK, art) } // 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 } // Do not serve from cache when any filters beyond pagination are used // including the 'featured' flag to ensure primary articles reflect immediately if featuredParam { 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{}) // Only allow listing unpublished when authenticated as admin/editor. Otherwise always filter to published=true. if pParam == "" || pParam == "true" { qb = qb.Where("published = ?", true) } else if pParam == "false" { roleVal, hasRole := c.Get("userRole") role, _ := roleVal.(string) if !hasRole || (role != "admin" && role != "editor") { // Not authorized to view drafts → fall back to published only 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) } } // Compute helper fields for list var aliases []models.CompetitionAlias _ = bc.DB.Find(&aliases).Error for i := range items { bc.addArticleComputedFields(&items[i], aliases) } 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) } } // Compute helper fields for list var aliases []models.CompetitionAlias _ = bc.DB.Find(&aliases).Error for i := range items { bc.addArticleComputedFields(&items[i], aliases) } c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size}) } // addArticleComputedFields populates non-persisted helper fields on the Article JSON func (bc *BaseController) addArticleComputedFields(a *models.Article, aliases []models.CompetitionAlias) { // Category slug if a.Category != nil && strings.TrimSpace(a.Category.Slug) != "" { a.CategorySlug = strings.TrimSpace(a.Category.Slug) } else if strings.TrimSpace(a.CategoryName) != "" { a.CategorySlug = makeSlug(a.CategoryName) } else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" { a.CategorySlug = makeSlug(a.Category.Name) } // Normalized category (for fast matching on FE) if strings.TrimSpace(a.CategoryName) != "" { a.NormalizedCategory = foldAccents(a.CategoryName) } else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" { a.NormalizedCategory = foldAccents(a.Category.Name) } // URL path if strings.TrimSpace(a.Slug) != "" { a.URL = "/news/" + strings.TrimSpace(a.Slug) } else { a.URL = fmt.Sprintf("/articles/%d", a.ID) } // Competition alias mapping (match category against alias or original name) cat := strings.TrimSpace(a.CategoryName) if cat == "" && a.Category != nil { cat = strings.TrimSpace(a.Category.Name) } if cat == "" || len(aliases) == 0 { return } ncat := foldAccents(cat) for _, al := range aliases { aliasNorm := foldAccents(al.Alias) origNorm := foldAccents(al.OriginalName) if aliasNorm != "" { if strings.Contains(ncat, aliasNorm) || strings.Contains(aliasNorm, ncat) { a.CompetitionAlias = al.Alias return } } if origNorm != "" { if strings.Contains(ncat, origNorm) || strings.Contains(origNorm, ncat) { a.CompetitionAlias = al.Alias return } } } } 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"` // Attachments array passed from frontend Attachments *[]struct { Name string `json:"name"` URL string `json:"url"` MimeType string `json:"mime_type"` Size *int `json:"size,omitempty"` } `json:"attachments"` } 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 { // Create category with a unique slug derived from name s := makeSlug(name) if s == "" { s = fmt.Sprintf("category-%d", time.Now().Unix()) } orig := s for i := 0; i < 50; i++ { var sc int64 if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&sc).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"}) return } if sc == 0 { break } s = fmt.Sprintf("%s-%d", orig, i+1) } cat = models.Category{Name: name, Slug: s} 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) } // Persist attachments JSON into text column if body.Attachments != nil { if len(*body.Attachments) == 0 { art.Attachments = "" } else { if b, err := json.Marshal(body.Attachments); err == nil { art.Attachments = string(b) } } } // Save changes tx := bc.DB.Begin() if err := tx.Save(&art).Error; err != nil { tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek", "detail": err.Error()}) return } if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba transakce při ukládání článku"}) return } go func(a models.Article) { ft := services.NewFileTracker(bc.DB) ft.TrackArticleFiles(&a) }(art) // Always refresh cache after any edit to a published article so /cache/prefetch/articles.json reflects changes if art.Published { go func() { var s models.Settings if err := bc.DB.First(&s).Error; err == nil { base := strings.TrimSpace(s.APIBaseURL) if base == "" { base = getPrefetchBaseURL() } services.PrefetchOnce(strings.TrimRight(base, "/")) } else { services.PrefetchOnce(getPrefetchBaseURL()) } }() } // Send blog notification only on first publish if art.Published && !oldPublished { go bc.triggerBlogNotification(&art) } 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 FACR matches merged with DB overrides (admin only) func (bc *BaseController) GetAdminMatches(c *gin.Context) { // Read cached FACR club info (contains competitions with matches) p := filepath.Join("cache", "prefetch", "facr_club_info.json") f, err := os.Open(p) if err != nil { c.JSON(http.StatusNoContent, gin.H{"message": "No cached FACR matches"}) return } defer f.Close() var facr struct { Competitions []struct { ID string `json:"id"` Name string `json:"name"` Matches []struct { MatchID string `json:"match_id"` DateTime string `json:"date_time"` Date string `json:"date"` Time string `json:"time"` Home string `json:"home"` HomeTeam string `json:"home_team"` HomeID string `json:"home_id"` HomeTeamID string `json:"home_team_id"` HomeTeamFACRID string `json:"home_team_facr_id"` Away string `json:"away"` AwayTeam string `json:"away_team"` AwayID string `json:"away_id"` AwayTeamID string `json:"away_team_id"` AwayTeamFACRID string `json:"away_team_facr_id"` Score string `json:"score"` Venue string `json:"venue"` HomeLogoURL string `json:"home_logo_url"` AwayLogoURL string `json:"away_logo_url"` } `json:"matches"` } `json:"competitions"` } if err := json.NewDecoder(f).Decode(&facr); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist FACR cache"}) return } // Helper to pick first non-empty string firstNonEmpty := func(ss ...string) string { for _, s := range ss { s = strings.TrimSpace(s) if s != "" { return s } } return "" } // Flatten and normalize to a simple slice of maps items := make([]map[string]any, 0, 256) for _, comp := range facr.Competitions { for _, m := range comp.Matches { id := strings.TrimSpace(m.MatchID) if id == "" { continue } home := firstNonEmpty(m.Home, m.HomeTeam) away := firstNonEmpty(m.Away, m.AwayTeam) homeID := firstNonEmpty(m.HomeID, m.HomeTeamID, m.HomeTeamFACRID) awayID := firstNonEmpty(m.AwayID, m.AwayTeamID, m.AwayTeamFACRID) item := map[string]any{ "id": id, "match_id": id, "date_time": strings.TrimSpace(m.DateTime), "date": strings.TrimSpace(m.Date), "time": strings.TrimSpace(m.Time), "competitionName": strings.TrimSpace(comp.Name), "competition_id": strings.TrimSpace(comp.ID), "home": home, "home_team": home, "home_id": homeID, "away": away, "away_team": away, "away_id": awayID, "score": strings.TrimSpace(m.Score), "venue": strings.TrimSpace(m.Venue), "home_logo_url": strings.TrimSpace(m.HomeLogoURL), "away_logo_url": strings.TrimSpace(m.AwayLogoURL), } items = append(items, item) } } // Load overrides and apply 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 } for _, m := range items { matchID, _ := m["match_id"].(string) 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 { // Keep a consistent ISO string for machines and Czech human-readable for display m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339) m["date"] = ov.DateTimeOverride.Format("02.01.2006") m["time"] = ov.DateTimeOverride.Format("15:04") } if ov.ScoreOverride != nil { m["score"] = strings.TrimSpace(*ov.ScoreOverride) } if ov.HomeLogoURL != nil { m["home_logo_url"] = *ov.HomeLogoURL } if ov.AwayLogoURL != nil { m["away_logo_url"] = *ov.AwayLogoURL } } if homeID, _ := m["home_id"].(string); homeID != "" { if tlo, ok := tloByTeam[homeID]; ok { if strings.TrimSpace(tlo.LogoURL) != "" { m["home_logo_url"] = tlo.LogoURL } if strings.TrimSpace(tlo.TeamName) != "" { m["home"] = tlo.TeamName m["home_team"] = tlo.TeamName } } } if awayID, _ := m["away_id"].(string); awayID != "" { if tlo, ok := tloByTeam[awayID]; ok { if strings.TrimSpace(tlo.LogoURL) != "" { m["away_logo_url"] = tlo.LogoURL } if strings.TrimSpace(tlo.TeamName) != "" { m["away"] = tlo.TeamName m["away_team"] = tlo.TeamName } } } } c.JSON(http.StatusOK, items) } // --- 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"` ScoreOverride *string `json:"score_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.ScoreOverride = body.ScoreOverride 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) } 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 body map[string]interface{} if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } delete(body, "external_match_id") if v, ok := body["date_time_override"]; ok { switch vv := v.(type) { case string: s := strings.TrimSpace(vv) if s == "" { body["date_time_override"] = nil } else { if t, err := time.Parse(time.RFC3339, s); err == nil { body["date_time_override"] = &t } else if t2, err2 := time.Parse("2006-01-02T15:04", s); err2 == nil { body["date_time_override"] = &t2 } else { c.JSON(http.StatusBadRequest, gin.H{"error": "Neplatný formát date_time_override"}) return } } } } var item models.MatchOverride if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil { if err == gorm.ErrRecordNotFound { attrs := map[string]interface{}{"external_match_id": extID} for k, v := range body { attrs[k] = v } if err := bc.DB.Where("external_match_id = ?", extID).Assign(attrs).FirstOrCreate(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit záznam"}) return } c.JSON(http.StatusOK, item) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"}) return } 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) } // 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 != "" { // Primary exact key m[it.TeamName] = it.LogoURL // Add smart aliases so frontends can match sponsor-shortened variants for _, alias := range generateTeamNameAliases(it.TeamName) { if alias != "" { m[alias] = 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 != "" { // Primary exact key m[it.TeamName] = it.LogoURL // Smart aliases (trim legal suffixes, sponsor initials like H&P) for _, alias := range generateTeamNameAliases(it.TeamName) { if alias != "" { m[alias] = 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 } // Best-effort: update public snapshot cache so frontend fallback sees latest aliases go bc.writeTeamLogoOverridesCache() 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 } // Basic SSRF hardening: block internal/private destinations and unusual ports host := u.Hostname() port := u.Port() if port != "" && port != "80" && port != "443" { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported port"}) return } ips, err := net.LookupIP(host) if err == nil { blockedCIDRs := []string{ "127.0.0.0/8", // loopback "10.0.0.0/8", // private "172.16.0.0/12", // private "192.168.0.0/16", // private "169.254.0.0/16", // link-local "::1/128", // IPv6 loopback "fc00::/7", // IPv6 unique local "fe80::/10", // IPv6 link-local } var nets []*net.IPNet for _, cidr := range blockedCIDRs { _, n, perr := net.ParseCIDR(cidr) if perr == nil { nets = append(nets, n) } } for _, ip := range ips { for _, n := range nets { if n.Contains(ip) { c.JSON(http.StatusBadRequest, gin.H{"error": "destination not allowed"}) 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 } } // Enforce a reasonable maximum size when Content-Length is provided (8MB) if cl := resp.Header.Get("Content-Length"); cl != "" { if n, err := strconv.Atoi(cl); err == nil { if n > 8*1024*1024 { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image too large"}) 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 != "" { name := "" addr := v if lt, gt := strings.Index(v, "<"), strings.Index(v, ">"); lt >= 0 && gt > lt { name = strings.TrimSpace(v[:lt]) addr = strings.TrimSpace(v[lt+1 : gt]) } addr = strings.Trim(addr, "\" ") name = strings.Trim(name, "\" ") s.SMTPFrom = addr if name != "" && !strings.Contains(strings.ToLower(name), "@") { s.SMTPFromName = name } } 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 } } services.StartErrorReviewAutoRegister(bc.DB) 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 } } go func(snap models.Settings) { defer func() { _ = recover() }() snap.LoadCustomNav() snap.LoadVideosOverrides() 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) } // Build map form of video title overrides for API/cache m := map[string]string{} if len(snap.VideosOverrides) > 0 { for _, it := range snap.VideosOverrides { vid := strings.TrimSpace(it.VideoID) t := strings.TrimSpace(it.Title) if vid != "" && t != "" { m[vid] = t } } } snap.VideosTitleOverrides = m 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, "videos_title_overrides": snap.VideosTitleOverrides, "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) go func(urlFromSettings string) { base := strings.TrimSpace(urlFromSettings) if base == "" { base = getPrefetchBaseURL() } services.PrefetchOnce(strings.TrimRight(base, "/")) }(s.APIBaseURL) if strings.TrimSpace(s.YoutubeURL) != "" { go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL) } 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 != "" { name := "" addr := v if lt, gt := strings.Index(v, "<"), strings.Index(v, ">"); lt >= 0 && gt > lt { name = strings.TrimSpace(v[:lt]) addr = strings.TrimSpace(v[lt+1 : gt]) } addr = strings.Trim(addr, "\" ") name = strings.Trim(name, "\" ") s.SMTPFrom = addr if name != "" && !strings.Contains(strings.ToLower(name), "@") { s.SMTPFromName = name } } 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 } } services.StartErrorReviewAutoRegister(bc.DB) 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() s.LoadVideosTitleOverrides() 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, "videos_title_overrides": s.VideosTitleOverrides, "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() }() baseURL := strings.TrimSpace(apiBase) if baseURL == "" { baseURL = getPrefetchBaseURL() } services.PrefetchOnce(strings.TrimRight(baseURL, "/")) logger.Info("Background prefetch completed") if config.AppConfig != nil && config.AppConfig.RembgEnabled { if services.StartFACRLogosBatch("cache/prefetch") { logger.Info("FACR logos batch started (rembg)") } else { logger.Info("FACR logos batch not started (already running or nothing to process)") } } else { logger.Info("FACR logos batch disabled by config") } // 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"` // Auto videos title overrides (YouTube): video_id -> title VideosTitleOverrides *map[string]string `json:"videos_title_overrides"` // 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"` // Storage quota and thresholds StorageQuotaMB *int `json:"storage_quota_mb"` StorageWarnThreshold *int `json:"storage_warn_threshold"` StorageCriticalThreshold *int `json:"storage_critical_threshold"` // External error-review integration ErrorReviewIngestURL *string `json:"error_review_ingest_url"` ErrorReviewIngestToken *string `json:"error_review_ingest_token"` ErrorReviewAdminURL *string `json:"error_review_admin_url"` ErrorReviewAdminToken *string `json:"error_review_admin_token"` ErrorReviewUIURL *string `json:"error_review_ui_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) } } // Auto videos title overrides (YouTube): video_id -> title if body.VideosTitleOverrides != nil { m := *body.VideosTitleOverrides items := make([]models.VideoTitleOverride, 0, len(m)) for id, title := range m { id = strings.TrimSpace(id) t := strings.TrimSpace(title) if id == "" || t == "" { continue } items = append(items, models.VideoTitleOverride{VideoID: id, Title: t}) } _ = s.SetVideosOverrides(items) } // 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) } } // Storage quota and thresholds if body.StorageQuotaMB != nil { s.StorageQuotaMB = *body.StorageQuotaMB } if body.StorageWarnThreshold != nil { s.StorageWarnThreshold = *body.StorageWarnThreshold } if body.StorageCriticalThreshold != nil { s.StorageCriticalThreshold = *body.StorageCriticalThreshold } if s.StorageWarnThreshold <= 0 { s.StorageWarnThreshold = 80 } if s.StorageCriticalThreshold <= 0 { s.StorageCriticalThreshold = 95 } if s.StorageWarnThreshold > s.StorageCriticalThreshold { s.StorageWarnThreshold = s.StorageCriticalThreshold - 5 if s.StorageWarnThreshold < 0 { s.StorageWarnThreshold = 0 } } // External error-review integration if body.ErrorReviewIngestURL != nil { s.ErrorReviewIngestURL = strings.TrimSpace(*body.ErrorReviewIngestURL) } if body.ErrorReviewIngestToken != nil { s.ErrorReviewIngestToken = strings.TrimSpace(*body.ErrorReviewIngestToken) } if body.ErrorReviewAdminURL != nil { s.ErrorReviewAdminURL = strings.TrimSpace(*body.ErrorReviewAdminURL) } if body.ErrorReviewAdminToken != nil { s.ErrorReviewAdminToken = strings.TrimSpace(*body.ErrorReviewAdminToken) } if body.ErrorReviewUIURL != nil { s.ErrorReviewUIURL = strings.TrimSpace(*body.ErrorReviewUIURL) } // 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 } if body.FrontendBaseURL != nil { v := strings.TrimSpace(*body.FrontendBaseURL) if v != "" { if u, err := url.Parse(v); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" { u.Path = "" s.FrontendBaseURL = u.String() } } } if body.APIBaseURL != nil { v := strings.TrimSpace(*body.APIBaseURL) if v != "" { if u, err := url.Parse(v); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" { if !strings.Contains(u.Path, "/api/") { u.Path = strings.TrimRight(u.Path, "/") + "/api/v1" } s.APIBaseURL = u.String() } } } 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 } } services.StartErrorReviewAutoRegister(bc.DB) 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) } } if strings.TrimSpace(s.FrontendBaseURL) != "" && config.AppConfig != nil { if u, err := url.Parse(s.FrontendBaseURL); err == nil { origin := u.Scheme + "://" + u.Host found := false for _, ao := range config.AppConfig.AllowedOrigins { if ao == origin { found = true break } } if !found { config.AppConfig.AllowedOrigins = append(config.AppConfig.AllowedOrigins, origin) } } } 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 } // Load video title overrides (list form stored as JSON) s.LoadVideosOverrides() // 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) } // Build video title overrides map (video_id -> title) for API consumers toMap := map[string]string{} if len(s.VideosOverrides) > 0 { for _, it := range s.VideosOverrides { vid := strings.TrimSpace(it.VideoID) t := strings.TrimSpace(it.Title) if vid != "" && t != "" { toMap[vid] = t } } } 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, // Runtime flags (env-based) "premium": config.AppConfig.Premium, // 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, "videos_title_overrides": toMap, // 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 } } // Auto-fill default external error-review URLs if missing so admin doesn't need to enter them if strings.TrimSpace(s.ErrorReviewUIURL) == "" { s.ErrorReviewUIURL = "https://error.tdvorak.dev" } if strings.TrimSpace(s.ErrorReviewAdminURL) == "" { s.ErrorReviewAdminURL = "https://error.tdvorak.dev/api/v1/admin" } if strings.TrimSpace(s.ErrorReviewIngestURL) == "" { s.ErrorReviewIngestURL = "https://errors.tdvorak.dev/api/v1/errors" } s.LoadCustomNav() s.LoadVideosOverrides() // derive map form for admin consumers mv := map[string]string{} if len(s.VideosOverrides) > 0 { for _, it := range s.VideosOverrides { vid := strings.TrimSpace(it.VideoID) t := strings.TrimSpace(it.Title) if vid != "" && t != "" { mv[vid] = t } } } s.VideosTitleOverrides = mv 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"` Gender string `json:"gender"` 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), Gender: strings.TrimSpace(body.Gender), 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"` Gender *string `json:"gender"` 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) 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) } func (bc *BaseController) CreateCategory(c *gin.Context) { var body struct { Name string `json:"name" binding:"required"` Description string `json:"description"` Slug string `json:"slug"` } 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 } var cnt int64 _ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ?", strings.ToLower(name)).Count(&cnt).Error if cnt > 0 { c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"}) return } slug := strings.TrimSpace(body.Slug) if slug == "" { slug = makeSlug(name) } else { slug = makeSlug(slug) } orig := slug for i := 0; i < 50; i++ { var sc int64 if err := bc.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&sc).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"}) return } if sc == 0 { break } slug = fmt.Sprintf("%s-%d", orig, i+1) } item := models.Category{ Name: name, Description: strings.TrimSpace(body.Description), Slug: slug, } if err := bc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"}) return } c.JSON(http.StatusCreated, item) } func (bc *BaseController) UpdateCategory(c *gin.Context) { id := c.Param("id") var item models.Category if err := bc.DB.First(&item, 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"` Slug *string `json:"slug"` } 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 kategorie nemůže být prázdný"}) return } var cnt int64 _ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ? AND id != ?", strings.ToLower(v), item.ID).Count(&cnt).Error if cnt > 0 { c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"}) return } item.Name = v } if body.Description != nil { item.Description = strings.TrimSpace(*body.Description) } if body.Slug != nil { s := strings.TrimSpace(*body.Slug) if s == "" { s = makeSlug(item.Name) } else { s = makeSlug(s) } orig := s for i := 0; i < 50; i++ { var sc int64 if err := bc.DB.Model(&models.Category{}).Where("slug = ? AND id != ?", s, item.ID).Count(&sc).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"}) return } if sc == 0 { break } s = fmt.Sprintf("%s-%d", orig, i+1) } item.Slug = s } if err := bc.DB.Save(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"}) return } c.JSON(http.StatusOK, item) } func (bc *BaseController) DeleteCategory(c *gin.Context) { id := c.Param("id") var item models.Category if err := bc.DB.First(&item, 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 cnt int64 if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", item.ID).Count(&cnt).Error; err == nil && cnt > 0 { c.JSON(http.StatusConflict, gin.H{"chyba": "Nelze smazat kategorii s přiřazenými články", "detail": cnt}) return } if err := bc.DB.Delete(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"}) return } // Successful deletion c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"}) } // UploadImage handles generic file uploads (images, documents, archives) 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)) // Allow images, PDFs, Office docs, text, archives, and common media allowed := map[string]bool{ // Images ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, // Documents ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".txt": true, ".csv": true, // Archives ".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true, // Media ".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true, } if !allowed[ext] { c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"}) return } // Light content sniffing to ensure payload matches extension and sanitize SVGs if src, err := f.Open(); err == nil { defer src.Close() buf := make([]byte, 2048) n, _ := io.ReadFull(src, buf) if n < 0 { n = 0 } dl := strings.ToLower(http.DetectContentType(buf[:n])) validCT := false switch ext { case ".pdf": validCT = strings.Contains(dl, "pdf") || dl == "application/octet-stream" case ".svg": validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/") lower := strings.ToLower(string(buf[:n])) if strings.Contains(lower, " 0 { h := strings.TrimSpace(parts[0]) if 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 } } } absolute := scheme + "://" + host + urlPath c.JSON(http.StatusOK, gin.H{ "url": urlPath, "absolute_url": absolute, "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) } }