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
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