mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
5295 lines
162 KiB
Go
5295 lines
162 KiB
Go
package controllers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"fotbal-club/internal/config"
|
|
"fotbal-club/internal/models"
|
|
"fotbal-club/internal/services"
|
|
"fotbal-club/pkg/email"
|
|
"fotbal-club/pkg/logger"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
"golang.org/x/text/transform"
|
|
"golang.org/x/text/unicode/norm"
|
|
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gopkg.in/mail.v2"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// BaseController handles all the base API endpoints
|
|
type BaseController struct {
|
|
DB *gorm.DB
|
|
}
|
|
|
|
// --- Helper functions for team-name aliasing ---
|
|
|
|
// generateTeamNameAliases returns alternative keys for a team name to improve matching on the frontend.
|
|
// Examples:
|
|
//
|
|
// "FK Hrtus & Partner Staré Město, z.s." -> ["FK Hrtus & Partner Staré Město", "FK H&P Staré Město"]
|
|
func generateTeamNameAliases(name string) []string {
|
|
base := strings.TrimSpace(name)
|
|
if base == "" {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, 5)
|
|
seen := map[string]struct{}{}
|
|
add := func(v string) {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" || v == base {
|
|
return
|
|
}
|
|
if _, ok := seen[v]; ok {
|
|
return
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
// Alias 1: trim common legal suffixes at the end (z.s., o.s.) and trailing comma/space
|
|
t := trimLegalSuffixes(base)
|
|
t = strings.TrimSpace(t)
|
|
if t != "" && t != base {
|
|
add(t)
|
|
}
|
|
// Alias 2: sponsor initials around '&' (e.g., "Hrtus & Partner" -> "H&P")
|
|
s := abbreviateAmpersand(t)
|
|
if s != "" && s != base && s != t {
|
|
add(s)
|
|
}
|
|
e := expandPNAbbrev(t)
|
|
if e != "" && e != base && e != t {
|
|
add(e)
|
|
}
|
|
es := abbreviateAmpersand(e)
|
|
if es != "" && es != base && es != t && es != e {
|
|
add(es)
|
|
}
|
|
|
|
// Generate PN-abbreviated variants like "... n. X." / "... p. X." from full forms (nad/pod)
|
|
makePNAbbrevs := func(s string) []string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
// Build variants for "nad <Word>" / "pod <Word>" ->
|
|
// n. W., n.W., n. W, n.W (and p. analogs)
|
|
mk := func(in string, re *regexp.Regexp, repPrefix string, withFinalDot bool, withSpace bool) string {
|
|
return re.ReplaceAllStringFunc(in, func(m string) string {
|
|
sub := re.FindStringSubmatch(m)
|
|
if len(sub) < 2 {
|
|
return m
|
|
}
|
|
letter := firstRuneUpper(sub[1])
|
|
if letter == "" {
|
|
return m
|
|
}
|
|
if withFinalDot {
|
|
if withSpace {
|
|
return repPrefix + " " + letter + "."
|
|
}
|
|
return repPrefix + letter + "."
|
|
}
|
|
if withSpace {
|
|
return repPrefix + " " + letter
|
|
}
|
|
return repPrefix + letter
|
|
})
|
|
}
|
|
// spaced + with dot
|
|
a := mk(s, rePNNadWord, "n.", true, true)
|
|
a = mk(a, rePNPodWord, "p.", true, true)
|
|
// no space + with dot
|
|
b := mk(s, rePNNadWord, "n.", true, false)
|
|
b = mk(b, rePNPodWord, "p.", true, false)
|
|
// spaced + without final dot
|
|
c := mk(s, rePNNadWord, "n.", false, true)
|
|
c = mk(c, rePNPodWord, "p.", false, true)
|
|
// no space + without final dot
|
|
d := mk(s, rePNNadWord, "n.", false, false)
|
|
d = mk(d, rePNPodWord, "p.", false, false)
|
|
// collect distinct, non-empty, changed variants
|
|
seen := map[string]struct{}{}
|
|
out := []string{}
|
|
addv := func(x string) {
|
|
x = strings.TrimSpace(x)
|
|
if x == "" || x == s {
|
|
return
|
|
}
|
|
if _, ok := seen[x]; ok {
|
|
return
|
|
}
|
|
seen[x] = struct{}{}
|
|
out = append(out, x)
|
|
}
|
|
addv(a)
|
|
addv(b)
|
|
addv(c)
|
|
addv(d)
|
|
return out
|
|
}
|
|
for _, v := range []string{t, e} {
|
|
for _, p := range makePNAbbrevs(v) {
|
|
add(p)
|
|
}
|
|
}
|
|
|
|
// Also generate and add versions with common club prefixes stripped (SK, FK, MFK, TJ, 1.BFK, ...)
|
|
st := stripOrgPrefixes(t)
|
|
se := stripOrgPrefixes(e)
|
|
if st != "" && st != t {
|
|
add(st)
|
|
}
|
|
if se != "" && se != e {
|
|
add(se)
|
|
}
|
|
// PN abbreviations for stripped versions as well
|
|
for _, v := range []string{st, se} {
|
|
for _, p := range makePNAbbrevs(v) {
|
|
add(p)
|
|
}
|
|
}
|
|
|
|
variants := []string{t, s, e, es, st, se}
|
|
for _, v := range variants {
|
|
if strings.TrimSpace(v) == "" {
|
|
continue
|
|
}
|
|
nd := strings.ReplaceAll(v, ".", "")
|
|
nd = strings.TrimSpace(reMultiSpace.ReplaceAllString(nd, " "))
|
|
if nd != "" && nd != base {
|
|
add(nd)
|
|
}
|
|
fa := foldAccents(v)
|
|
if fa != "" && fa != base {
|
|
add(fa)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)[\s]*$`)
|
|
|
|
func trimLegalSuffixes(s string) string {
|
|
return strings.TrimSpace(reLegalSuffix.ReplaceAllString(s, ""))
|
|
}
|
|
|
|
var (
|
|
reAbbrevP = regexp.MustCompile(`(?i)\bp\s*\.\s*`)
|
|
reAbbrevN = regexp.MustCompile(`(?i)\bn\s*\.\s*`)
|
|
reMultiSpace = regexp.MustCompile(`\s+`)
|
|
)
|
|
|
|
var (
|
|
rePNNadWord = regexp.MustCompile(`(?i)\bnad\s+([\p{L}-]+)`)
|
|
rePNPodWord = regexp.MustCompile(`(?i)\bpod\s+([\p{L}-]+)`)
|
|
)
|
|
|
|
// Remove leading organization tokens like "1.BFK", "FK", "SK", "TJ", "MFK", "SFC", ...
|
|
var reLeadingOrg = regexp.MustCompile(`(?i)^(?:\d+\.)?\s*(?:sfc|afc|fc|fk|mfk|tj|sk|afk|bfk|hfk)\.?\s+`)
|
|
|
|
func stripOrgPrefixes(s string) string {
|
|
x := strings.TrimSpace(s)
|
|
if x == "" {
|
|
return x
|
|
}
|
|
for {
|
|
nx := reLeadingOrg.ReplaceAllString(x, "")
|
|
nx = strings.TrimSpace(nx)
|
|
if nx == x || nx == "" {
|
|
return nx
|
|
}
|
|
x = nx
|
|
}
|
|
}
|
|
|
|
func expandPNAbbrev(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
x := reAbbrevP.ReplaceAllString(s, "pod ")
|
|
x = reAbbrevN.ReplaceAllString(x, "nad ")
|
|
x = strings.TrimSpace(reMultiSpace.ReplaceAllString(x, " "))
|
|
return x
|
|
}
|
|
|
|
// abbreviateAmpersand finds the first "Word1 & Word2" pattern and replaces it with "W1&W2" initials.
|
|
func abbreviateAmpersand(s string) string {
|
|
idx := strings.Index(s, "&")
|
|
if idx < 0 {
|
|
return s
|
|
}
|
|
// Find word to the left
|
|
left := s[:idx]
|
|
right := s[idx+1:]
|
|
// Trim neighborhood spaces
|
|
left = strings.TrimSpace(left)
|
|
right = strings.TrimSpace(right)
|
|
if left == "" || right == "" {
|
|
return s
|
|
}
|
|
// Extract last word from left and first word from right
|
|
lw := lastWord(left)
|
|
rw := firstWord(right)
|
|
if lw == "" || rw == "" {
|
|
return s
|
|
}
|
|
// Build initials
|
|
li := firstRuneUpper(lw)
|
|
ri := firstRuneUpper(rw)
|
|
if li == "" || ri == "" {
|
|
return s
|
|
}
|
|
abbr := li + "&" + ri
|
|
// Replace the "lw & rw" segment while keeping any surrounding context intact
|
|
// Identify the segment boundaries to replace precisely
|
|
// We replace the last occurrence of lw before &, and the first occurrence of rw after &
|
|
// For safety, operate on tokens
|
|
leftCut := strings.TrimSuffix(left, lw)
|
|
rightCut := strings.TrimPrefix(right, rw)
|
|
return strings.TrimSpace(leftCut + abbr + rightCut)
|
|
}
|
|
|
|
func lastWord(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Fields(s)
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return parts[len(parts)-1]
|
|
}
|
|
|
|
func firstWord(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Fields(s)
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return parts[0]
|
|
}
|
|
|
|
func firstRuneUpper(s string) string {
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
r, _ := utf8.DecodeRuneInString(s)
|
|
if r == utf8.RuneError {
|
|
return ""
|
|
}
|
|
return string(unicode.ToUpper(r))
|
|
}
|
|
|
|
// GetArticleBySlug returns a single article by slug (public)
|
|
func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
|
|
slug := strings.TrimSpace(c.Param("slug"))
|
|
if slug == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"})
|
|
return
|
|
}
|
|
var art models.Article
|
|
if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// Fallback: try to locate the article in cached JSON so article pages work without DB seed
|
|
lookup := func(path string) (*models.Article, bool) {
|
|
b, e := os.ReadFile(path)
|
|
if e != nil {
|
|
return nil, false
|
|
}
|
|
// Try wrapper {items: []}
|
|
var wrap struct {
|
|
Items []models.Article `json:"items"`
|
|
}
|
|
if json.Unmarshal(b, &wrap) == nil && len(wrap.Items) > 0 {
|
|
for i := range wrap.Items {
|
|
if strings.TrimSpace(strings.ToLower(wrap.Items[i].Slug)) == strings.ToLower(slug) {
|
|
return &wrap.Items[i], true
|
|
}
|
|
}
|
|
}
|
|
// Fallback to raw array
|
|
var arr []models.Article
|
|
if json.Unmarshal(b, &arr) == nil && len(arr) > 0 {
|
|
for i := range arr {
|
|
if strings.TrimSpace(strings.ToLower(arr[i].Slug)) == strings.ToLower(slug) {
|
|
return &arr[i], true
|
|
}
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
if a, ok := lookup(filepath.Join("cache", "blogs", "articles.json")); ok {
|
|
art = *a
|
|
} else if a2, ok2 := lookup(filepath.Join("cache", "prefetch", "articles.json")); ok2 {
|
|
art = *a2
|
|
} else {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
|
|
return
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
}
|
|
// Restrict unpublished article visibility
|
|
if !art.Published {
|
|
roleVal, hasRole := c.Get("userRole")
|
|
role, _ := roleVal.(string)
|
|
uidVal, hasUID := c.Get("userID")
|
|
var uid uint
|
|
if hasUID {
|
|
if u, ok := uidVal.(uint); ok {
|
|
uid = u
|
|
}
|
|
}
|
|
isOwner := (art.AuthorID != nil && uid != 0 && *art.AuthorID == uid)
|
|
if !hasRole || (role != "admin" && role != "editor" && !isOwner) {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
|
|
return
|
|
}
|
|
}
|
|
if art.ImageURL == "" {
|
|
art.ImageURL = "/dist/img/logo-club-empty.svg"
|
|
}
|
|
if art.ReadTime == 0 {
|
|
art.ReadTime = computeEstimatedReadMinutes(art.Content)
|
|
}
|
|
var matchLink models.ArticleMatchLink
|
|
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
|
|
art.MatchLink = &matchLink
|
|
}
|
|
var aliases []models.CompetitionAlias
|
|
_ = bc.DB.Find(&aliases).Error
|
|
bc.addArticleComputedFields(&art, aliases)
|
|
c.JSON(http.StatusOK, art)
|
|
}
|
|
|
|
// respondArticlesFromCache tries to serve articles from on-disk cache and returns true if it did.
|
|
func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool {
|
|
// Helper: read JSON file and respond with pagination
|
|
readAndRespond := func(path string) bool {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
// Try payload {items: [...]} first
|
|
var wrap struct {
|
|
Items []models.Article `json:"items"`
|
|
}
|
|
if json.Unmarshal(b, &wrap) == nil && len(wrap.Items) > 0 {
|
|
items := wrap.Items
|
|
total := len(items)
|
|
start := (page - 1) * size
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if start > total {
|
|
start = total
|
|
}
|
|
end := start + size
|
|
if end > total {
|
|
end = total
|
|
}
|
|
paged := items[start:end]
|
|
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
|
|
return true
|
|
}
|
|
// Fallback: raw array of articles
|
|
var arr []models.Article
|
|
if json.Unmarshal(b, &arr) == nil && len(arr) > 0 {
|
|
total := len(arr)
|
|
start := (page - 1) * size
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
if start > total {
|
|
start = total
|
|
}
|
|
end := start + size
|
|
if end > total {
|
|
end = total
|
|
}
|
|
paged := arr[start:end]
|
|
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
// Try blogs cache first, then prefetch
|
|
if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) {
|
|
return true
|
|
}
|
|
if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func makeSlug(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
t := transform.Chain(norm.NFD, transform.RemoveFunc(func(r rune) bool {
|
|
return unicode.Is(unicode.Mn, r)
|
|
}), norm.NFC)
|
|
s, _, _ = transform.String(t, s)
|
|
reSpace := regexp.MustCompile(`\s+`)
|
|
s = reSpace.ReplaceAllString(s, "-")
|
|
reInvalid := regexp.MustCompile(`[^a-z0-9-]`)
|
|
s = reInvalid.ReplaceAllString(s, "")
|
|
reHyphens := regexp.MustCompile(`-+`)
|
|
s = reHyphens.ReplaceAllString(s, "-")
|
|
s = strings.Trim(s, "-")
|
|
return s
|
|
}
|
|
|
|
func normalizePhone(raw, country string) string {
|
|
s := strings.TrimSpace(raw)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
re := regexp.MustCompile(`[\s\-\.\(\)]`)
|
|
s = re.ReplaceAllString(s, "")
|
|
if strings.HasPrefix(s, "00") {
|
|
s = "+" + s[2:]
|
|
}
|
|
if strings.HasPrefix(s, "+") {
|
|
return s
|
|
}
|
|
if matched, _ := regexp.MatchString(`^420\d{9}$`, s); matched {
|
|
return "+" + s
|
|
}
|
|
if matched, _ := regexp.MatchString(`^\d{9}$`, s); matched {
|
|
c := strings.ToLower(country)
|
|
if strings.Contains(c, "česk") || strings.Contains(c, "czech") {
|
|
return "+420" + s
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func getPrefetchBaseURL() string {
|
|
base := strings.TrimSpace(os.Getenv("PREFETCH_TARGET"))
|
|
if base == "" {
|
|
port := strings.TrimSpace(os.Getenv("PORT"))
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
base = "http://127.0.0.1:" + port + "/api/v1"
|
|
}
|
|
return base
|
|
}
|
|
|
|
func foldAccents(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
t := transform.Chain(norm.NFD, transform.RemoveFunc(func(r rune) bool { return unicode.Is(unicode.Mn, r) }), norm.NFC)
|
|
out, _, _ := transform.String(t, s)
|
|
return out
|
|
}
|
|
|
|
// GetMatchesHistory returns cached past matches with overrides applied (public)
|
|
// Optional query: q= filters by home/away/venue/competition
|
|
func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
|
|
p := filepath.Join("cache", "prefetch", "events_past.json")
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
c.JSON(http.StatusNoContent, gin.H{"message": "No cached past matches"})
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
var matches []map[string]interface{}
|
|
if err := json.NewDecoder(f).Decode(&matches); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached past matches"})
|
|
return
|
|
}
|
|
|
|
// Apply overrides (same as in GetMatches)
|
|
var movs []models.MatchOverride
|
|
if err := bc.DB.Find(&movs).Error; err == nil {
|
|
movByID := map[string]models.MatchOverride{}
|
|
for _, m := range movs {
|
|
movByID[m.ExternalMatchID] = m
|
|
}
|
|
|
|
var tlovs []models.TeamLogoOverride
|
|
if err := bc.DB.Find(&tlovs).Error; err == nil {
|
|
tloByTeam := map[string]models.TeamLogoOverride{}
|
|
for _, t := range tlovs {
|
|
tloByTeam[t.ExternalTeamID] = t
|
|
}
|
|
|
|
for _, m := range matches {
|
|
var matchID string
|
|
if v, ok := m["match_id"].(string); ok {
|
|
matchID = v
|
|
} else if v2, ok2 := m["id"].(string); ok2 {
|
|
matchID = v2
|
|
}
|
|
|
|
if ov, ok := movByID[matchID]; ok {
|
|
if ov.HomeNameOverride != nil {
|
|
m["home"] = *ov.HomeNameOverride
|
|
m["home_team"] = *ov.HomeNameOverride
|
|
}
|
|
if ov.AwayNameOverride != nil {
|
|
m["away"] = *ov.AwayNameOverride
|
|
m["away_team"] = *ov.AwayNameOverride
|
|
}
|
|
if ov.VenueOverride != nil {
|
|
m["venue"] = *ov.VenueOverride
|
|
}
|
|
if ov.DateTimeOverride != nil {
|
|
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
|
|
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
|
|
}
|
|
if ov.HomeLogoURL != nil {
|
|
m["home_logo_url"] = *ov.HomeLogoURL
|
|
}
|
|
if ov.AwayLogoURL != nil {
|
|
m["away_logo_url"] = *ov.AwayLogoURL
|
|
}
|
|
}
|
|
|
|
if homeTeamID, ok := m["home_team_id"].(string); ok {
|
|
if tlo, found := tloByTeam[homeTeamID]; found {
|
|
if tlo.LogoURL != "" {
|
|
m["home_logo_url"] = tlo.LogoURL
|
|
}
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
m["home"] = tlo.TeamName
|
|
m["home_team"] = tlo.TeamName
|
|
}
|
|
}
|
|
}
|
|
if awayTeamID, ok := m["away_team_id"].(string); ok {
|
|
if tlo, found := tloByTeam[awayTeamID]; found {
|
|
if tlo.LogoURL != "" {
|
|
m["away_logo_url"] = tlo.LogoURL
|
|
}
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
m["away"] = tlo.TeamName
|
|
m["away_team"] = tlo.TeamName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Optional search filter
|
|
if s := strings.ToLower(strings.TrimSpace(c.Query("q"))); s != "" {
|
|
sq := foldAccents(s)
|
|
filtered := make([]map[string]interface{}, 0, len(matches))
|
|
for _, m := range matches {
|
|
get := func(k string) string {
|
|
if v, ok := m[k]; ok {
|
|
if vs, ok2 := v.(string); ok2 {
|
|
return vs
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
fields := []string{get("home"), get("away"), get("venue"), get("competition"), get("competition_name"), get("league")}
|
|
matched := false
|
|
for _, f := range fields {
|
|
if f == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(foldAccents(f), sq) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
matches = filtered
|
|
}
|
|
|
|
// Respond with filtered/processed past matches
|
|
c.Header("Cache-Control", "public, max-age=60")
|
|
c.JSON(http.StatusOK, matches)
|
|
}
|
|
|
|
func (bc *BaseController) GetMatches(c *gin.Context) {
|
|
p := filepath.Join("cache", "prefetch", "matches.json")
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
p2 := filepath.Join("cache", "prefetch", "events_upcoming.json")
|
|
f, err = os.Open(p2)
|
|
if err != nil {
|
|
c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"})
|
|
return
|
|
}
|
|
}
|
|
defer f.Close()
|
|
var matches []map[string]any
|
|
if err := json.NewDecoder(f).Decode(&matches); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached matches"})
|
|
return
|
|
}
|
|
var movs []models.MatchOverride
|
|
if err := bc.DB.Find(&movs).Error; err == nil {
|
|
movByID := map[string]models.MatchOverride{}
|
|
for _, m := range movs {
|
|
movByID[m.ExternalMatchID] = m
|
|
}
|
|
var tlovs []models.TeamLogoOverride
|
|
if err := bc.DB.Find(&tlovs).Error; err == nil {
|
|
tloByTeam := map[string]models.TeamLogoOverride{}
|
|
for _, t := range tlovs {
|
|
tloByTeam[t.ExternalTeamID] = t
|
|
}
|
|
for _, m := range matches {
|
|
var matchID string
|
|
if v, ok := m["match_id"].(string); ok {
|
|
matchID = v
|
|
} else if v2, ok2 := m["id"].(string); ok2 {
|
|
matchID = v2
|
|
}
|
|
if ov, ok := movByID[matchID]; ok {
|
|
if ov.HomeNameOverride != nil {
|
|
m["home"] = *ov.HomeNameOverride
|
|
m["home_team"] = *ov.HomeNameOverride
|
|
}
|
|
if ov.AwayNameOverride != nil {
|
|
m["away"] = *ov.AwayNameOverride
|
|
m["away_team"] = *ov.AwayNameOverride
|
|
}
|
|
if ov.VenueOverride != nil {
|
|
m["venue"] = *ov.VenueOverride
|
|
}
|
|
if ov.DateTimeOverride != nil {
|
|
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
|
|
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
|
|
}
|
|
if ov.HomeLogoURL != nil {
|
|
m["home_logo_url"] = *ov.HomeLogoURL
|
|
}
|
|
if ov.AwayLogoURL != nil {
|
|
m["away_logo_url"] = *ov.AwayLogoURL
|
|
}
|
|
}
|
|
if homeID, ok := m["home_id"].(string); ok {
|
|
if tlo, found := tloByTeam[homeID]; found {
|
|
if tlo.LogoURL != "" {
|
|
m["home_logo_url"] = tlo.LogoURL
|
|
}
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
m["home"] = tlo.TeamName
|
|
m["home_team"] = tlo.TeamName
|
|
}
|
|
}
|
|
} else if homeTeamID, ok2 := m["home_team_id"].(string); ok2 {
|
|
if tlo, found := tloByTeam[homeTeamID]; found {
|
|
if tlo.LogoURL != "" {
|
|
m["home_logo_url"] = tlo.LogoURL
|
|
}
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
m["home"] = tlo.TeamName
|
|
m["home_team"] = tlo.TeamName
|
|
}
|
|
}
|
|
}
|
|
if awayID, ok := m["away_id"].(string); ok {
|
|
if tlo, found := tloByTeam[awayID]; found {
|
|
if tlo.LogoURL != "" {
|
|
m["away_logo_url"] = tlo.LogoURL
|
|
}
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
m["away"] = tlo.TeamName
|
|
m["away_team"] = tlo.TeamName
|
|
}
|
|
}
|
|
} else if awayTeamID, ok2 := m["away_team_id"].(string); ok2 {
|
|
if tlo, found := tloByTeam[awayTeamID]; found {
|
|
if tlo.LogoURL != "" {
|
|
m["away_logo_url"] = tlo.LogoURL
|
|
}
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
m["away"] = tlo.TeamName
|
|
m["away_team"] = tlo.TeamName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if raw := strings.TrimSpace(c.Query("q")); raw != "" {
|
|
sq := foldAccents(raw)
|
|
filtered := make([]map[string]any, 0, len(matches))
|
|
for _, m := range matches {
|
|
get := func(k string) string {
|
|
if v, ok := m[k]; ok {
|
|
if vs, ok2 := v.(string); ok2 {
|
|
return vs
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
fields := []string{get("home"), get("away"), get("venue"), get("competition"), get("competition_name"), get("league")}
|
|
matched := false
|
|
for _, f := range fields {
|
|
if f == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(foldAccents(f), sq) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
matches = filtered
|
|
}
|
|
c.Header("Cache-Control", "public, max-age=60")
|
|
c.JSON(http.StatusOK, matches)
|
|
}
|
|
|
|
func (bc *BaseController) GetStandings(c *gin.Context) {
|
|
p := filepath.Join("cache", "prefetch", "facr_tables.json")
|
|
f, err := os.Open(p)
|
|
// ... (rest of the code remains the same)
|
|
if err != nil {
|
|
c.JSON(http.StatusNoContent, gin.H{"message": "No cached standings"})
|
|
return
|
|
}
|
|
defer f.Close()
|
|
var payload struct {
|
|
Competitions []struct {
|
|
Name string `json:"name"`
|
|
Code string `json:"code"`
|
|
Table struct {
|
|
Overall []struct {
|
|
Rank string `json:"rank"`
|
|
Team string `json:"team"`
|
|
TeamID string `json:"team_id"`
|
|
TeamLogoURL string `json:"team_logo_url"`
|
|
Played string `json:"played"`
|
|
Wins string `json:"wins"`
|
|
Draws string `json:"draws"`
|
|
Losses string `json:"losses"`
|
|
Score string `json:"score"`
|
|
Points string `json:"points"`
|
|
} `json:"overall"`
|
|
} `json:"table"`
|
|
} `json:"competitions"`
|
|
}
|
|
if err := json.NewDecoder(f).Decode(&payload); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Cannot parse cached standings"})
|
|
return
|
|
}
|
|
var rows []map[string]any
|
|
if len(payload.Competitions) > 0 {
|
|
comp := payload.Competitions[0]
|
|
for _, r := range comp.Table.Overall {
|
|
rows = append(rows, map[string]any{
|
|
"rank": r.Rank,
|
|
"team": r.Team,
|
|
"team_id": r.TeamID,
|
|
"team_logo_url": r.TeamLogoURL,
|
|
"played": r.Played,
|
|
"wins": r.Wins,
|
|
"draws": r.Draws,
|
|
"losses": r.Losses,
|
|
"score": r.Score,
|
|
"points": r.Points,
|
|
})
|
|
}
|
|
}
|
|
// Apply team overrides (name/logo) if present
|
|
if len(rows) > 0 {
|
|
var tlovs []models.TeamLogoOverride
|
|
if err := bc.DB.Find(&tlovs).Error; err == nil {
|
|
tloByID := map[string]models.TeamLogoOverride{}
|
|
for _, it := range tlovs {
|
|
if it.ExternalTeamID == "" {
|
|
continue
|
|
}
|
|
tloByID[strings.ToLower(it.ExternalTeamID)] = it
|
|
}
|
|
for i := range rows {
|
|
id, _ := rows[i]["team_id"].(string)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if tlo, ok := tloByID[strings.ToLower(id)]; ok {
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
rows[i]["team"] = tlo.TeamName
|
|
}
|
|
if strings.TrimSpace(tlo.LogoURL) != "" {
|
|
rows[i]["team_logo_url"] = tlo.LogoURL
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
c.Header("Cache-Control", "public, max-age=120")
|
|
c.JSON(http.StatusOK, rows)
|
|
}
|
|
|
|
// writeCompetitionAliasesCache writes a JSON snapshot of competition aliases to cache/prefetch/competition_aliases.json
|
|
// ...
|
|
// This keeps the on-disk cache in sync immediately after admin changes, without waiting for the prefetcher cycle.
|
|
func (bc *BaseController) writeCompetitionAliasesCache() {
|
|
// Load all aliases ordered by display_order (same as public API)
|
|
var items []models.CompetitionAlias
|
|
if err := bc.DB.Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil {
|
|
return
|
|
}
|
|
// Marshal pretty JSON for easier inspection
|
|
b, err := json.MarshalIndent(items, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Ensure destination directory exists
|
|
dir := filepath.Join("cache", "prefetch")
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
// Atomic write via temporary file then rename
|
|
tmp := filepath.Join(dir, "competition_aliases.json.tmp")
|
|
dst := filepath.Join(dir, "competition_aliases.json")
|
|
if err := os.WriteFile(tmp, b, 0o644); err == nil {
|
|
_ = os.Rename(tmp, dst)
|
|
// Sidecar header with minimal metadata, similar to prefetch pattern
|
|
hdr := map[string]string{
|
|
"fetched_at": time.Now().Format(time.RFC3339),
|
|
}
|
|
if hb, err := json.Marshal(hdr); err == nil {
|
|
_ = os.WriteFile(dst+".hdr", hb, 0o644)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Zonerama integration (public proxy + unified picks cache) ---
|
|
|
|
// GetZoneramaAlbum proxies a single Zonerama album request without caching.
|
|
// Query: link=ZONERAMA_ALBUM_URL (&photo_limit=10) (&rendered=true|false)
|
|
func (bc *BaseController) GetZoneramaAlbum(c *gin.Context) {
|
|
link := strings.TrimSpace(c.Query("link"))
|
|
if link == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing link"})
|
|
return
|
|
}
|
|
photoLimit := strings.TrimSpace(c.DefaultQuery("photo_limit", "24"))
|
|
rendered := strings.TrimSpace(c.DefaultQuery("rendered", "true"))
|
|
// Build external URL
|
|
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
|
|
}
|
|
// Restrict unpublished article visibility
|
|
if !art.Published {
|
|
roleVal, hasRole := c.Get("userRole")
|
|
role, _ := roleVal.(string)
|
|
uidVal, hasUID := c.Get("userID")
|
|
var uid uint
|
|
if hasUID {
|
|
if u, ok := uidVal.(uint); ok {
|
|
uid = u
|
|
}
|
|
}
|
|
isOwner := (art.AuthorID != nil && uid != 0 && *art.AuthorID == uid)
|
|
if !hasRole || (role != "admin" && role != "editor" && !isOwner) {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
|
|
return
|
|
}
|
|
}
|
|
if art.ImageURL == "" {
|
|
art.ImageURL = "/dist/img/logo-club-empty.svg"
|
|
}
|
|
if art.ReadTime == 0 {
|
|
art.ReadTime = computeEstimatedReadMinutes(art.Content)
|
|
}
|
|
// Load match link if exists
|
|
var matchLink models.ArticleMatchLink
|
|
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
|
|
art.MatchLink = &matchLink
|
|
}
|
|
// Compute helper fields (category_slug, competition_alias, normalized_category, url)
|
|
var aliases []models.CompetitionAlias
|
|
_ = bc.DB.Find(&aliases).Error
|
|
bc.addArticleComputedFields(&art, aliases)
|
|
c.JSON(http.StatusOK, art)
|
|
}
|
|
|
|
// GetArticles returns a paginated list of articles (public by default, admin can request all with published=false)
|
|
func (bc *BaseController) GetArticles(c *gin.Context) {
|
|
pageStr := strings.TrimSpace(c.DefaultQuery("page", "1"))
|
|
sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "10"))
|
|
page, _ := strconv.Atoi(pageStr)
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
size, _ := strconv.Atoi(sizeStr)
|
|
if size <= 0 {
|
|
size = 10
|
|
}
|
|
if size > 100 {
|
|
size = 100
|
|
}
|
|
|
|
pParam := strings.ToLower(strings.TrimSpace(c.Query("published")))
|
|
featuredParam := strings.ToLower(strings.TrimSpace(c.Query("featured"))) == "true"
|
|
q := strings.TrimSpace(c.Query("q"))
|
|
slug := strings.TrimSpace(c.Query("slug"))
|
|
catRaw := strings.TrimSpace(c.Query("category_id"))
|
|
matchID := strings.TrimSpace(c.Query("match_id"))
|
|
monthStr := strings.TrimSpace(c.Query("month"))
|
|
if monthStr == "" {
|
|
monthStr = strings.TrimSpace(c.Query("date"))
|
|
}
|
|
catID := 0
|
|
if catRaw != "" {
|
|
if v, err := strconv.Atoi(catRaw); err == nil {
|
|
catID = v
|
|
}
|
|
}
|
|
|
|
skipCache := false
|
|
if pParam == "false" {
|
|
skipCache = true
|
|
}
|
|
// Do not serve from cache when any filters beyond pagination are used
|
|
// including the 'featured' flag to ensure primary articles reflect immediately
|
|
if featuredParam {
|
|
skipCache = true
|
|
}
|
|
if q != "" || slug != "" || catID > 0 || matchID != "" || monthStr != "" {
|
|
skipCache = true
|
|
}
|
|
if !skipCache {
|
|
if bc.respondArticlesFromCache(c, page, size) {
|
|
return
|
|
}
|
|
}
|
|
|
|
var items []models.Article
|
|
qb := bc.DB.Model(&models.Article{})
|
|
// Only allow listing unpublished when authenticated as admin/editor. Otherwise always filter to published=true.
|
|
if pParam == "" || pParam == "true" {
|
|
qb = qb.Where("published = ?", true)
|
|
} else if pParam == "false" {
|
|
roleVal, hasRole := c.Get("userRole")
|
|
role, _ := roleVal.(string)
|
|
if !hasRole || (role != "admin" && role != "editor") {
|
|
// Not authorized to view drafts → fall back to published only
|
|
qb = qb.Where("published = ?", true)
|
|
}
|
|
}
|
|
if featuredParam {
|
|
qb = qb.Where("featured = ?", true)
|
|
}
|
|
if catID > 0 {
|
|
qb = qb.Where("category_id = ?", catID)
|
|
}
|
|
if slug != "" {
|
|
qb = qb.Where("slug = ?", slug)
|
|
}
|
|
if q != "" {
|
|
like := "%" + strings.ToLower(q) + "%"
|
|
qb = qb.Where("LOWER(title) LIKE ? OR LOWER(content) LIKE ? OR LOWER(category_name) LIKE ?", like, like, like)
|
|
}
|
|
if matchID != "" {
|
|
qb = qb.Joins("JOIN article_match_links aml ON aml.article_id = articles.id").Where("aml.external_match_id = ?", matchID)
|
|
}
|
|
if monthStr != "" {
|
|
var y, m int
|
|
if len(monthStr) >= 7 {
|
|
if yy, err := strconv.Atoi(monthStr[0:4]); err == nil {
|
|
y = yy
|
|
}
|
|
if mm, err := strconv.Atoi(monthStr[5:7]); err == nil {
|
|
m = mm
|
|
}
|
|
}
|
|
if y > 0 && m >= 1 && m <= 12 {
|
|
start := time.Date(y, time.Month(m), 1, 0, 0, 0, 0, time.UTC)
|
|
nm := m + 1
|
|
ny := y
|
|
if nm == 13 {
|
|
nm = 1
|
|
ny = y + 1
|
|
}
|
|
end := time.Date(ny, time.Month(nm), 1, 0, 0, 0, 0, time.UTC)
|
|
qb = qb.Where("COALESCE(published_at, created_at) >= ? AND COALESCE(published_at, created_at) < ?", start, end)
|
|
}
|
|
}
|
|
|
|
var total int64
|
|
if err := qb.Count(&total).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
if err := qb.Preload("Author").Preload("Category").
|
|
Order("COALESCE(published_at, created_at) DESC, created_at DESC").
|
|
Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
for i := range items {
|
|
if items[i].ImageURL == "" {
|
|
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
|
|
}
|
|
if items[i].ReadTime == 0 {
|
|
items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content)
|
|
}
|
|
}
|
|
// Compute helper fields for list
|
|
var aliases []models.CompetitionAlias
|
|
_ = bc.DB.Find(&aliases).Error
|
|
for i := range items {
|
|
bc.addArticleComputedFields(&items[i], aliases)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
|
|
}
|
|
|
|
// GetFeaturedArticles returns a paginated list of featured, published articles (public)
|
|
func (bc *BaseController) GetFeaturedArticles(c *gin.Context) {
|
|
pageStr := strings.TrimSpace(c.DefaultQuery("page", "1"))
|
|
sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "6"))
|
|
page, _ := strconv.Atoi(pageStr)
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
size, _ := strconv.Atoi(sizeStr)
|
|
if size <= 0 {
|
|
size = 6
|
|
}
|
|
if size > 100 {
|
|
size = 100
|
|
}
|
|
|
|
var items []models.Article
|
|
qb := bc.DB.Model(&models.Article{}).
|
|
Where("published = ?", true).
|
|
Where("featured = ?", true)
|
|
|
|
var total int64
|
|
if err := qb.Count(&total).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
if err := qb.Preload("Author").Preload("Category").
|
|
Order("COALESCE(published_at, created_at) DESC, created_at DESC").
|
|
Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
for i := range items {
|
|
if items[i].ImageURL == "" {
|
|
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
|
|
}
|
|
if items[i].ReadTime == 0 {
|
|
items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content)
|
|
}
|
|
}
|
|
// Compute helper fields for list
|
|
var aliases []models.CompetitionAlias
|
|
_ = bc.DB.Find(&aliases).Error
|
|
for i := range items {
|
|
bc.addArticleComputedFields(&items[i], aliases)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
|
|
}
|
|
|
|
// addArticleComputedFields populates non-persisted helper fields on the Article JSON
|
|
func (bc *BaseController) addArticleComputedFields(a *models.Article, aliases []models.CompetitionAlias) {
|
|
// Category slug
|
|
if a.Category != nil && strings.TrimSpace(a.Category.Slug) != "" {
|
|
a.CategorySlug = strings.TrimSpace(a.Category.Slug)
|
|
} else if strings.TrimSpace(a.CategoryName) != "" {
|
|
a.CategorySlug = makeSlug(a.CategoryName)
|
|
} else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" {
|
|
a.CategorySlug = makeSlug(a.Category.Name)
|
|
}
|
|
// Normalized category (for fast matching on FE)
|
|
if strings.TrimSpace(a.CategoryName) != "" {
|
|
a.NormalizedCategory = foldAccents(a.CategoryName)
|
|
} else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" {
|
|
a.NormalizedCategory = foldAccents(a.Category.Name)
|
|
}
|
|
// URL path
|
|
if strings.TrimSpace(a.Slug) != "" {
|
|
a.URL = "/news/" + strings.TrimSpace(a.Slug)
|
|
} else {
|
|
a.URL = fmt.Sprintf("/articles/%d", a.ID)
|
|
}
|
|
// Competition alias mapping (match category against alias or original name)
|
|
cat := strings.TrimSpace(a.CategoryName)
|
|
if cat == "" && a.Category != nil {
|
|
cat = strings.TrimSpace(a.Category.Name)
|
|
}
|
|
if cat == "" || len(aliases) == 0 {
|
|
return
|
|
}
|
|
ncat := foldAccents(cat)
|
|
for _, al := range aliases {
|
|
aliasNorm := foldAccents(al.Alias)
|
|
origNorm := foldAccents(al.OriginalName)
|
|
if aliasNorm != "" {
|
|
if strings.Contains(ncat, aliasNorm) || strings.Contains(aliasNorm, ncat) {
|
|
a.CompetitionAlias = al.Alias
|
|
return
|
|
}
|
|
}
|
|
if origNorm != "" {
|
|
if strings.Contains(ncat, origNorm) || strings.Contains(origNorm, ncat) {
|
|
a.CompetitionAlias = al.Alias
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
func (bc *BaseController) UpdateArticle(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var art models.Article
|
|
if err := bc.DB.First(&art, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
|
|
type reqBody struct {
|
|
Title *string `json:"title"`
|
|
Content *string `json:"content"`
|
|
CategoryID *uint `json:"category_id"`
|
|
CategoryName *string `json:"category_name"`
|
|
ImageURL *string `json:"image_url"`
|
|
Published *bool `json:"published"`
|
|
PublishedAt *string `json:"published_at"`
|
|
Featured *bool `json:"featured"`
|
|
Slug *string `json:"slug"`
|
|
SeoTitle *string `json:"seo_title"`
|
|
SeoDescription *string `json:"seo_description"`
|
|
OgImageURL *string `json:"og_image_url"`
|
|
GalleryAlbumID *string `json:"gallery_album_id"`
|
|
GalleryAlbumURL *string `json:"gallery_album_url"`
|
|
GalleryPhotoIDs *[]string `json:"gallery_photo_ids"`
|
|
YouTubeVideoID *string `json:"youtube_video_id"`
|
|
YouTubeVideoTitle *string `json:"youtube_video_title"`
|
|
YouTubeVideoURL *string `json:"youtube_video_url"`
|
|
YouTubeVideoThumbnail *string `json:"youtube_video_thumbnail"`
|
|
// Attachments array passed from frontend
|
|
Attachments *[]struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
MimeType string `json:"mime_type"`
|
|
Size *int `json:"size,omitempty"`
|
|
} `json:"attachments"`
|
|
}
|
|
var body reqBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
|
|
oldPublished := art.Published
|
|
|
|
if body.Title != nil {
|
|
art.Title = strings.TrimSpace(*body.Title)
|
|
}
|
|
if body.Content != nil {
|
|
art.Content = *body.Content
|
|
}
|
|
|
|
if body.CategoryID != nil {
|
|
if *body.CategoryID == 0 {
|
|
art.CategoryID = nil
|
|
} else {
|
|
art.CategoryID = body.CategoryID
|
|
}
|
|
} else if body.CategoryName != nil {
|
|
name := strings.TrimSpace(*body.CategoryName)
|
|
if name == "" {
|
|
art.CategoryID = nil
|
|
} else {
|
|
var cat models.Category
|
|
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// Create category with a unique slug derived from name
|
|
s := makeSlug(name)
|
|
if s == "" {
|
|
s = fmt.Sprintf("category-%d", time.Now().Unix())
|
|
}
|
|
orig := s
|
|
for i := 0; i < 50; i++ {
|
|
var sc int64
|
|
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&sc).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
|
|
return
|
|
}
|
|
if sc == 0 {
|
|
break
|
|
}
|
|
s = fmt.Sprintf("%s-%d", orig, i+1)
|
|
}
|
|
cat = models.Category{Name: name, Slug: s}
|
|
if err := bc.DB.Create(&cat).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
|
return
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při hledání kategorie"})
|
|
return
|
|
}
|
|
}
|
|
art.CategoryID = &cat.ID
|
|
}
|
|
}
|
|
|
|
if body.ImageURL != nil {
|
|
art.ImageURL = strings.TrimSpace(*body.ImageURL)
|
|
}
|
|
if body.Featured != nil {
|
|
art.Featured = *body.Featured
|
|
}
|
|
|
|
if body.Published != nil {
|
|
art.Published = *body.Published
|
|
}
|
|
if body.PublishedAt != nil {
|
|
t := strings.TrimSpace(*body.PublishedAt)
|
|
if t == "" {
|
|
art.PublishedAt = nil
|
|
} else if tt, err := time.Parse(time.RFC3339, t); err == nil {
|
|
art.PublishedAt = &tt
|
|
}
|
|
}
|
|
if art.Published && art.PublishedAt == nil {
|
|
now := time.Now()
|
|
art.PublishedAt = &now
|
|
}
|
|
if art.Published && strings.TrimSpace(art.Content) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Obsah je povinný pro publikovaný článek"})
|
|
return
|
|
}
|
|
|
|
if body.Slug != nil {
|
|
s := strings.TrimSpace(*body.Slug)
|
|
if s == "" {
|
|
s = makeSlug(art.Title)
|
|
} else {
|
|
s = makeSlug(s)
|
|
}
|
|
orig := s
|
|
for i := 0; i < 50; i++ {
|
|
var cnt int64
|
|
if err := bc.DB.Model(&models.Article{}).Where("slug = ? AND id != ?", s, art.ID).Count(&cnt).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
|
|
return
|
|
}
|
|
if cnt == 0 {
|
|
break
|
|
}
|
|
s = fmt.Sprintf("%s-%d", orig, i+1)
|
|
}
|
|
art.Slug = s
|
|
}
|
|
|
|
if body.SeoTitle != nil {
|
|
v := strings.TrimSpace(*body.SeoTitle)
|
|
if v == "" {
|
|
v = strings.TrimSpace(art.Title)
|
|
}
|
|
art.SEOTitle = v
|
|
}
|
|
if body.SeoDescription != nil {
|
|
v := strings.TrimSpace(*body.SeoDescription)
|
|
if v == "" {
|
|
v = deriveSeoDescription(art.Content)
|
|
}
|
|
art.SEODescription = v
|
|
}
|
|
if body.OgImageURL != nil {
|
|
art.OGImageURL = strings.TrimSpace(*body.OgImageURL)
|
|
}
|
|
|
|
if body.GalleryAlbumID != nil {
|
|
art.GalleryAlbumID = strings.TrimSpace(*body.GalleryAlbumID)
|
|
}
|
|
if body.GalleryAlbumURL != nil {
|
|
art.GalleryAlbumURL = strings.TrimSpace(*body.GalleryAlbumURL)
|
|
}
|
|
if body.GalleryPhotoIDs != nil {
|
|
ids := *body.GalleryPhotoIDs
|
|
if len(ids) > 0 {
|
|
art.GalleryPhotoIDs = strings.Join(ids, ",")
|
|
} else {
|
|
art.GalleryPhotoIDs = ""
|
|
}
|
|
}
|
|
|
|
if body.YouTubeVideoID != nil {
|
|
art.YouTubeVideoID = strings.TrimSpace(*body.YouTubeVideoID)
|
|
}
|
|
if body.YouTubeVideoTitle != nil {
|
|
art.YouTubeVideoTitle = strings.TrimSpace(*body.YouTubeVideoTitle)
|
|
}
|
|
if body.YouTubeVideoURL != nil {
|
|
art.YouTubeVideoURL = strings.TrimSpace(*body.YouTubeVideoURL)
|
|
}
|
|
if body.YouTubeVideoThumbnail != nil {
|
|
art.YouTubeVideoThumbnail = strings.TrimSpace(*body.YouTubeVideoThumbnail)
|
|
}
|
|
|
|
// Persist attachments JSON into text column
|
|
if body.Attachments != nil {
|
|
if len(*body.Attachments) == 0 {
|
|
art.Attachments = ""
|
|
} else {
|
|
if b, err := json.Marshal(body.Attachments); err == nil {
|
|
art.Attachments = string(b)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save changes
|
|
tx := bc.DB.Begin()
|
|
if err := tx.Save(&art).Error; err != nil {
|
|
tx.Rollback()
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek", "detail": err.Error()})
|
|
return
|
|
}
|
|
if err := tx.Commit().Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba transakce při ukládání článku"})
|
|
return
|
|
}
|
|
|
|
go func(a models.Article) {
|
|
ft := services.NewFileTracker(bc.DB)
|
|
ft.TrackArticleFiles(&a)
|
|
}(art)
|
|
|
|
// Always refresh cache after any edit to a published article so /cache/prefetch/articles.json reflects changes
|
|
if art.Published {
|
|
go func() {
|
|
var s models.Settings
|
|
if err := bc.DB.First(&s).Error; err == nil {
|
|
base := strings.TrimSpace(s.APIBaseURL)
|
|
if base == "" {
|
|
base = getPrefetchBaseURL()
|
|
}
|
|
services.PrefetchOnce(strings.TrimRight(base, "/"))
|
|
} else {
|
|
services.PrefetchOnce(getPrefetchBaseURL())
|
|
}
|
|
}()
|
|
}
|
|
// Send blog notification only on first publish
|
|
if art.Published && !oldPublished {
|
|
go bc.triggerBlogNotification(&art)
|
|
}
|
|
|
|
bc.DB.Preload("Author").Preload("Category").First(&art, art.ID)
|
|
if art.ImageURL == "" {
|
|
art.ImageURL = "/dist/img/logo-club-empty.svg"
|
|
}
|
|
if art.ReadTime == 0 {
|
|
art.ReadTime = computeEstimatedReadMinutes(art.Content)
|
|
}
|
|
c.JSON(http.StatusOK, art)
|
|
}
|
|
|
|
// DeleteArticle deletes an article (protected)
|
|
func (bc *BaseController) DeleteArticle(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var art models.Article
|
|
if err := bc.DB.First(&art, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
_ = bc.DB.Where("article_id = ?", art.ID).Delete(&models.ArticleMatchLink{}).Error
|
|
_ = bc.DB.Where("article_id = ?", art.ID).Delete(&models.ArticleTeamLink{}).Error
|
|
if err := bc.DB.Delete(&art).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat článek"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"success": true})
|
|
}
|
|
|
|
// ValidateSMTP attempts to connect to an SMTP server using provided settings.
|
|
// It does NOT send an email; it only tries to establish a connection (and authenticate if username/password provided).
|
|
// POST /api/v1/setup/validate-smtp { host, port, username?, password?, from?, use_tls? }
|
|
func (bc *BaseController) ValidateSMTP(c *gin.Context) {
|
|
type req struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
From string `json:"from"`
|
|
UseTLS *bool `json:"use_tls"`
|
|
}
|
|
var body req
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
|
|
return
|
|
}
|
|
host := strings.TrimSpace(body.Host)
|
|
if host == "" || body.Port <= 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "host and port are required"})
|
|
return
|
|
}
|
|
|
|
d := mail.NewDialer(host, body.Port, strings.TrimSpace(body.Username), body.Password)
|
|
// Determine encryption: implicit SSL for 465, STARTTLS otherwise (as supported by server)
|
|
if body.UseTLS != nil {
|
|
// If explicitly requested TLS and port is 465, use implicit SSL
|
|
if *body.UseTLS && body.Port == 465 {
|
|
d.SSL = true
|
|
} else {
|
|
d.SSL = false
|
|
}
|
|
} else {
|
|
d.SSL = body.Port == 465
|
|
}
|
|
d.TLSConfig = &tls.Config{InsecureSkipVerify: false, ServerName: host}
|
|
d.Timeout = 20 * time.Second
|
|
|
|
// Try to open the connection (auth is attempted automatically if username/password provided)
|
|
sc, err := d.Dial()
|
|
if err != nil {
|
|
errorMsg := err.Error()
|
|
// Provide more helpful error messages for common authentication issues
|
|
if strings.Contains(errorMsg, "535") || strings.Contains(strings.ToLower(errorMsg), "authentication failed") {
|
|
errorMsg = "Chyba autentizace (535): Zkontrolujte prosím uživatelské jméno a heslo. " + errorMsg
|
|
} else if strings.Contains(errorMsg, "530") {
|
|
errorMsg = "Vyžadována autentizace (530): Server vyžaduje uživatelské jméno a heslo. " + errorMsg
|
|
} else if strings.Contains(errorMsg, "connection refused") || strings.Contains(errorMsg, "no route to host") {
|
|
errorMsg = "Nelze se připojit k serveru: Zkontrolujte host a port. " + errorMsg
|
|
} else if strings.Contains(errorMsg, "certificate") || strings.Contains(errorMsg, "tls") {
|
|
errorMsg = "Chyba TLS/SSL: Zkontrolujte nastavení šifrování a port. " + errorMsg
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"ok": false, "error": errorMsg})
|
|
return
|
|
}
|
|
_ = sc.Close()
|
|
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "SMTP připojení a autentizace proběhly úspěšně"})
|
|
}
|
|
|
|
// GetYouTubeVideos returns cached YouTube channel JSON from prefetch cache (public)
|
|
// It reads cache/prefetch/youtube_channel.json and streams the JSON payload as-is.
|
|
func (bc *BaseController) GetYouTubeVideos(c *gin.Context) {
|
|
p := filepath.Join("cache", "prefetch", "youtube_channel.json")
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
c.JSON(http.StatusNoContent, gin.H{"message": "No cached YouTube data"})
|
|
return
|
|
}
|
|
defer f.Close()
|
|
c.Header("Cache-Control", "public, max-age=600")
|
|
c.Header("Content-Type", "application/json; charset=utf-8")
|
|
if _, err := io.Copy(c.Writer, f); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// HealthCheck returns a simple status and verifies database connectivity
|
|
func (bc *BaseController) HealthCheck(c *gin.Context) {
|
|
// Default status
|
|
status := gin.H{"status": "ok"}
|
|
|
|
if bc.DB != nil {
|
|
if sqlDB, err := bc.DB.DB(); err == nil {
|
|
if err := sqlDB.Ping(); err != nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "db": "down"})
|
|
return
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unhealthy", "db": "unavailable"})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, status)
|
|
}
|
|
|
|
// --- Competition Aliases ---
|
|
|
|
// Admin: list all competition aliases
|
|
func (bc *BaseController) GetCompetitionAliases(c *gin.Context) {
|
|
var items []models.CompetitionAlias
|
|
// Order by display_order first (0 values go last), then by code
|
|
if err := bc.DB.Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
// Admin: create or replace alias by code (idempotent)
|
|
func (bc *BaseController) PutCompetitionAlias(c *gin.Context) {
|
|
code := strings.TrimSpace(c.Param("code"))
|
|
if code == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba code"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Alias string `json:"alias"`
|
|
OriginalName string `json:"original_name"`
|
|
DisplayOrder *int `json:"display_order,omitempty"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.Alias) == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "alias je povinný"})
|
|
return
|
|
}
|
|
var item models.CompetitionAlias
|
|
if err := bc.DB.Where("code = ?", code).First(&item).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
item = models.CompetitionAlias{Code: code}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
}
|
|
item.Alias = body.Alias
|
|
item.OriginalName = body.OriginalName
|
|
if body.DisplayOrder != nil {
|
|
item.DisplayOrder = *body.DisplayOrder
|
|
}
|
|
if item.ID == 0 {
|
|
if err := bc.DB.Create(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit alias"})
|
|
return
|
|
}
|
|
} else {
|
|
if err := bc.DB.Save(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit alias"})
|
|
return
|
|
}
|
|
}
|
|
// Update cache snapshot so /cache/prefetch/competition_aliases.json reflects changes immediately
|
|
bc.writeCompetitionAliasesCache()
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
// Admin: delete alias by code
|
|
func (bc *BaseController) DeleteCompetitionAlias(c *gin.Context) {
|
|
code := strings.TrimSpace(c.Param("code"))
|
|
if code == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba code"})
|
|
return
|
|
}
|
|
if err := bc.DB.Where("code = ?", code).Delete(&models.CompetitionAlias{}).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze smazat alias"})
|
|
return
|
|
}
|
|
// Update cache snapshot after deletion
|
|
bc.writeCompetitionAliasesCache()
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// Admin: bulk reorder competition aliases
|
|
func (bc *BaseController) ReorderCompetitionAliases(c *gin.Context) {
|
|
var body struct {
|
|
Items []struct {
|
|
Code string `json:"code"`
|
|
DisplayOrder int `json:"display_order"`
|
|
} `json:"items"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Update each item's display_order in a transaction
|
|
tx := bc.DB.Begin()
|
|
for _, item := range body.Items {
|
|
if err := tx.Model(&models.CompetitionAlias{}).
|
|
Where("code = ?", item.Code).
|
|
Update("display_order", item.DisplayOrder).Error; err != nil {
|
|
tx.Rollback()
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze aktualizovat pořadí"})
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit().Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
|
|
return
|
|
}
|
|
|
|
// Update cache snapshot after reordering
|
|
bc.writeCompetitionAliasesCache()
|
|
c.JSON(http.StatusOK, gin.H{"ok": true, "updated": len(body.Items)})
|
|
}
|
|
|
|
// autoPopulateCompetitionAliases reads FACR cache and creates missing competition aliases
|
|
func (bc *BaseController) autoPopulateCompetitionAliases() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
logger.Error("Panic in autoPopulateCompetitionAliases: panic=%v", r)
|
|
}
|
|
}()
|
|
|
|
// Read FACR club info cache
|
|
cacheFile := filepath.Join("cache", "prefetch", "facr_club_info.json")
|
|
data, err := os.ReadFile(cacheFile)
|
|
if err != nil {
|
|
logger.Warn("Cannot read FACR cache for alias auto-population: %v", err)
|
|
return
|
|
}
|
|
|
|
var facrData struct {
|
|
Competitions []struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Name string `json:"name"`
|
|
} `json:"competitions"`
|
|
}
|
|
if err := json.Unmarshal(data, &facrData); err != nil {
|
|
logger.Warn("Cannot parse FACR cache: %v", err)
|
|
return
|
|
}
|
|
|
|
// Get existing aliases
|
|
var existing []models.CompetitionAlias
|
|
if err := bc.DB.Find(&existing).Error; err != nil {
|
|
logger.Warn("Cannot fetch existing competition aliases: %v", err)
|
|
return
|
|
}
|
|
|
|
existingCodes := make(map[string]bool)
|
|
for _, alias := range existing {
|
|
existingCodes[alias.Code] = true
|
|
}
|
|
|
|
// Create missing aliases
|
|
created := 0
|
|
for _, comp := range facrData.Competitions {
|
|
// Use code if available, otherwise fall back to ID
|
|
code := strings.TrimSpace(comp.Code)
|
|
if code == "" {
|
|
code = strings.TrimSpace(comp.ID)
|
|
}
|
|
name := strings.TrimSpace(comp.Name)
|
|
if code == "" || existingCodes[code] {
|
|
continue
|
|
}
|
|
|
|
newAlias := models.CompetitionAlias{
|
|
Code: code,
|
|
Alias: name, // Default alias to original name
|
|
OriginalName: name,
|
|
}
|
|
if err := bc.DB.Create(&newAlias).Error; err != nil {
|
|
logger.Warn("Failed to create competition alias: code=%s error=%v", code, err)
|
|
continue
|
|
}
|
|
created++
|
|
existingCodes[code] = true
|
|
}
|
|
|
|
if created > 0 {
|
|
logger.Info("Auto-populated competition aliases: count=%d", created)
|
|
}
|
|
}
|
|
|
|
// autoPopulateYouTubeVideos reads YouTube cache and auto-selects 5 most recent videos for homepage
|
|
func (bc *BaseController) autoPopulateYouTubeVideos(settings *models.Settings) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
logger.Error("Panic in autoPopulateYouTubeVideos: panic=%v", r)
|
|
}
|
|
}()
|
|
|
|
// Read YouTube cache
|
|
cacheFile := filepath.Join("cache", "prefetch", "youtube_channel.json")
|
|
data, err := os.ReadFile(cacheFile)
|
|
if err != nil {
|
|
logger.Warn("Cannot read YouTube cache for auto-population: %v", err)
|
|
return
|
|
}
|
|
|
|
var ytData struct {
|
|
Videos []struct {
|
|
VideoID string `json:"video_id"`
|
|
Title string `json:"title"`
|
|
ThumbnailURL string `json:"thumbnail_url"`
|
|
PublishedText string `json:"published_text"`
|
|
PublishedDate string `json:"published_date"`
|
|
} `json:"videos"`
|
|
}
|
|
if err := json.Unmarshal(data, &ytData); err != nil {
|
|
logger.Warn("Cannot parse YouTube cache: %v", err)
|
|
return
|
|
}
|
|
|
|
if len(ytData.Videos) == 0 {
|
|
logger.Info("No YouTube videos to auto-populate")
|
|
return
|
|
}
|
|
|
|
// Take first 5 videos (they're already sorted by most recent from API)
|
|
limit := 5
|
|
if len(ytData.Videos) < limit {
|
|
limit = len(ytData.Videos)
|
|
}
|
|
|
|
type VideoItem struct {
|
|
URL string `json:"url"`
|
|
Title string `json:"title"`
|
|
Length string `json:"length"`
|
|
UploadedAt string `json:"uploaded_at"`
|
|
ThumbnailURL string `json:"thumbnail_url"`
|
|
}
|
|
|
|
videoItems := make([]VideoItem, 0, limit)
|
|
for i := 0; i < limit; i++ {
|
|
v := ytData.Videos[i]
|
|
videoItems = append(videoItems, VideoItem{
|
|
URL: "https://www.youtube.com/watch?v=" + v.VideoID,
|
|
Title: v.Title,
|
|
Length: "",
|
|
UploadedAt: v.PublishedDate,
|
|
ThumbnailURL: v.ThumbnailURL,
|
|
})
|
|
}
|
|
|
|
// Save to settings VideosItemsJSON
|
|
itemsJSON, err := json.Marshal(videoItems)
|
|
if err != nil {
|
|
logger.Warn("Failed to marshal video items: %v", err)
|
|
return
|
|
}
|
|
|
|
settings.VideosItemsJSON = string(itemsJSON)
|
|
settings.VideosModuleEnabled = true
|
|
settings.VideosSource = "auto" // Auto source from YouTube channel
|
|
settings.VideosLimit = limit
|
|
settings.VideosStyle = "slider" // Default to slider display
|
|
|
|
if err := bc.DB.Save(settings).Error; err != nil {
|
|
logger.Warn("Failed to save auto-populated YouTube videos: %v", err)
|
|
return
|
|
}
|
|
|
|
logger.Info("Auto-populated %d YouTube videos to homepage", limit)
|
|
}
|
|
|
|
// Public: list aliases for frontend mapping
|
|
func (bc *BaseController) GetPublicCompetitionAliases(c *gin.Context) {
|
|
var items []models.CompetitionAlias
|
|
// Order by display_order first (0 values go last), then by code
|
|
if err := bc.DB.Select("code", "alias", "original_name", "display_order").Order("CASE WHEN display_order = 0 THEN 999999 ELSE display_order END ASC, code ASC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
// TrackArticleView increments the view counter for an article (public)
|
|
func (bc *BaseController) TrackArticleView(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
// Use atomic SQL update to increment view_count
|
|
result := bc.DB.Model(&models.Article{}).
|
|
Where("id = ?", id).
|
|
UpdateColumn("view_count", gorm.Expr("view_count + ?", 1))
|
|
|
|
if result.Error != nil {
|
|
logger.Error("Failed to track article view: id=%s error=%v", id, result.Error)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to track view"})
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected == 0 {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Article not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// IncrementArticleRead increments the read counter for an article (public)
|
|
func (bc *BaseController) IncrementArticleRead(c *gin.Context) {
|
|
id := c.Param("id")
|
|
// Use atomic SQL update
|
|
if err := bc.DB.Model(&models.Article{}).
|
|
Where("id = ?", id).
|
|
UpdateColumn("read_count", gorm.Expr("read_count + ?", 1)).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Clanek nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat ctenost"})
|
|
return
|
|
}
|
|
var art models.Article
|
|
if err := bc.DB.Preload("Author").First(&art, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
if art.ImageURL == "" {
|
|
art.ImageURL = "/dist/img/logo-club-empty.svg"
|
|
}
|
|
c.JSON(http.StatusOK, art)
|
|
}
|
|
|
|
// computeEstimatedReadMinutes estimates reading time by stripping HTML and counting words.
|
|
func computeEstimatedReadMinutes(html string) int {
|
|
// remove tags
|
|
reTags := regexp.MustCompile("<[^>]+>")
|
|
text := reTags.ReplaceAllString(html, " ")
|
|
// collapse whitespace
|
|
text = strings.TrimSpace(strings.Join(strings.Fields(text), " "))
|
|
if text == "" {
|
|
return 1
|
|
}
|
|
words := len(strings.Fields(text))
|
|
// assume 200 wpm
|
|
minutes := (words + 199) / 200
|
|
if minutes < 1 {
|
|
minutes = 1
|
|
}
|
|
if minutes > 999 {
|
|
minutes = 999
|
|
}
|
|
return minutes
|
|
}
|
|
|
|
// GetAdminMatches returns cached FACR matches merged with DB overrides (admin only)
|
|
func (bc *BaseController) GetAdminMatches(c *gin.Context) {
|
|
// Read cached FACR club info (contains competitions with matches)
|
|
p := filepath.Join("cache", "prefetch", "facr_club_info.json")
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
c.JSON(http.StatusNoContent, gin.H{"message": "No cached FACR matches"})
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
var facr struct {
|
|
Competitions []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Matches []struct {
|
|
MatchID string `json:"match_id"`
|
|
DateTime string `json:"date_time"`
|
|
Date string `json:"date"`
|
|
Time string `json:"time"`
|
|
Home string `json:"home"`
|
|
HomeTeam string `json:"home_team"`
|
|
HomeID string `json:"home_id"`
|
|
HomeTeamID string `json:"home_team_id"`
|
|
HomeTeamFACRID string `json:"home_team_facr_id"`
|
|
Away string `json:"away"`
|
|
AwayTeam string `json:"away_team"`
|
|
AwayID string `json:"away_id"`
|
|
AwayTeamID string `json:"away_team_id"`
|
|
AwayTeamFACRID string `json:"away_team_facr_id"`
|
|
Score string `json:"score"`
|
|
Venue string `json:"venue"`
|
|
HomeLogoURL string `json:"home_logo_url"`
|
|
AwayLogoURL string `json:"away_logo_url"`
|
|
} `json:"matches"`
|
|
} `json:"competitions"`
|
|
}
|
|
if err := json.NewDecoder(f).Decode(&facr); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist FACR cache"})
|
|
return
|
|
}
|
|
|
|
// Helper to pick first non-empty string
|
|
firstNonEmpty := func(ss ...string) string {
|
|
for _, s := range ss {
|
|
s = strings.TrimSpace(s)
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Flatten and normalize to a simple slice of maps
|
|
items := make([]map[string]any, 0, 256)
|
|
for _, comp := range facr.Competitions {
|
|
for _, m := range comp.Matches {
|
|
id := strings.TrimSpace(m.MatchID)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
home := firstNonEmpty(m.Home, m.HomeTeam)
|
|
away := firstNonEmpty(m.Away, m.AwayTeam)
|
|
homeID := firstNonEmpty(m.HomeID, m.HomeTeamID, m.HomeTeamFACRID)
|
|
awayID := firstNonEmpty(m.AwayID, m.AwayTeamID, m.AwayTeamFACRID)
|
|
item := map[string]any{
|
|
"id": id,
|
|
"match_id": id,
|
|
"date_time": strings.TrimSpace(m.DateTime),
|
|
"date": strings.TrimSpace(m.Date),
|
|
"time": strings.TrimSpace(m.Time),
|
|
"competitionName": strings.TrimSpace(comp.Name),
|
|
"competition_id": strings.TrimSpace(comp.ID),
|
|
"home": home,
|
|
"home_team": home,
|
|
"home_id": homeID,
|
|
"away": away,
|
|
"away_team": away,
|
|
"away_id": awayID,
|
|
"score": strings.TrimSpace(m.Score),
|
|
"venue": strings.TrimSpace(m.Venue),
|
|
"home_logo_url": strings.TrimSpace(m.HomeLogoURL),
|
|
"away_logo_url": strings.TrimSpace(m.AwayLogoURL),
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
|
|
// Load overrides and apply
|
|
var movs []models.MatchOverride
|
|
if err := bc.DB.Find(&movs).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
|
|
return
|
|
}
|
|
movByID := map[string]models.MatchOverride{}
|
|
for _, m := range movs {
|
|
movByID[m.ExternalMatchID] = m
|
|
}
|
|
|
|
var tlovs []models.TeamLogoOverride
|
|
if err := bc.DB.Find(&tlovs).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
|
|
return
|
|
}
|
|
tloByTeam := map[string]models.TeamLogoOverride{}
|
|
for _, t := range tlovs {
|
|
tloByTeam[t.ExternalTeamID] = t
|
|
}
|
|
|
|
for _, m := range items {
|
|
matchID, _ := m["match_id"].(string)
|
|
if ov, ok := movByID[matchID]; ok {
|
|
if ov.HomeNameOverride != nil {
|
|
m["home"] = *ov.HomeNameOverride
|
|
m["home_team"] = *ov.HomeNameOverride
|
|
}
|
|
if ov.AwayNameOverride != nil {
|
|
m["away"] = *ov.AwayNameOverride
|
|
m["away_team"] = *ov.AwayNameOverride
|
|
}
|
|
if ov.VenueOverride != nil {
|
|
m["venue"] = *ov.VenueOverride
|
|
}
|
|
if ov.DateTimeOverride != nil {
|
|
// Keep a consistent ISO string for machines and Czech human-readable for display
|
|
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
|
|
m["date"] = ov.DateTimeOverride.Format("02.01.2006")
|
|
m["time"] = ov.DateTimeOverride.Format("15:04")
|
|
}
|
|
if ov.ScoreOverride != nil {
|
|
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
|
|
}
|
|
if ov.HomeLogoURL != nil {
|
|
m["home_logo_url"] = *ov.HomeLogoURL
|
|
}
|
|
if ov.AwayLogoURL != nil {
|
|
m["away_logo_url"] = *ov.AwayLogoURL
|
|
}
|
|
}
|
|
if homeID, _ := m["home_id"].(string); homeID != "" {
|
|
if tlo, ok := tloByTeam[homeID]; ok {
|
|
if strings.TrimSpace(tlo.LogoURL) != "" {
|
|
m["home_logo_url"] = tlo.LogoURL
|
|
}
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
m["home"] = tlo.TeamName
|
|
m["home_team"] = tlo.TeamName
|
|
}
|
|
}
|
|
}
|
|
if awayID, _ := m["away_id"].(string); awayID != "" {
|
|
if tlo, ok := tloByTeam[awayID]; ok {
|
|
if strings.TrimSpace(tlo.LogoURL) != "" {
|
|
m["away_logo_url"] = tlo.LogoURL
|
|
}
|
|
if strings.TrimSpace(tlo.TeamName) != "" {
|
|
m["away"] = tlo.TeamName
|
|
m["away_team"] = tlo.TeamName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
// --- Admin: Match & Team Logo Overrides ---
|
|
|
|
// GetMatchOverrides lists all match overrides
|
|
func (bc *BaseController) GetMatchOverrides(c *gin.Context) {
|
|
var items []models.MatchOverride
|
|
if err := bc.DB.Order("updated_at DESC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
// PutMatchOverride creates or replaces an override by external_match_id
|
|
func (bc *BaseController) PutMatchOverride(c *gin.Context) {
|
|
extID := c.Param("external_match_id")
|
|
if extID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Chybí external_match_id"})
|
|
return
|
|
}
|
|
var body struct {
|
|
HomeNameOverride *string `json:"home_name_override"`
|
|
AwayNameOverride *string `json:"away_name_override"`
|
|
VenueOverride *string `json:"venue_override"`
|
|
DateTimeOverride *time.Time `json:"date_time_override"`
|
|
ScoreOverride *string `json:"score_override"`
|
|
HomeLogoURL *string `json:"home_logo_url"`
|
|
AwayLogoURL *string `json:"away_logo_url"`
|
|
Notes *string `json:"notes"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
var item models.MatchOverride
|
|
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// create new
|
|
item = models.MatchOverride{ExternalMatchID: extID}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databaze"})
|
|
return
|
|
}
|
|
}
|
|
item.HomeNameOverride = body.HomeNameOverride
|
|
item.AwayNameOverride = body.AwayNameOverride
|
|
item.VenueOverride = body.VenueOverride
|
|
item.DateTimeOverride = body.DateTimeOverride
|
|
item.ScoreOverride = body.ScoreOverride
|
|
item.HomeLogoURL = body.HomeLogoURL
|
|
item.AwayLogoURL = body.AwayLogoURL
|
|
if body.Notes != nil {
|
|
item.Notes = *body.Notes
|
|
}
|
|
if item.ID == 0 {
|
|
if err := bc.DB.Create(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvorit zaznam"})
|
|
return
|
|
}
|
|
} else {
|
|
if err := bc.DB.Save(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit zmeny"})
|
|
return
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
// 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")
|
|
// Normalize date_time_override to *time.Time if provided as string
|
|
if v, ok := body["date_time_override"]; ok {
|
|
switch vv := v.(type) {
|
|
case string:
|
|
s := strings.TrimSpace(vv)
|
|
if s == "" {
|
|
body["date_time_override"] = nil
|
|
} else {
|
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
body["date_time_override"] = &t
|
|
} else if t2, err2 := time.Parse("2006-01-02T15:04", s); err2 == nil {
|
|
body["date_time_override"] = &t2
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Neplatný formát date_time_override"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if err := bc.DB.Model(&item).Updates(body).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
// GetTeamLogoOverrides lists all team logo overrides
|
|
func (bc *BaseController) GetTeamLogoOverrides(c *gin.Context) {
|
|
var items []models.TeamLogoOverride
|
|
if err := bc.DB.Order("updated_at DESC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
// GetPublicTeamLogoOverrides returns a simple mapping usable by widgets without auth
|
|
//
|
|
// Shape: {
|
|
// "by_name": { "Team Name": "https://.../logo.png" },
|
|
// "by_id": { "<external_team_id>": { "name": "Team Name", "logo_url": "https://.../logo.png" } }
|
|
// }
|
|
func (bc *BaseController) GetPublicTeamLogoOverrides(c *gin.Context) {
|
|
var items []models.TeamLogoOverride
|
|
if err := bc.DB.Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
m := make(map[string]string, len(items))
|
|
byID := make(map[string]any, len(items))
|
|
for _, it := range items {
|
|
if it.TeamName != "" && it.LogoURL != "" {
|
|
// Primary exact key
|
|
m[it.TeamName] = it.LogoURL
|
|
// Add smart aliases so frontends can match sponsor-shortened variants
|
|
for _, alias := range generateTeamNameAliases(it.TeamName) {
|
|
if alias != "" {
|
|
m[alias] = it.LogoURL
|
|
}
|
|
}
|
|
}
|
|
if it.ExternalTeamID != "" {
|
|
byID[it.ExternalTeamID] = map[string]string{
|
|
"name": it.TeamName,
|
|
"logo_url": it.LogoURL,
|
|
}
|
|
}
|
|
}
|
|
// Public cacheable response
|
|
c.Header("Cache-Control", "public, max-age=120")
|
|
c.JSON(http.StatusOK, gin.H{"by_name": m, "by_id": byID})
|
|
}
|
|
|
|
// writeTeamLogoOverridesCache writes a JSON snapshot of team-logo overrides to cache/prefetch/team_logo_overrides.json
|
|
//
|
|
// Shape: {
|
|
// "by_name": { "Team Name": "https://..." },
|
|
// "by_id": { "<external_team_id>": { "name": "Team Name", "logo_url": "https://..." } }
|
|
// }
|
|
func (bc *BaseController) writeTeamLogoOverridesCache() {
|
|
var items []models.TeamLogoOverride
|
|
if err := bc.DB.Find(&items).Error; err != nil {
|
|
return
|
|
}
|
|
m := make(map[string]string, len(items))
|
|
byID := make(map[string]any, len(items))
|
|
for _, it := range items {
|
|
if it.TeamName != "" && it.LogoURL != "" {
|
|
// Primary exact key
|
|
m[it.TeamName] = it.LogoURL
|
|
// Smart aliases (trim legal suffixes, sponsor initials like H&P)
|
|
for _, alias := range generateTeamNameAliases(it.TeamName) {
|
|
if alias != "" {
|
|
m[alias] = it.LogoURL
|
|
}
|
|
}
|
|
}
|
|
if it.ExternalTeamID != "" {
|
|
byID[it.ExternalTeamID] = map[string]string{
|
|
"name": it.TeamName,
|
|
"logo_url": it.LogoURL,
|
|
}
|
|
}
|
|
}
|
|
payload := map[string]any{"by_name": m, "by_id": byID}
|
|
b, err := json.MarshalIndent(payload, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
dir := filepath.Join("cache", "prefetch")
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
tmp := filepath.Join(dir, "team_logo_overrides.json.tmp")
|
|
dst := filepath.Join(dir, "team_logo_overrides.json")
|
|
if err := os.WriteFile(tmp, b, 0o644); err == nil {
|
|
_ = os.Rename(tmp, dst)
|
|
}
|
|
}
|
|
|
|
// PutTeamLogoOverride creates or replaces a team logo override by external_team_id
|
|
func (bc *BaseController) PutTeamLogoOverride(c *gin.Context) {
|
|
extID := c.Param("external_team_id")
|
|
if extID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_team_id"})
|
|
return
|
|
}
|
|
var body struct {
|
|
TeamName string `json:"team_name"`
|
|
LogoURL string `json:"logo_url"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
var item models.TeamLogoOverride
|
|
if err := bc.DB.Where("external_team_id = ?", extID).First(&item).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
item = models.TeamLogoOverride{ExternalTeamID: extID}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
}
|
|
item.TeamName = body.TeamName
|
|
item.LogoURL = body.LogoURL
|
|
if item.ID == 0 {
|
|
if err := bc.DB.Create(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit záznam"})
|
|
return
|
|
}
|
|
} else if err := bc.DB.Save(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
|
|
return
|
|
}
|
|
// Best-effort: write JSON snapshot to cache
|
|
go bc.writeTeamLogoOverridesCache()
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
// PatchTeamLogoOverride partially updates a team logo override by external_team_id
|
|
func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
|
|
extID := c.Param("external_team_id")
|
|
if extID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_team_id"})
|
|
return
|
|
}
|
|
var item models.TeamLogoOverride
|
|
if err := bc.DB.Where("external_team_id = ?", extID).First(&item).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
var body map[string]interface{}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
delete(body, "external_team_id")
|
|
if err := bc.DB.Model(&item).Updates(body).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
|
|
return
|
|
}
|
|
// Best-effort: update public snapshot cache so frontend fallback sees latest aliases
|
|
go bc.writeTeamLogoOverridesCache()
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (bc *BaseController) ProxyImage(c *gin.Context) {
|
|
raw := c.Query("url")
|
|
if raw == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing url parameter"})
|
|
return
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"})
|
|
return
|
|
}
|
|
|
|
// Basic SSRF hardening: block internal/private destinations and unusual ports
|
|
host := u.Hostname()
|
|
port := u.Port()
|
|
if port != "" && port != "80" && port != "443" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported port"})
|
|
return
|
|
}
|
|
ips, err := net.LookupIP(host)
|
|
if err == nil {
|
|
blockedCIDRs := []string{
|
|
"127.0.0.0/8", // loopback
|
|
"10.0.0.0/8", // private
|
|
"172.16.0.0/12", // private
|
|
"192.168.0.0/16", // private
|
|
"169.254.0.0/16", // link-local
|
|
"::1/128", // IPv6 loopback
|
|
"fc00::/7", // IPv6 unique local
|
|
"fe80::/10", // IPv6 link-local
|
|
}
|
|
var nets []*net.IPNet
|
|
for _, cidr := range blockedCIDRs {
|
|
_, n, perr := net.ParseCIDR(cidr)
|
|
if perr == nil {
|
|
nets = append(nets, n)
|
|
}
|
|
}
|
|
for _, ip := range ips {
|
|
for _, n := range nets {
|
|
if n.Contains(ip) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "destination not allowed"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch with a short timeout
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
req, err := http.NewRequest("GET", u.String(), nil)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "request init failed"})
|
|
return
|
|
}
|
|
// Use realistic browser headers - some CDNs block unknown clients
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36")
|
|
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
|
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
|
|
// Set a benign referer tied to the target host to satisfy anti-hotlink checks
|
|
if u.Host != "" {
|
|
ref := u.Scheme + "://" + u.Host + "/"
|
|
req.Header.Set("Referer", ref)
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "remote returned status", "status": resp.StatusCode})
|
|
return
|
|
}
|
|
|
|
ct := resp.Header.Get("Content-Type")
|
|
// Allow only images for safety
|
|
if ct == "" || (ct != "image/jpeg" && ct != "image/png" && ct != "image/gif" && ct != "image/webp" && ct != "image/svg+xml") {
|
|
// try to infer from URL
|
|
ext := filepath.Ext(u.Path)
|
|
switch ext {
|
|
case ".jpg", ".jpeg":
|
|
ct = "image/jpeg"
|
|
case ".png":
|
|
ct = "image/png"
|
|
case ".gif":
|
|
ct = "image/gif"
|
|
case ".webp":
|
|
ct = "image/webp"
|
|
case ".svg":
|
|
ct = "image/svg+xml"
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported content type"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Enforce a reasonable maximum size when Content-Length is provided (8MB)
|
|
if cl := resp.Header.Get("Content-Length"); cl != "" {
|
|
if n, err := strconv.Atoi(cl); err == nil {
|
|
if n > 8*1024*1024 {
|
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image too large"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stream response
|
|
c.Header("Access-Control-Allow-Origin", "*")
|
|
c.Header("Cache-Control", "public, max-age=86400")
|
|
c.DataFromReader(http.StatusOK, resp.ContentLength, ct, resp.Body, nil)
|
|
}
|
|
|
|
// SetupStatus reports whether initial setup is required
|
|
// requires_setup is true if no admin user exists OR settings lack a ClubID
|
|
func (bc *BaseController) SetupStatus(c *gin.Context) {
|
|
var adminCount int64
|
|
if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
// Ensure settings table exists to avoid noisy errors on first run
|
|
_ = bc.DB.AutoMigrate(&models.Settings{})
|
|
var s models.Settings
|
|
_ = bc.DB.First(&s).Error // ignore not found
|
|
requires := adminCount == 0 || s.ClubID == ""
|
|
c.JSON(http.StatusOK, gin.H{"requires_setup": requires})
|
|
}
|
|
|
|
// It is allowed only if no admin exists yet. SMTP is optional.
|
|
func (bc *BaseController) SetupInitialize(c *gin.Context) {
|
|
// Ensure required tables exist even if global migrations were skipped
|
|
_ = bc.DB.AutoMigrate(&models.User{}, &models.Settings{})
|
|
|
|
// Check if an admin already exists
|
|
var adminCount int64
|
|
if err := bc.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
|
|
if adminCount > 0 {
|
|
// Allow idempotent updates to Settings (club basics if missing, and SMTP at any time)
|
|
type setupBody struct {
|
|
ClubID string `json:"club_id"`
|
|
ClubType string `json:"club_type"`
|
|
ClubName string `json:"club_name"`
|
|
ClubLogoURL string `json:"club_logo_url"`
|
|
ClubURL string `json:"club_url"`
|
|
|
|
// Optional bases
|
|
FrontendBaseURL string `json:"frontend_base_url"`
|
|
APIBaseURL string `json:"api_base_url"`
|
|
|
|
// Social profiles (optional)
|
|
FacebookURL string `json:"facebook_url"`
|
|
InstagramURL string `json:"instagram_url"`
|
|
YoutubeURL string `json:"youtube_url"`
|
|
|
|
// Gallery (optional)
|
|
GalleryURL string `json:"gallery_url"`
|
|
GalleryLabel string `json:"gallery_label"`
|
|
|
|
// Location/Contact (optional)
|
|
ContactAddress string `json:"contact_address"`
|
|
ContactCity string `json:"contact_city"`
|
|
ContactZip string `json:"contact_zip"`
|
|
ContactCountry string `json:"contact_country"`
|
|
ContactPhone string `json:"contact_phone"`
|
|
ContactEmail string `json:"contact_email"`
|
|
LocationLatitude float64 `json:"location_latitude"`
|
|
LocationLongitude float64 `json:"location_longitude"`
|
|
MapStyle string `json:"map_style"`
|
|
|
|
// Frontpage style (optional)
|
|
FrontpageStyle string `json:"frontpage_style"`
|
|
|
|
// Theme (optional, can set later)
|
|
PrimaryColor string `json:"primary_color"`
|
|
SecondaryColor string `json:"secondary_color"`
|
|
AccentColor string `json:"accent_color"`
|
|
BackgroundColor string `json:"background_color"`
|
|
TextColor string `json:"text_color"`
|
|
FontHeading string `json:"font_heading"`
|
|
FontBody string `json:"font_body"`
|
|
|
|
// SMTP optional
|
|
SMTP *struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
From string `json:"from"`
|
|
UseTLS *bool `json:"use_tls"`
|
|
} `json:"smtp"`
|
|
}
|
|
var body setupBody
|
|
_ = c.ShouldBindJSON(&body) // best-effort; all fields optional here
|
|
|
|
var s models.Settings
|
|
_ = bc.DB.First(&s).Error // ignore not found
|
|
if s.ID == 0 {
|
|
s = models.Settings{}
|
|
}
|
|
|
|
// Only write club basics if not set yet and payload contains values
|
|
if s.ClubID == "" && body.ClubID != "" {
|
|
s.ClubID = body.ClubID
|
|
s.ClubType = body.ClubType
|
|
s.ClubName = body.ClubName
|
|
s.ClubLogoURL = body.ClubLogoURL
|
|
s.ClubURL = body.ClubURL
|
|
if body.PrimaryColor != "" {
|
|
s.PrimaryColor = body.PrimaryColor
|
|
}
|
|
if body.SecondaryColor != "" {
|
|
s.SecondaryColor = body.SecondaryColor
|
|
}
|
|
if body.AccentColor != "" {
|
|
s.AccentColor = body.AccentColor
|
|
}
|
|
if body.BackgroundColor != "" {
|
|
s.BackgroundColor = body.BackgroundColor
|
|
}
|
|
if body.TextColor != "" {
|
|
s.TextColor = body.TextColor
|
|
}
|
|
if body.FontHeading != "" {
|
|
s.FontHeading = body.FontHeading
|
|
}
|
|
if body.FontBody != "" {
|
|
s.FontBody = body.FontBody
|
|
}
|
|
}
|
|
|
|
// Always allow updating social profiles idempotently if provided
|
|
if v := strings.TrimSpace(body.FacebookURL); v != "" {
|
|
s.FacebookURL = v
|
|
}
|
|
if v := strings.TrimSpace(body.InstagramURL); v != "" {
|
|
s.InstagramURL = v
|
|
}
|
|
if v := strings.TrimSpace(body.YoutubeURL); v != "" {
|
|
// Normalize: allow @handle, full URLs, or www.* without scheme
|
|
if strings.HasPrefix(strings.ToLower(v), "www.") {
|
|
v = "https://" + v
|
|
}
|
|
s.YoutubeURL = v
|
|
}
|
|
|
|
// Gallery
|
|
if body.GalleryURL != "" {
|
|
s.GalleryURL = strings.TrimSpace(body.GalleryURL)
|
|
}
|
|
if body.GalleryLabel != "" {
|
|
s.GalleryLabel = strings.TrimSpace(body.GalleryLabel)
|
|
}
|
|
// Frontpage style
|
|
if body.FrontpageStyle != "" {
|
|
s.FrontpageStyle = body.FrontpageStyle
|
|
}
|
|
|
|
// Detect and persist base URLs
|
|
if v := strings.TrimSpace(body.APIBaseURL); v != "" {
|
|
s.APIBaseURL = v
|
|
}
|
|
if v := strings.TrimSpace(body.FrontendBaseURL); v != "" {
|
|
s.FrontendBaseURL = v
|
|
}
|
|
// If not provided, infer from current request and proxy headers
|
|
{
|
|
scheme := "http"
|
|
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
|
|
scheme = "https"
|
|
}
|
|
host := c.Request.Host
|
|
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
|
parts := strings.Split(xf, ",")
|
|
if len(parts) > 0 {
|
|
if h := strings.TrimSpace(parts[0]); h != "" {
|
|
host = h
|
|
}
|
|
}
|
|
}
|
|
if !strings.Contains(host, ":") {
|
|
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
|
|
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
|
|
host = host + ":" + xfp
|
|
}
|
|
}
|
|
}
|
|
if strings.TrimSpace(s.APIBaseURL) == "" {
|
|
s.APIBaseURL = scheme + "://" + host + "/api/v1"
|
|
}
|
|
if strings.TrimSpace(s.FrontendBaseURL) == "" {
|
|
if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" {
|
|
s.FrontendBaseURL = origin
|
|
} else {
|
|
s.FrontendBaseURL = scheme + "://" + host
|
|
}
|
|
}
|
|
}
|
|
// SMTP overrides from initial setup
|
|
if body.SMTP != nil {
|
|
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
|
|
s.SMTPHost = v
|
|
}
|
|
if body.SMTP.Port > 0 {
|
|
s.SMTPPort = body.SMTP.Port
|
|
}
|
|
if v := strings.TrimSpace(body.SMTP.Username); v != "" {
|
|
s.SMTPUser = v
|
|
s.SMTPAuth = true
|
|
}
|
|
if v := body.SMTP.Password; v != "" {
|
|
s.SMTPPassword = v
|
|
}
|
|
if v := strings.TrimSpace(body.SMTP.From); v != "" {
|
|
name := ""
|
|
addr := v
|
|
if lt, gt := strings.Index(v, "<"), strings.Index(v, ">"); lt >= 0 && gt > lt {
|
|
name = strings.TrimSpace(v[:lt])
|
|
addr = strings.TrimSpace(v[lt+1 : gt])
|
|
}
|
|
addr = strings.Trim(addr, "\" ")
|
|
name = strings.Trim(name, "\" ")
|
|
s.SMTPFrom = addr
|
|
if name != "" && !strings.Contains(strings.ToLower(name), "@") {
|
|
s.SMTPFromName = name
|
|
}
|
|
}
|
|
if s.SMTPFromName == "" {
|
|
s.SMTPFromName = "Fotbal Club"
|
|
}
|
|
if body.SMTP.UseTLS != nil {
|
|
if *body.SMTP.UseTLS {
|
|
if body.SMTP.Port == 465 {
|
|
s.SMTPEncryption = "ssl"
|
|
} else {
|
|
s.SMTPEncryption = "tls"
|
|
}
|
|
} else {
|
|
s.SMTPEncryption = "none"
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.ID == 0 {
|
|
if err := bc.DB.Create(&s).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"})
|
|
return
|
|
}
|
|
} else {
|
|
if err := bc.DB.Save(&s).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"})
|
|
return
|
|
}
|
|
}
|
|
services.StartErrorReviewAutoRegister(bc.DB)
|
|
if strings.TrimSpace(s.ClubID) != "" {
|
|
if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(s.ClubID)); err == nil && strings.TrimSpace(url) != "" {
|
|
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", s.ID).Update("club_logo_url", url).Error
|
|
}
|
|
}
|
|
go func(snap models.Settings) {
|
|
defer func() { _ = recover() }()
|
|
snap.LoadCustomNav()
|
|
snap.LoadVideosOverrides()
|
|
var pubVids []string
|
|
if snap.VideosJSON != "" {
|
|
_ = json.Unmarshal([]byte(snap.VideosJSON), &pubVids)
|
|
}
|
|
var pubVidsItems any
|
|
if snap.VideosItemsJSON != "" {
|
|
_ = json.Unmarshal([]byte(snap.VideosItemsJSON), &pubVidsItems)
|
|
}
|
|
var pubMerchItems any
|
|
if snap.MerchItemsJSON != "" {
|
|
_ = json.Unmarshal([]byte(snap.MerchItemsJSON), &pubMerchItems)
|
|
}
|
|
// Build map form of video title overrides for API/cache
|
|
m := map[string]string{}
|
|
if len(snap.VideosOverrides) > 0 {
|
|
for _, it := range snap.VideosOverrides {
|
|
vid := strings.TrimSpace(it.VideoID)
|
|
t := strings.TrimSpace(it.Title)
|
|
if vid != "" && t != "" {
|
|
m[vid] = t
|
|
}
|
|
}
|
|
}
|
|
snap.VideosTitleOverrides = m
|
|
resp := map[string]any{
|
|
"club_id": snap.ClubID,
|
|
"club_type": snap.ClubType,
|
|
"club_name": snap.ClubName,
|
|
"club_logo_url": snap.ClubLogoURL,
|
|
"club_url": snap.ClubURL,
|
|
"primary_color": snap.PrimaryColor,
|
|
"secondary_color": snap.SecondaryColor,
|
|
"accent_color": snap.AccentColor,
|
|
"background_color": snap.BackgroundColor,
|
|
"text_color": snap.TextColor,
|
|
"font_heading": snap.FontHeading,
|
|
"font_body": snap.FontBody,
|
|
"sponsors_layout": snap.SponsorsLayout,
|
|
"sponsors_theme": snap.SponsorsTheme,
|
|
"facebook_url": snap.FacebookURL,
|
|
"instagram_url": snap.InstagramURL,
|
|
"youtube_url": snap.YoutubeURL,
|
|
"gallery_url": snap.GalleryURL,
|
|
"gallery_label": snap.GalleryLabel,
|
|
"videos_module_enabled": snap.VideosModuleEnabled,
|
|
"videos_style": snap.VideosStyle,
|
|
"videos_source": snap.VideosSource,
|
|
"videos_limit": snap.VideosLimit,
|
|
"videos": pubVids,
|
|
"videos_items": pubVidsItems,
|
|
"videos_title_overrides": snap.VideosTitleOverrides,
|
|
"merch_module_enabled": snap.MerchModuleEnabled,
|
|
"merch_style": snap.MerchStyle,
|
|
"merch_source": snap.MerchSource,
|
|
"merch_limit": snap.MerchLimit,
|
|
"merch_items": pubMerchItems,
|
|
"about_html": snap.AboutHTML,
|
|
"show_about_in_nav": snap.ShowAboutInNav,
|
|
"custom_nav": snap.CustomNav,
|
|
"contact_address": snap.ContactAddress,
|
|
"contact_city": snap.ContactCity,
|
|
"contact_zip": snap.ContactZip,
|
|
"contact_country": snap.ContactCountry,
|
|
"contact_phone": snap.ContactPhone,
|
|
"contact_email": snap.ContactEmail,
|
|
"location_latitude": snap.LocationLatitude,
|
|
"location_longitude": snap.LocationLongitude,
|
|
"map_zoom_level": snap.MapZoomLevel,
|
|
"map_style": snap.MapStyle,
|
|
"show_map_on_homepage": snap.ShowMapOnHomepage,
|
|
}
|
|
b, _ := json.MarshalIndent(resp, "", " ")
|
|
outPath := filepath.Join("cache", "prefetch", "settings.json")
|
|
_ = os.MkdirAll(filepath.Dir(outPath), 0o755)
|
|
tmp := outPath + ".tmp"
|
|
_ = os.WriteFile(tmp, b, 0o644)
|
|
_ = os.Rename(tmp, outPath)
|
|
}(s)
|
|
go func(urlFromSettings string) {
|
|
base := strings.TrimSpace(urlFromSettings)
|
|
if base == "" {
|
|
base = getPrefetchBaseURL()
|
|
}
|
|
services.PrefetchOnce(strings.TrimRight(base, "/"))
|
|
}(s.APIBaseURL)
|
|
if strings.TrimSpace(s.YoutubeURL) != "" {
|
|
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL)
|
|
}
|
|
if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
|
|
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL})
|
|
return
|
|
}
|
|
|
|
// No admin exists yet: run full initial setup
|
|
type reqBody struct {
|
|
// Admin user
|
|
AdminEmail string `json:"admin_email" binding:"required,email"`
|
|
AdminPassword string `json:"admin_password" binding:"required,min=8"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
|
|
// JWT
|
|
JWTSecret string `json:"jwt_secret"`
|
|
|
|
// Club (FACR)
|
|
ClubID string `json:"club_id"`
|
|
ClubType string `json:"club_type"`
|
|
ClubName string `json:"club_name"`
|
|
ClubLogoURL string `json:"club_logo_url"`
|
|
ClubURL string `json:"club_url"`
|
|
|
|
// Social (optional)
|
|
FacebookURL string `json:"facebook_url"`
|
|
InstagramURL string `json:"instagram_url"`
|
|
YoutubeURL string `json:"youtube_url"`
|
|
|
|
// Gallery (optional)
|
|
GalleryURL string `json:"gallery_url"`
|
|
GalleryLabel string `json:"gallery_label"`
|
|
|
|
// Location/Contact (optional)
|
|
ContactAddress string `json:"contact_address"`
|
|
ContactCity string `json:"contact_city"`
|
|
ContactZip string `json:"contact_zip"`
|
|
ContactCountry string `json:"contact_country"`
|
|
ContactPhone string `json:"contact_phone"`
|
|
ContactEmail string `json:"contact_email"`
|
|
LocationLatitude float64 `json:"location_latitude"`
|
|
LocationLongitude float64 `json:"location_longitude"`
|
|
MapStyle string `json:"map_style"`
|
|
|
|
// Frontpage style (optional)
|
|
FrontpageStyle string `json:"frontpage_style"`
|
|
|
|
// Theme (optional, can set later)
|
|
PrimaryColor string `json:"primary_color"`
|
|
SecondaryColor string `json:"secondary_color"`
|
|
AccentColor string `json:"accent_color"`
|
|
BackgroundColor string `json:"background_color"`
|
|
TextColor string `json:"text_color"`
|
|
FontHeading string `json:"font_heading"`
|
|
FontBody string `json:"font_body"`
|
|
|
|
// SMTP optional
|
|
SMTP *struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
From string `json:"from"`
|
|
UseTLS *bool `json:"use_tls"`
|
|
} `json:"smtp"`
|
|
|
|
// Optional bases
|
|
FrontendBaseURL string `json:"frontend_base_url"`
|
|
APIBaseURL string `json:"api_base_url"`
|
|
}
|
|
var body reqBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
logger.Info("SetupInitialize payload received: admin_email=%s club_id=%s club_type=%s club_name=%s gallery_url=%s gallery_label=%s", body.AdminEmail, body.ClubID, body.ClubType, body.ClubName, body.GalleryURL, body.GalleryLabel)
|
|
|
|
// Optionally persist JWT secret to environment if empty in config (best set via env in deployment)
|
|
if body.JWTSecret != "" && config.AppConfig.JWTSecret == "" {
|
|
// Not writing to disk; just warn that server restart needed to take effect
|
|
}
|
|
|
|
// Create admin user
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(body.AdminPassword), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze zpracovat heslo"})
|
|
return
|
|
}
|
|
admin := models.User{
|
|
Email: body.AdminEmail,
|
|
Password: string(hashed),
|
|
FirstName: body.FirstName,
|
|
LastName: body.LastName,
|
|
Role: "admin",
|
|
}
|
|
if err := bc.DB.Create(&admin).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Email již existuje"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit admina"})
|
|
return
|
|
}
|
|
logger.Info("Admin user created: email=%s id=%d", admin.Email, admin.ID)
|
|
|
|
// Upsert settings
|
|
var s models.Settings
|
|
if err := bc.DB.First(&s).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
s = models.Settings{}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
|
|
return
|
|
}
|
|
}
|
|
if s.ID == 0 {
|
|
s = models.Settings{}
|
|
}
|
|
if body.ClubID != "" {
|
|
s.ClubID = body.ClubID
|
|
}
|
|
if body.ClubType != "" {
|
|
s.ClubType = body.ClubType
|
|
}
|
|
if body.ClubName != "" {
|
|
s.ClubName = body.ClubName
|
|
}
|
|
if body.ClubLogoURL != "" {
|
|
s.ClubLogoURL = body.ClubLogoURL
|
|
}
|
|
if body.ClubURL != "" {
|
|
s.ClubURL = body.ClubURL
|
|
}
|
|
// Social profiles
|
|
if v := strings.TrimSpace(body.FacebookURL); v != "" {
|
|
s.FacebookURL = v
|
|
}
|
|
if v := strings.TrimSpace(body.InstagramURL); v != "" {
|
|
s.InstagramURL = v
|
|
}
|
|
if v := strings.TrimSpace(body.YoutubeURL); v != "" {
|
|
if strings.HasPrefix(strings.ToLower(v), "www.") {
|
|
v = "https://" + v
|
|
}
|
|
s.YoutubeURL = v
|
|
}
|
|
if body.PrimaryColor != "" {
|
|
s.PrimaryColor = body.PrimaryColor
|
|
}
|
|
if body.SecondaryColor != "" {
|
|
s.SecondaryColor = body.SecondaryColor
|
|
}
|
|
if body.AccentColor != "" {
|
|
s.AccentColor = body.AccentColor
|
|
}
|
|
if body.BackgroundColor != "" {
|
|
s.BackgroundColor = body.BackgroundColor
|
|
}
|
|
if body.TextColor != "" {
|
|
s.TextColor = body.TextColor
|
|
}
|
|
if body.FontHeading != "" {
|
|
s.FontHeading = body.FontHeading
|
|
}
|
|
if body.FontBody != "" {
|
|
s.FontBody = body.FontBody
|
|
}
|
|
// Gallery
|
|
if body.GalleryURL != "" {
|
|
s.GalleryURL = strings.TrimSpace(body.GalleryURL)
|
|
}
|
|
if body.GalleryLabel != "" {
|
|
s.GalleryLabel = strings.TrimSpace(body.GalleryLabel)
|
|
}
|
|
// Location/Contact
|
|
if v := strings.TrimSpace(body.ContactAddress); v != "" {
|
|
s.ContactAddress = v
|
|
}
|
|
if v := strings.TrimSpace(body.ContactCity); v != "" {
|
|
s.ContactCity = v
|
|
}
|
|
if v := strings.TrimSpace(body.ContactZip); v != "" {
|
|
s.ContactZip = v
|
|
}
|
|
if v := strings.TrimSpace(body.ContactCountry); v != "" {
|
|
s.ContactCountry = v
|
|
}
|
|
if v := strings.TrimSpace(body.ContactPhone); v != "" {
|
|
s.ContactPhone = normalizePhone(v, body.ContactCountry)
|
|
}
|
|
if v := strings.TrimSpace(body.ContactEmail); v != "" {
|
|
s.ContactEmail = v
|
|
}
|
|
if body.LocationLatitude != 0 {
|
|
s.LocationLatitude = body.LocationLatitude
|
|
}
|
|
if body.LocationLongitude != 0 {
|
|
s.LocationLongitude = body.LocationLongitude
|
|
}
|
|
if v := strings.TrimSpace(body.MapStyle); v != "" {
|
|
s.MapStyle = v
|
|
}
|
|
if body.GalleryLabel != "" {
|
|
s.GalleryLabel = strings.TrimSpace(body.GalleryLabel)
|
|
}
|
|
// Frontpage style
|
|
if body.FrontpageStyle != "" {
|
|
s.FrontpageStyle = body.FrontpageStyle
|
|
}
|
|
|
|
// Persist base URLs (prefer request body, otherwise infer)
|
|
if v := strings.TrimSpace(body.APIBaseURL); v != "" {
|
|
s.APIBaseURL = v
|
|
}
|
|
if v := strings.TrimSpace(body.FrontendBaseURL); v != "" {
|
|
s.FrontendBaseURL = v
|
|
}
|
|
{
|
|
scheme := "http"
|
|
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
|
|
scheme = "https"
|
|
}
|
|
host := c.Request.Host
|
|
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
|
parts := strings.Split(xf, ",")
|
|
if len(parts) > 0 {
|
|
if h := strings.TrimSpace(parts[0]); h != "" {
|
|
host = h
|
|
}
|
|
}
|
|
}
|
|
if !strings.Contains(host, ":") {
|
|
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
|
|
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
|
|
host = host + ":" + xfp
|
|
}
|
|
}
|
|
}
|
|
if strings.TrimSpace(s.APIBaseURL) == "" {
|
|
s.APIBaseURL = scheme + "://" + host + "/api/v1"
|
|
}
|
|
if strings.TrimSpace(s.FrontendBaseURL) == "" {
|
|
if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" {
|
|
s.FrontendBaseURL = origin
|
|
} else {
|
|
s.FrontendBaseURL = scheme + "://" + host
|
|
}
|
|
}
|
|
}
|
|
// SMTP overrides from initial setup
|
|
if body.SMTP != nil {
|
|
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
|
|
s.SMTPHost = v
|
|
}
|
|
if body.SMTP.Port > 0 {
|
|
s.SMTPPort = body.SMTP.Port
|
|
}
|
|
if v := strings.TrimSpace(body.SMTP.Username); v != "" {
|
|
s.SMTPUser = v
|
|
s.SMTPAuth = true
|
|
}
|
|
if v := body.SMTP.Password; v != "" {
|
|
s.SMTPPassword = v
|
|
}
|
|
if v := strings.TrimSpace(body.SMTP.From); v != "" {
|
|
name := ""
|
|
addr := v
|
|
if lt, gt := strings.Index(v, "<"), strings.Index(v, ">"); lt >= 0 && gt > lt {
|
|
name = strings.TrimSpace(v[:lt])
|
|
addr = strings.TrimSpace(v[lt+1 : gt])
|
|
}
|
|
addr = strings.Trim(addr, "\" ")
|
|
name = strings.Trim(name, "\" ")
|
|
s.SMTPFrom = addr
|
|
if name != "" && !strings.Contains(strings.ToLower(name), "@") {
|
|
s.SMTPFromName = name
|
|
}
|
|
}
|
|
if s.SMTPFromName == "" {
|
|
s.SMTPFromName = "Fotbal Club"
|
|
}
|
|
if body.SMTP.UseTLS != nil {
|
|
if *body.SMTP.UseTLS {
|
|
if body.SMTP.Port == 465 {
|
|
s.SMTPEncryption = "ssl"
|
|
} else {
|
|
s.SMTPEncryption = "tls"
|
|
}
|
|
} else {
|
|
s.SMTPEncryption = "none"
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.ID == 0 {
|
|
if err := bc.DB.Create(&s).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"})
|
|
return
|
|
}
|
|
} else {
|
|
if err := bc.DB.Save(&s).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze ulozit nastaveni"})
|
|
return
|
|
}
|
|
}
|
|
services.StartErrorReviewAutoRegister(bc.DB)
|
|
if strings.TrimSpace(s.ClubID) != "" {
|
|
go func(id uint, clubID string) {
|
|
if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(clubID)); err == nil && strings.TrimSpace(url) != "" {
|
|
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_logo_url", url).Error
|
|
}
|
|
}(s.ID, s.ClubID)
|
|
}
|
|
logger.Info("Initial settings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel)
|
|
|
|
// Immediately write public settings cache from current Settings snapshot
|
|
go func() {
|
|
defer func() { _ = recover() }()
|
|
s.LoadCustomNav()
|
|
s.LoadVideosTitleOverrides()
|
|
var pubVids []string
|
|
if s.VideosJSON != "" {
|
|
_ = json.Unmarshal([]byte(s.VideosJSON), &pubVids)
|
|
}
|
|
var pubVidsItems any
|
|
if s.VideosItemsJSON != "" {
|
|
_ = json.Unmarshal([]byte(s.VideosItemsJSON), &pubVidsItems)
|
|
}
|
|
var pubMerchItems any
|
|
if s.MerchItemsJSON != "" {
|
|
_ = json.Unmarshal([]byte(s.MerchItemsJSON), &pubMerchItems)
|
|
}
|
|
resp := map[string]any{
|
|
"club_id": s.ClubID,
|
|
"club_type": s.ClubType,
|
|
"club_name": s.ClubName,
|
|
"club_logo_url": s.ClubLogoURL,
|
|
"club_url": s.ClubURL,
|
|
"primary_color": s.PrimaryColor,
|
|
"secondary_color": s.SecondaryColor,
|
|
"accent_color": s.AccentColor,
|
|
"background_color": s.BackgroundColor,
|
|
"text_color": s.TextColor,
|
|
"font_heading": s.FontHeading,
|
|
"font_body": s.FontBody,
|
|
"sponsors_layout": s.SponsorsLayout,
|
|
"sponsors_theme": s.SponsorsTheme,
|
|
"facebook_url": s.FacebookURL,
|
|
"instagram_url": s.InstagramURL,
|
|
"youtube_url": s.YoutubeURL,
|
|
"gallery_url": s.GalleryURL,
|
|
"gallery_label": s.GalleryLabel,
|
|
"videos_module_enabled": s.VideosModuleEnabled,
|
|
"videos_style": s.VideosStyle,
|
|
"videos_source": s.VideosSource,
|
|
"videos_limit": s.VideosLimit,
|
|
"videos": pubVids,
|
|
"videos_items": pubVidsItems,
|
|
"videos_title_overrides": s.VideosTitleOverrides,
|
|
"merch_module_enabled": s.MerchModuleEnabled,
|
|
"merch_style": s.MerchStyle,
|
|
"merch_source": s.MerchSource,
|
|
"merch_limit": s.MerchLimit,
|
|
"merch_items": pubMerchItems,
|
|
"about_html": s.AboutHTML,
|
|
"show_about_in_nav": s.ShowAboutInNav,
|
|
"custom_nav": s.CustomNav,
|
|
"contact_address": s.ContactAddress,
|
|
"contact_city": s.ContactCity,
|
|
"contact_zip": s.ContactZip,
|
|
"contact_country": s.ContactCountry,
|
|
"contact_phone": s.ContactPhone,
|
|
"contact_email": s.ContactEmail,
|
|
"location_latitude": s.LocationLatitude,
|
|
"location_longitude": s.LocationLongitude,
|
|
"map_zoom_level": s.MapZoomLevel,
|
|
"map_style": s.MapStyle,
|
|
"show_map_on_homepage": s.ShowMapOnHomepage,
|
|
}
|
|
b, _ := json.MarshalIndent(resp, "", " ")
|
|
outPath := filepath.Join("cache", "prefetch", "settings.json")
|
|
_ = os.MkdirAll(filepath.Dir(outPath), 0o755)
|
|
tmp := outPath + ".tmp"
|
|
_ = os.WriteFile(tmp, b, 0o644)
|
|
_ = os.Rename(tmp, outPath)
|
|
}()
|
|
|
|
// Seed default homepage page elements with all available sections
|
|
bc.seedDefaultHomePageElements()
|
|
logger.Info("Default homepage page elements seeded")
|
|
|
|
// Run all setup operations asynchronously in background to provide immediate response
|
|
logger.Info("Starting initial data prefetch and setup operations in background...")
|
|
|
|
// Run all setup operations in a single background goroutine
|
|
go func(settingsID uint, youtubeURL, galleryURL, adminEmail string, apiBase string) {
|
|
defer func() { _ = recover() }()
|
|
baseURL := strings.TrimSpace(apiBase)
|
|
if baseURL == "" {
|
|
baseURL = getPrefetchBaseURL()
|
|
}
|
|
services.PrefetchOnce(strings.TrimRight(baseURL, "/"))
|
|
logger.Info("Background prefetch completed")
|
|
if config.AppConfig != nil && config.AppConfig.RembgEnabled {
|
|
if services.StartFACRLogosBatch("cache/prefetch") {
|
|
logger.Info("FACR logos batch started (rembg)")
|
|
} else {
|
|
logger.Info("FACR logos batch not started (already running or nothing to process)")
|
|
}
|
|
} else {
|
|
logger.Info("FACR logos batch disabled by config")
|
|
}
|
|
|
|
// Auto-populate competition aliases from FACR data
|
|
bc.autoPopulateCompetitionAliases()
|
|
logger.Info("Background competition aliases populated")
|
|
|
|
// 2. If YouTube channel is configured, refresh its cache
|
|
if strings.TrimSpace(youtubeURL) != "" {
|
|
if err := services.RefreshYouTubeChannelNow(youtubeURL); err != nil {
|
|
logger.Warn("YouTube cache refresh failed during setup: %v", err)
|
|
} else {
|
|
logger.Info("Background YouTube cache refreshed")
|
|
// Auto-populate 5 most recent videos into settings
|
|
var settings models.Settings
|
|
if err := bc.DB.First(&settings, settingsID).Error; err == nil {
|
|
bc.autoPopulateYouTubeVideos(&settings)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. If gallery_url is a Zonerama link, refresh Zonerama cache
|
|
if g := strings.TrimSpace(galleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
|
|
if err := services.RefreshZoneramaNow(g); err != nil {
|
|
logger.Warn("Zonerama cache refresh failed during setup: %v", err)
|
|
} else {
|
|
logger.Info("Background Zonerama cache refreshed")
|
|
}
|
|
}
|
|
|
|
// 4. Send welcome email
|
|
es := email.NewEmailService(config.AppConfig, bc.DB)
|
|
if err := es.SendAdminWelcome(adminEmail); err != nil {
|
|
logger.Error("Failed to send admin welcome email: error=%v email=%s", err, adminEmail)
|
|
} else {
|
|
logger.Info("Welcome email sent to %s", adminEmail)
|
|
}
|
|
|
|
logger.Info("All background setup operations completed")
|
|
}(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email, s.APIBaseURL)
|
|
|
|
logger.Info("SetupInitialize finished successfully - background operations running")
|
|
c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL})
|
|
}
|
|
|
|
// UpdateSettings updates settings (upsert singleton)
|
|
func (bc *BaseController) UpdateSettings(c *gin.Context) {
|
|
// Ensure latest schema for settings (adds new columns if needed)
|
|
_ = bc.DB.AutoMigrate(&models.Settings{})
|
|
type reqBody struct {
|
|
FrontpageLayout *string `json:"frontpage_layout"`
|
|
FrontpageStyle *string `json:"frontpage_style"`
|
|
// Sponsors module prefs
|
|
SponsorsLayout *string `json:"sponsors_layout"`
|
|
SponsorsTheme *string `json:"sponsors_theme"`
|
|
ClubID *string `json:"club_id"`
|
|
ClubType *string `json:"club_type"`
|
|
ClubName *string `json:"club_name"`
|
|
ClubLogoURL *string `json:"club_logo_url"`
|
|
ClubURL *string `json:"club_url"`
|
|
|
|
// Theme customization
|
|
PrimaryColor *string `json:"primary_color"`
|
|
SecondaryColor *string `json:"secondary_color"`
|
|
AccentColor *string `json:"accent_color"`
|
|
BackgroundColor *string `json:"background_color"`
|
|
TextColor *string `json:"text_color"`
|
|
FontHeading *string `json:"font_heading"`
|
|
FontBody *string `json:"font_body"`
|
|
|
|
// Custom overrides
|
|
CustomCSS *string `json:"custom_css"`
|
|
CustomJS *string `json:"custom_js"`
|
|
CustomHTMLHome *string `json:"custom_html_home"`
|
|
CustomHTMLBlogList *string `json:"custom_html_blog_list"`
|
|
CustomHTMLBlogPost *string `json:"custom_html_blog_post"`
|
|
|
|
// Custom pages & navigation
|
|
AboutHTML *string `json:"about_html"`
|
|
ShowAboutInNav *bool `json:"show_about_in_nav"`
|
|
CustomNav *[]models.CustomNavLink `json:"custom_nav"`
|
|
|
|
// Gallery
|
|
GalleryURL *string `json:"gallery_url"`
|
|
GalleryLabel *string `json:"gallery_label"`
|
|
|
|
// SMTP (optional, dynamic)
|
|
SMTPHost *string `json:"smtp_host"`
|
|
SMTPPort *int `json:"smtp_port"`
|
|
SMTPUser *string `json:"smtp_user"`
|
|
SMTPPassword *string `json:"smtp_password"`
|
|
SMTPFrom *string `json:"smtp_from"`
|
|
SMTPFromName *string `json:"smtp_from_name"`
|
|
SMTPEncryption *string `json:"smtp_encryption"` // tls|ssl|none
|
|
SMTPAuth *bool `json:"smtp_auth"`
|
|
SMTPSkipVerify *bool `json:"smtp_skip_verify"`
|
|
|
|
// Social profiles
|
|
FacebookURL *string `json:"facebook_url"`
|
|
InstagramURL *string `json:"instagram_url"`
|
|
YoutubeURL *string `json:"youtube_url"`
|
|
|
|
// Videos module
|
|
VideosModuleEnabled *bool `json:"videos_module_enabled"`
|
|
VideosStyle *string `json:"videos_style"`
|
|
VideosSource *string `json:"videos_source"`
|
|
VideosLimit *int `json:"videos_limit"`
|
|
// Manual videos
|
|
Videos *[]string `json:"videos"`
|
|
VideosItems *[]struct {
|
|
URL string `json:"url"`
|
|
Title *string `json:"title"`
|
|
Length *string `json:"length"`
|
|
UploadedAt *string `json:"uploaded_at"`
|
|
ThumbnailURL *string `json:"thumbnail_url"`
|
|
} `json:"videos_items"`
|
|
// Auto videos title overrides (YouTube): video_id -> title
|
|
VideosTitleOverrides *map[string]string `json:"videos_title_overrides"`
|
|
|
|
// Merch module
|
|
MerchModuleEnabled *bool `json:"merch_module_enabled"`
|
|
MerchStyle *string `json:"merch_style"`
|
|
MerchSource *string `json:"merch_source"`
|
|
MerchLimit *int `json:"merch_limit"`
|
|
// Manual merch
|
|
MerchItems *[]struct {
|
|
Title *string `json:"title"`
|
|
ImageURL string `json:"image_url"`
|
|
URL *string `json:"url"`
|
|
} `json:"merch_items"`
|
|
|
|
// Newsletter defaults
|
|
DefaultDigestType *string `json:"default_digest_type"`
|
|
DefaultDigestCompetitions *string `json:"default_digest_competitions"`
|
|
|
|
// Newsletter scheduling
|
|
EnableWeekly *bool `json:"enable_weekly"`
|
|
EnableMatchReminders *bool `json:"enable_match_reminders"`
|
|
EnableResults *bool `json:"enable_results"`
|
|
NewsletterWeeklyDay *string `json:"newsletter_weekly_day"`
|
|
NewsletterWeeklyHour *int `json:"newsletter_weekly_hour"`
|
|
NewsletterReminderLeadHours *int `json:"newsletter_reminder_lead_hours"`
|
|
NewsletterQuietStart *int `json:"newsletter_quiet_start"`
|
|
NewsletterQuietEnd *int `json:"newsletter_quiet_end"`
|
|
|
|
// Contact/Location information
|
|
ContactAddress *string `json:"contact_address"`
|
|
ContactCity *string `json:"contact_city"`
|
|
ContactZip *string `json:"contact_zip"`
|
|
ContactCountry *string `json:"contact_country"`
|
|
ContactPhone *string `json:"contact_phone"`
|
|
ContactEmail *string `json:"contact_email"`
|
|
LocationLatitude *float64 `json:"location_latitude"`
|
|
LocationLongitude *float64 `json:"location_longitude"`
|
|
MapZoomLevel *int `json:"map_zoom_level"`
|
|
MapStyle *string `json:"map_style"`
|
|
ShowMapOnHomepage *bool `json:"show_map_on_homepage"`
|
|
|
|
// Homepage matches display configuration
|
|
FinishedMatchDisplayDays *int `json:"finished_match_display_days"`
|
|
|
|
// Deployment base URLs (optional, for domain/IP change)
|
|
FrontendBaseURL *string `json:"frontend_base_url"`
|
|
APIBaseURL *string `json:"api_base_url"`
|
|
|
|
// Storage quota and thresholds
|
|
StorageQuotaMB *int `json:"storage_quota_mb"`
|
|
StorageWarnThreshold *int `json:"storage_warn_threshold"`
|
|
StorageCriticalThreshold *int `json:"storage_critical_threshold"`
|
|
|
|
// External error-review integration
|
|
ErrorReviewIngestURL *string `json:"error_review_ingest_url"`
|
|
ErrorReviewIngestToken *string `json:"error_review_ingest_token"`
|
|
ErrorReviewAdminURL *string `json:"error_review_admin_url"`
|
|
ErrorReviewAdminToken *string `json:"error_review_admin_token"`
|
|
ErrorReviewUIURL *string `json:"error_review_ui_url"`
|
|
}
|
|
var body reqBody
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
|
|
var s models.Settings
|
|
if err := bc.DB.First(&s).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
s = models.Settings{}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Ensure navigation cache is hydrated before modifications
|
|
s.LoadCustomNav()
|
|
|
|
if body.FrontpageLayout != nil {
|
|
s.FrontpageLayout = *body.FrontpageLayout
|
|
}
|
|
if body.FrontpageStyle != nil {
|
|
s.FrontpageStyle = *body.FrontpageStyle
|
|
}
|
|
// Sponsors module
|
|
if body.SponsorsLayout != nil {
|
|
v := strings.TrimSpace(*body.SponsorsLayout)
|
|
switch v {
|
|
case "grid", "slider", "scroller", "pyramid":
|
|
s.SponsorsLayout = v
|
|
default:
|
|
// keep previous or default to grid
|
|
if s.SponsorsLayout == "" {
|
|
s.SponsorsLayout = "grid"
|
|
}
|
|
}
|
|
}
|
|
if body.SponsorsTheme != nil {
|
|
v := strings.TrimSpace(*body.SponsorsTheme)
|
|
if v == "dark" || v == "light" {
|
|
s.SponsorsTheme = v
|
|
} else {
|
|
if s.SponsorsTheme == "" {
|
|
s.SponsorsTheme = "light"
|
|
}
|
|
}
|
|
}
|
|
if body.ClubID != nil {
|
|
s.ClubID = *body.ClubID
|
|
}
|
|
if body.ClubType != nil {
|
|
s.ClubType = *body.ClubType
|
|
}
|
|
if body.ClubName != nil {
|
|
s.ClubName = *body.ClubName
|
|
}
|
|
if body.ClubLogoURL != nil {
|
|
s.ClubLogoURL = *body.ClubLogoURL
|
|
}
|
|
if body.ClubURL != nil {
|
|
s.ClubURL = *body.ClubURL
|
|
}
|
|
|
|
if body.PrimaryColor != nil {
|
|
s.PrimaryColor = *body.PrimaryColor
|
|
}
|
|
if body.SecondaryColor != nil {
|
|
s.SecondaryColor = *body.SecondaryColor
|
|
}
|
|
if body.AccentColor != nil {
|
|
s.AccentColor = *body.AccentColor
|
|
}
|
|
if body.BackgroundColor != nil {
|
|
s.BackgroundColor = *body.BackgroundColor
|
|
}
|
|
if body.TextColor != nil {
|
|
s.TextColor = *body.TextColor
|
|
}
|
|
if body.FontHeading != nil {
|
|
s.FontHeading = *body.FontHeading
|
|
}
|
|
if body.FontBody != nil {
|
|
s.FontBody = *body.FontBody
|
|
}
|
|
|
|
if body.CustomCSS != nil {
|
|
s.CustomCSS = *body.CustomCSS
|
|
}
|
|
// Newsletter defaults
|
|
if body.DefaultDigestType != nil {
|
|
s.DefaultDigestType = *body.DefaultDigestType
|
|
}
|
|
if body.DefaultDigestCompetitions != nil {
|
|
s.DefaultDigestCompetitions = *body.DefaultDigestCompetitions
|
|
}
|
|
|
|
// Newsletter scheduling
|
|
if body.EnableWeekly != nil {
|
|
s.EnableWeekly = *body.EnableWeekly
|
|
}
|
|
if body.EnableMatchReminders != nil {
|
|
s.EnableMatchReminders = *body.EnableMatchReminders
|
|
}
|
|
if body.EnableResults != nil {
|
|
s.EnableResults = *body.EnableResults
|
|
}
|
|
if body.NewsletterWeeklyDay != nil {
|
|
s.NewsletterWeeklyDay = strings.ToLower(strings.TrimSpace(*body.NewsletterWeeklyDay))
|
|
}
|
|
if body.NewsletterWeeklyHour != nil {
|
|
s.NewsletterWeeklyHour = *body.NewsletterWeeklyHour
|
|
}
|
|
if body.NewsletterReminderLeadHours != nil {
|
|
s.NewsletterReminderLeadHours = *body.NewsletterReminderLeadHours
|
|
}
|
|
if body.NewsletterQuietStart != nil {
|
|
s.NewsletterQuietStart = *body.NewsletterQuietStart
|
|
}
|
|
if body.NewsletterQuietEnd != nil {
|
|
s.NewsletterQuietEnd = *body.NewsletterQuietEnd
|
|
}
|
|
if body.CustomJS != nil {
|
|
s.CustomJS = *body.CustomJS
|
|
}
|
|
if body.CustomHTMLHome != nil {
|
|
s.CustomHTMLHome = *body.CustomHTMLHome
|
|
}
|
|
if body.CustomHTMLBlogList != nil {
|
|
s.CustomHTMLBlogList = *body.CustomHTMLBlogList
|
|
}
|
|
if body.CustomHTMLBlogPost != nil {
|
|
s.CustomHTMLBlogPost = *body.CustomHTMLBlogPost
|
|
}
|
|
|
|
if body.AboutHTML != nil {
|
|
s.AboutHTML = *body.AboutHTML
|
|
}
|
|
if body.ShowAboutInNav != nil {
|
|
s.ShowAboutInNav = *body.ShowAboutInNav
|
|
}
|
|
if body.CustomNav != nil {
|
|
if err := s.SetCustomNav(*body.CustomNav); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná navigace"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Gallery
|
|
if body.GalleryURL != nil {
|
|
s.GalleryURL = *body.GalleryURL
|
|
}
|
|
if body.GalleryLabel != nil {
|
|
s.GalleryLabel = strings.TrimSpace(*body.GalleryLabel)
|
|
}
|
|
|
|
// Social profiles
|
|
if body.FacebookURL != nil {
|
|
s.FacebookURL = *body.FacebookURL
|
|
}
|
|
if body.InstagramURL != nil {
|
|
s.InstagramURL = *body.InstagramURL
|
|
}
|
|
if body.YoutubeURL != nil {
|
|
s.YoutubeURL = *body.YoutubeURL
|
|
// Auto-enable videos module when YouTube URL is provided and videos_source is auto
|
|
if strings.TrimSpace(*body.YoutubeURL) != "" {
|
|
// Only auto-enable if not explicitly disabled by user
|
|
if body.VideosModuleEnabled == nil {
|
|
// Check if videos_source is auto (default) or explicitly set to auto
|
|
if s.VideosSource == "" || s.VideosSource == "auto" || (body.VideosSource != nil && *body.VideosSource == "auto") {
|
|
s.VideosModuleEnabled = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Videos module
|
|
if body.VideosModuleEnabled != nil {
|
|
s.VideosModuleEnabled = *body.VideosModuleEnabled
|
|
}
|
|
if body.VideosStyle != nil {
|
|
s.VideosStyle = strings.TrimSpace(*body.VideosStyle)
|
|
}
|
|
if body.VideosSource != nil {
|
|
s.VideosSource = strings.TrimSpace(*body.VideosSource)
|
|
} else if s.VideosSource == "" {
|
|
// Default to auto source
|
|
s.VideosSource = "auto"
|
|
}
|
|
if body.VideosLimit != nil {
|
|
s.VideosLimit = *body.VideosLimit
|
|
} else if s.VideosLimit == 0 {
|
|
// Default to 6 videos on homepage
|
|
s.VideosLimit = 6
|
|
}
|
|
// Manual videos
|
|
if body.Videos != nil {
|
|
if b, err := json.Marshal(body.Videos); err == nil {
|
|
s.VideosJSON = string(b)
|
|
}
|
|
}
|
|
if body.VideosItems != nil {
|
|
if b, err := json.Marshal(body.VideosItems); err == nil {
|
|
s.VideosItemsJSON = string(b)
|
|
}
|
|
}
|
|
// Auto videos title overrides (YouTube): video_id -> title
|
|
if body.VideosTitleOverrides != nil {
|
|
m := *body.VideosTitleOverrides
|
|
items := make([]models.VideoTitleOverride, 0, len(m))
|
|
for id, title := range m {
|
|
id = strings.TrimSpace(id)
|
|
t := strings.TrimSpace(title)
|
|
if id == "" || t == "" {
|
|
continue
|
|
}
|
|
items = append(items, models.VideoTitleOverride{VideoID: id, Title: t})
|
|
}
|
|
_ = s.SetVideosOverrides(items)
|
|
}
|
|
|
|
// Merch module
|
|
if body.MerchModuleEnabled != nil {
|
|
s.MerchModuleEnabled = *body.MerchModuleEnabled
|
|
}
|
|
if body.MerchStyle != nil {
|
|
s.MerchStyle = strings.TrimSpace(*body.MerchStyle)
|
|
}
|
|
if body.MerchSource != nil {
|
|
s.MerchSource = strings.TrimSpace(*body.MerchSource)
|
|
}
|
|
if body.MerchLimit != nil {
|
|
s.MerchLimit = *body.MerchLimit
|
|
}
|
|
if body.MerchItems != nil {
|
|
if b, err := json.Marshal(body.MerchItems); err == nil {
|
|
s.MerchItemsJSON = string(b)
|
|
}
|
|
}
|
|
|
|
// Storage quota and thresholds
|
|
if body.StorageQuotaMB != nil {
|
|
s.StorageQuotaMB = *body.StorageQuotaMB
|
|
}
|
|
if body.StorageWarnThreshold != nil {
|
|
s.StorageWarnThreshold = *body.StorageWarnThreshold
|
|
}
|
|
if body.StorageCriticalThreshold != nil {
|
|
s.StorageCriticalThreshold = *body.StorageCriticalThreshold
|
|
}
|
|
if s.StorageWarnThreshold <= 0 {
|
|
s.StorageWarnThreshold = 80
|
|
}
|
|
if s.StorageCriticalThreshold <= 0 {
|
|
s.StorageCriticalThreshold = 95
|
|
}
|
|
if s.StorageWarnThreshold > s.StorageCriticalThreshold {
|
|
s.StorageWarnThreshold = s.StorageCriticalThreshold - 5
|
|
if s.StorageWarnThreshold < 0 {
|
|
s.StorageWarnThreshold = 0
|
|
}
|
|
}
|
|
|
|
// External error-review integration
|
|
if body.ErrorReviewIngestURL != nil {
|
|
s.ErrorReviewIngestURL = strings.TrimSpace(*body.ErrorReviewIngestURL)
|
|
}
|
|
if body.ErrorReviewIngestToken != nil {
|
|
s.ErrorReviewIngestToken = strings.TrimSpace(*body.ErrorReviewIngestToken)
|
|
}
|
|
if body.ErrorReviewAdminURL != nil {
|
|
s.ErrorReviewAdminURL = strings.TrimSpace(*body.ErrorReviewAdminURL)
|
|
}
|
|
if body.ErrorReviewAdminToken != nil {
|
|
s.ErrorReviewAdminToken = strings.TrimSpace(*body.ErrorReviewAdminToken)
|
|
}
|
|
if body.ErrorReviewUIURL != nil {
|
|
s.ErrorReviewUIURL = strings.TrimSpace(*body.ErrorReviewUIURL)
|
|
}
|
|
|
|
// SMTP dynamic settings (if provided)
|
|
if body.SMTPHost != nil {
|
|
s.SMTPHost = strings.TrimSpace(*body.SMTPHost)
|
|
}
|
|
if body.SMTPPort != nil {
|
|
s.SMTPPort = *body.SMTPPort
|
|
}
|
|
if body.SMTPUser != nil {
|
|
s.SMTPUser = strings.TrimSpace(*body.SMTPUser)
|
|
}
|
|
// Only update password if a new one is provided (not empty)
|
|
if body.SMTPPassword != nil && *body.SMTPPassword != "" {
|
|
s.SMTPPassword = *body.SMTPPassword
|
|
}
|
|
if body.SMTPFrom != nil {
|
|
s.SMTPFrom = strings.TrimSpace(*body.SMTPFrom)
|
|
}
|
|
if body.SMTPFromName != nil {
|
|
s.SMTPFromName = strings.TrimSpace(*body.SMTPFromName)
|
|
}
|
|
if body.SMTPEncryption != nil {
|
|
s.SMTPEncryption = strings.ToLower(strings.TrimSpace(*body.SMTPEncryption))
|
|
}
|
|
if body.SMTPAuth != nil {
|
|
s.SMTPAuth = *body.SMTPAuth
|
|
}
|
|
if body.SMTPSkipVerify != nil {
|
|
s.SMTPSkipVerify = *body.SMTPSkipVerify
|
|
}
|
|
|
|
// Contact/Location information
|
|
if body.ContactAddress != nil {
|
|
s.ContactAddress = strings.TrimSpace(*body.ContactAddress)
|
|
}
|
|
if body.ContactCity != nil {
|
|
s.ContactCity = strings.TrimSpace(*body.ContactCity)
|
|
}
|
|
if body.ContactZip != nil {
|
|
s.ContactZip = strings.TrimSpace(*body.ContactZip)
|
|
}
|
|
if body.ContactCountry != nil {
|
|
s.ContactCountry = strings.TrimSpace(*body.ContactCountry)
|
|
}
|
|
if body.ContactPhone != nil {
|
|
v := strings.TrimSpace(*body.ContactPhone)
|
|
country := s.ContactCountry
|
|
if body.ContactCountry != nil {
|
|
country = strings.TrimSpace(*body.ContactCountry)
|
|
}
|
|
s.ContactPhone = normalizePhone(v, country)
|
|
}
|
|
if body.ContactEmail != nil {
|
|
s.ContactEmail = strings.TrimSpace(*body.ContactEmail)
|
|
}
|
|
if body.LocationLatitude != nil {
|
|
s.LocationLatitude = *body.LocationLatitude
|
|
}
|
|
if body.LocationLongitude != nil {
|
|
s.LocationLongitude = *body.LocationLongitude
|
|
}
|
|
if body.MapZoomLevel != nil {
|
|
s.MapZoomLevel = *body.MapZoomLevel
|
|
}
|
|
if body.MapStyle != nil {
|
|
s.MapStyle = strings.TrimSpace(*body.MapStyle)
|
|
}
|
|
if body.ShowMapOnHomepage != nil {
|
|
s.ShowMapOnHomepage = *body.ShowMapOnHomepage
|
|
}
|
|
if body.FinishedMatchDisplayDays != nil {
|
|
s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays
|
|
}
|
|
|
|
if body.FrontendBaseURL != nil {
|
|
v := strings.TrimSpace(*body.FrontendBaseURL)
|
|
if v != "" {
|
|
if u, err := url.Parse(v); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
|
|
u.Path = ""
|
|
s.FrontendBaseURL = u.String()
|
|
}
|
|
}
|
|
}
|
|
if body.APIBaseURL != nil {
|
|
v := strings.TrimSpace(*body.APIBaseURL)
|
|
if v != "" {
|
|
if u, err := url.Parse(v); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
|
|
if !strings.Contains(u.Path, "/api/") {
|
|
u.Path = strings.TrimRight(u.Path, "/") + "/api/v1"
|
|
}
|
|
s.APIBaseURL = u.String()
|
|
}
|
|
}
|
|
}
|
|
|
|
if s.ID == 0 {
|
|
if err := bc.DB.Create(&s).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
|
|
return
|
|
}
|
|
} else {
|
|
if err := bc.DB.Save(&s).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
|
|
return
|
|
}
|
|
}
|
|
services.StartErrorReviewAutoRegister(bc.DB)
|
|
if strings.TrimSpace(s.ClubID) != "" && (strings.HasPrefix(strings.ToLower(strings.TrimSpace(s.ClubLogoURL)), "http://") || strings.HasPrefix(strings.ToLower(strings.TrimSpace(s.ClubLogoURL)), "https://") || strings.TrimSpace(s.ClubLogoURL) == "") {
|
|
go func(id uint, clubID string) {
|
|
if url, err := services.CacheClubLogo(bc.DB, strings.TrimSpace(clubID)); err == nil && strings.TrimSpace(url) != "" {
|
|
_ = bc.DB.Model(&models.Settings{}).Where("id = ?", id).Update("club_logo_url", url).Error
|
|
}
|
|
}(s.ID, s.ClubID)
|
|
}
|
|
logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel)
|
|
// Best-effort: trigger prefetch so cached settings.json and dependent files update immediately
|
|
go func(urlFromSettings string) {
|
|
base := strings.TrimSpace(urlFromSettings)
|
|
if base == "" {
|
|
base = getPrefetchBaseURL()
|
|
}
|
|
services.PrefetchOnce(strings.TrimRight(base, "/"))
|
|
}(s.APIBaseURL)
|
|
// If gallery_url is a Zonerama link, refresh Zonerama cache immediately
|
|
if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
|
|
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
|
|
}
|
|
// If YouTube URL updated, trigger immediate refresh
|
|
if body.YoutubeURL != nil {
|
|
v := strings.TrimSpace(*body.YoutubeURL)
|
|
if v != "" {
|
|
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(v)
|
|
}
|
|
}
|
|
if strings.TrimSpace(s.FrontendBaseURL) != "" && config.AppConfig != nil {
|
|
if u, err := url.Parse(s.FrontendBaseURL); err == nil {
|
|
origin := u.Scheme + "://" + u.Host
|
|
found := false
|
|
for _, ao := range config.AppConfig.AllowedOrigins {
|
|
if ao == origin {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
config.AppConfig.AllowedOrigins = append(config.AppConfig.AllowedOrigins, origin)
|
|
}
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, s)
|
|
}
|
|
|
|
// NewBaseController creates a new instance of BaseController
|
|
func (bc *BaseController) GetPublicSettings(c *gin.Context) {
|
|
var s models.Settings
|
|
if err := bc.DB.First(&s).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
// mirror defaults from GetSettings
|
|
s = models.Settings{
|
|
FrontpageLayout: "classic",
|
|
FrontpageStyle: "light",
|
|
PrimaryColor: "#1a365d",
|
|
SecondaryColor: "#2b6cb0",
|
|
AccentColor: "#e53e3e",
|
|
BackgroundColor: "#ffffff",
|
|
TextColor: "#1a202c",
|
|
FontHeading: "Poppins, sans-serif",
|
|
FontBody: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
|
|
VideosModuleEnabled: false,
|
|
VideosSource: "auto",
|
|
VideosStyle: "slider",
|
|
VideosLimit: 6,
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Apply defaults for videos settings if not set
|
|
if s.VideosSource == "" {
|
|
s.VideosSource = "auto"
|
|
}
|
|
if s.VideosLimit == 0 {
|
|
s.VideosLimit = 6
|
|
}
|
|
if s.VideosStyle == "" {
|
|
s.VideosStyle = "slider"
|
|
}
|
|
// Auto-enable videos module if YouTube URL is provided and source is auto
|
|
if s.YoutubeURL != "" && s.VideosSource == "auto" && !s.VideosModuleEnabled {
|
|
s.VideosModuleEnabled = true
|
|
}
|
|
// Load video title overrides (list form stored as JSON)
|
|
s.LoadVideosOverrides()
|
|
|
|
// Build a whitelist response
|
|
// Conditional GET based on settings timestamp
|
|
last := s.UpdatedAt
|
|
if last.IsZero() {
|
|
last = time.Now().Add(-1 * time.Hour)
|
|
}
|
|
if ims := c.GetHeader("If-Modified-Since"); ims != "" {
|
|
if t, err := time.Parse(http.TimeFormat, ims); err == nil {
|
|
if !last.After(t) {
|
|
c.Status(http.StatusNotModified)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
// Decode manual videos for public payload
|
|
var pubVids []string
|
|
if s.VideosJSON != "" {
|
|
_ = json.Unmarshal([]byte(s.VideosJSON), &pubVids)
|
|
}
|
|
var pubVidsItems any
|
|
if s.VideosItemsJSON != "" {
|
|
_ = json.Unmarshal([]byte(s.VideosItemsJSON), &pubVidsItems)
|
|
}
|
|
var pubMerchItems any
|
|
if s.MerchItemsJSON != "" {
|
|
_ = json.Unmarshal([]byte(s.MerchItemsJSON), &pubMerchItems)
|
|
}
|
|
// Build video title overrides map (video_id -> title) for API consumers
|
|
toMap := map[string]string{}
|
|
if len(s.VideosOverrides) > 0 {
|
|
for _, it := range s.VideosOverrides {
|
|
vid := strings.TrimSpace(it.VideoID)
|
|
t := strings.TrimSpace(it.Title)
|
|
if vid != "" && t != "" {
|
|
toMap[vid] = t
|
|
}
|
|
}
|
|
}
|
|
|
|
resp := gin.H{
|
|
// Core site identity (needed for prefetch to derive FACR endpoints)
|
|
"club_id": s.ClubID,
|
|
"club_type": s.ClubType,
|
|
"club_name": s.ClubName,
|
|
"club_logo_url": s.ClubLogoURL,
|
|
"club_url": s.ClubURL,
|
|
// Runtime flags (env-based)
|
|
"premium": config.AppConfig.Premium,
|
|
|
|
// Theme
|
|
"primary_color": s.PrimaryColor,
|
|
"secondary_color": s.SecondaryColor,
|
|
"accent_color": s.AccentColor,
|
|
"background_color": s.BackgroundColor,
|
|
"text_color": s.TextColor,
|
|
"font_heading": s.FontHeading,
|
|
"font_body": s.FontBody,
|
|
|
|
// Sponsors module prefs
|
|
"sponsors_layout": s.SponsorsLayout,
|
|
"sponsors_theme": s.SponsorsTheme,
|
|
// Social / media
|
|
"facebook_url": s.FacebookURL,
|
|
"instagram_url": s.InstagramURL,
|
|
"youtube_url": s.YoutubeURL,
|
|
"gallery_url": s.GalleryURL,
|
|
"gallery_label": s.GalleryLabel,
|
|
// Videos module
|
|
"videos_module_enabled": s.VideosModuleEnabled,
|
|
"videos_style": s.VideosStyle,
|
|
"videos_source": s.VideosSource,
|
|
"videos_limit": s.VideosLimit,
|
|
// Manual videos (public so homepage can render)
|
|
"videos": pubVids,
|
|
"videos_items": pubVidsItems,
|
|
"videos_title_overrides": toMap,
|
|
// Merch config + items
|
|
"merch_module_enabled": s.MerchModuleEnabled,
|
|
"merch_style": s.MerchStyle,
|
|
"merch_source": s.MerchSource,
|
|
"merch_limit": s.MerchLimit,
|
|
"merch_items": pubMerchItems,
|
|
// Custom club page & navigation
|
|
"about_html": s.AboutHTML,
|
|
"show_about_in_nav": s.ShowAboutInNav,
|
|
"custom_nav": s.CustomNav,
|
|
|
|
// Contact/Map information (public for displaying on contact page and homepage)
|
|
"contact_address": s.ContactAddress,
|
|
"contact_city": s.ContactCity,
|
|
"contact_zip": s.ContactZip,
|
|
"contact_country": s.ContactCountry,
|
|
"contact_phone": s.ContactPhone,
|
|
"contact_email": s.ContactEmail,
|
|
"location_latitude": s.LocationLatitude,
|
|
"location_longitude": s.LocationLongitude,
|
|
"map_zoom_level": s.MapZoomLevel,
|
|
"map_style": s.MapStyle,
|
|
"show_map_on_homepage": s.ShowMapOnHomepage,
|
|
// Deployment base URLs (hints for frontend tooling)
|
|
"frontend_base_url": s.FrontendBaseURL,
|
|
"api_base_url": s.APIBaseURL,
|
|
}
|
|
logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel)
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
func (bc *BaseController) GetSettings(c *gin.Context) {
|
|
_ = bc.DB.AutoMigrate(&models.Settings{})
|
|
var s models.Settings
|
|
if err := bc.DB.First(&s).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
s = models.Settings{
|
|
FrontpageLayout: "classic",
|
|
FrontpageStyle: "light",
|
|
PrimaryColor: "#1a365d",
|
|
SecondaryColor: "#2b6cb0",
|
|
AccentColor: "#e53e3e",
|
|
BackgroundColor: "#ffffff",
|
|
TextColor: "#1a202c",
|
|
FontHeading: "Poppins, sans-serif",
|
|
FontBody: "Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
|
|
VideosModuleEnabled: false,
|
|
VideosSource: "auto",
|
|
VideosStyle: "slider",
|
|
VideosLimit: 6,
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
}
|
|
// Auto-fill default external error-review URLs if missing so admin doesn't need to enter them
|
|
if strings.TrimSpace(s.ErrorReviewUIURL) == "" {
|
|
s.ErrorReviewUIURL = "https://error.tdvorak.dev"
|
|
}
|
|
if strings.TrimSpace(s.ErrorReviewAdminURL) == "" {
|
|
s.ErrorReviewAdminURL = "https://error.tdvorak.dev/api/v1/admin"
|
|
}
|
|
if strings.TrimSpace(s.ErrorReviewIngestURL) == "" {
|
|
s.ErrorReviewIngestURL = "https://errors.tdvorak.dev/api/v1/errors"
|
|
}
|
|
s.LoadCustomNav()
|
|
s.LoadVideosOverrides()
|
|
// derive map form for admin consumers
|
|
mv := map[string]string{}
|
|
if len(s.VideosOverrides) > 0 {
|
|
for _, it := range s.VideosOverrides {
|
|
vid := strings.TrimSpace(it.VideoID)
|
|
t := strings.TrimSpace(it.Title)
|
|
if vid != "" && t != "" {
|
|
mv[vid] = t
|
|
}
|
|
}
|
|
}
|
|
s.VideosTitleOverrides = mv
|
|
c.JSON(http.StatusOK, s)
|
|
}
|
|
|
|
func (bc *BaseController) GetTeams(c *gin.Context) {
|
|
var items []models.Team
|
|
q := bc.DB.Model(&models.Team{})
|
|
activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false"
|
|
if activeOnly {
|
|
q = q.Where("is_active = ?", true)
|
|
}
|
|
if err := q.Order("name ASC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
func (bc *BaseController) GetTeam(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Team
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Tým nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (bc *BaseController) CreateTeam(c *gin.Context) {
|
|
var body struct {
|
|
Name string `json:"name" binding:"required"`
|
|
ShortName string `json:"short_name"`
|
|
Description string `json:"description"`
|
|
LogoURL string `json:"logo_url"`
|
|
IsActive *bool `json:"is_active"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název týmu je povinný"})
|
|
return
|
|
}
|
|
item := models.Team{
|
|
Name: name,
|
|
ShortName: strings.TrimSpace(body.ShortName),
|
|
Description: strings.TrimSpace(body.Description),
|
|
LogoURL: strings.TrimSpace(body.LogoURL),
|
|
IsActive: true,
|
|
}
|
|
if body.IsActive != nil {
|
|
item.IsActive = *body.IsActive
|
|
}
|
|
if err := bc.DB.Create(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit tým"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
func (bc *BaseController) UpdateTeam(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Team
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Tým nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Name *string `json:"name"`
|
|
ShortName *string `json:"short_name"`
|
|
Description *string `json:"description"`
|
|
LogoURL *string `json:"logo_url"`
|
|
IsActive *bool `json:"is_active"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
if body.Name != nil {
|
|
v := strings.TrimSpace(*body.Name)
|
|
if v == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název týmu nemůže být prázdný"})
|
|
return
|
|
}
|
|
item.Name = v
|
|
}
|
|
if body.ShortName != nil {
|
|
item.ShortName = strings.TrimSpace(*body.ShortName)
|
|
}
|
|
if body.Description != nil {
|
|
item.Description = strings.TrimSpace(*body.Description)
|
|
}
|
|
if body.LogoURL != nil {
|
|
item.LogoURL = strings.TrimSpace(*body.LogoURL)
|
|
}
|
|
if body.IsActive != nil {
|
|
item.IsActive = *body.IsActive
|
|
}
|
|
if err := bc.DB.Save(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat tým"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (bc *BaseController) DeleteTeam(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Team
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Tým nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
var cnt int64
|
|
if err := bc.DB.Model(&models.Player{}).Where("team_id = ?", item.ID).Count(&cnt).Error; err == nil && cnt > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"chyba": "Nelze smazat tým s přiřazenými hráči", "detail": cnt})
|
|
return
|
|
}
|
|
if err := bc.DB.Delete(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat tým"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"zprava": "Tým byl smazán"})
|
|
}
|
|
|
|
func (bc *BaseController) GetPlayers(c *gin.Context) {
|
|
var items []models.Player
|
|
q := bc.DB.Model(&models.Player{})
|
|
if v := strings.TrimSpace(c.Query("team_id")); v != "" {
|
|
q = q.Where("team_id = ?", v)
|
|
}
|
|
activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false"
|
|
if activeOnly {
|
|
q = q.Where("is_active = ?", true)
|
|
}
|
|
if err := q.Order("last_name ASC, first_name ASC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
func (bc *BaseController) GetPlayer(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Player
|
|
if err := bc.DB.Preload("Team").First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Hráč nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (bc *BaseController) CreatePlayer(c *gin.Context) {
|
|
var body struct {
|
|
FirstName string `json:"first_name" binding:"required"`
|
|
LastName string `json:"last_name" binding:"required"`
|
|
DateOfBirth string `json:"date_of_birth"`
|
|
Position string `json:"position"`
|
|
JerseyNumber *int `json:"jersey_number"`
|
|
TeamID *uint `json:"team_id"`
|
|
Nationality string `json:"nationality"`
|
|
Height *int `json:"height"`
|
|
Weight *int `json:"weight"`
|
|
ImageURL string `json:"image_url"`
|
|
Gender string `json:"gender"`
|
|
IsActive *bool `json:"is_active"`
|
|
Email string `json:"email"`
|
|
Phone string `json:"phone"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
item := models.Player{
|
|
FirstName: strings.TrimSpace(body.FirstName),
|
|
LastName: strings.TrimSpace(body.LastName),
|
|
Position: strings.TrimSpace(body.Position),
|
|
Nationality: strings.TrimSpace(body.Nationality),
|
|
ImageURL: strings.TrimSpace(body.ImageURL),
|
|
Gender: strings.TrimSpace(body.Gender),
|
|
IsActive: true,
|
|
Email: strings.TrimSpace(body.Email),
|
|
}
|
|
if body.JerseyNumber != nil {
|
|
item.JerseyNumber = *body.JerseyNumber
|
|
}
|
|
if body.TeamID != nil {
|
|
item.TeamID = *body.TeamID
|
|
}
|
|
if body.Height != nil {
|
|
item.Height = *body.Height
|
|
}
|
|
if body.Weight != nil {
|
|
item.Weight = *body.Weight
|
|
}
|
|
if body.IsActive != nil {
|
|
item.IsActive = *body.IsActive
|
|
}
|
|
if v := strings.TrimSpace(body.Phone); v != "" {
|
|
item.Phone = normalizePhone(v, "")
|
|
}
|
|
if dob := strings.TrimSpace(body.DateOfBirth); dob != "" {
|
|
if t, err := time.Parse("2006-01-02", dob); err == nil {
|
|
item.DateOfBirth = t
|
|
} else if t2, err2 := time.Parse(time.RFC3339, dob); err2 == nil {
|
|
item.DateOfBirth = t2
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatné datum narození"})
|
|
return
|
|
}
|
|
}
|
|
if err := bc.DB.Create(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit hráče"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
func (bc *BaseController) UpdatePlayer(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Player
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Hráč nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
var body struct {
|
|
FirstName *string `json:"first_name"`
|
|
LastName *string `json:"last_name"`
|
|
DateOfBirth *string `json:"date_of_birth"`
|
|
Position *string `json:"position"`
|
|
JerseyNumber *int `json:"jersey_number"`
|
|
TeamID *uint `json:"team_id"`
|
|
Nationality *string `json:"nationality"`
|
|
Height *int `json:"height"`
|
|
Weight *int `json:"weight"`
|
|
ImageURL *string `json:"image_url"`
|
|
Gender *string `json:"gender"`
|
|
IsActive *bool `json:"is_active"`
|
|
Email *string `json:"email"`
|
|
Phone *string `json:"phone"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
if body.FirstName != nil {
|
|
item.FirstName = strings.TrimSpace(*body.FirstName)
|
|
}
|
|
if body.LastName != nil {
|
|
item.LastName = strings.TrimSpace(*body.LastName)
|
|
}
|
|
if body.Position != nil {
|
|
item.Position = strings.TrimSpace(*body.Position)
|
|
}
|
|
if body.JerseyNumber != nil {
|
|
item.JerseyNumber = *body.JerseyNumber
|
|
}
|
|
if body.TeamID != nil {
|
|
item.TeamID = *body.TeamID
|
|
}
|
|
if body.Nationality != nil {
|
|
item.Nationality = strings.TrimSpace(*body.Nationality)
|
|
}
|
|
if body.Height != nil {
|
|
item.Height = *body.Height
|
|
}
|
|
if body.Weight != nil {
|
|
item.Weight = *body.Weight
|
|
}
|
|
if body.ImageURL != nil {
|
|
item.ImageURL = strings.TrimSpace(*body.ImageURL)
|
|
}
|
|
if body.IsActive != nil {
|
|
item.IsActive = *body.IsActive
|
|
}
|
|
if body.Email != nil {
|
|
item.Email = strings.TrimSpace(*body.Email)
|
|
}
|
|
if body.Phone != nil {
|
|
item.Phone = normalizePhone(strings.TrimSpace(*body.Phone), "")
|
|
}
|
|
if body.DateOfBirth != nil {
|
|
v := strings.TrimSpace(*body.DateOfBirth)
|
|
if v == "" {
|
|
// leave as-is
|
|
} else if t, err := time.Parse("2006-01-02", v); err == nil {
|
|
item.DateOfBirth = t
|
|
} else if t2, err2 := time.Parse(time.RFC3339, v); err2 == nil {
|
|
item.DateOfBirth = t2
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatné datum narození"})
|
|
return
|
|
}
|
|
}
|
|
if err := bc.DB.Save(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat hráče"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (bc *BaseController) DeletePlayer(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Player
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Hráč nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
if err := bc.DB.Delete(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat hráče"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"zprava": "Hráč byl smazán"})
|
|
}
|
|
|
|
func (bc *BaseController) GetSponsors(c *gin.Context) {
|
|
var items []models.Sponsor
|
|
q := bc.DB.Model(&models.Sponsor{})
|
|
activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false"
|
|
if activeOnly {
|
|
q = q.Where("is_active = ?", true)
|
|
}
|
|
if err := q.Order("display_order ASC, name ASC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
func (bc *BaseController) CreateSponsor(c *gin.Context) {
|
|
var body struct {
|
|
Name string `json:"name" binding:"required"`
|
|
LogoURL string `json:"logo_url"`
|
|
WebsiteURL string `json:"website_url"`
|
|
Description string `json:"description"`
|
|
IsActive *bool `json:"is_active"`
|
|
Tier string `json:"tier"`
|
|
DisplayOrder *int `json:"display_order"`
|
|
Placement string `json:"placement"`
|
|
Width *int `json:"width"`
|
|
Height *int `json:"height"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název sponzora je povinný"})
|
|
return
|
|
}
|
|
item := models.Sponsor{
|
|
Name: name,
|
|
LogoURL: strings.TrimSpace(body.LogoURL),
|
|
WebsiteURL: strings.TrimSpace(body.WebsiteURL),
|
|
Description: strings.TrimSpace(body.Description),
|
|
IsActive: true,
|
|
Tier: strings.TrimSpace(body.Tier),
|
|
Placement: strings.TrimSpace(body.Placement),
|
|
}
|
|
if body.DisplayOrder != nil {
|
|
item.DisplayOrder = *body.DisplayOrder
|
|
}
|
|
if body.Width != nil {
|
|
item.Width = *body.Width
|
|
}
|
|
if body.Height != nil {
|
|
item.Height = *body.Height
|
|
}
|
|
if body.IsActive != nil {
|
|
item.IsActive = *body.IsActive
|
|
}
|
|
if err := bc.DB.Create(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit sponzora"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
func (bc *BaseController) UpdateSponsor(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Sponsor
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Sponzor nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Name *string `json:"name"`
|
|
LogoURL *string `json:"logo_url"`
|
|
WebsiteURL *string `json:"website_url"`
|
|
Description *string `json:"description"`
|
|
IsActive *bool `json:"is_active"`
|
|
Tier *string `json:"tier"`
|
|
DisplayOrder *int `json:"display_order"`
|
|
Placement *string `json:"placement"`
|
|
Width *int `json:"width"`
|
|
Height *int `json:"height"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
if body.Name != nil {
|
|
v := strings.TrimSpace(*body.Name)
|
|
if v == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název sponzora nemůže být prázdný"})
|
|
return
|
|
}
|
|
item.Name = v
|
|
}
|
|
if body.LogoURL != nil {
|
|
item.LogoURL = strings.TrimSpace(*body.LogoURL)
|
|
}
|
|
if body.WebsiteURL != nil {
|
|
item.WebsiteURL = strings.TrimSpace(*body.WebsiteURL)
|
|
}
|
|
if body.Description != nil {
|
|
item.Description = strings.TrimSpace(*body.Description)
|
|
}
|
|
if body.IsActive != nil {
|
|
item.IsActive = *body.IsActive
|
|
}
|
|
if body.Tier != nil {
|
|
item.Tier = strings.TrimSpace(*body.Tier)
|
|
}
|
|
if body.DisplayOrder != nil {
|
|
item.DisplayOrder = *body.DisplayOrder
|
|
}
|
|
if body.Placement != nil {
|
|
item.Placement = strings.TrimSpace(*body.Placement)
|
|
}
|
|
if body.Width != nil {
|
|
item.Width = *body.Width
|
|
}
|
|
if body.Height != nil {
|
|
item.Height = *body.Height
|
|
}
|
|
if err := bc.DB.Save(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat sponzora"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (bc *BaseController) DeleteSponsor(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Sponsor
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Sponzor nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
if err := bc.DB.Delete(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat sponzora"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"zprava": "Sponzor byl smazán"})
|
|
}
|
|
|
|
// Banners (separate from sponsors)
|
|
func (bc *BaseController) GetBanners(c *gin.Context) {
|
|
var items []models.Banner
|
|
q := bc.DB.Model(&models.Banner{})
|
|
activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false"
|
|
if activeOnly {
|
|
q = q.Where("is_active = ?", true)
|
|
}
|
|
if p := strings.TrimSpace(c.Query("placement")); p != "" {
|
|
q = q.Where("placement = ?", p)
|
|
}
|
|
if err := q.Order("display_order ASC, created_at ASC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
func (bc *BaseController) CreateBanner(c *gin.Context) {
|
|
var body struct {
|
|
Name string `json:"name" binding:"required"`
|
|
ImageURL string `json:"image_url"`
|
|
ClickURL string `json:"click_url"`
|
|
Placement string `json:"placement"`
|
|
Width *int `json:"width"`
|
|
Height *int `json:"height"`
|
|
IsActive *bool `json:"is_active"`
|
|
DisplayOrder *int `json:"display_order"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru je povinný"})
|
|
return
|
|
}
|
|
item := models.Banner{
|
|
Name: name,
|
|
ImageURL: strings.TrimSpace(body.ImageURL),
|
|
ClickURL: strings.TrimSpace(body.ClickURL),
|
|
Placement: strings.TrimSpace(body.Placement),
|
|
IsActive: true,
|
|
}
|
|
if body.Width != nil {
|
|
item.Width = *body.Width
|
|
}
|
|
if body.Height != nil {
|
|
item.Height = *body.Height
|
|
}
|
|
if body.DisplayOrder != nil {
|
|
item.DisplayOrder = *body.DisplayOrder
|
|
}
|
|
if body.IsActive != nil {
|
|
item.IsActive = *body.IsActive
|
|
}
|
|
if err := bc.DB.Create(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit banner"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
func (bc *BaseController) UpdateBanner(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Banner
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Name *string `json:"name"`
|
|
ImageURL *string `json:"image_url"`
|
|
ClickURL *string `json:"click_url"`
|
|
Placement *string `json:"placement"`
|
|
Width *int `json:"width"`
|
|
Height *int `json:"height"`
|
|
IsActive *bool `json:"is_active"`
|
|
DisplayOrder *int `json:"display_order"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
if body.Name != nil {
|
|
v := strings.TrimSpace(*body.Name)
|
|
if v == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru nemůže být prázdný"})
|
|
return
|
|
}
|
|
item.Name = v
|
|
}
|
|
if body.ImageURL != nil {
|
|
item.ImageURL = strings.TrimSpace(*body.ImageURL)
|
|
}
|
|
if body.ClickURL != nil {
|
|
item.ClickURL = strings.TrimSpace(*body.ClickURL)
|
|
}
|
|
if body.Placement != nil {
|
|
item.Placement = strings.TrimSpace(*body.Placement)
|
|
}
|
|
if body.Width != nil {
|
|
item.Width = *body.Width
|
|
}
|
|
if body.Height != nil {
|
|
item.Height = *body.Height
|
|
}
|
|
if body.IsActive != nil {
|
|
item.IsActive = *body.IsActive
|
|
}
|
|
if body.DisplayOrder != nil {
|
|
item.DisplayOrder = *body.DisplayOrder
|
|
}
|
|
if err := bc.DB.Save(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat banner"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (bc *BaseController) DeleteBanner(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Banner
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
if err := bc.DB.Delete(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat banner"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"zprava": "Banner byl smazán"})
|
|
}
|
|
|
|
func (bc *BaseController) GetCategories(c *gin.Context) {
|
|
var items []models.Category
|
|
if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, items)
|
|
}
|
|
|
|
func (bc *BaseController) CreateCategory(c *gin.Context) {
|
|
var body struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Description string `json:"description"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(body.Name)
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie je povinný"})
|
|
return
|
|
}
|
|
var cnt int64
|
|
_ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ?", strings.ToLower(name)).Count(&cnt).Error
|
|
if cnt > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
|
|
return
|
|
}
|
|
slug := strings.TrimSpace(body.Slug)
|
|
if slug == "" {
|
|
slug = makeSlug(name)
|
|
} else {
|
|
slug = makeSlug(slug)
|
|
}
|
|
orig := slug
|
|
for i := 0; i < 50; i++ {
|
|
var sc int64
|
|
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&sc).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
|
|
return
|
|
}
|
|
if sc == 0 {
|
|
break
|
|
}
|
|
slug = fmt.Sprintf("%s-%d", orig, i+1)
|
|
}
|
|
item := models.Category{
|
|
Name: name,
|
|
Description: strings.TrimSpace(body.Description),
|
|
Slug: slug,
|
|
}
|
|
if err := bc.DB.Create(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusCreated, item)
|
|
}
|
|
|
|
func (bc *BaseController) UpdateCategory(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Category
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
var body struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Slug *string `json:"slug"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
|
|
return
|
|
}
|
|
if body.Name != nil {
|
|
v := strings.TrimSpace(*body.Name)
|
|
if v == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie nemůže být prázdný"})
|
|
return
|
|
}
|
|
var cnt int64
|
|
_ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ? AND id != ?", strings.ToLower(v), item.ID).Count(&cnt).Error
|
|
if cnt > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
|
|
return
|
|
}
|
|
item.Name = v
|
|
}
|
|
if body.Description != nil {
|
|
item.Description = strings.TrimSpace(*body.Description)
|
|
}
|
|
if body.Slug != nil {
|
|
s := strings.TrimSpace(*body.Slug)
|
|
if s == "" {
|
|
s = makeSlug(item.Name)
|
|
} else {
|
|
s = makeSlug(s)
|
|
}
|
|
orig := s
|
|
for i := 0; i < 50; i++ {
|
|
var sc int64
|
|
if err := bc.DB.Model(&models.Category{}).Where("slug = ? AND id != ?", s, item.ID).Count(&sc).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
|
|
return
|
|
}
|
|
if sc == 0 {
|
|
break
|
|
}
|
|
s = fmt.Sprintf("%s-%d", orig, i+1)
|
|
}
|
|
item.Slug = s
|
|
}
|
|
if err := bc.DB.Save(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, item)
|
|
}
|
|
|
|
func (bc *BaseController) DeleteCategory(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var item models.Category
|
|
if err := bc.DB.First(&item, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
|
|
return
|
|
}
|
|
var cnt int64
|
|
if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", item.ID).Count(&cnt).Error; err == nil && cnt > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"chyba": "Nelze smazat kategorii s přiřazenými články", "detail": cnt})
|
|
return
|
|
}
|
|
if err := bc.DB.Delete(&item).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
|
|
return
|
|
}
|
|
// Successful deletion
|
|
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
|
|
}
|
|
|
|
// UploadImage handles generic file uploads (images, documents, archives)
|
|
func (bc *BaseController) UploadImage(c *gin.Context) {
|
|
f, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
|
return
|
|
}
|
|
// Enforce maximum upload size (bytes)
|
|
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
|
|
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(f.Filename)
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
// Allow images, PDFs, Office docs, text, archives, and common media
|
|
allowed := map[string]bool{
|
|
// Images
|
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true,
|
|
// Documents
|
|
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".txt": true, ".csv": true,
|
|
// Archives
|
|
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true,
|
|
// Media
|
|
".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true,
|
|
}
|
|
if !allowed[ext] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
|
|
return
|
|
}
|
|
// Light content sniffing to ensure payload matches extension and sanitize SVGs
|
|
if src, err := f.Open(); err == nil {
|
|
defer src.Close()
|
|
buf := make([]byte, 2048)
|
|
n, _ := io.ReadFull(src, buf)
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
dl := strings.ToLower(http.DetectContentType(buf[:n]))
|
|
|
|
validCT := false
|
|
switch ext {
|
|
case ".pdf":
|
|
validCT = strings.Contains(dl, "pdf") || dl == "application/octet-stream"
|
|
case ".svg":
|
|
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
|
|
lower := strings.ToLower(string(buf[:n]))
|
|
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
|
|
return
|
|
}
|
|
case ".docx", ".xlsx", ".pptx":
|
|
validCT = strings.Contains(dl, "officedocument") || strings.Contains(dl, "application/zip") || dl == "application/octet-stream"
|
|
case ".doc", ".xls", ".ppt":
|
|
validCT = strings.Contains(dl, "msword") || strings.Contains(dl, "vnd.ms-") || dl == "application/octet-stream"
|
|
case ".zip":
|
|
validCT = strings.Contains(dl, "zip") || dl == "application/octet-stream"
|
|
case ".rar":
|
|
validCT = strings.Contains(dl, "rar") || dl == "application/octet-stream"
|
|
case ".7z":
|
|
validCT = strings.Contains(dl, "7z") || dl == "application/octet-stream"
|
|
case ".tar":
|
|
validCT = strings.Contains(dl, "tar") || dl == "application/octet-stream"
|
|
case ".gz":
|
|
validCT = strings.Contains(dl, "gzip") || dl == "application/octet-stream"
|
|
case ".txt", ".csv":
|
|
validCT = strings.HasPrefix(dl, "text/") || dl == "application/octet-stream"
|
|
case ".mp4", ".avi", ".mov":
|
|
validCT = strings.HasPrefix(dl, "video/") || dl == "application/octet-stream"
|
|
case ".mp3", ".wav":
|
|
validCT = strings.HasPrefix(dl, "audio/") || dl == "application/octet-stream"
|
|
default:
|
|
validCT = strings.HasPrefix(dl, "image/")
|
|
}
|
|
if !validCT {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"})
|
|
return
|
|
}
|
|
}
|
|
|
|
dir := config.AppConfig.UploadDir
|
|
if strings.TrimSpace(dir) == "" {
|
|
dir = "./uploads"
|
|
}
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
b := make([]byte, 8)
|
|
_, _ = rand.Read(b)
|
|
randHex := hex.EncodeToString(b)
|
|
outName := fmt.Sprintf("upload_%d_%s%s", time.Now().Unix(), randHex, ext)
|
|
outPath := filepath.Join(dir, outName)
|
|
if err := c.SaveUploadedFile(f, outPath); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
|
|
return
|
|
}
|
|
urlPath := "/uploads/" + outName
|
|
ft := services.NewFileTracker(bc.DB)
|
|
mimeType := f.Header.Get("Content-Type")
|
|
_ = ft.TrackFileUpload(outPath, urlPath, outName, mimeType, f.Size, nil)
|
|
|
|
// Build absolute URL from request (supports proxies)
|
|
scheme := "http"
|
|
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
|
|
scheme = "https"
|
|
}
|
|
host := c.Request.Host
|
|
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
|
parts := strings.Split(xf, ",")
|
|
if len(parts) > 0 {
|
|
h := strings.TrimSpace(parts[0])
|
|
if h != "" {
|
|
host = h
|
|
}
|
|
}
|
|
}
|
|
if !strings.Contains(host, ":") {
|
|
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
|
|
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
|
|
host = host + ":" + xfp
|
|
}
|
|
}
|
|
}
|
|
absolute := scheme + "://" + host + urlPath
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"url": urlPath,
|
|
"absolute_url": absolute,
|
|
"name": outName,
|
|
"type": mimeType,
|
|
"size": f.Size,
|
|
})
|
|
}
|
|
|
|
// Global newsletter automation instance (set from main)
|
|
var globalNewsletterAutomation *services.NewsletterAutomation
|
|
|
|
// SetNewsletterAutomation sets the global newsletter automation instance
|
|
func SetNewsletterAutomation(na *services.NewsletterAutomation) {
|
|
globalNewsletterAutomation = na
|
|
}
|
|
|
|
// triggerBlogNotification sends newsletter notification when blog is published
|
|
func (bc *BaseController) triggerBlogNotification(article *models.Article) {
|
|
if globalNewsletterAutomation == nil {
|
|
logger.Warn("Newsletter automation not initialized, skipping blog notification")
|
|
return
|
|
}
|
|
|
|
if err := globalNewsletterAutomation.SendBlogNotification(article); err != nil {
|
|
logger.Error("Failed to send blog notification: %v", err)
|
|
}
|
|
}
|