This commit is contained in:
Tomas Dvorak
2025-10-29 21:20:16 +01:00
parent 823fabee02
commit 16e4533202
61 changed files with 2308 additions and 942 deletions
+272 -18
View File
@@ -2266,8 +2266,6 @@ func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
c.JSON(http.StatusOK, item)
}
// ProxyImage streams a remote image to the client to avoid browser CORS restrictions for Canvas operations
// GET /api/v1/proxy/image?url=<remote_image_url>
func (bc *BaseController) ProxyImage(c *gin.Context) {
raw := c.Query("url")
if raw == "" {
@@ -2287,8 +2285,15 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request init failed"})
return
}
// Some CDNs require a UA
req.Header.Set("User-Agent", "fotbal-club/1.0 (+https://localhost)")
// Use realistic browser headers - some CDNs block unknown clients
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36")
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
req.Header.Set("Accept-Language", "cs-CZ,cs;q=0.9,en;q=0.8")
// Set a benign referer tied to the target host to satisfy anti-hotlink checks
if u.Host != "" {
ref := u.Scheme + "://" + u.Host + "/"
req.Header.Set("Referer", ref)
}
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "fetch failed"})
@@ -2366,6 +2371,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
ClubLogoURL string `json:"club_logo_url"`
ClubURL string `json:"club_url"`
// Optional bases
FrontendBaseURL string `json:"frontend_base_url"`
APIBaseURL string `json:"api_base_url"`
// Social profiles (optional)
FacebookURL string `json:"facebook_url"`
InstagramURL string `json:"instagram_url"`
@@ -2473,6 +2482,45 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
if body.FrontpageStyle != "" {
s.FrontpageStyle = body.FrontpageStyle
}
// Detect and persist base URLs
if v := strings.TrimSpace(body.APIBaseURL); v != "" {
s.APIBaseURL = v
}
if v := strings.TrimSpace(body.FrontendBaseURL); v != "" {
s.FrontendBaseURL = v
}
// If not provided, infer from current request and proxy headers
{
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
parts := strings.Split(xf, ",")
if len(parts) > 0 {
if h := strings.TrimSpace(parts[0]); h != "" { host = h }
}
}
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
if strings.TrimSpace(s.APIBaseURL) == "" {
s.APIBaseURL = scheme + "://" + host + "/api/v1"
}
if strings.TrimSpace(s.FrontendBaseURL) == "" {
if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" {
s.FrontendBaseURL = origin
} else {
s.FrontendBaseURL = scheme + "://" + host
}
}
}
// SMTP overrides from initial setup
if body.SMTP != nil {
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
@@ -2594,7 +2642,11 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
_ = os.WriteFile(tmp, b, 0o644)
_ = os.Rename(tmp, outPath)
}(s)
go services.PrefetchOnce(getPrefetchBaseURL())
{
base := strings.TrimSpace(s.APIBaseURL)
if base == "" { base = getPrefetchBaseURL() }
go services.PrefetchOnce(strings.TrimRight(base, "/"))
}
if strings.TrimSpace(s.YoutubeURL) != "" {
go func(u string) { _ = services.RefreshYouTubeChannelNow(u) }(s.YoutubeURL)
}
@@ -2603,7 +2655,7 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
}
c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena"})
c.JSON(http.StatusOK, gin.H{"message": "Inicializace již byla provedena", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL})
return
}
@@ -2666,6 +2718,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
From string `json:"from"`
UseTLS *bool `json:"use_tls"`
} `json:"smtp"`
// Optional bases
FrontendBaseURL string `json:"frontend_base_url"`
APIBaseURL string `json:"api_base_url"`
}
var body reqBody
if err := c.ShouldBindJSON(&body); err != nil {
@@ -2806,6 +2862,44 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
if body.FrontpageStyle != "" {
s.FrontpageStyle = body.FrontpageStyle
}
// Persist base URLs (prefer request body, otherwise infer)
if v := strings.TrimSpace(body.APIBaseURL); v != "" {
s.APIBaseURL = v
}
if v := strings.TrimSpace(body.FrontendBaseURL); v != "" {
s.FrontendBaseURL = v
}
{
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
parts := strings.Split(xf, ",")
if len(parts) > 0 {
if h := strings.TrimSpace(parts[0]); h != "" { host = h }
}
}
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
if strings.TrimSpace(s.APIBaseURL) == "" {
s.APIBaseURL = scheme + "://" + host + "/api/v1"
}
if strings.TrimSpace(s.FrontendBaseURL) == "" {
if origin := strings.TrimSpace(c.Request.Header.Get("Origin")); origin != "" {
s.FrontendBaseURL = origin
} else {
s.FrontendBaseURL = scheme + "://" + host
}
}
}
// SMTP overrides from initial setup
if body.SMTP != nil {
if v := strings.TrimSpace(body.SMTP.Host); v != "" {
@@ -2940,12 +3034,13 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
logger.Info("Starting initial data prefetch and setup operations in background...")
// Run all setup operations in a single background goroutine
go func(settingsID uint, youtubeURL, galleryURL, adminEmail string) {
go func(settingsID uint, youtubeURL, galleryURL, adminEmail string, apiBase string) {
defer func() { _ = recover() }()
// 1. Trigger prefetch (matches, standings, etc.)
baseURL := getPrefetchBaseURL()
services.PrefetchOnce(baseURL)
baseURL := strings.TrimSpace(apiBase)
if baseURL == "" { baseURL = getPrefetchBaseURL() }
services.PrefetchOnce(strings.TrimRight(baseURL, "/"))
logger.Info("Background prefetch completed")
// Auto-populate competition aliases from FACR data
@@ -2984,10 +3079,10 @@ func (bc *BaseController) SetupInitialize(c *gin.Context) {
}
logger.Info("All background setup operations completed")
}(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email)
}(s.ID, s.YoutubeURL, s.GalleryURL, admin.Email, s.APIBaseURL)
logger.Info("SetupInitialize finished successfully - background operations running")
c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully"})
c.JSON(http.StatusOK, gin.H{"message": "Setup completed successfully", "frontend_base_url": s.FrontendBaseURL, "api_base_url": s.APIBaseURL})
}
// UpdateSettings updates settings (upsert singleton)
@@ -3103,6 +3198,10 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
// Homepage matches display configuration
FinishedMatchDisplayDays *int `json:"finished_match_display_days"`
// Deployment base URLs (optional, for domain/IP change)
FrontendBaseURL *string `json:"frontend_base_url"`
APIBaseURL *string `json:"api_base_url"`
}
var body reqBody
if err := c.ShouldBindJSON(&body); err != nil {
@@ -3404,6 +3503,14 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
s.FinishedMatchDisplayDays = *body.FinishedMatchDisplayDays
}
// Deployment base URLs
if body.FrontendBaseURL != nil {
s.FrontendBaseURL = strings.TrimSpace(*body.FrontendBaseURL)
}
if body.APIBaseURL != nil {
s.APIBaseURL = strings.TrimSpace(*body.APIBaseURL)
}
if s.ID == 0 {
if err := bc.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit nastavení"})
@@ -3425,10 +3532,11 @@ func (bc *BaseController) UpdateSettings(c *gin.Context) {
}
logger.Info("UpdateSettings saved: club_id=%s club_name=%s gallery_url=%s gallery_label=%s", s.ClubID, s.ClubName, s.GalleryURL, s.GalleryLabel)
// Best-effort: trigger prefetch so cached settings.json and dependent files update immediately
go func() {
base := getPrefetchBaseURL()
services.PrefetchOnce(base)
}()
go func(urlFromSettings string) {
base := strings.TrimSpace(urlFromSettings)
if base == "" { base = getPrefetchBaseURL() }
services.PrefetchOnce(strings.TrimRight(base, "/"))
}(s.APIBaseURL)
// If gallery_url is a Zonerama link, refresh Zonerama cache immediately
if g := strings.TrimSpace(s.GalleryURL); g != "" && strings.Contains(strings.ToLower(g), "zonerama.com") {
go func(link string) { _ = services.RefreshZoneramaNow(link) }(g)
@@ -3569,6 +3677,9 @@ func (bc *BaseController) GetPublicSettings(c *gin.Context) {
"map_zoom_level": s.MapZoomLevel,
"map_style": s.MapStyle,
"show_map_on_homepage": s.ShowMapOnHomepage,
// Deployment base URLs (hints for frontend tooling)
"frontend_base_url": s.FrontendBaseURL,
"api_base_url": s.APIBaseURL,
}
logger.Debug("GetPublicSettings response includes gallery: url=%s label=%s", s.GalleryURL, s.GalleryLabel)
c.JSON(http.StatusOK, resp)
@@ -4087,6 +4198,127 @@ func (bc *BaseController) DeleteSponsor(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"zprava": "Sponzor byl smazán"})
}
// Banners (separate from sponsors)
func (bc *BaseController) GetBanners(c *gin.Context) {
var items []models.Banner
q := bc.DB.Model(&models.Banner{})
activeOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("active", "true"))) != "false"
if activeOnly {
q = q.Where("is_active = ?", true)
}
if p := strings.TrimSpace(c.Query("placement")); p != "" {
q = q.Where("placement = ?", p)
}
if err := q.Order("display_order ASC, created_at ASC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
c.JSON(http.StatusOK, items)
}
func (bc *BaseController) CreateBanner(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
ImageURL string `json:"image_url"`
ClickURL string `json:"click_url"`
Placement string `json:"placement"`
Width *int `json:"width"`
Height *int `json:"height"`
IsActive *bool `json:"is_active"`
DisplayOrder *int `json:"display_order"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru je povinný"})
return
}
item := models.Banner{
Name: name,
ImageURL: strings.TrimSpace(body.ImageURL),
ClickURL: strings.TrimSpace(body.ClickURL),
Placement: strings.TrimSpace(body.Placement),
IsActive: true,
}
if body.Width != nil { item.Width = *body.Width }
if body.Height != nil { item.Height = *body.Height }
if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder }
if body.IsActive != nil { item.IsActive = *body.IsActive }
if err := bc.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit banner"})
return
}
c.JSON(http.StatusCreated, item)
}
func (bc *BaseController) UpdateBanner(c *gin.Context) {
id := c.Param("id")
var item models.Banner
if err := bc.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
var body struct {
Name *string `json:"name"`
ImageURL *string `json:"image_url"`
ClickURL *string `json:"click_url"`
Placement *string `json:"placement"`
Width *int `json:"width"`
Height *int `json:"height"`
IsActive *bool `json:"is_active"`
DisplayOrder *int `json:"display_order"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
if body.Name != nil {
v := strings.TrimSpace(*body.Name)
if v == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název banneru nemůže být prázdný"})
return
}
item.Name = v
}
if body.ImageURL != nil { item.ImageURL = strings.TrimSpace(*body.ImageURL) }
if body.ClickURL != nil { item.ClickURL = strings.TrimSpace(*body.ClickURL) }
if body.Placement != nil { item.Placement = strings.TrimSpace(*body.Placement) }
if body.Width != nil { item.Width = *body.Width }
if body.Height != nil { item.Height = *body.Height }
if body.IsActive != nil { item.IsActive = *body.IsActive }
if body.DisplayOrder != nil { item.DisplayOrder = *body.DisplayOrder }
if err := bc.DB.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat banner"})
return
}
c.JSON(http.StatusOK, item)
}
func (bc *BaseController) DeleteBanner(c *gin.Context) {
id := c.Param("id")
var item models.Banner
if err := bc.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Banner nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
if err := bc.DB.Delete(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat banner"})
return
}
c.JSON(http.StatusOK, gin.H{"zprava": "Banner byl smazán"})
}
func (bc *BaseController) UploadImage(c *gin.Context) {
f, err := c.FormFile("file")
if err != nil {
@@ -4150,15 +4382,37 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
// Build absolute URL from request (supports proxies)
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") {
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
scheme = "https"
}
host := c.Request.Host
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
host = xf
// Take the first value if comma-separated
parts := strings.Split(xf, ",")
if len(parts) > 0 {
h := strings.TrimSpace(parts[0])
if h != "" { host = h }
}
}
// Append forwarded port when host has no explicit port and it's non-default
if !strings.Contains(host, ":") {
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
host = host + ":" + xfp
}
}
}
absolute := scheme + "://" + host + urlPath
c.JSON(http.StatusOK, gin.H{"url": absolute})
c.JSON(http.StatusOK, gin.H{
// Always return a backend-relative path for storage
"url": urlPath,
// Convenience absolute URL for immediate usage in UIs
"absolute_url": absolute,
// Basic metadata (best-effort)
"name": outName,
"type": mimeType,
"size": f.Size,
})
}
// Global newsletter automation instance (set from main)