mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-03 18:22:57 +00:00
365 lines
12 KiB
Go
365 lines
12 KiB
Go
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:])
|
|
}
|