This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
+364
View File
@@ -0,0 +1,364 @@
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 {
// Ensure settings table has the SEO fields
_ = db.AutoMigrate(&models.Settings{})
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:])
}