This commit is contained in:
Tomas Dvorak
2025-11-21 08:44:44 +01:00
parent c941313fd5
commit f5b6f83974
108 changed files with 8642 additions and 5871 deletions
+26 -20
View File
@@ -14,10 +14,10 @@ import (
// Config holds all configuration for the application
type Config struct {
// App settings
AppEnv string
Port string
Debug bool
Premium bool
AppEnv string
Port string
Debug bool
Premium bool
// Database settings
DatabaseURL string
@@ -68,15 +68,15 @@ type Config struct {
AllowedOrigins []string
// External services
ScraperBaseURL string
FrontendBaseURL string
ScraperBaseURL string
FrontendBaseURL string
PublicAPIBaseURL string
// Umami Analytics
UmamiURL string
UmamiUsername string
UmamiPassword string
UmamiWebsiteID string
UmamiURL string
UmamiUsername string
UmamiPassword string
UmamiWebsiteID string
ErrorIngestURL string
ErrorIngestToken string
@@ -85,6 +85,9 @@ type Config struct {
ClamAVEnabled bool
ClamAVHost string
ClamAVPort int
// Feature flags
RembgEnabled bool
}
var AppConfig *Config
@@ -96,10 +99,10 @@ func LoadConfig() {
AppConfig = &Config{
// App settings
AppEnv: getEnv("APP_ENV", "development"),
Port: getEnv("PORT", "8080"),
Debug: getEnvAsBool("DEBUG", true),
Premium: getEnvAsBool("PREMIUM", false),
AppEnv: getEnv("APP_ENV", "development"),
Port: getEnv("PORT", "8080"),
Debug: getEnvAsBool("DEBUG", true),
Premium: getEnvAsBool("PREMIUM", false),
// Database settings
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/fotbal_club?sslmode=disable"),
@@ -131,11 +134,11 @@ func LoadConfig() {
"image/svg+xml",
// Documents
"application/pdf",
"application/msword", // .doc
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.ms-excel", // .xls
"application/msword", // .doc
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/vnd.ms-excel", // .xls
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
"application/vnd.ms-powerpoint", // .ppt
"application/vnd.ms-powerpoint", // .ppt
"application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
// Text
"text/plain",
@@ -178,7 +181,7 @@ func LoadConfig() {
ScraperBaseURL: getEnv("FACR_SCRAPER_BASE_URL", "http://localhost:8081"),
FrontendBaseURL: getEnv("FRONTEND_BASE_URL", "http://localhost:3000"),
PublicAPIBaseURL: getEnv("PUBLIC_API_BASE_URL", "http://localhost:8080/api/v1"),
// Umami Analytics
UmamiURL: getEnv("UMAMI_URL", ""),
UmamiUsername: getEnv("UMAMI_USERNAME", ""),
@@ -192,6 +195,9 @@ func LoadConfig() {
ClamAVEnabled: getEnvAsBool("CLAMAV_ENABLED", false),
ClamAVHost: getEnv("CLAMAV_HOST", "127.0.0.1"),
ClamAVPort: getEnvAsInt("CLAMAV_PORT", 3310),
// Feature flags
RembgEnabled: getEnvAsBool("REMBG_ENABLED", true),
}
// Override allowed origins if specified in environment (comma-separated)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+120 -1
View File
@@ -14,6 +14,7 @@ import (
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/PuerkitoBio/goquery"
"github.com/gin-gonic/gin"
@@ -225,6 +226,10 @@ func getLogoBySearch(name string) string {
best = payload.Results[0].LogoURL
}
if best != "" {
// Attempt to process FACR logos to transparent PNG via rembg (best-effort)
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
best = p
}
logoCache[key] = best
return best
}
@@ -280,6 +285,9 @@ func getLogoBySearch(name string) string {
best = partial
}
if best != "" {
if p, err := services.ProcessFACRLogo(best); err == nil && strings.TrimSpace(p) != "" {
best = p
}
logoCache[key] = best
}
return best
@@ -292,11 +300,18 @@ func getLogo(teamName, teamID string) string {
return placeholder
}
if logo := getLogoBySearch(teamName); logo != "" {
if p, err := services.ProcessFACRLogo(logo); err == nil && strings.TrimSpace(p) != "" {
return p
}
return logo
}
tid := strings.TrimSpace(teamID)
if tid != "" {
return fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
u := fmt.Sprintf("https://is1.fotbal.cz/media/kluby/%s/%s_crop.jpg", tid, tid)
if p, err := services.ProcessFACRLogo(u); err == nil && strings.TrimSpace(p) != "" {
return p
}
return u
}
return placeholder
}
@@ -410,6 +425,10 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
}
img := a.Find("img").First()
logoURL, _ := img.Attr("src")
// Best-effort: Process FACR logos to transparent PNG. Non-facr URLs are returned unchanged.
if p, err := services.ProcessFACRLogo(logoURL); err == nil && strings.TrimSpace(p) != "" {
logoURL = p
}
category := strings.TrimSpace(li.Find(".ClubCategories .BadgeCategory").First().Text())
address := strings.TrimSpace(li.Find(".ClubAddress p").First().Text())
clubType := "football"
@@ -535,6 +554,63 @@ func (fc *FACRController) GetClubInfo(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
return
}
// Best-effort: rewrite FACR logos in matches to processed local PNGs via rembg
// so that all consumers receive transparent logos consistently.
{
var orig map[string]any
if json.Unmarshal(b, &orig) == nil {
if comps, ok := orig["competitions"].([]any); ok {
seen := map[string]string{}
for i := range comps {
comp, _ := comps[i].(map[string]any)
if comp == nil {
continue
}
if matches, ok2 := comp["matches"].([]any); ok2 {
for j := range matches {
m, _ := matches[j].(map[string]any)
if m == nil {
continue
}
// home_logo_url
if s, ok3 := m["home_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
if rep, ok := seen[s]; ok {
if rep != "" && rep != s {
m["home_logo_url"] = rep
}
} else {
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
seen[s] = p
m["home_logo_url"] = p
} else {
seen[s] = s
}
}
}
// away_logo_url
if s, ok3 := m["away_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
if rep, ok := seen[s]; ok {
if rep != "" && rep != s {
m["away_logo_url"] = rep
}
} else {
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
seen[s] = p
m["away_logo_url"] = p
} else {
seen[s] = s
}
}
}
}
}
}
if nb, err := json.Marshal(orig); err == nil {
b = nb
}
}
}
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
}
@@ -573,6 +649,49 @@ func (fc *FACRController) GetClubTables(c *gin.Context) {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("read error: %v", err)})
return
}
// Best-effort: rewrite team_logo_url in tables to processed local PNGs via rembg
{
var orig map[string]any
if json.Unmarshal(b, &orig) == nil {
if comps, ok := orig["competitions"].([]any); ok {
seen := map[string]string{}
for i := range comps {
comp, _ := comps[i].(map[string]any)
if comp == nil {
continue
}
tbl, _ := comp["table"].(map[string]any)
if tbl == nil {
continue
}
overall, _ := tbl["overall"].([]any)
for j := range overall {
row, _ := overall[j].(map[string]any)
if row == nil {
continue
}
if s, ok3 := row["team_logo_url"].(string); ok3 && strings.TrimSpace(s) != "" {
if rep, ok := seen[s]; ok {
if rep != "" && rep != s {
row["team_logo_url"] = rep
}
} else {
if p, err := services.ProcessFACRLogo(s); err == nil && strings.TrimSpace(p) != "" && p != s {
seen[s] = p
row["team_logo_url"] = p
} else {
seen[s] = s
}
}
}
}
}
if nb, err := json.Marshal(orig); err == nil {
b = nb
}
}
}
}
setCachedJSON(cacheKey, b)
c.Data(http.StatusOK, "application/json", b)
}
+317 -168
View File
@@ -26,7 +26,7 @@ func NewNavigationController(db *gorm.DB) *NavigationController {
// @Router /api/v1/navigation [get]
func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
var items []models.NavigationItem
// Get only top-level items (no parent) that are visible and NOT admin-only
if err := nc.DB.Where("parent_id IS NULL AND visible = ? AND requires_admin = ?", true, false).
Order("display_order ASC").
@@ -37,7 +37,7 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch navigation items"})
return
}
// Compute URLs for items
for i := range items {
if items[i].URL == "" {
@@ -49,7 +49,7 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
}
}
}
c.JSON(http.StatusOK, items)
}
@@ -63,7 +63,7 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
// @Router /api/v1/admin/navigation [get]
func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
var items []models.NavigationItem
if err := nc.DB.Where("parent_id IS NULL").
Order("requires_admin ASC, display_order ASC").
Preload("Children", func(db *gorm.DB) *gorm.DB {
@@ -73,7 +73,7 @@ func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch navigation items"})
return
}
// Compute URLs for items
for i := range items {
if items[i].URL == "" {
@@ -85,7 +85,7 @@ func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
}
}
}
c.JSON(http.StatusOK, items)
}
@@ -101,40 +101,40 @@ func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
// @Router /api/v1/admin/navigation [post]
func (nc *NavigationController) CreateNavigationItem(c *gin.Context) {
var item models.NavigationItem
if err := c.ShouldBindJSON(&item); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If no display order is set, put it at the end
if item.DisplayOrder == 0 {
var maxOrder int
query := nc.DB.Model(&models.NavigationItem{})
// Calculate max order for items at the same level (same parent) and same admin status
if item.ParentID == nil {
query = query.Where("parent_id IS NULL")
} else {
query = query.Where("parent_id = ?", *item.ParentID)
}
// Also consider requires_admin to keep frontend and admin items separate
query = query.Where("requires_admin = ?", item.RequiresAdmin)
query.Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxOrder)
item.DisplayOrder = maxOrder
}
if err := nc.DB.Create(&item).Error; err != nil {
// Log the actual error for debugging
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to create navigation item",
"error": "Failed to create navigation item",
"details": err.Error(),
})
return
}
c.JSON(http.StatusCreated, item)
}
@@ -155,13 +155,13 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var item models.NavigationItem
if err := nc.DB.First(&item, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Navigation item not found"})
return
}
// Bind into a generic map to know which fields are present (partial update)
var raw map[string]interface{}
if err := c.ShouldBindJSON(&raw); err != nil {
@@ -173,19 +173,29 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
updates := map[string]interface{}{}
if v, ok := raw["label"]; ok {
if s, ok2 := v.(string); ok2 { updates["label"] = s }
if s, ok2 := v.(string); ok2 {
updates["label"] = s
}
}
if v, ok := raw["url"]; ok {
if s, ok2 := v.(string); ok2 { updates["url"] = s }
if s, ok2 := v.(string); ok2 {
updates["url"] = s
}
}
if v, ok := raw["icon"]; ok {
if s, ok2 := v.(string); ok2 { updates["icon"] = s }
if s, ok2 := v.(string); ok2 {
updates["icon"] = s
}
}
if v, ok := raw["type"]; ok {
if s, ok2 := v.(string); ok2 { updates["type"] = s }
if s, ok2 := v.(string); ok2 {
updates["type"] = s
}
}
if v, ok := raw["page_type"]; ok {
if s, ok2 := v.(string); ok2 { updates["page_type"] = s }
if s, ok2 := v.(string); ok2 {
updates["page_type"] = s
}
}
if v, ok := raw["page_id"]; ok {
switch t := v.(type) {
@@ -202,7 +212,9 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
}
}
if v, ok := raw["visible"]; ok {
if b, ok2 := v.(bool); ok2 { updates["visible"] = b }
if b, ok2 := v.(bool); ok2 {
updates["visible"] = b
}
}
if v, ok := raw["display_order"]; ok {
switch t := v.(type) {
@@ -231,16 +243,24 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
}
}
if v, ok := raw["target"]; ok {
if s, ok2 := v.(string); ok2 { updates["target"] = s }
if s, ok2 := v.(string); ok2 {
updates["target"] = s
}
}
if v, ok := raw["css_class"]; ok {
if s, ok2 := v.(string); ok2 { updates["css_class"] = s }
if s, ok2 := v.(string); ok2 {
updates["css_class"] = s
}
}
if v, ok := raw["requires_auth"]; ok {
if b, ok2 := v.(bool); ok2 { updates["requires_auth"] = b }
if b, ok2 := v.(bool); ok2 {
updates["requires_auth"] = b
}
}
if v, ok := raw["requires_admin"]; ok {
if b, ok2 := v.(bool); ok2 { updates["requires_admin"] = b }
if b, ok2 := v.(bool); ok2 {
updates["requires_admin"] = b
}
}
if len(updates) == 0 {
@@ -251,7 +271,7 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
if err := nc.DB.Model(&item).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to update navigation item",
"error": "Failed to update navigation item",
"details": err.Error(),
})
return
@@ -280,12 +300,12 @@ func (nc *NavigationController) DeleteNavigationItem(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
if err := nc.DB.Delete(&models.NavigationItem{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete navigation item"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Navigation item deleted successfully"})
}
@@ -305,7 +325,7 @@ func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update each item's order in a transaction
err := nc.DB.Transaction(func(tx *gorm.DB) error {
for _, order := range orders {
@@ -314,7 +334,7 @@ func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
if !ok1 || !ok2 {
continue
}
if err := tx.Model(&models.NavigationItem{}).
Where("id = ?", id).
Update("display_order", displayOrder).Error; err != nil {
@@ -323,12 +343,12 @@ func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder navigation items"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Navigation items reordered successfully"})
}
@@ -341,14 +361,14 @@ func (nc *NavigationController) ReorderNavigationItems(c *gin.Context) {
// @Router /api/v1/social-links [get]
func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
var links []models.SocialLink
if err := nc.DB.Where("visible = ?", true).
Order("display_order ASC").
Find(&links).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch social links"})
return
}
c.JSON(http.StatusOK, links)
}
@@ -362,12 +382,12 @@ func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
// @Router /api/v1/admin/social-links [get]
func (nc *NavigationController) GetAllSocialLinks(c *gin.Context) {
var links []models.SocialLink
if err := nc.DB.Order("display_order ASC").Find(&links).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch social links"})
return
}
c.JSON(http.StatusOK, links)
}
@@ -383,12 +403,12 @@ func (nc *NavigationController) GetAllSocialLinks(c *gin.Context) {
// @Router /api/v1/admin/social-links [post]
func (nc *NavigationController) CreateSocialLink(c *gin.Context) {
var link models.SocialLink
if err := c.ShouldBindJSON(&link); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// If no display order is set, put it at the end
if link.DisplayOrder == 0 {
var maxOrder int
@@ -397,12 +417,12 @@ func (nc *NavigationController) CreateSocialLink(c *gin.Context) {
Scan(&maxOrder)
link.DisplayOrder = maxOrder
}
if err := nc.DB.Create(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create social link"})
return
}
c.JSON(http.StatusCreated, link)
}
@@ -423,30 +443,30 @@ func (nc *NavigationController) UpdateSocialLink(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var link models.SocialLink
if err := nc.DB.First(&link, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Social link not found"})
return
}
var updates models.SocialLink
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
link.Platform = updates.Platform
link.URL = updates.URL
link.DisplayOrder = updates.DisplayOrder
link.Visible = updates.Visible
link.Icon = updates.Icon
if err := nc.DB.Save(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update social link"})
return
}
c.JSON(http.StatusOK, link)
}
@@ -465,12 +485,12 @@ func (nc *NavigationController) DeleteSocialLink(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
if err := nc.DB.Delete(&models.SocialLink{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete social link"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Social link deleted successfully"})
}
@@ -490,7 +510,7 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := nc.DB.Transaction(func(tx *gorm.DB) error {
for _, order := range orders {
id, ok1 := order["id"]
@@ -498,7 +518,7 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
if !ok1 || !ok2 {
continue
}
if err := tx.Model(&models.SocialLink{}).
Where("id = ?", id).
Update("display_order", displayOrder).Error; err != nil {
@@ -507,12 +527,12 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder social links"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Social links reordered successfully"})
}
@@ -524,17 +544,17 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/navigation/seed [post]
func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
// Check existing counts for frontend and admin separately
var frontendCount int64
var adminCount int64
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
// Default frontend navigation items
frontendItems := []models.NavigationItem{
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
// Check existing counts for frontend and admin separately
var frontendCount int64
var adminCount int64
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
// Default frontend navigation items
frontendItems := []models.NavigationItem{
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
{Label: "Zápasy", Type: models.NavTypePage, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: false},
{Label: "Aktivity", Type: models.NavTypePage, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: false},
{Label: "Hráči", Type: models.NavTypePage, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: false},
@@ -545,128 +565,257 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
{Label: "Sponzoři", Type: models.NavTypePage, PageType: "sponsors", DisplayOrder: 10, Visible: true, RequiresAdmin: false},
{Label: "Kontakt", Type: models.NavTypePage, PageType: "contact", DisplayOrder: 11, Visible: true, RequiresAdmin: false},
}
// Create items in a transaction with admin categories and children (seed missing parts only)
seededFrontend := false
seededAdmin := false
err := nc.DB.Transaction(func(tx *gorm.DB) error {
if frontendCount == 0 {
for _, item := range frontendItems {
if err := tx.Create(&item).Error; err != nil {
return err
}
}
seededFrontend = true
}
if adminCount == 0 {
catOrder := 0
createCategory := func(label string) (*models.NavigationItem, error) {
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
catOrder++
if err := tx.Create(cat).Error; err != nil {
return nil, err
}
return cat, nil
}
// Create items in a transaction with admin categories and children (seed missing parts only)
seededFrontend := false
seededAdmin := false
addedMissing := false
err := nc.DB.Transaction(func(tx *gorm.DB) error {
if frontendCount == 0 {
for _, item := range frontendItems {
if err := tx.Create(&item).Error; err != nil {
return err
}
}
seededFrontend = true
}
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
child.ParentID = &pid
return tx.Create(child).Error
}
if adminCount == 0 {
catOrder := 0
createCategory := func(label string) (*models.NavigationItem, error) {
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
catOrder++
if err := tx.Create(cat).Error; err != nil {
return nil, err
}
return cat, nil
}
zakladni, err := createCategory("Základní")
if err != nil { return err }
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil { return err }
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
child.ParentID = &pid
return tx.Create(child).Error
}
sport, err := createCategory("Sport")
if err != nil { return err }
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
if err := createChild(sport, "Zápasy", "matches", 1); err != nil { return err }
if err := createChild(sport, "Hráči", "players", 2); err != nil { return err }
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
if err := createChild(sport, "Tabule (Scoreboard)", "scoreboard", 4); err != nil { return err }
if err := createChild(sport, "Scoreboard Remote", "scoreboard_remote", 5); err != nil { return err }
zakladni, err := createCategory("Základní")
if err != nil {
return err
}
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil {
return err
}
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil {
return err
}
obsah, err := createCategory("Obsah")
if err != nil { return err }
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil { return err }
// Kategorie admin page removed (categories derived from competition aliases)
if err := createChild(obsah, "Komentáře", "comments", 2); err != nil { return err }
sport, err := createCategory("Sport")
if err != nil {
return err
}
if err := createChild(sport, "Týmy", "teams", 0); err != nil {
return err
}
if err := createChild(sport, "Zápasy", "matches", 1); err != nil {
return err
}
if err := createChild(sport, "Hráči", "players", 2); err != nil {
return err
}
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil {
return err
}
if err := createChild(sport, "Tabule (Scoreboard)", "scoreboard", 4); err != nil {
return err
}
if err := createChild(sport, "Scoreboard Remote", "scoreboard_remote", 5); err != nil {
return err
}
media, err := createCategory("Média")
if err != nil { return err }
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil { return err }
if err := createChild(media, "Soubory", "files", 2); err != nil { return err }
obsah, err := createCategory("Obsah")
if err != nil {
return err
}
if err := createChild(obsah, "Články", "articles", 0); err != nil {
return err
}
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil {
return err
}
// "O klubu" admin page
if err := createChild(obsah, "O klubu", "about", 2); err != nil {
return err
}
// Kategorie admin page removed (categories derived from competition aliases)
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil {
return err
}
kom, err := createCategory("Komunikace")
if err != nil { return err }
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
if err := createChild(kom, "Zpravodaj", "newsletter", 1); err != nil { return err }
if err := createChild(kom, "Kontakty", "contacts", 2); err != nil { return err }
media, err := createCategory("Média")
if err != nil {
return err
}
if err := createChild(media, "Videa", "videos", 0); err != nil {
return err
}
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil {
return err
}
if err := createChild(media, "Soubory", "files", 2); err != nil {
return err
}
marketing, err := createCategory("Marketing")
if err != nil { return err }
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
if err := createChild(marketing, "Bannery", "banners", 1); err != nil { return err }
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil { return err }
if err := createChild(marketing, "Ankety", "polls", 3); err != nil { return err }
if err := createChild(marketing, "Soutěže", "sweepstakes", 4); err != nil { return err }
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 5); err != nil { return err }
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil { return err }
kom, err := createCategory("Komunikace")
if err != nil {
return err
}
if err := createChild(kom, "Zprávy", "messages", 0); err != nil {
return err
}
if err := createChild(kom, "Zpravodaj", "newsletter", 1); err != nil {
return err
}
if err := createChild(kom, "Kontakty", "contacts", 2); err != nil {
return err
}
nastroje, err := createCategory("Nástroje")
if err != nil { return err }
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil { return err }
marketing, err := createCategory("Marketing")
if err != nil {
return err
}
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil {
return err
}
if err := createChild(marketing, "Bannery", "banners", 1); err != nil {
return err
}
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil {
return err
}
if err := createChild(marketing, "Ankety", "polls", 3); err != nil {
return err
}
if err := createChild(marketing, "Soutěže", "sweepstakes", 4); err != nil {
return err
}
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 5); err != nil {
return err
}
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil {
return err
}
nastaveni, err := createCategory("Nastavení")
if err != nil { return err }
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil { return err }
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil { return err }
if err := createChild(nastaveni, "Navigace", "navigation", 2); err != nil { return err }
nastroje, err := createCategory("Nástroje")
if err != nil {
return err
}
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil {
return err
}
napoveda, err := createCategory("Nápověda")
if err != nil { return err }
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
nastaveni, err := createCategory("Nastavení")
if err != nil {
return err
}
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil {
return err
}
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil {
return err
}
if err := createChild(nastaveni, "Navigace", "navigation", 2); err != nil {
return err
}
seededAdmin = true
}
napoveda, err := createCategory("Nápověda")
if err != nil {
return err
}
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil {
return err
}
seededAdmin = true
}
return nil
})
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed navigation items"})
return
}
// Since creation is split, compute counts again
var total int64
nc.DB.Model(&models.NavigationItem{}).Count(&total)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
message := "Navigation items already exist"
if seededFrontend && seededAdmin {
message = "Default frontend and admin navigation created successfully"
} else if seededFrontend {
message = "Default frontend navigation created successfully"
} else if seededAdmin {
message = "Default admin navigation created successfully"
}
// Also add missing admin "O klubu" item under "Obsah" when admin navigation exists but the item is missing
if adminCount > 0 {
var aboutCount int64
// Check if an admin nav item with page_type 'about' exists
if err := nc.DB.Model(&models.NavigationItem{}).
Where("requires_admin = ? AND page_type = ?", true, "about").
Count(&aboutCount).Error; err == nil {
if aboutCount == 0 {
// Ensure the 'Obsah' category exists (admin dropdown)
var obsah models.NavigationItem
findCatErr := nc.DB.Where("parent_id IS NULL AND requires_admin = ? AND type = ? AND label = ?", true, models.NavTypeDropdown, "Obsah").First(&obsah).Error
if findCatErr != nil {
if findCatErr == gorm.ErrRecordNotFound {
// Create category at the end of admin categories
var maxCat int
nc.DB.Model(&models.NavigationItem{}).
Where("parent_id IS NULL AND requires_admin = ?", true).
Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxCat)
obsah = models.NavigationItem{Label: "Obsah", Type: models.NavTypeDropdown, DisplayOrder: maxCat, Visible: true, RequiresAdmin: true}
if err := nc.DB.Create(&obsah).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin category"})
return
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
}
// Create the missing child under 'Obsah'
var maxChild int
nc.DB.Model(&models.NavigationItem{}).
Where("parent_id = ?", obsah.ID).
Select("COALESCE(MAX(display_order), -1) + 1").Scan(&maxChild)
pid := obsah.ID
aboutNav := models.NavigationItem{Label: "O klubu", Type: models.NavTypeInternal, PageType: "about", DisplayOrder: maxChild, Visible: true, RequiresAdmin: true}
aboutNav.ParentID = &pid
if err := nc.DB.Create(&aboutNav).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create about nav item"})
return
}
addedMissing = true
}
}
}
c.JSON(http.StatusOK, gin.H{
"message": message,
"count": total,
"frontend_count": frontendCount,
"admin_count": adminCount,
"seeded": seededFrontend || seededAdmin,
"seeded_frontend": seededFrontend,
"seeded_admin": seededAdmin,
})
// Since creation is split, compute counts again
var total int64
nc.DB.Model(&models.NavigationItem{}).Count(&total)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
message := "Navigation items already exist"
if seededFrontend && seededAdmin {
message = "Default frontend and admin navigation created successfully"
} else if seededFrontend {
message = "Default frontend navigation created successfully"
} else if seededAdmin {
message = "Default admin navigation created successfully"
}
if addedMissing && !(seededFrontend || seededAdmin) {
message = "Added missing navigation items"
}
c.JSON(http.StatusOK, gin.H{
"message": message,
"count": total,
"frontend_count": frontendCount,
"admin_count": adminCount,
"seeded": (seededFrontend || seededAdmin || addedMissing),
"seeded_frontend": seededFrontend,
"seeded_admin": seededAdmin,
})
}
+30
View File
@@ -0,0 +1,30 @@
package controllers
import (
"net/http"
"path/filepath"
"strings"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
)
type RembgController struct{}
func NewRembgController() *RembgController { return &RembgController{} }
func (rc *RembgController) Status(c *gin.Context) {
s := services.GetRembgStatus()
c.JSON(http.StatusOK, s)
}
func (rc *RembgController) Start(c *gin.Context) {
cacheDir := strings.TrimSpace(c.Query("cache_dir"))
if cacheDir == "" {
cacheDir = filepath.Join("cache", "prefetch")
}
started := services.StartFACRLogosBatch(cacheDir)
s := services.GetRembgStatus()
c.JSON(http.StatusOK, gin.H{"started": started, "status": s})
}
@@ -1,296 +1,354 @@
package controllers
import (
"bytes"
"fmt"
"image"
"image/png"
_ "image/gif"
_ "image/jpeg"
"mime/multipart"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin"
)
func uploadsBaseDir() string {
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
}
return dir
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
}
return dir
}
// sanitizeAndWriteLogo trims white/transparent borders and resizes to fixed height (64px), then writes PNG to outPath.
func sanitizeAndWriteLogo(data []byte, outPath string) error {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return err
}
b := img.Bounds()
minX, minY := b.Max.X, b.Max.Y
maxX, maxY := b.Min.X, b.Min.Y
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, bl, a := img.At(x, y).RGBA()
if a <= 0x10 { // near transparent
continue
}
rr, gg, bb := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
if rr > 245 && gg > 245 && bb > 245 { // nearly white background
continue
}
if x < minX { minX = x }
if y < minY { minY = y }
if x > maxX { maxX = x }
if y > maxY { maxY = y }
}
}
if minX >= maxX || minY >= maxY {
// fallback to full image
minX, minY = b.Min.X, b.Min.Y
maxX, maxY = b.Max.X-1, b.Max.Y-1
}
cw, ch := maxX-minX+1, maxY-minY+1
nrgba := image.NewNRGBA(image.Rect(0, 0, cw, ch))
for y := 0; y < ch; y++ {
for x := 0; x < cw; x++ {
nrgba.Set(x, y, img.At(minX+x, minY+y))
}
}
// resize to 64px height using nearest-neighbor
targetH := 64
if ch != targetH {
targetW := int(float64(cw) * float64(targetH) / float64(ch))
if targetW < 1 { targetW = 1 }
resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH))
for y2 := 0; y2 < targetH; y2++ {
srcY := y2 * ch / targetH
for x2 := 0; x2 < targetW; x2++ {
srcX := x2 * cw / targetW
c := nrgba.NRGBAAt(srcX, srcY)
resized.SetNRGBA(x2, y2, c)
}
}
nrgba = resized
}
// write PNG
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { return err }
f, err := os.Create(outPath)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, nrgba)
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return err
}
b := img.Bounds()
minX, minY := b.Max.X, b.Max.Y
maxX, maxY := b.Min.X, b.Min.Y
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, bl, a := img.At(x, y).RGBA()
if a <= 0x10 { // near transparent
continue
}
rr, gg, bb := uint8(r>>8), uint8(g>>8), uint8(bl>>8)
if rr > 245 && gg > 245 && bb > 245 { // nearly white background
continue
}
if x < minX {
minX = x
}
if y < minY {
minY = y
}
if x > maxX {
maxX = x
}
if y > maxY {
maxY = y
}
}
}
if minX >= maxX || minY >= maxY {
// fallback to full image
minX, minY = b.Min.X, b.Min.Y
maxX, maxY = b.Max.X-1, b.Max.Y-1
}
cw, ch := maxX-minX+1, maxY-minY+1
nrgba := image.NewNRGBA(image.Rect(0, 0, cw, ch))
for y := 0; y < ch; y++ {
for x := 0; x < cw; x++ {
nrgba.Set(x, y, img.At(minX+x, minY+y))
}
}
// resize to 64px height using nearest-neighbor
targetH := 64
if ch != targetH {
targetW := int(float64(cw) * float64(targetH) / float64(ch))
if targetW < 1 {
targetW = 1
}
resized := image.NewNRGBA(image.Rect(0, 0, targetW, targetH))
for y2 := 0; y2 < targetH; y2++ {
srcY := y2 * ch / targetH
for x2 := 0; x2 < targetW; x2++ {
srcX := x2 * cw / targetW
c := nrgba.NRGBAAt(srcX, srcY)
resized.SetNRGBA(x2, y2, c)
}
}
nrgba = resized
}
// write PNG
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return err
}
f, err := os.Create(outPath)
if err != nil {
return err
}
defer f.Close()
return png.Encode(f, nrgba)
}
// ensureUniqueFilename ensures name does not collide within dir, adding -1, -2 etc.
func ensureUniqueFilename(dir, name string) string {
base := name
ext := ""
if i := strings.LastIndex(name, "."); i >= 0 {
base = name[:i]
ext = name[i:]
}
try := name
idx := 1
for {
if _, err := os.Stat(filepath.Join(dir, try)); os.IsNotExist(err) {
return try
}
try = fmt.Sprintf("%s-%d%s", base, idx, ext)
idx++
}
base := name
ext := ""
if i := strings.LastIndex(name, "."); i >= 0 {
base = name[:i]
ext = name[i:]
}
try := name
idx := 1
for {
if _, err := os.Stat(filepath.Join(dir, try)); os.IsNotExist(err) {
return try
}
try = fmt.Sprintf("%s-%d%s", base, idx, ext)
idx++
}
}
// ListSponsors returns list of sponsor logo URLs under /uploads/sponsors
func (c *ScoreboardController) ListSponsors(ctx *gin.Context) {
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
entries, err := os.ReadDir(sponsorDir)
if err != nil {
ctx.JSON(http.StatusOK, []string{})
return
}
out := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() { continue }
name := e.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".gif") || strings.HasSuffix(lower, ".webp") || strings.HasSuffix(lower, ".svg") {
out = append(out, "/uploads/sponsors/"+name)
}
}
ctx.JSON(http.StatusOK, out)
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
entries, err := os.ReadDir(sponsorDir)
if err != nil {
ctx.JSON(http.StatusOK, []string{})
return
}
out := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
lower := strings.ToLower(name)
if strings.HasSuffix(lower, ".png") || strings.HasSuffix(lower, ".jpg") || strings.HasSuffix(lower, ".jpeg") || strings.HasSuffix(lower, ".gif") || strings.HasSuffix(lower, ".webp") || strings.HasSuffix(lower, ".svg") {
out = append(out, "/uploads/sponsors/"+name)
}
}
ctx.JSON(http.StatusOK, out)
}
// UploadSponsors accepts multipart form files under field name "files" (or single "file")
func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
if err := ctx.Request.ParseMultipartForm(200 << 20); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
return
}
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
_ = os.MkdirAll(sponsorDir, 0o755)
if err := ctx.Request.ParseMultipartForm(200 << 20); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid upload"})
return
}
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
_ = os.MkdirAll(sponsorDir, 0o755)
saved := 0
created := make([]string, 0, 8)
if ctx.Request.MultipartForm != nil {
files := ctx.Request.MultipartForm.File["files"]
if len(files) == 0 {
if f, hdr, err := ctx.Request.FormFile("file"); err == nil {
_ = f.Close()
files = []*multipart.FileHeader{hdr}
}
}
for _, hdr := range files {
if hdr == nil { continue }
src, err := hdr.Open()
if err != nil { continue }
// do not defer: loop
name := sanitizeFilename(hdr.Filename)
if name == "" { name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
base := name
if i := strings.LastIndex(name, "."); i >= 0 { base = name[:i] }
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
saved := 0
created := make([]string, 0, 8)
if ctx.Request.MultipartForm != nil {
files := ctx.Request.MultipartForm.File["files"]
if len(files) == 0 {
if f, hdr, err := ctx.Request.FormFile("file"); err == nil {
_ = f.Close()
files = []*multipart.FileHeader{hdr}
}
}
for _, hdr := range files {
if hdr == nil {
continue
}
src, err := hdr.Open()
if err != nil {
continue
}
// do not defer: loop
name := sanitizeFilename(hdr.Filename)
if name == "" {
name = fmt.Sprintf("sponsor-%d", time.Now().UnixNano())
}
base := name
if i := strings.LastIndex(name, "."); i >= 0 {
base = name[:i]
}
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
var buf bytes.Buffer
if _, err := io.Copy(&buf, src); err == nil {
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
saved++
created = append(created, "/uploads/sponsors/"+outName)
} else {
// Fallback: write original bytes with original extension
rawName := ensureUniqueFilename(sponsorDir, name)
rawPath := filepath.Join(sponsorDir, rawName)
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
saved++
created = append(created, "/uploads/sponsors/"+rawName)
}
}
_ = src.Close()
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created})
var buf bytes.Buffer
if _, err := io.Copy(&buf, src); err == nil {
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
saved++
created = append(created, "/uploads/sponsors/"+outName)
} else {
// Fallback: write original bytes with original extension
rawName := ensureUniqueFilename(sponsorDir, name)
rawPath := filepath.Join(sponsorDir, rawName)
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
saved++
created = append(created, "/uploads/sponsors/"+rawName)
}
}
_ = src.Close()
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created})
}
// DeleteSponsor deletes a sponsor logo by filename (?name=)
func (c *ScoreboardController) DeleteSponsor(ctx *gin.Context) {
name := sanitizeFilename(ctx.Query("name"))
if name == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
return
}
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
if _, err := os.Stat(p); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := os.Remove(p); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
name := sanitizeFilename(ctx.Query("name"))
if name == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing name"})
return
}
p := filepath.Join(uploadsBaseDir(), "sponsors", name)
if _, err := os.Stat(p); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := os.Remove(p); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// DeleteQR deletes the QR image (uploads/qr.png) if present
func (c *ScoreboardController) DeleteQR(ctx *gin.Context) {
path := filepath.Join(uploadsBaseDir(), "qr.png")
if _, err := os.Stat(path); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err := os.Remove(path); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot delete"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// GetQR returns the current QR image URL if present
func (c *ScoreboardController) GetQR(ctx *gin.Context) {
path := filepath.Join(uploadsBaseDir(), "qr.png")
if _, err := os.Stat(path); err == nil {
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
return
}
ctx.JSON(http.StatusOK, gin.H{"qr": ""})
path := filepath.Join(uploadsBaseDir(), "qr.png")
if _, err := os.Stat(path); err == nil {
ctx.JSON(http.StatusOK, gin.H{"qr": "/uploads/qr.png"})
return
}
ctx.JSON(http.StatusOK, gin.H{"qr": ""})
}
// UploadQR accepts a single file and stores/overwrites uploads/qr.png
func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
file, _, err := ctx.Request.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file not provided (field 'file')"})
return
}
defer file.Close()
dir := uploadsBaseDir()
_ = os.MkdirAll(dir, 0o755)
out, err := os.Create(filepath.Join(dir, "qr.png"))
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
file, _, err := ctx.Request.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file not provided (field 'file')"})
return
}
defer file.Close()
dir := uploadsBaseDir()
_ = os.MkdirAll(dir, 0o755)
out, err := os.Create(filepath.Join(dir, "qr.png"))
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "write failed"})
return
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// PrefillSponsorsFromPage copies logo images from existing Sponsors into uploads/sponsors for overlay use.
// Optional JSON body: { "ids": [1,2,3] } to limit to specific sponsors.
func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) {
var body struct{ IDs []uint `json:"ids"` }
_ = ctx.ShouldBindJSON(&body)
var list []models.Sponsor
q := c.DB.Model(&models.Sponsor{})
if len(body.IDs) > 0 {
q = q.Where("id IN ?", body.IDs)
} else {
q = q.Where("is_active = ?", true)
}
if err := q.Find(&list).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
_ = os.MkdirAll(sponsorDir, 0o755)
created := make([]string, 0, len(list))
for _, s := range list {
logo := strings.TrimSpace(s.LogoURL)
if logo == "" { continue }
var data []byte
if strings.HasPrefix(logo, "/uploads/") {
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
if b, err := os.ReadFile(p); err == nil { data = b } else { continue }
} else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") {
resp, err := http.Get(logo)
if err != nil { continue }
func() {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return }
b, _ := io.ReadAll(resp.Body)
if len(b) > 0 { data = b }
}()
if len(data) == 0 { continue }
} else {
continue
}
base := sanitizeFilename(s.Name)
if base == "" {
seg := logo
if i := strings.LastIndex(seg, "/"); i >= 0 { seg = seg[i+1:] }
if j := strings.LastIndex(seg, "."); j >= 0 { seg = seg[:j] }
base = sanitizeFilename(seg)
if base == "" { base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
}
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
if err := sanitizeAndWriteLogo(data, outPath); err != nil {
// fallback to raw write
rawName := ensureUniqueFilename(sponsorDir, base+".png")
_ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644)
created = append(created, "/uploads/sponsors/"+rawName)
} else {
created = append(created, "/uploads/sponsors/"+outName)
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created})
var body struct {
IDs []uint `json:"ids"`
}
_ = ctx.ShouldBindJSON(&body)
var list []models.Sponsor
q := c.DB.Model(&models.Sponsor{})
if len(body.IDs) > 0 {
q = q.Where("id IN ?", body.IDs)
} else {
q = q.Where("is_active = ?", true)
}
if err := q.Find(&list).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
_ = os.MkdirAll(sponsorDir, 0o755)
created := make([]string, 0, len(list))
for _, s := range list {
logo := strings.TrimSpace(s.LogoURL)
if logo == "" {
continue
}
var data []byte
if strings.HasPrefix(logo, "/uploads/") {
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
if b, err := os.ReadFile(p); err == nil {
data = b
} else {
continue
}
} else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") {
resp, err := http.Get(logo)
if err != nil {
continue
}
func() {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return
}
b, _ := io.ReadAll(resp.Body)
if len(b) > 0 {
data = b
}
}()
if len(data) == 0 {
continue
}
} else {
continue
}
base := sanitizeFilename(s.Name)
if base == "" {
seg := logo
if i := strings.LastIndex(seg, "/"); i >= 0 {
seg = seg[i+1:]
}
if j := strings.LastIndex(seg, "."); j >= 0 {
seg = seg[:j]
}
base = sanitizeFilename(seg)
if base == "" {
base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano())
}
}
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
if err := sanitizeAndWriteLogo(data, outPath); err != nil {
// fallback to raw write
rawName := ensureUniqueFilename(sponsorDir, base+".png")
_ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644)
created = append(created, "/uploads/sponsors/"+rawName)
} else {
created = append(created, "/uploads/sponsors/"+outName)
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created})
}
File diff suppressed because it is too large Load Diff
+29 -12
View File
@@ -136,6 +136,23 @@ func codeFromHash(s string, n int) string {
return string(out)
}
func sanitizeCode(in string) string {
s := strings.TrimSpace(in)
if s == "" { return "" }
// filter allowed runes
rb := make([]rune, 0, len(s))
for _, ch := range s {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' {
rb = append(rb, ch)
}
}
if len(rb) == 0 { return "" }
if len(rb) > 16 {
rb = rb[:16]
}
return string(rb)
}
func getScheme(c *gin.Context) string {
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
return p
@@ -256,18 +273,18 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return
}
code := strings.TrimSpace(body.Code)
if code == "" {
for i := 0; i < 5; i++ {
cnd, _ := randCode(7)
var cnt int64
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
if cnt == 0 {
code = cnd
break
}
}
}
code := sanitizeCode(strings.TrimSpace(body.Code))
if code == "" {
for i := 0; i < 5; i++ {
cnd, _ := randCode(7)
var cnt int64
s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt)
if cnt == 0 {
code = cnd
break
}
}
}
if code == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"})
return
+6 -1
View File
@@ -57,6 +57,11 @@ func ValidateContentType() gin.HandlerFunc {
return
}
if strings.Contains(path, "/rembg/start") {
c.Next()
return
}
// Require JSON for other API endpoints
if !strings.Contains(contentType, "application/json") {
c.JSON(http.StatusUnsupportedMediaType, gin.H{
@@ -127,6 +132,6 @@ func LogSecurityEvent(c *gin.Context, eventType string, details map[string]inter
}
// Log to your logger
// logger.Warn("SECURITY_EVENT: type=%s user_id=%d ip=%s path=%s",
// logger.Warn("SECURITY_EVENT: type=%s user_id=%d ip=%s path=%s",
// event.Type, event.UserID, event.IP, event.Path)
}
+2
View File
@@ -14,6 +14,8 @@ type ScoreboardState struct {
AwayShort string `json:"away_short"`
PrimaryColor string `json:"primary_color"`
SecondaryColor string `json:"secondary_color"`
HomeTextColor string `json:"home_text_color"`
AwayTextColor string `json:"away_text_color"`
HomeScore int `json:"home_score"`
AwayScore int `json:"away_score"`
HalfLength int `json:"half_length"`
+6
View File
@@ -42,6 +42,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
notificationsController := controllers.NewNotificationsController(db, emailService)
emailController := controllers.NewEmailController(db)
prefetchController := controllers.NewPrefetchController()
rembgController := controllers.NewRembgController()
seoController := controllers.NewSEOController(db)
navigationController := controllers.NewNavigationController(db)
pollController := controllers.NewPollController(db)
@@ -96,6 +97,10 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// SMTP validation (public during setup; does not send email, only connects)
api.POST("/setup/validate-smtp", baseController.ValidateSMTP)
// Rembg batch (public status + start)
api.GET("/rembg/status", rembgController.Status)
api.POST("/rembg/start", rembgController.Start)
api.POST("/errors", middleware.RateLimit(120, time.Minute), errorController.Ingest)
// Auth routes
@@ -333,6 +338,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
admin.DELETE("/scoreboard/sponsors", scoreboardController.DeleteSponsor)
admin.GET("/scoreboard/qr", scoreboardController.GetQR)
admin.POST("/scoreboard/qr", scoreboardController.UploadQR)
admin.DELETE("/scoreboard/qr", scoreboardController.DeleteQR)
// Users (admin)
admin.GET("/users", authController.ListUsers)
+231
View File
@@ -0,0 +1,231 @@
package services
import (
"context"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/config"
)
// saveAsPNG decodes an image from srcPath and writes it as PNG to dstPath.
func saveAsPNG(srcPath, dstPath string) error {
f, err := os.Open(srcPath)
if err != nil {
return err
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
tmp := dstPath + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return err
}
// Use png encoder (imported via blank import)
if err := pngEncode(out, img); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, dstPath)
}
// pngEncode wraps png.Encode without importing directly to top to keep imports tidy
func pngEncode(w io.Writer, img image.Image) error { return png.Encode(w, img) }
// ProcessFACRLogo downloads a logo from fotbal.cz (FACR) and removes background via rembg.
// Returns a public URL (under /uploads) to a transparent PNG. If processing fails or the
// URL is not a FACR source, returns the original URL.
func ProcessFACRLogo(src string) (string, error) {
u := strings.TrimSpace(src)
if u == "" {
return "", fmt.Errorf("empty url")
}
// Feature flag: allow disabling background removal entirely via .env
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
// Simply return the original URL (no processing)
return u, nil
}
// Unwrap proxied URLs like /api/v1/proxy/image?url=...
if strings.Contains(u, "/proxy/image") {
if parsed, err := neturl.Parse(u); err == nil {
raw := parsed.Query().Get("url")
if strings.TrimSpace(raw) != "" {
u = raw
}
}
}
if !isFACRURL(u) {
// Only process fotbal.cz sources; leave others (logoapi etc.) unchanged
return u, nil
}
baseUpload := strings.TrimSpace(config.AppConfig.UploadDir)
if baseUpload == "" {
baseUpload = "./uploads"
}
key := facrKeyFromURL(u)
outPath := filepath.Join(baseUpload, "logos", "facr", key+".png")
if info, err := os.Stat(outPath); err == nil && info.Size() > 0 {
return toPublicURL(outPath), nil
}
// Ensure directory
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return u, err
}
// Download source to temp file
inTmp := outPath + ".in"
if err := downloadWithUA(u, inTmp); err != nil {
// On download failure, fallback to original URL
return u, err
}
defer os.Remove(inTmp)
// Run Python rembg script with timeout
script := filepath.Join("scripts", "rembg_remove_bg.py")
outTmp := outPath + ".tmp"
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "python3", script, inTmp, outTmp)
// Inherit minimal env; ensure PATH is present. If python3 not installed, this will fail.
if err := cmd.Run(); err != nil {
// If script missing or python not installed, simply return original FACR URL (fallback)
_ = os.Remove(outTmp)
return u, err
}
// Move tmp to destination if non-empty
if fi, err := os.Stat(outTmp); err == nil && fi.Size() > 0 {
if err := os.Rename(outTmp, outPath); err == nil {
return toPublicURL(outPath), nil
}
}
_ = os.Remove(outTmp)
return u, errors.New("rembg produced no output")
}
func isFACRURL(s string) bool {
pu, err := neturl.Parse(s)
if err != nil {
return strings.Contains(strings.ToLower(s), "fotbal.cz")
}
h := strings.ToLower(pu.Host)
return strings.Contains(h, "fotbal.cz")
}
// facrKeyFromURL tries to extract a stable key (team UUID) from known FACR paths; falls back to sha1(url).
func facrKeyFromURL(s string) string {
// Try to parse URL path and find /media/kluby/<id>/
if u, err := neturl.Parse(s); err == nil {
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i := 0; i < len(parts)-1; i++ {
if parts[i] == "kluby" && i+1 < len(parts) {
cand := strings.TrimSpace(parts[i+1])
if cand != "" {
return sanitizeFileKey(cand)
}
}
}
// Some search images may embed id in filename like <id>_crop.jpg
base := filepath.Base(u.Path)
if idx := strings.Index(strings.ToLower(base), "_crop"); idx > 0 {
return sanitizeFileKey(base[:idx])
}
}
// Fallback: sha1 of URL
h := sha1.Sum([]byte(s))
return hex.EncodeToString(h[:])
}
func sanitizeFileKey(s string) string {
s = strings.ToLower(s)
// Keep alphanum and dashes only
cleaned := make([]rune, 0, len(s))
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
cleaned = append(cleaned, r)
}
}
if len(cleaned) == 0 {
return "unknown"
}
return string(cleaned)
}
func toPublicURL(path string) string {
// Assumes files live under ./uploads
// Convert absolute/relative FS path to /uploads/...
i := strings.Index(path, string(filepath.Separator)+"uploads"+string(filepath.Separator))
if i >= 0 {
rel := path[i+1:]
return "/" + filepath.ToSlash(rel)
}
// Try to find trailing uploads/... even if base dir differs
idx := strings.Index(strings.ReplaceAll(path, "\\", "/"), "/uploads/")
if idx >= 0 {
return path[idx:]
}
// Fallback: cannot compute public URL
return path
}
func downloadWithUA(src, outPath string) error {
client := &http.Client{Timeout: 20 * time.Second}
req, err := http.NewRequest("GET", src, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "fotbal-club/1.0")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("status %d", resp.StatusCode)
}
f, err := os.Create(outPath)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
_ = f.Close()
_ = os.Remove(outPath)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(outPath)
return err
}
return nil
}
+181
View File
@@ -0,0 +1,181 @@
package services
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"fotbal-club/internal/config"
)
// EnsureFACRLogosProcessed triggers post-processing of FACR logos referenced
// in cached prefetch JSON files (facr_club_info.json, facr_tables.json).
// It is idempotent and safe to call multiple times.
func EnsureFACRLogosProcessed(cacheDir string) error {
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
return nil
}
var firstErr error
clubInfo := filepath.Join(cacheDir, "facr_club_info.json")
if _, err := os.Stat(clubInfo); err == nil {
if err := postProcessFACRClubInfoLogos(cacheDir); err != nil && firstErr == nil {
firstErr = fmt.Errorf("club_info: %w", err)
}
}
tables := filepath.Join(cacheDir, "facr_tables.json")
if _, err := os.Stat(tables); err == nil {
if err := postProcessFACRTablesLogos(cacheDir); err != nil && firstErr == nil {
firstErr = fmt.Errorf("tables: %w", err)
}
}
return firstErr
}
// progress state for rembg batch
var rembgMu sync.RWMutex
var rembgRunning bool
var rembgTotal int
var rembgDone int
var rembgStarted time.Time
var rembgFinished *time.Time
// RembgStatus represents current background removal progress
type RembgStatus struct {
Running bool `json:"running"`
Total int `json:"total"`
Done int `json:"done"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
// GetRembgStatus returns a snapshot of current rembg batch progress
func GetRembgStatus() RembgStatus {
rembgMu.RLock()
defer rembgMu.RUnlock()
return RembgStatus{
Running: rembgRunning,
Total: rembgTotal,
Done: rembgDone,
StartedAt: rembgStarted,
FinishedAt: rembgFinished,
}
}
// StartFACRLogosBatch scans cached FACR JSONs and processes all referenced FACR logos
// using ProcessFACRLogo. Progress is tracked via GetRembgStatus. If a batch is already
// running, this call is a no-op and returns false. Otherwise returns true and starts
// a background goroutine.
func StartFACRLogosBatch(cacheDir string) bool {
if config.AppConfig != nil && !config.AppConfig.RembgEnabled {
return false
}
rembgMu.Lock()
if rembgRunning {
rembgMu.Unlock()
return false
}
// Collect unique FACR logo URLs to determine total
urls := collectFACRLogos(cacheDir)
rembgRunning = true
rembgTotal = len(urls)
rembgDone = 0
rembgStarted = time.Now()
rembgFinished = nil
rembgMu.Unlock()
if len(urls) == 0 {
// nothing to do; mark finished quickly
rembgMu.Lock()
rembgRunning = false
now := time.Now()
rembgFinished = &now
rembgMu.Unlock()
return true
}
go func(urlList []string) {
defer func() {
// Best-effort: after processing, ensure JSONs are rewritten to point to processed URLs
_ = EnsureFACRLogosProcessed(cacheDir)
rembgMu.Lock()
rembgRunning = false
now := time.Now()
rembgFinished = &now
rembgMu.Unlock()
}()
for _, u := range urlList {
_, _ = ProcessFACRLogo(u) // best effort; skip errors
rembgMu.Lock()
rembgDone++
rembgMu.Unlock()
}
}(urls)
return true
}
// collectFACRLogos returns unique FACR-hosted logo URLs from cached prefetch JSONs.
func collectFACRLogos(cacheDir string) []string {
uniq := map[string]struct{}{}
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
// Only FACR sources; skip already-processed local /uploads and other hosts
ls := strings.ToLower(s)
if strings.HasPrefix(ls, "/uploads/") || strings.HasPrefix(ls, "/dist/") {
return
}
if !strings.Contains(ls, "fotbal.cz") {
return
}
uniq[s] = struct{}{}
}
// facr_club_info.json
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_club_info.json")); err == nil {
var payload struct {
Competitions []struct {
Matches []struct {
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
} `json:"matches"`
} `json:"competitions"`
}
if json.Unmarshal(b, &payload) == nil {
for _, c := range payload.Competitions {
for _, m := range c.Matches {
add(m.HomeLogoURL)
add(m.AwayLogoURL)
}
}
}
}
// facr_tables.json
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_tables.json")); err == nil {
var payload struct {
Competitions []struct {
Table struct {
Overall []struct {
TeamLogoURL string `json:"team_logo_url"`
} `json:"overall"`
} `json:"table"`
} `json:"competitions"`
}
if json.Unmarshal(b, &payload) == nil {
for _, c := range payload.Competitions {
for _, r := range c.Table.Overall {
add(r.TeamLogoURL)
}
}
}
}
out := make([]string, 0, len(uniq))
for k := range uniq {
out = append(out, k)
}
return out
}
+385 -142
View File
@@ -14,6 +14,8 @@ import (
"strings"
"sync/atomic"
"time"
"fotbal-club/internal/config"
)
// StartPrefetcher starts a background job that periodically fetches
@@ -81,7 +83,7 @@ func fetchZonerama(link string) error {
// Increase timeout to 60s since the API can take longer to fetch
client := &http.Client{Timeout: 60 * time.Second}
// Retry logic with exponential backoff (3 attempts)
var resp *http.Response
var err error
@@ -114,7 +116,7 @@ func fetchZonerama(link string) error {
time.Sleep(backoff)
}
}
if err != nil {
return err
}
@@ -165,7 +167,7 @@ func fetchZonerama(link string) error {
}
log.Printf("[prefetch] Zonerama: Fetching %d albums with photos...", len(profile.Albums))
// Fetch individual albums with photos
photoLimit := envInt("ZONERAMA_PHOTO_LIMIT", 50) // Default 50 photos per album
fetchedAlbums, err := fetchZoneramaAlbums(profile.Albums, photoLimit, client)
@@ -223,7 +225,7 @@ func fetchZoneramaAlbums(albums []struct {
// Fetch album with photos
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
url.QueryEscape(album.URL), photoLimit)
log.Printf("[prefetch] Zonerama: Fetching album %d/%d: %s", i+1, len(albums), album.URL)
req, err := http.NewRequest("GET", apiURL, nil)
@@ -238,7 +240,7 @@ func fetchZoneramaAlbums(albums []struct {
log.Printf("[prefetch] Zonerama: Failed to fetch album %s: %v", album.ID, err)
continue
}
if resp.StatusCode != 200 {
log.Printf("[prefetch] Zonerama: Album %s returned status %d", album.ID, resp.StatusCode)
_ = resp.Body.Close()
@@ -255,9 +257,9 @@ func fetchZoneramaAlbums(albums []struct {
albumData.FetchedAt = fetchedAt
result = append(result, albumData)
log.Printf("[prefetch] Zonerama: Album %s fetched with %d photos", albumData.ID, len(albumData.Photos))
// Small delay between requests to avoid overwhelming the API
if i < len(albums)-1 {
time.Sleep(500 * time.Millisecond)
@@ -506,28 +508,28 @@ func PrefetchOnce(baseURL string) {
// doPrefetchCycle performs one full prefetch cycle: static public endpoints + dynamic FACR endpoints.
func doPrefetchCycle(client *http.Client, baseURL string) {
cacheDir := filepath.Join("cache", "prefetch")
_ = os.MkdirAll(cacheDir, 0o755)
start := time.Now()
// Track endpoint fetch statuses for admin/debugging payload
type epStatus struct {
Path string `json:"path"`
File string `json:"file"`
Ok bool `json:"ok"`
Error string `json:"error,omitempty"`
}
var statuses []epStatus
cacheDir := filepath.Join("cache", "prefetch")
_ = os.MkdirAll(cacheDir, 0o755)
start := time.Now()
// Track endpoint fetch statuses for admin/debugging payload
type epStatus struct {
Path string `json:"path"`
File string `json:"file"`
Ok bool `json:"ok"`
Error string `json:"error,omitempty"`
}
var statuses []epStatus
// 1) Static public endpoints
endpoints := map[string]string{
"/settings": "settings.json",
"/seo": "seo.json",
"/articles?page=1&page_size=10&published=true": "articles.json",
"/sponsors": "sponsors.json",
"/events/upcoming": "events_upcoming.json",
"/public/team-logo-overrides": "team_logo_overrides.json",
"/competition-aliases": "competition_aliases.json",
}
// 1) Static public endpoints
endpoints := map[string]string{
"/settings": "settings.json",
"/seo": "seo.json",
"/articles?page=1&page_size=10&published=true": "articles.json",
"/sponsors": "sponsors.json",
"/events/upcoming": "events_upcoming.json",
"/public/team-logo-overrides": "team_logo_overrides.json",
"/competition-aliases": "competition_aliases.json",
}
statuses = make([]epStatus, 0, len(endpoints))
for path, file := range endpoints {
url := baseURL + path
@@ -542,82 +544,96 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
}
}
// Prepare matches.json from FACR data if available; fallback to events_upcoming.json
createdMatches := false
buildMatchesFromFACR := func(data []byte) bool {
type facrMatch struct {
DateTime string `json:"date_time"`
Home string `json:"home"`
Away string `json:"away"`
Venue string `json:"venue"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
MatchID string `json:"match_id"`
}
var facr struct {
Competitions []struct {
Name string `json:"name"`
Code string `json:"code"`
Matches []facrMatch `json:"matches"`
} `json:"competitions"`
}
if err := json.Unmarshal(data, &facr); err != nil {
return false
}
// Collect upcoming matches (next 30 days) in simplified format
now := time.Now()
max := now.Add(30 * 24 * time.Hour)
var out []map[string]any
for _, c := range facr.Competitions {
compName := strings.TrimSpace(c.Name)
if compName == "" {
compName = strings.TrimSpace(c.Code)
}
for _, m := range c.Matches {
dt := strings.TrimSpace(m.DateTime)
if dt == "" {
continue
}
// dt like "12.08.2023 18:00"
parts := strings.SplitN(dt, " ", 2)
d := parts[0]
t := ""
if len(parts) > 1 { t = parts[1] }
// parse date dd.mm.yyyy
dd := strings.Split(d, ".")
if len(dd) < 3 { continue }
day := dd[0]
month := dd[1]
year := dd[2]
if len(month) == 1 { month = "0" + month }
if len(day) == 1 { day = "0" + day }
isoDate := year + "-" + month + "-" + day
if len(t) >= 5 {
t = t[:5]
} else {
t = "18:00"
}
// Build time.Time for filtering
ts, err := time.ParseInLocation("2006-01-02 15:04", isoDate+" "+t, time.Local)
if err != nil { continue }
if ts.Before(now) || ts.After(max) { continue }
out = append(out, map[string]any{
"id": m.MatchID,
"home": m.Home,
"away": m.Away,
"competition": compName,
"date": isoDate,
"time": t,
"venue": m.Venue,
"home_logo_url": m.HomeLogoURL,
"away_logo_url": m.AwayLogoURL,
})
}
}
if len(out) == 0 { return false }
_ = writeJSONAtomic(filepath.Join(cacheDir, "matches.json"), out)
return true
}
// Prepare matches.json from FACR data if available; fallback to events_upcoming.json
createdMatches := false
buildMatchesFromFACR := func(data []byte) bool {
type facrMatch struct {
DateTime string `json:"date_time"`
Home string `json:"home"`
Away string `json:"away"`
Venue string `json:"venue"`
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
MatchID string `json:"match_id"`
}
var facr struct {
Competitions []struct {
Name string `json:"name"`
Code string `json:"code"`
Matches []facrMatch `json:"matches"`
} `json:"competitions"`
}
if err := json.Unmarshal(data, &facr); err != nil {
return false
}
// Collect upcoming matches (next 30 days) in simplified format
now := time.Now()
max := now.Add(30 * 24 * time.Hour)
var out []map[string]any
for _, c := range facr.Competitions {
compName := strings.TrimSpace(c.Name)
if compName == "" {
compName = strings.TrimSpace(c.Code)
}
for _, m := range c.Matches {
dt := strings.TrimSpace(m.DateTime)
if dt == "" {
continue
}
// dt like "12.08.2023 18:00"
parts := strings.SplitN(dt, " ", 2)
d := parts[0]
t := ""
if len(parts) > 1 {
t = parts[1]
}
// parse date dd.mm.yyyy
dd := strings.Split(d, ".")
if len(dd) < 3 {
continue
}
day := dd[0]
month := dd[1]
year := dd[2]
if len(month) == 1 {
month = "0" + month
}
if len(day) == 1 {
day = "0" + day
}
isoDate := year + "-" + month + "-" + day
if len(t) >= 5 {
t = t[:5]
} else {
t = "18:00"
}
// Build time.Time for filtering
ts, err := time.ParseInLocation("2006-01-02 15:04", isoDate+" "+t, time.Local)
if err != nil {
continue
}
if ts.Before(now) || ts.After(max) {
continue
}
out = append(out, map[string]any{
"id": m.MatchID,
"home": m.Home,
"away": m.Away,
"competition": compName,
"date": isoDate,
"time": t,
"venue": m.Venue,
"home_logo_url": m.HomeLogoURL,
"away_logo_url": m.AwayLogoURL,
})
}
}
if len(out) == 0 {
return false
}
_ = writeJSONAtomic(filepath.Join(cacheDir, "matches.json"), out)
return true
}
// 2) Dynamic FACR endpoints based on saved settings
settingsPath := filepath.Join(cacheDir, "settings.json")
@@ -651,12 +667,31 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
statuses = append(statuses, epStatus{Path: path, File: file, Ok: false, Error: err.Error()})
} else {
log.Printf("[prefetch] FACR SUCCESS: updated %s", file)
// Post-process logo URLs only when REMBG is enabled
if config.AppConfig != nil && config.AppConfig.RembgEnabled {
// Post-process club info logos to ensure transparent backgrounds on FACR logos
if file == "facr_club_info.json" {
if err := postProcessFACRClubInfoLogos(cacheDir); err != nil {
log.Printf("[prefetch] FACR post-process WARN: %v", err)
}
}
// Post-process tables logos similarly (team_logo_url)
if file == "facr_tables.json" {
if err := postProcessFACRTablesLogos(cacheDir); err != nil {
log.Printf("[prefetch] FACR tables post-process WARN: %v", err)
}
}
} else {
log.Printf("[prefetch] REMBG disabled, skipping FACR logo post-processing for %s", file)
}
statuses = append(statuses, epStatus{Path: path, File: file, Ok: true})
}
}
// Try to build matches.json from freshly fetched FACR prefetch file
if b, err := os.ReadFile(filepath.Join(cacheDir, "facr_club_info.json")); err == nil {
if buildMatchesFromFACR(b) { createdMatches = true }
if buildMatchesFromFACR(b) {
createdMatches = true
}
}
} else {
log.Printf("[prefetch] WARNING: FACR skipped: missing club_id=%q or club_type=%q in settings", clubID, clubType)
@@ -666,9 +701,13 @@ func doPrefetchCycle(client *http.Client, baseURL string) {
if !createdMatches && clubID != "" && clubType != "" {
facrCachePath := filepath.Join("cache", "facr", fmt.Sprintf("%s_%s_info.json", clubType, clubID))
if b, err := os.ReadFile(facrCachePath); err == nil {
var cached struct{ Data []byte `json:"data"` }
var cached struct {
Data []byte `json:"data"`
}
if jsonErr := json.Unmarshal(b, &cached); jsonErr == nil && len(cached.Data) > 0 {
if buildMatchesFromFACR(cached.Data) { createdMatches = true }
if buildMatchesFromFACR(cached.Data) {
createdMatches = true
}
}
}
}
@@ -700,22 +739,22 @@ var prefetchInFlight int32
var prefetchPending int32
func doPrefetchCycleGuarded(client *http.Client, baseURL string) {
if !atomic.CompareAndSwapInt32(&prefetchInFlight, 0, 1) {
// Mark a rerun so we don't lose triggers (e.g., from setup) while a cycle is running
atomic.StoreInt32(&prefetchPending, 1)
log.Printf("[prefetch] in-flight: marked pending rerun")
return
}
defer func() {
atomic.StoreInt32(&prefetchInFlight, 0)
// If a trigger arrived during this run, execute one more cycle immediately
if atomic.SwapInt32(&prefetchPending, 0) == 1 {
// Small delay to allow DB/cache writes to settle
time.Sleep(500 * time.Millisecond)
doPrefetchCycleGuarded(client, baseURL)
}
}()
doPrefetchCycle(client, baseURL)
if !atomic.CompareAndSwapInt32(&prefetchInFlight, 0, 1) {
// Mark a rerun so we don't lose triggers (e.g., from setup) while a cycle is running
atomic.StoreInt32(&prefetchPending, 1)
log.Printf("[prefetch] in-flight: marked pending rerun")
return
}
defer func() {
atomic.StoreInt32(&prefetchInFlight, 0)
// If a trigger arrived during this run, execute one more cycle immediately
if atomic.SwapInt32(&prefetchPending, 0) == 1 {
// Small delay to allow DB/cache writes to settle
time.Sleep(500 * time.Millisecond)
doPrefetchCycleGuarded(client, baseURL)
}
}()
doPrefetchCycle(client, baseURL)
}
func isDuringMatch(cacheDir string) bool {
@@ -935,7 +974,7 @@ func fetchYouTubeChannel(channel string) error {
func RegenerateFlatGalleryFiles() error {
cacheDir := filepath.Join("cache", "prefetch")
albumsFile := filepath.Join(cacheDir, "zonerama_albums.json")
// Read albums
data, err := os.ReadFile(albumsFile)
if err != nil {
@@ -945,7 +984,7 @@ func RegenerateFlatGalleryFiles() error {
emptyJSON, _ := json.MarshalIndent(emptyList, "", " ")
_ = os.WriteFile(filepath.Join(cacheDir, "gallery.json"), emptyJSON, 0644)
_ = os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json"), emptyJSON, 0644)
hdr := map[string]string{
"fetched_at": time.Now().Format(time.RFC3339),
"link": "",
@@ -956,7 +995,7 @@ func RegenerateFlatGalleryFiles() error {
}
return fmt.Errorf("failed to read albums: %w", err)
}
// Define album and photo structures
type ZoneramaPhoto struct {
ID string `json:"id"`
@@ -970,26 +1009,26 @@ func RegenerateFlatGalleryFiles() error {
Photos []ZoneramaPhoto `json:"photos"`
FetchedAt string `json:"fetched_at,omitempty"`
}
var albums []ZoneramaAlbum
if err := json.Unmarshal(data, &albums); err != nil {
return fmt.Errorf("failed to parse albums: %w", err)
}
// Flatten all photos from all albums
type FlatPhoto struct {
ID string `json:"id"`
AlbumID string `json:"album_id"`
PageURL string `json:"page_url"`
Local string `json:"local"`
Src string `json:"src"`
Title string `json:"title"`
ID string `json:"id"`
AlbumID string `json:"album_id"`
PageURL string `json:"page_url"`
Local string `json:"local"`
Src string `json:"src"`
Title string `json:"title"`
}
var flatPhotos []FlatPhoto
var latestFetchTime time.Time
var sourceLink string
for _, album := range albums {
// Track the most recent fetch time
if album.FetchedAt != "" {
@@ -1000,7 +1039,7 @@ func RegenerateFlatGalleryFiles() error {
}
}
}
for _, photo := range album.Photos {
flatPhotos = append(flatPhotos, FlatPhoto{
ID: photo.ID,
@@ -1012,7 +1051,7 @@ func RegenerateFlatGalleryFiles() error {
})
}
}
// Write gallery.json
galleryJSON, err := json.MarshalIndent(flatPhotos, "", " ")
if err != nil {
@@ -1021,18 +1060,18 @@ func RegenerateFlatGalleryFiles() error {
if err := os.WriteFile(filepath.Join(cacheDir, "gallery.json"), galleryJSON, 0644); err != nil {
return fmt.Errorf("failed to write gallery.json: %w", err)
}
// Write zonerama_flat.json (same content for compatibility)
if err := os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json"), galleryJSON, 0644); err != nil {
return fmt.Errorf("failed to write zonerama_flat.json: %w", err)
}
// Write header file with metadata
fetchedAt := latestFetchTime.Format(time.RFC3339)
if fetchedAt == "0001-01-01T00:00:00Z" {
fetchedAt = time.Now().Format(time.RFC3339)
}
hdr := map[string]string{
"fetched_at": fetchedAt,
"link": sourceLink,
@@ -1044,7 +1083,7 @@ func RegenerateFlatGalleryFiles() error {
if err := os.WriteFile(filepath.Join(cacheDir, "zonerama_flat.json.hdr"), hdrJSON, 0644); err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
log.Printf("[gallery] Regenerated flat gallery files: %d photos from %d albums", len(flatPhotos), len(albums))
return nil
}
@@ -1120,3 +1159,207 @@ func fetchToFile(client *http.Client, url string, outPath string) error {
}
return nil
}
// postProcessFACRClubInfoLogos rewrites home_logo_url/away_logo_url in facr_club_info.json
// to point to locally cached transparent PNGs produced by rembg.
// Best-effort: logs warning on failure and preserves original file.
func postProcessFACRClubInfoLogos(cacheDir string) error {
p := filepath.Join(cacheDir, "facr_club_info.json")
b, err := os.ReadFile(p)
if err != nil {
return err
}
// Fast check: if file is tiny or empty, skip
if len(strings.TrimSpace(string(b))) == 0 {
return fmt.Errorf("facr_club_info.json empty")
}
// Parse into a minimal shape we care about
var payload struct {
Competitions []struct {
Matches []struct {
HomeLogoURL string `json:"home_logo_url"`
AwayLogoURL string `json:"away_logo_url"`
} `json:"matches"`
} `json:"competitions"`
}
if err := json.Unmarshal(b, &payload); err != nil {
return fmt.Errorf("parse facr_club_info: %w", err)
}
// Build a map of original -> processed URL to avoid duplicate processing
seen := make(map[string]string, 64)
changed := false
// Walk and rewrite
for ci := range payload.Competitions {
for mi := range payload.Competitions[ci].Matches {
h := strings.TrimSpace(payload.Competitions[ci].Matches[mi].HomeLogoURL)
if h != "" {
if rep, ok := seen[h]; ok {
if rep != h && rep != "" {
payload.Competitions[ci].Matches[mi].HomeLogoURL = rep
changed = true
}
} else {
if newURL, err := ProcessFACRLogo(h); err == nil && newURL != "" && newURL != h {
seen[h] = newURL
payload.Competitions[ci].Matches[mi].HomeLogoURL = newURL
changed = true
} else {
seen[h] = h
}
}
}
a := strings.TrimSpace(payload.Competitions[ci].Matches[mi].AwayLogoURL)
if a != "" {
if rep, ok := seen[a]; ok {
if rep != a && rep != "" {
payload.Competitions[ci].Matches[mi].AwayLogoURL = rep
changed = true
}
} else {
if newURL, err := ProcessFACRLogo(a); err == nil && newURL != "" && newURL != a {
seen[a] = newURL
payload.Competitions[ci].Matches[mi].AwayLogoURL = newURL
changed = true
} else {
seen[a] = a
}
}
}
}
}
if !changed {
return nil
}
// Merge back into original JSON to preserve other fields
// Unmarshal original into generic map, then overlay changes
var orig map[string]any
if err := json.Unmarshal(b, &orig); err != nil {
return fmt.Errorf("parse orig for merge: %w", err)
}
// Rebuild competitions with rewritten logos
if comps, ok := orig["competitions"].([]any); ok {
for i := range comps {
comp, _ := comps[i].(map[string]any)
if comp == nil {
continue
}
if matches, ok2 := comp["matches"].([]any); ok2 {
for j := range matches {
m, _ := matches[j].(map[string]any)
if m == nil {
continue
}
// Safely get strings
hv := ""
av := ""
if s, ok3 := m["home_logo_url"].(string); ok3 {
hv = s
}
if s, ok3 := m["away_logo_url"].(string); ok3 {
av = s
}
if rep, ok3 := seen[hv]; ok3 && rep != "" && rep != hv {
m["home_logo_url"] = rep
}
if rep, ok3 := seen[av]; ok3 && rep != "" && rep != av {
m["away_logo_url"] = rep
}
}
}
}
}
// Write back atomically
if err := writeJSONAtomic(p, orig); err != nil {
return err
}
return nil
}
// postProcessFACRTablesLogos rewrites team_logo_url entries in facr_tables.json
// to point to locally cached transparent PNGs produced by rembg.
// Best-effort: logs warning on failure and preserves original file.
func postProcessFACRTablesLogos(cacheDir string) error {
p := filepath.Join(cacheDir, "facr_tables.json")
b, err := os.ReadFile(p)
if err != nil {
return err
}
// Fast check: if file is tiny or empty, skip
if len(strings.TrimSpace(string(b))) == 0 {
return fmt.Errorf("facr_tables.json empty")
}
// Parse minimal shape
var payload struct {
Competitions []struct {
Table struct {
Overall []struct {
TeamLogoURL string `json:"team_logo_url"`
} `json:"overall"`
} `json:"table"`
} `json:"competitions"`
}
if err := json.Unmarshal(b, &payload); err != nil {
return fmt.Errorf("parse facr_tables: %w", err)
}
seen := make(map[string]string, 64)
changed := false
for ci := range payload.Competitions {
rows := payload.Competitions[ci].Table.Overall
for ri := range rows {
s := strings.TrimSpace(rows[ri].TeamLogoURL)
if s == "" {
continue
}
if rep, ok := seen[s]; ok {
if rep != "" && rep != s {
payload.Competitions[ci].Table.Overall[ri].TeamLogoURL = rep
changed = true
}
} else {
if purl, err := ProcessFACRLogo(s); err == nil && strings.TrimSpace(purl) != "" && purl != s {
seen[s] = purl
payload.Competitions[ci].Table.Overall[ri].TeamLogoURL = purl
changed = true
} else {
seen[s] = s
}
}
}
}
if !changed {
return nil
}
// Merge back into original JSON preserving other fields
var orig map[string]any
if err := json.Unmarshal(b, &orig); err != nil {
return fmt.Errorf("parse orig for merge: %w", err)
}
if comps, ok := orig["competitions"].([]any); ok {
for i := range comps {
comp, _ := comps[i].(map[string]any)
if comp == nil {
continue
}
tbl, _ := comp["table"].(map[string]any)
if tbl == nil {
continue
}
overall, _ := tbl["overall"].([]any)
for j := range overall {
row, _ := overall[j].(map[string]any)
if row == nil {
continue
}
if s, ok3 := row["team_logo_url"].(string); ok3 {
if rep, ok := seen[s]; ok && rep != "" && rep != s {
row["team_logo_url"] = rep
}
}
}
}
}
if err := writeJSONAtomic(p, orig); err != nil {
return err
}
return nil
}