mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
4736 lines
143 KiB
Go
4736 lines
143 KiB
Go
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
|
||
scheme := "http"
|
||
if c.Request.TLS != nil {
|
||
scheme = "https"
|
||
}
|
||
host := c.Request.Host
|
||
if host != "" {
|
||
baseURL := scheme + "://" + host + "/api/v1"
|
||
go services.PrefetchOnce(baseURL)
|
||
}
|
||
if strings.TrimSpace(s.YoutubeURL) != "" {
|
||
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL)
|
||
}
|
||
// If gallery_url is a Zonerama link, refresh Zonerama cache immediately
|
||
if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
|
||
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena"})
|
||
return
|
||
}
|
||
|
||
// No admin exists yet: run full initial setup
|
||
type reqBody struct {
|
||
// Admin user
|
||
AdminEmail string `json:"admin_email" binding:"required,email"`
|
||
AdminPassword string `json:"admin_password" binding:"required,min=8"`
|
||
FirstName string `json:"first_name"`
|
||
LastName string `json:"last_name"`
|
||
|
||
// JWT
|
||
JWTSecret string `json:"jwt_secret"`
|
||
|
||
// Club (FACR)
|
||
ClubID string `json:"club_id"`
|
||
ClubType string `json:"club_type"`
|
||
ClubName string `json:"club_name"`
|
||
ClubLogoURL string `json:"club_logo_url"`
|
||
ClubURL string `json:"club_url"`
|
||
|
||
// Social (optional)
|
||
FacebookURL string `json:"facebook_url"`
|
||
InstagramURL string `json:"instagram_url"`
|
||
YoutubeURL string `json:"youtube_url"`
|
||
|
||
// Gallery (optional)
|
||
GalleryURL string `json:"gallery_url"`
|
||
GalleryLabel string `json:"gallery_label"`
|
||
|
||
// Location/Contact (optional)
|
||
ContactAddress string `json:"contact_address"`
|
||
ContactCity string `json:"contact_city"`
|
||
ContactZip string `json:"contact_zip"`
|
||
ContactCountry string `json:"contact_country"`
|
||
ContactPhone string `json:"contact_phone"`
|
||
ContactEmail string `json:"contact_email"`
|
||
LocationLatitude float64 `json:"location_latitude"`
|
||
LocationLongitude float64 `json:"location_longitude"`
|
||
MapStyle string `json:"map_style"`
|
||
|
||
// Frontpage style (optional)
|
||
FrontpageStyle string `json:"frontpage_style"`
|
||
|
||
// Theme (optional, can set later)
|
||
PrimaryColor string `json:"primary_color"`
|
||
SecondaryColor string `json:"secondary_color"`
|
||
AccentColor string `json:"accent_color"`
|
||
BackgroundColor string `json:"background_color"`
|
||
TextColor string `json:"text_color"`
|
||
FontHeading string `json:"font_heading"`
|
||
FontBody string `json:"font_body"`
|
||
|
||
// SMTP optional
|
||
SMTP *struct {
|
||
Host string `json:"host"`
|
||
Port int `json:"port"`
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
From string `json:"from"`
|
||
UseTLS *bool `json:"use_tls"`
|
||
} `json:"smtp"`
|
||
}
|
||
var body reqBody
|
||
if err := c.ShouldBindJSON(&body); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
logger.Info("SetupInitialize payload received: admin_email=%s club_id=%s club_type=%s club_name=%s gallery_url=%s gallery_label=%s", body.AdminEmail, body.ClubID, body.ClubType, body.ClubName, body.GalleryURL, body.GalleryLabel)
|
||
|
||
// Optionally persist JWT secret to environment if empty in config (best set via env in deployment)
|
||
if body.JWTSecret != "" && config.AppConfig.JWTSecret == "" {
|
||
// Not writing to disk; just warn that server restart needed to take effect
|
||
}
|
||
|
||
// Create admin user
|
||
hashed, err := bcrypt.GenerateFromPassword([]byte(body.AdminPassword), bcrypt.DefaultCost)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze zpracovat heslo"})
|
||
return
|
||
}
|
||
admin := models.User{
|
||
Email: body.AdminEmail,
|
||
Password: string(hashed),
|
||
FirstName: body.FirstName,
|
||
LastName: body.LastName,
|
||
Role: "admin",
|
||
}
|
||
if err := bc.DB.Create(&admin).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "Email již existuje"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit admina"})
|
||
return
|
||
}
|
||
logger.Info("Admin user created: email=%s id=%d", admin.Email, admin.ID)
|
||
|
||
// Upsert settings
|
||
var s models.Settings
|
||
if err := bc.DB.First(&s).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
s = models.Settings{}
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
||
return
|
||
}
|
||
}
|
||
if s.ID == 0 {
|
||
s = models.Settings{}
|
||
}
|
||
if body.ClubID != "" {
|
||
s.ClubID = body.ClubID
|
||
}
|
||
if body.ClubType != "" {
|
||
s.ClubType = body.ClubType
|
||
}
|
||
if body.ClubName != "" {
|
||
s.ClubName = body.ClubName
|
||
}
|
||
if body.ClubLogoURL != "" {
|
||
s.ClubLogoURL = body.ClubLogoURL
|
||
}
|
||
if body.ClubURL != "" {
|
||
s.ClubURL = body.ClubURL
|
||
}
|
||
// Social profiles
|
||
if v := strings.TrimSpace(body.FacebookURL); v != "" {
|
||
s.FacebookURL = v
|
||
}
|
||
if v := strings.TrimSpace(body.InstagramURL); v != "" {
|
||
s.InstagramURL = v
|
||
}
|
||
if v := strings.TrimSpace(body.YoutubeURL); v != "" {
|
||
if strings.HasPrefix(strings.ToLower(v), "www.") {
|
||
v = "https://" + v
|
||
}
|
||
s.YoutubeURL = v
|
||
}
|
||
if body.PrimaryColor != "" {
|
||
s.PrimaryColor = body.PrimaryColor
|
||
}
|
||
if body.SecondaryColor != "" {
|
||
s.SecondaryColor = body.SecondaryColor
|
||
}
|
||
if body.AccentColor != "" {
|
||
s.AccentColor = body.AccentColor
|
||
}
|
||
if body.BackgroundColor != "" {
|
||
s.BackgroundColor = body.BackgroundColor
|
||
}
|
||
if body.TextColor != "" {
|
||
s.TextColor = body.TextColor
|
||
}
|
||
if body.FontHeading != "" {
|
||
s.FontHeading = body.FontHeading
|
||
}
|
||
if body.FontBody != "" {
|
||
s.FontBody = body.FontBody
|
||
}
|
||
// Gallery
|
||
if body.GalleryURL != "" {
|
||
s.GalleryURL = strings.TrimSpace(body.GalleryURL)
|
||
}
|
||
if body.GalleryLabel != "" {
|
||
s.GalleryLabel = strings.TrimSpace(body.GalleryLabel)
|
||
}
|
||
// Location/Contact
|
||
if v := strings.TrimSpace(body.ContactAddress); v != "" {
|
||
s.ContactAddress = v
|
||
}
|
||
if v := strings.TrimSpace(body.ContactCity); v != "" {
|
||
s.ContactCity = v
|
||
}
|
||
if v := strings.TrimSpace(body.ContactZip); v != "" {
|
||
s.ContactZip = v
|
||
}
|
||
if v := strings.TrimSpace(body.ContactCountry); v != "" {
|
||
s.ContactCountry = v
|
||
}
|
||
if v := strings.TrimSpace(body.ContactPhone); v != "" {
|
||
s.ContactPhone = 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)
|
||
|
||
// 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
|
||
scheme := "http"
|
||
if c.Request.TLS != nil {
|
||
scheme = "https"
|
||
}
|
||
host := c.Request.Host
|
||
|
||
logger.Info("Starting initial data prefetch and setup operations in background...")
|
||
|
||
// Run all setup operations in a single background goroutine
|
||
go func(settingsID uint, youtubeURL, galleryURL, adminEmail, baseHost string) {
|
||
defer func() { _ = recover() }()
|
||
|
||
// 1. Trigger prefetch (matches, standings, etc.)
|
||
if baseHost != "" {
|
||
baseURL := scheme + "://" + baseHost + "/api/v1"
|
||
services.PrefetchOnce(baseURL)
|
||
logger.Info("Background prefetch completed")
|
||
|
||
// Auto-populate competition aliases from FACR data
|
||
bc.autoPopulateCompetitionAliases()
|
||
logger.Info("Background competition aliases populated")
|
||
}
|
||
|
||
// 2. If YouTube channel is configured, refresh its cache
|
||
if strings.TrimSpace(youtubeURL) != "" {
|
||
if err := services.RefreshYouTubeChannelNow(youtubeURL); err != nil {
|
||
logger.Warn("YouTube cache refresh failed during setup: %v", err)
|
||
} else {
|
||
logger.Info("Background YouTube cache refreshed")
|
||
// Auto-populate 5 most recent videos into settings
|
||
var settings models.Settings
|
||
if err := bc.DB.First(&settings, settingsID).Error; err == nil {
|
||
bc.autoPopulateYouTubeVideos(&settings)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. If gallery_url is a Zonerama link, refresh Zonerama cache
|
||
if g := strings.TrimSpace(galleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
|
||
if err := services.RefreshZoneramaNow(g); err != nil {
|
||
logger.Warn("Zonerama cache refresh failed during setup: %v", err)
|
||
} else {
|
||
logger.Info("Background Zonerama cache refreshed")
|
||
}
|
||
}
|
||
|
||
// 4. Send welcome email
|
||
es := email.NewEmailService(config.AppConfig, bc.DB)
|
||
if err := es.SendAdminWelcome(adminEmail); err != nil {
|
||
logger.Error("Failed to send admin welcome email: error=%v email=%s", err, adminEmail)
|
||
} else {
|
||
logger.Info("Welcome email sent to %s", adminEmail)
|
||
}
|
||
|
||
logger.Info("All background setup operations completed")
|
||
}(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email, host)
|
||
|
||
logger.Info("SetupInitialize finished successfully - background operations running")
|
||
c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully"})
|
||
}
|
||
|
||
// UploadImage handles image/file uploads
|
||
func (bc *BaseController) UploadImage(c *gin.Context) {
|
||
// Auth required in normal operation. However during initial setup there is no admin user yet
|
||
// so allow unauthenticated uploads only when no admin exists. Otherwise require authenticated user.
|
||
if _, ok := c.Get("user"); !ok {
|
||
// Try to parse Authorization bearer token to identify user when called on public route
|
||
authHeader := c.GetHeader("Authorization")
|
||
if authHeader != "" {
|
||
parts := strings.Split(authHeader, " ")
|
||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||
if claims, err := utils.ParseJWT(parts[1]); err == nil {
|
||
var u models.User
|
||
if err := bc.DB.First(&u, claims.UserID).Error; err == nil {
|
||
c.Set("user", &u)
|
||
c.Set("userRole", u.Role)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if _, ok2 := c.Get("user"); !ok2 {
|
||
var adminCount int64
|
||
if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err == nil {
|
||
if adminCount > 0 {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"chyba": "Uzivatel neni prihlasen"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
file, header, err := c.Request.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Soubor nebyl nalezen v požadavku (pole 'file')"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// Read first 512 bytes for MIME detection
|
||
var sniff [512]byte
|
||
n, _ := io.ReadFull(file, sniff[:])
|
||
mimeType := http.DetectContentType(sniff[:n])
|
||
|
||
// Validate MIME type
|
||
allowed := false
|
||
for _, mt := range config.AppConfig.AllowedMimeTypes {
|
||
if mt == mimeType {
|
||
allowed = true
|
||
break
|
||
}
|
||
}
|
||
if !allowed {
|
||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Nepodporovany typ souboru"})
|
||
return
|
||
}
|
||
|
||
// Reset reader to include sniffed bytes
|
||
reader := io.MultiReader(bytesReader(sniff[:n]), file)
|
||
|
||
// preserve_quality form flag: when true, store file as-is without re-encoding (max quality).
|
||
preserveFlag := strings.ToLower(strings.TrimSpace(c.PostForm("preserve_quality")))
|
||
preserve := preserveFlag == "1" || preserveFlag == "true" || preserveFlag == "yes"
|
||
|
||
// Ensure upload directory exists (e.g., /uploads/2025/08)
|
||
subdir := time.Now().Format("2006/01")
|
||
destDir := filepath.Join(config.AppConfig.UploadDir, subdir)
|
||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvorit adresar pro upload"})
|
||
return
|
||
}
|
||
|
||
// Generate unique filename preserving extension
|
||
ext := filepath.Ext(header.Filename)
|
||
if ext == "" {
|
||
// derive from MIME
|
||
switch mimeType {
|
||
case "image/jpeg":
|
||
ext = ".jpg"
|
||
case "image/png":
|
||
ext = ".png"
|
||
case "image/gif":
|
||
ext = ".gif"
|
||
case "image/webp":
|
||
ext = ".webp"
|
||
case "image/svg+xml":
|
||
ext = ".svg"
|
||
case "application/pdf":
|
||
ext = ".pdf"
|
||
case "application/msword":
|
||
ext = ".doc"
|
||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||
ext = ".docx"
|
||
case "application/vnd.ms-excel":
|
||
ext = ".xls"
|
||
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
||
ext = ".xlsx"
|
||
case "application/vnd.ms-powerpoint":
|
||
ext = ".ppt"
|
||
case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||
ext = ".pptx"
|
||
case "text/plain":
|
||
ext = ".txt"
|
||
case "application/zip", "application/x-zip-compressed":
|
||
ext = ".zip"
|
||
case "application/x-rar-compressed", "application/vnd.rar":
|
||
ext = ".rar"
|
||
default:
|
||
ext = ""
|
||
}
|
||
}
|
||
randBytes := make([]byte, 16)
|
||
if _, err := rand.Read(randBytes); err != nil {
|
||
randBytes = []byte(time.Now().Format("150405.000"))
|
||
}
|
||
fname := time.Now().Format("20060102-150405") + "-" + hex.EncodeToString(randBytes) + ext
|
||
destPath := filepath.Join(destDir, fname)
|
||
|
||
// Save file with optional re-encoding/compression
|
||
out, err := os.Create(destPath)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze ulozit soubor"})
|
||
return
|
||
}
|
||
defer out.Close()
|
||
|
||
var written int64
|
||
if preserve {
|
||
// Store original bytes without decoding/re-encoding to preserve maximal quality
|
||
n64, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize))
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nepovedlo se ulozit soubor"})
|
||
return
|
||
}
|
||
written = n64
|
||
if written >= config.AppConfig.MaxUploadSize {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je prilis velky"})
|
||
return
|
||
}
|
||
} else {
|
||
switch mimeType {
|
||
case "image/jpeg":
|
||
// Decode and re-encode JPEG with quality reduction to reduce size while keeping good quality
|
||
img, err := jpeg.Decode(io.LimitReader(reader, config.AppConfig.MaxUploadSize))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatny JPEG soubor"})
|
||
return
|
||
}
|
||
if err := jpeg.Encode(out, img, &jpeg.Options{Quality: 82}); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Komprese JPEG selhala"})
|
||
return
|
||
}
|
||
fi, _ := out.Stat()
|
||
written = fi.Size()
|
||
case "image/png":
|
||
// Decode and re-encode PNG with best compression (lossless)
|
||
img, err := png.Decode(io.LimitReader(reader, config.AppConfig.MaxUploadSize))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatný PNG soubor"})
|
||
return
|
||
}
|
||
enc := png.Encoder{CompressionLevel: png.BestCompression}
|
||
if err := enc.Encode(out, img); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Komprese PNG selhala"})
|
||
return
|
||
}
|
||
fi, _ := out.Stat()
|
||
written = fi.Size()
|
||
case "image/svg+xml":
|
||
// SVG is text-based; keep as-is without modification
|
||
n, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize))
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při ukládání souboru"})
|
||
return
|
||
}
|
||
written = n
|
||
if written >= config.AppConfig.MaxUploadSize {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je příliš velký"})
|
||
return
|
||
}
|
||
default:
|
||
// Fallback: store as-is (for allowed types only)
|
||
n, err := io.Copy(out, io.LimitReader(reader, config.AppConfig.MaxUploadSize))
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při ukládání souboru"})
|
||
return
|
||
}
|
||
written = n
|
||
if written >= config.AppConfig.MaxUploadSize {
|
||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"chyba": "Soubor je příliš velký"})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build public URL and respond
|
||
// Files are served under /uploads in main.go, so construct the URL accordingly
|
||
publicURL := "/uploads/" + filepath.ToSlash(filepath.Join(subdir, fname))
|
||
// Also include a relative filesystem path for convenience
|
||
relPath := filepath.Join("uploads", subdir, fname)
|
||
|
||
// Track uploaded file in database
|
||
var uploadedByID *uint
|
||
if userID, exists := c.Get("user_id"); exists {
|
||
if uid, ok := userID.(uint); ok {
|
||
uploadedByID = &uid
|
||
}
|
||
}
|
||
|
||
uploadedFile := models.UploadedFile{
|
||
Filename: fname,
|
||
FilePath: destPath,
|
||
FileURL: publicURL,
|
||
FileSize: written,
|
||
MimeType: mimeType,
|
||
UploadedByID: uploadedByID,
|
||
}
|
||
|
||
// Best effort - don't fail if tracking fails
|
||
if err := bc.DB.Create(&uploadedFile).Error; err != nil {
|
||
logger.Warn("Failed to track uploaded file: %v", err)
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"url": publicURL,
|
||
"path": relPath,
|
||
"size": written,
|
||
"mime": mimeType,
|
||
"filename": fname,
|
||
})
|
||
}
|
||
|
||
// helper to wrap bytes as io.Reader without extra allocs
|
||
func bytesReader(b []byte) io.Reader { return &sliceReader{b: b} }
|
||
|
||
type sliceReader struct{ b []byte }
|
||
|
||
func (r *sliceReader) Read(p []byte) (int, error) {
|
||
if len(r.b) == 0 {
|
||
return 0, io.EOF
|
||
}
|
||
n := copy(p, r.b)
|
||
r.b = r.b[n:]
|
||
return n, nil
|
||
}
|
||
func makeSlug(s string) string {
|
||
s = strings.ToLower(strings.TrimSpace(s))
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
|
||
// Map of Czech/Slovak diacritics to ASCII equivalents
|
||
diacriticsMap := map[rune]rune{
|
||
'á': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'ě': 'e', 'í': 'i', 'ň': 'n',
|
||
'ó': 'o', 'ř': 'r', 'š': 's', 'ť': 't', 'ú': 'u', 'ů': 'u', 'ý': 'y', 'ž': 'z',
|
||
}
|
||
|
||
// Replace diacritics with ASCII equivalents
|
||
var result []rune
|
||
for _, char := range s {
|
||
if replacement, ok := diacriticsMap[char]; ok {
|
||
result = append(result, replacement)
|
||
} else {
|
||
result = append(result, char)
|
||
}
|
||
}
|
||
s = string(result)
|
||
|
||
// replace non-alphanumeric with hyphens
|
||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||
s = re.ReplaceAllString(s, "-")
|
||
s = strings.Trim(s, "-")
|
||
if s == "" {
|
||
s = "article"
|
||
}
|
||
if len(s) > 120 {
|
||
s = s[:120]
|
||
}
|
||
return s
|
||
}
|
||
|
||
// 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í 0–99"})
|
||
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í 50–250 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í 30–200 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í 0–99"})
|
||
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í 50–250 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í 30–200 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 body.ShowMapOnHomepage != nil {
|
||
s.ShowMapOnHomepage = *body.ShowMapOnHomepage
|
||
}
|
||
|
||
if s.ID == 0 {
|
||
if err := bc.DB.Create(&s).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
|
||
return
|
||
}
|
||
} else {
|
||
if err := bc.DB.Save(&s).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
|
||
return
|
||
}
|
||
}
|
||
logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel)
|
||
// If gallery_url is a Zonerama link, refresh Zonerama cache immediately
|
||
if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
|
||
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
|
||
}
|
||
// If YouTube URL updated, trigger immediate refresh
|
||
if body.YoutubeURL != nil {
|
||
v := strings.TrimSpace(*body.YoutubeURL)
|
||
if v != "" {
|
||
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(v)
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, s)
|
||
}
|
||
|
||
// NewBaseController creates a new instance of BaseController
|
||
func (bc *BaseController) GetPublicSettings(c *gin.Context) {
|
||
var s models.Settings
|
||
if err := bc.DB.First(&s).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
// mirror defaults from GetSettings
|
||
s = models.Settings{
|
||
FrontpageLayout: "classic",
|
||
FrontpageStyle: "light",
|
||
PrimaryColor: "#1a365d",
|
||
SecondaryColor: "#2b6cb0",
|
||
AccentColor: "#e53e3e",
|
||
BackgroundColor: "#ffffff",
|
||
TextColor: "#1a202c",
|
||
FontHeading: "Poppins, sans-serif",
|
||
FontBody: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
|
||
VideosModuleEnabled: false,
|
||
VideosSource: "auto",
|
||
VideosStyle: "slider",
|
||
VideosLimit: 6,
|
||
}
|
||
} else {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
||
return
|
||
}
|
||
}
|
||
|
||
// Apply defaults for videos settings if not set
|
||
if s.VideosSource == "" {
|
||
s.VideosSource = "auto"
|
||
}
|
||
if s.VideosLimit == 0 {
|
||
s.VideosLimit = 6
|
||
}
|
||
if s.VideosStyle == "" {
|
||
s.VideosStyle = "slider"
|
||
}
|
||
// Auto-enable videos module if YouTube URL is provided and source is auto
|
||
if s.YoutubeURL != "" && s.VideosSource == "auto" && !s.VideosModuleEnabled {
|
||
s.VideosModuleEnabled = true
|
||
}
|
||
|
||
// Build a whitelist response
|
||
// Conditional GET based on settings timestamp
|
||
last := s.UpdatedAt
|
||
if last.IsZero() {
|
||
last = time.Now().Add(-1 * time.Hour)
|
||
}
|
||
if ims := c.GetHeader("If-Modified-Since"); ims != "" {
|
||
if t, err := time.Parse(http.TimeFormat, ims); err == nil {
|
||
if !last.After(t) {
|
||
c.Status(http.StatusNotModified)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
// Decode manual videos for public payload
|
||
var pubVids []string
|
||
if s.VideosJSON != "" {
|
||
_ = json.Unmarshal([]byte(s.VideosJSON), &pubVids)
|
||
}
|
||
var pubVidsItems any
|
||
if s.VideosItemsJSON != "" {
|
||
_ = json.Unmarshal([]byte(s.VideosItemsJSON), &pubVidsItems)
|
||
}
|
||
var pubMerchItems any
|
||
if s.MerchItemsJSON != "" {
|
||
_ = json.Unmarshal([]byte(s.MerchItemsJSON), &pubMerchItems)
|
||
}
|
||
resp := gin.H{
|
||
// Core site identity (needed for prefetch to derive FACR endpoints)
|
||
"club_id": s.ClubID,
|
||
"club_type": s.ClubType,
|
||
"club_name": s.ClubName,
|
||
"club_logo_url": s.ClubLogoURL,
|
||
"club_url": s.ClubURL,
|
||
|
||
// Theme
|
||
"primary_color": s.PrimaryColor,
|
||
"secondary_color": s.SecondaryColor,
|
||
"accent_color": s.AccentColor,
|
||
"background_color": s.BackgroundColor,
|
||
"text_color": s.TextColor,
|
||
"font_heading": s.FontHeading,
|
||
"font_body": s.FontBody,
|
||
|
||
// Sponsors module prefs
|
||
"sponsors_layout": s.SponsorsLayout,
|
||
"sponsors_theme": s.SponsorsTheme,
|
||
// Social / media
|
||
"facebook_url": s.FacebookURL,
|
||
"instagram_url": s.InstagramURL,
|
||
"youtube_url": s.YoutubeURL,
|
||
"gallery_url": s.GalleryURL,
|
||
"gallery_label": s.GalleryLabel,
|
||
// Videos module
|
||
"videos_module_enabled": s.VideosModuleEnabled,
|
||
"videos_style": s.VideosStyle,
|
||
"videos_source": s.VideosSource,
|
||
"videos_limit": s.VideosLimit,
|
||
// Manual videos (public so homepage can render)
|
||
"videos": pubVids,
|
||
"videos_items": pubVidsItems,
|
||
// Merch config + items
|
||
"merch_module_enabled": s.MerchModuleEnabled,
|
||
"merch_style": s.MerchStyle,
|
||
"merch_source": s.MerchSource,
|
||
"merch_limit": s.MerchLimit,
|
||
"merch_items": pubMerchItems,
|
||
// Custom club page & navigation
|
||
"about_html": s.AboutHTML,
|
||
"show_about_in_nav": s.ShowAboutInNav,
|
||
"custom_nav": s.CustomNav,
|
||
|
||
// Contact/Map information (public for displaying on contact page and homepage)
|
||
"contact_address": s.ContactAddress,
|
||
"contact_city": s.ContactCity,
|
||
"contact_zip": s.ContactZip,
|
||
"contact_country": s.ContactCountry,
|
||
"contact_phone": s.ContactPhone,
|
||
"contact_email": s.ContactEmail,
|
||
"location_latitude": s.LocationLatitude,
|
||
"location_longitude": s.LocationLongitude,
|
||
"map_zoom_level": s.MapZoomLevel,
|
||
"map_style": s.MapStyle,
|
||
"show_map_on_homepage": s.ShowMapOnHomepage,
|
||
}
|
||
logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel)
|
||
c.JSON(http.StatusOK, resp)
|
||
}
|
||
|
||
// Global newsletter automation instance (set from main)
|
||
var globalNewsletterAutomation *services.NewsletterAutomation
|
||
|
||
// SetNewsletterAutomation sets the global newsletter automation instance
|
||
func SetNewsletterAutomation(na *services.NewsletterAutomation) {
|
||
globalNewsletterAutomation = na
|
||
}
|
||
|
||
// triggerBlogNotification sends newsletter notification when blog is published
|
||
func (bc *BaseController) triggerBlogNotification(article *models.Article) {
|
||
if globalNewsletterAutomation == nil {
|
||
logger.Warn("Newsletter automation not initialized, skipping blog notification")
|
||
return
|
||
}
|
||
|
||
if err := globalNewsletterAutomation.SendBlogNotification(article); err != nil {
|
||
logger.Error("Failed to send blog notification: %v", err)
|
||
}
|
||
}
|