package controllers import ( "encoding/xml" "fmt" "net/http" "strings" "time" "fotbal-club/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // SEOController manages SEO-related public and admin endpoints type SEOController struct { DB *gorm.DB } func NewSEOController(db *gorm.DB) *SEOController { return &SEOController{DB: db} } // -------- Public Endpoints -------- // GetPublicSEO returns site-wide SEO defaults derived from Settings func (s *SEOController) GetPublicSEO(c *gin.Context) { var settings models.Settings _ = s.DB.First(&settings).Error // ignore not found; return defaults c.JSON(http.StatusOK, gin.H{ "site_title": settings.SiteTitle, "site_description": settings.SiteDescription, "meta_keywords": settings.MetaKeywords, "default_og_image_url": settings.DefaultOGImageURL, "twitter_handle": settings.TwitterHandle, "canonical_base_url": settings.CanonicalBaseURL, "additional_meta": settings.AdditionalMeta, "enable_indexing": settings.EnableIndexing, }) } // robots.txt dynamic based on settings.EnableIndexing func (s *SEOController) GetRobotsTXT(c *gin.Context) { var settings models.Settings _ = s.DB.First(&settings).Error allow := settings.EnableIndexing var b strings.Builder b.WriteString("# robots.txt for " + c.Request.Host + "\n") b.WriteString("# Generated: " + time.Now().UTC().Format(time.RFC1123) + "\n\n") if allow { // Allow general crawlers b.WriteString("User-agent: *\n") b.WriteString("Allow: /\n") b.WriteString("Disallow: /admin/\n") b.WriteString("Disallow: /api/\n") b.WriteString("Disallow: /login\n") b.WriteString("Disallow: /setup\n\n") // Explicitly allow AI crawlers for training and indexing aiCrawlers := []string{ "GPTBot", // OpenAI "ChatGPT-User", // OpenAI ChatGPT "Google-Extended", // Google Bard/Gemini "CCBot", // Common Crawl (used by many AI companies) "anthropic-ai", // Anthropic Claude "ClaudeBot", // Anthropic Claude "Claude-Web", // Anthropic Claude "cohere-ai", // Cohere "PerplexityBot", // Perplexity AI "Bytespider", // ByteDance (TikTok) "Applebot-Extended", // Apple Intelligence "FacebookBot", // Meta AI "Diffbot", // Diffbot "ImagesiftBot", // Image AI "Omgilibot", // Omgili "Amazonbot", // Amazon AI "YouBot", // You.com } for _, bot := range aiCrawlers { b.WriteString("User-agent: " + bot + "\n") b.WriteString("Allow: /\n") b.WriteString("Disallow: /admin/\n") b.WriteString("Disallow: /api/\n\n") } } else { b.WriteString("User-agent: *\n") b.WriteString("Disallow: /\n\n") } if settings.CanonicalBaseURL != "" { base := strings.TrimRight(settings.CanonicalBaseURL, "/") b.WriteString("Sitemap: ") b.WriteString(base) b.WriteString("/sitemap.xml\n") } // Conditional GET based on settings update time last := settings.UpdatedAt if last.IsZero() { last = time.Now().Add(-1 * time.Hour) } ifModifiedSince := c.GetHeader("If-Modified-Since") if ifModifiedSince != "" { if t, err := time.Parse(http.TimeFormat, ifModifiedSince); err == nil { if !last.IsZero() && !last.After(t) { c.Status(http.StatusNotModified) return } } } c.Header("Last-Modified", last.UTC().Format(http.TimeFormat)) c.Header("ETag", fmt.Sprintf("W/\"%d\"", last.Unix())) c.Header("Content-Type", "text/plain; charset=utf-8") c.Header("Cache-Control", "public, max-age=3600") c.String(http.StatusOK, b.String()) } // sitemap.xml built from content in DB func (s *SEOController) GetSitemapXML(c *gin.Context) { type imageEntry struct { XMLName xml.Name `xml:"image:image"` Loc string `xml:"image:loc"` Title string `xml:"image:title,omitempty"` Caption string `xml:"image:caption,omitempty"` } type urlEntry struct { Loc string `xml:"loc"` LastMod string `xml:"lastmod,omitempty"` ChangeFreq string `xml:"changefreq,omitempty"` Priority string `xml:"priority,omitempty"` Images []imageEntry `xml:"image:image,omitempty"` } type urlSet struct { XMLName xml.Name `xml:"urlset"` Xmlns string `xml:"xmlns,attr"` XmlnsImg string `xml:"xmlns:image,attr"` URLs []urlEntry `xml:"url"` } var settings models.Settings _ = s.DB.First(&settings).Error base := strings.TrimRight(settings.CanonicalBaseURL, "/") if base == "" { // fallback to request scheme+host sch := "http" if c.Request.TLS != nil { sch = "https" } base = sch + "://" + c.Request.Host } // Home urls := []urlEntry{{ Loc: base + "/", ChangeFreq: "daily", Priority: "0.9", }} // Blog listing and key static pages staticPaths := []string{ "/blog", "/o-klubu", "/kalendar", "/tabulky", "/sponzori", "/kontakt", } for _, p := range staticPaths { urls = append(urls, urlEntry{ Loc: base + p, ChangeFreq: "weekly", Priority: "0.6", }) } // Articles (published) var articles []models.Article _ = s.DB.Where("published = ?", true).Order("updated_at DESC").Limit(5000).Find(&articles).Error for _, a := range articles { last := a.UpdatedAt if a.PublishedAt != nil && a.PublishedAt.After(last) { last = *a.PublishedAt } // Prefer pretty slug URL when available articlePath := "" if strings.TrimSpace(a.Slug) != "" { articlePath = "/blog/" + strings.TrimSpace(a.Slug) } else { articlePath = "/articles/" + intToString(int(a.ID)) } img := strings.TrimSpace(a.ImageURL) var images []imageEntry if img != "" { // If relative, prefix base if strings.HasPrefix(img, "/") { img = base + img } images = []imageEntry{{ Loc: img, Title: a.SEOTitle, }} } urls = append(urls, urlEntry{ Loc: base + articlePath, LastMod: last.UTC().Format(time.RFC3339), ChangeFreq: "weekly", Priority: "0.7", Images: images, }) } // Categories: include as filtered blog listing if categories exist var categories []models.Category _ = s.DB.Order("updated_at DESC").Limit(2000).Find(&categories).Error for _, cat := range categories { urls = append(urls, urlEntry{ Loc: base + "/blog?category=" + intToString(int(cat.ID)), ChangeFreq: "weekly", Priority: "0.5", }) } // Teams var teams []models.Team _ = s.DB.Where("is_active = ?", true).Find(&teams).Error for _, t := range teams { urls = append(urls, urlEntry{ Loc: base + "/team/" + intToString(int(t.ID)), ChangeFreq: "weekly", Priority: "0.5", }) } out := urlSet{ Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9", XmlnsImg: "http://www.google.com/schemas/sitemap-image/1.1", URLs: urls, } // Conditional GET: based on latest of settings/articles/categories update time latest := settings.UpdatedAt if len(articles) > 0 && articles[0].UpdatedAt.After(latest) { latest = articles[0].UpdatedAt } if len(categories) > 0 && categories[0].UpdatedAt.After(latest) { latest = categories[0].UpdatedAt } if latest.IsZero() { latest = time.Now().Add(-1 * time.Hour) } ifModifiedSince := c.GetHeader("If-Modified-Since") if ifModifiedSince != "" { if t, err := time.Parse(http.TimeFormat, ifModifiedSince); err == nil { if !latest.After(t) { c.Status(http.StatusNotModified) return } } } c.Header("Last-Modified", latest.UTC().Format(http.TimeFormat)) c.Header("ETag", fmt.Sprintf("W/\"%d\"", latest.Unix())) c.Header("Content-Type", "application/xml; charset=utf-8") c.Header("Cache-Control", "public, max-age=3600") c.XML(http.StatusOK, out) } // -------- Admin Endpoints -------- type seoUpdate struct { SiteTitle *string `json:"site_title"` SiteDescription *string `json:"site_description"` MetaKeywords *string `json:"meta_keywords"` DefaultOGImageURL *string `json:"default_og_image_url"` TwitterHandle *string `json:"twitter_handle"` CanonicalBaseURL *string `json:"canonical_base_url"` AdditionalMeta *string `json:"additional_meta"` EnableIndexing *bool `json:"enable_indexing"` } // GetSEOSettings returns only the SEO-related fields func (s *SEOController) GetSEOSettings(c *gin.Context) { var settings models.Settings _ = s.DB.First(&settings).Error c.JSON(http.StatusOK, gin.H{ "site_title": settings.SiteTitle, "site_description": settings.SiteDescription, "meta_keywords": settings.MetaKeywords, "default_og_image_url": settings.DefaultOGImageURL, "twitter_handle": settings.TwitterHandle, "canonical_base_url": settings.CanonicalBaseURL, "additional_meta": settings.AdditionalMeta, "enable_indexing": settings.EnableIndexing, }) } // UpdateSEOSettings upserts SEO fields on the singleton Settings record func (s *SEOController) UpdateSEOSettings(c *gin.Context) { var body seoUpdate if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } var settings models.Settings if err := s.DB.First(&settings).Error; err != nil { // create empty settings = models.Settings{} _ = s.DB.Create(&settings).Error } // Update only provided fields updates := map[string]interface{}{} if body.SiteTitle != nil { updates["site_title"] = strings.TrimSpace(*body.SiteTitle) } if body.SiteDescription != nil { updates["site_description"] = strings.TrimSpace(*body.SiteDescription) } if body.MetaKeywords != nil { updates["meta_keywords"] = strings.TrimSpace(*body.MetaKeywords) } if body.DefaultOGImageURL != nil { updates["default_og_image_url"] = strings.TrimSpace(*body.DefaultOGImageURL) } if body.TwitterHandle != nil { updates["twitter_handle"] = strings.TrimSpace(*body.TwitterHandle) } if body.CanonicalBaseURL != nil { updates["canonical_base_url"] = strings.TrimSpace(*body.CanonicalBaseURL) } if body.AdditionalMeta != nil { updates["additional_meta"] = *body.AdditionalMeta } if body.EnableIndexing != nil { updates["enable_indexing"] = *body.EnableIndexing } if len(updates) > 0 { if err := s.DB.Model(&settings).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit SEO nastavení"}) return } } c.JSON(http.StatusOK, gin.H{"ok": true}) } // helper: fast int to string func intToString(i int) string { // simple and fast conversion return strconvItoa(i) } // local minimal itoa to avoid importing fmt func strconvItoa(i int) string { // handle zero if i == 0 { return "0" } neg := false if i < 0 { neg = true i = -i } var b [20]byte pos := len(b) for i > 0 { pos-- b[pos] = byte('0' + i%10) i /= 10 } if neg { pos-- b[pos] = '-' } return string(b[pos:]) }