mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
upload
This commit is contained in:
@@ -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:])
|
||||
}
|
||||
Reference in New Issue
Block a user