Files
MyClub/internal/controllers/base_controller.go
T
Tomas Dvorak 857e6f007d finally please
2025-10-25 14:57:59 +02:00

4848 lines
148 KiB
Go
Raw Blame History

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