This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
+10
View File
@@ -76,6 +76,11 @@ type Config struct {
UmamiUsername string
UmamiPassword string
UmamiWebsiteID string // If empty, will auto-create on production
// Antivirus (optional)
ClamAVEnabled bool
ClamAVHost string
ClamAVPort int
}
var AppConfig *Config
@@ -174,6 +179,11 @@ func LoadConfig() {
UmamiUsername: getEnv("UMAMI_USERNAME", ""),
UmamiPassword: getEnv("UMAMI_PASSWORD", ""),
UmamiWebsiteID: getEnv("UMAMI_WEBSITE_ID", ""),
// Antivirus (optional)
ClamAVEnabled: getEnvAsBool("CLAMAV_ENABLED", false),
ClamAVHost: getEnv("CLAMAV_HOST", "127.0.0.1"),
ClamAVPort: getEnvAsInt("CLAMAV_PORT", 3310),
}
// Override allowed origins if specified in environment (comma-separated)
+148
View File
@@ -298,6 +298,154 @@ type aiCSSResponse struct {
CSS string `json:"css"`
}
// Instagram caption generation
type aiInstaMatch struct {
Home string `json:"home"`
Away string `json:"away"`
Competition string `json:"competition"`
DateTime string `json:"date_time"`
Venue string `json:"venue"`
Score string `json:"score"`
}
type aiInstagramRequest struct {
Type string `json:"type"` // "article" | "event" | "generic"
Title string `json:"title"`
Content string `json:"content"` // plain text, HTML will be ignored
ClubName string `json:"club_name"`
Link string `json:"link"`
Hashtags []string `json:"hashtags"`
Audience string `json:"audience"`
Tone string `json:"tone"`
Match *aiInstaMatch `json:"match"`
}
type aiInstagramResponse struct {
Text string `json:"text"`
}
// GenerateInstagram creates an Instagram caption in Czech using OpenRouter
func (ac *AIController) GenerateInstagram(c *gin.Context) {
var req aiInstagramRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Normalize
t := strings.ToLower(strings.TrimSpace(req.Type))
if t == "" { t = "article" }
club := strings.TrimSpace(req.ClubName)
if club == "" { club = "Náš klub" }
audience := strings.TrimSpace(req.Audience)
if audience == "" { audience = "fanoušci klubu" }
tone := strings.TrimSpace(req.Tone)
if tone == "" { tone = "informativní, přátelský" }
// Build system and user messages
system := "Jsi zkušený český social media copywriter pro fotbalový klub. Píšeš poutavé, ale profesionální popisky na Instagram v gramaticky správné češtině (bez neologismů). Buď konkrétní, z textu vyber to nejdůležitější, vyhni se klišé. Výsledek vrať POUZE JSON: {\"text\": \"...\"}."
// Compose contextual notes
var notes []string
if req.Title != "" { notes = append(notes, "Titulek: "+req.Title) }
if strings.TrimSpace(req.Content) != "" { notes = append(notes, "Obsah (zkrácený): "+strings.TrimSpace(req.Content)) }
if req.Match != nil {
m := req.Match
line := []string{}
if m.Home != "" || m.Away != "" { line = append(line, fmt.Sprintf("%s vs %s", strings.TrimSpace(m.Home), strings.TrimSpace(m.Away))) }
if strings.TrimSpace(m.Score) != "" { line = append(line, "Výsledek: "+strings.TrimSpace(m.Score)) }
if strings.TrimSpace(m.Competition) != "" { line = append(line, strings.TrimSpace(m.Competition)) }
if strings.TrimSpace(m.DateTime) != "" { line = append(line, strings.TrimSpace(m.DateTime)) }
if strings.TrimSpace(m.Venue) != "" { line = append(line, "Místo: "+strings.TrimSpace(m.Venue)) }
if len(line) > 0 { notes = append(notes, "Zápas: "+strings.Join(line, " • ")) }
}
if strings.TrimSpace(req.Link) != "" { notes = append(notes, "Krátký odkaz: "+strings.TrimSpace(req.Link)) }
if len(req.Hashtags) > 0 { notes = append(notes, "Preferované hashtagy: "+strings.Join(req.Hashtags, ", ")) }
// Hard requirements
requirements := []string{
"Délka 80140 slov, rozdělit do 23 krátkých odstavců.",
"Použij maximálně 6 emotikonů (žádné dlouhé řetězy).",
"Na konec vlož oddělovač a řádek '🔗 ' následovaný přesně poskytnutým krátkým odkazem.",
"Přidej 46 relevantních českých hashtagů (včetně klubového), přirozeně na konci.",
"Drž se zadaného obsahu. Bez vymýšlení neexistujících informací.",
fmt.Sprintf("Tón: %s. Publikum: %s.", tone, audience),
}
// Build user prompt
user := fmt.Sprintf("Typ: %s\nKlub: %s\n\nPoznámky:\n- %s\n\nPožadavky:\n- %s\n\nVrať POUZE JSON bez formátování.", t, club, strings.Join(notes, "\n- "), strings.Join(requirements, "\n- "))
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
model := getOpenRouterModel()
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
callModel := func(modelName string) (string, int, error) {
payload := map[string]interface{}{
"model": modelName,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"temperature": 0.5,
"max_tokens": 800,
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil { return "", http.StatusInternalServerError, err }
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(reqHTTP)
if err != nil { return "", http.StatusBadGateway, err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&e)
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
}
var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` }
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err }
if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") }
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
}
content, _, err := callModel(model)
if err != nil || strings.TrimSpace(content) == "" {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return }
if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}); return }
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return
}
}
sanitized := sanitizeAIResponse(content)
var out aiInstagramResponse
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
if strings.TrimSpace(out.Text) == "" {
// minimal fallback
txt := req.Title
if txt == "" { txt = "Novinky z klubu" }
out.Text = fmt.Sprintf("%s\n\n🔗 %s", txt, strings.TrimSpace(req.Link))
}
c.JSON(http.StatusOK, out)
}
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
func (ac *AIController) GenerateBlog(c *gin.Context) {
var req aiBlogRequest
+16 -2
View File
@@ -116,8 +116,22 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
var category models.Category
err := ac.DB.Where("name = ?", categoryName).First(&category).Error
if err == gorm.ErrRecordNotFound {
// Create new category
category = models.Category{Name: categoryName}
// Create new category with unique slug derived from name
s := makeSlug(categoryName)
if s == "" {
s = fmt.Sprintf("category-%d", time.Now().Unix())
}
orig := s
for i := 0; i < 50; i++ {
var sc int64
if err := ac.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 { break }
s = fmt.Sprintf("%s-%d", orig, i+1)
}
category = models.Category{Name: categoryName, Slug: s}
if err := ac.DB.Create(&category).Error; err != nil {
logger.Error("CreateArticle: Error creating category '%s': %v", categoryName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit kategorii"})
+371 -393
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"net"
"os"
"path/filepath"
"regexp"
@@ -39,6 +40,75 @@ type BaseController struct {
DB *gorm.DB
}
// GetArticleBySlug returns a single article by slug (public)
func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
slug := strings.TrimSpace(c.Param("slug"))
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"})
return
}
var art models.Article
if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" }
if art.ReadTime == 0 { art.ReadTime = computeEstimatedReadMinutes(art.Content) }
var matchLink models.ArticleMatchLink
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
art.MatchLink = &matchLink
}
var aliases []models.CompetitionAlias
_ = bc.DB.Find(&aliases).Error
bc.addArticleComputedFields(&art, aliases)
c.JSON(http.StatusOK, art)
}
// respondArticlesFromCache tries to serve articles from on-disk cache and returns true if it did.
func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool {
// Helper: read JSON file and respond with pagination
readAndRespond := func(path string) bool {
b, err := os.ReadFile(path)
if err != nil { return false }
// Try payload {items: [...]} first
var wrap struct{ Items []models.Article `json:"items"` }
if json.Unmarshal(b, &wrap) == nil && len(wrap.Items) > 0 {
items := wrap.Items
total := len(items)
start := (page - 1) * size
if start < 0 { start = 0 }
if start > total { start = total }
end := start + size
if end > total { end = total }
paged := items[start:end]
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
return true
}
// Fallback: raw array of articles
var arr []models.Article
if json.Unmarshal(b, &arr) == nil && len(arr) > 0 {
total := len(arr)
start := (page - 1) * size
if start < 0 { start = 0 }
if start > total { start = total }
end := start + size
if end > total { end = total }
paged := arr[start:end]
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
return true
}
return false
}
// Try blogs cache first, then prefetch
if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) { return true }
if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) { return true }
return false
}
func makeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
@@ -832,329 +902,22 @@ func (bc *BaseController) GetArticle(c *gin.Context) {
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
art.MatchLink = &matchLink
}
// Compute helper fields (category_slug, competition_alias, normalized_category, url)
var aliases []models.CompetitionAlias
_ = bc.DB.Find(&aliases).Error
bc.addArticleComputedFields(&art, aliases)
c.JSON(http.StatusOK, art)
}
// GetCategories returns a list of all categories (public)
func (bc *BaseController) GetCategories(c *gin.Context) {
var items []models.Category
if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
c.JSON(http.StatusOK, items)
}
// CreateCategory creates a new category (admin only)
func (bc *BaseController) CreateCategory(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
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 kategorie je povinný"})
return
}
// Check if category with same name already exists
var existing models.Category
if err := bc.DB.Where("name = ?", name).First(&existing).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
return
}
cat := models.Category{
Name: name,
Description: strings.TrimSpace(body.Description),
}
// Ensure category slug is set and unique
s := makeSlug(cat.Name)
if s == "" {
s = "category"
}
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat.Slug = s
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
}
c.JSON(http.StatusCreated, cat)
}
// UpdateCategory updates an existing category (admin only)
func (bc *BaseController) UpdateCategory(c *gin.Context) {
id := c.Param("id")
var cat models.Category
if err := bc.DB.First(&cat, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
if body.Name != nil {
name := strings.TrimSpace(*body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie nemůže být prázdný"})
return
}
// Check if another category with same name exists
var existing models.Category
if err := bc.DB.Where("name = ? AND id != ?", name, id).First(&existing).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
return
}
cat.Name = name
}
if body.Description != nil {
cat.Description = strings.TrimSpace(*body.Description)
}
if err := bc.DB.Save(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"})
return
}
c.JSON(http.StatusOK, cat)
}
// DeleteCategory deletes a category (admin only)
func (bc *BaseController) DeleteCategory(c *gin.Context) {
id := c.Param("id")
var cat models.Category
if err := bc.DB.First(&cat, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
// Check if any articles are using this category
var articleCount int64
if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", id).Count(&articleCount).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole článků"})
return
}
if articleCount > 0 {
c.JSON(http.StatusConflict, gin.H{
"chyba": "Nelze smazat kategorii, která obsahuje články",
"detail": fmt.Sprintf("Kategorie obsahuje %d článků", articleCount),
})
return
}
if err := bc.DB.Delete(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
return
}
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
}
// GetArticleBySlug returns a single article by slug (public)
func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
slug := strings.TrimSpace(c.Param("slug"))
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"})
return
}
var art models.Article
if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
if art.ImageURL == "" {
art.ImageURL = "/dist/img/logo-club-empty.svg"
}
if art.ReadTime == 0 {
art.ReadTime = computeEstimatedReadMinutes(art.Content)
}
// Load match link if exists
var matchLink models.ArticleMatchLink
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
art.MatchLink = &matchLink
}
c.JSON(http.StatusOK, art)
}
// writeArticlesCache writes a JSON snapshot of PUBLISHED articles to cache/blogs/articles.json
// Shape: { "items": [Article], "total": N, "page": 1, "page_size": N }
func (bc *BaseController) writeArticlesCache() {
// Load only published articles ordered by published_at desc, created_at desc
var items []models.Article
if err := bc.DB.Where("published = ?", true).Order("published_at DESC, created_at DESC").Find(&items).Error; err != nil {
return
}
// Ensure image fallback
for i := range items {
if items[i].ImageURL == "" {
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
}
}
payload := map[string]any{
"items": items,
"total": len(items),
"page": 1,
"page_size": len(items),
}
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return
}
dir := filepath.Join("cache", "blogs")
_ = os.MkdirAll(dir, 0o755)
tmp := filepath.Join(dir, "articles.json.tmp")
dst := filepath.Join(dir, "articles.json")
if err := os.WriteFile(tmp, b, 0o644); err == nil {
_ = os.Rename(tmp, dst)
}
}
// respondArticlesFromCache attempts to read cache/blogs/articles.json (or cache/prefetch/articles.json) and respond.
// Returns true if a response was written.
func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool {
// Helper to page slice safely
pageSlice := func(arr []map[string]any, page, size int) []map[string]any {
if size <= 0 {
size = len(arr)
}
if page <= 0 {
page = 1
}
start := (page - 1) * size
if start >= len(arr) {
return []map[string]any{}
}
end := start + size
if end > len(arr) {
end = len(arr)
}
return arr[start:end]
}
readAndRespond := func(p string) bool {
f, err := os.Open(p)
if err != nil {
return false
}
defer f.Close()
var raw map[string]any
if err := json.NewDecoder(f).Decode(&raw); err != nil {
return false
}
// Normalize items array
var arr []map[string]any
if its, ok := raw["items"].([]any); ok {
for _, it := range its {
if m, ok := it.(map[string]any); ok {
arr = append(arr, m)
}
}
} else if its, ok := raw["data"].([]any); ok {
for _, it := range its {
if m, ok := it.(map[string]any); ok {
arr = append(arr, m)
}
}
}
if len(arr) == 0 {
return false
}
// Optional filters from query
publishedOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("published", "false"))) == "true"
featuredOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("featured", "false"))) == "true"
if publishedOnly || featuredOnly {
filtered := make([]map[string]any, 0, len(arr))
for _, m := range arr {
if publishedOnly {
if v, ok := m["published"].(bool); !ok || !v {
continue
}
}
if featuredOnly {
if v, ok := m["featured"].(bool); !ok || !v {
continue
}
}
filtered = append(filtered, m)
}
arr = filtered
}
if len(arr) == 0 {
return false
}
total := len(arr)
paged := pageSlice(arr, page, size)
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
return true
}
// Try blogs cache first
if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) {
return true
}
// Fallback to prefetch cache if available
if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) {
return true
}
return false
}
// GetArticles returns a paginated list of articles (public by default, admin can request all with published=false)
func (bc *BaseController) GetArticles(c *gin.Context) {
pageStr := strings.TrimSpace(c.DefaultQuery("page", "1"))
sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "10"))
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
if page < 1 { page = 1 }
size, _ := strconv.Atoi(sizeStr)
if size <= 0 {
size = 10
}
if size > 100 {
size = 100
}
if size <= 0 { size = 10 }
if size > 100 { size = 100 }
pParam := strings.ToLower(strings.TrimSpace(c.Query("published")))
featuredParam := strings.ToLower(strings.TrimSpace(c.Query("featured"))) == "true"
@@ -1163,70 +926,38 @@ func (bc *BaseController) GetArticles(c *gin.Context) {
catRaw := strings.TrimSpace(c.Query("category_id"))
matchID := strings.TrimSpace(c.Query("match_id"))
monthStr := strings.TrimSpace(c.Query("month"))
if monthStr == "" {
monthStr = strings.TrimSpace(c.Query("date"))
}
if monthStr == "" { monthStr = strings.TrimSpace(c.Query("date")) }
catID := 0
if catRaw != "" {
if v, err := strconv.Atoi(catRaw); err == nil {
catID = v
}
}
if catRaw != "" { if v, err := strconv.Atoi(catRaw); err == nil { catID = v } }
skipCache := false
if pParam == "false" {
skipCache = true
}
if q != "" || slug != "" || catID > 0 || matchID != "" || monthStr != "" {
skipCache = true
}
if pParam == "false" { skipCache = true }
if q != "" || slug != "" || catID > 0 || matchID != "" || monthStr != "" { skipCache = true }
if !skipCache {
if bc.respondArticlesFromCache(c, page, size) {
return
}
if bc.respondArticlesFromCache(c, page, size) { return }
}
var items []models.Article
qb := bc.DB.Model(&models.Article{})
if pParam == "" || pParam == "true" {
qb = qb.Where("published = ?", true)
}
if featuredParam {
qb = qb.Where("featured = ?", true)
}
if catID > 0 {
qb = qb.Where("category_id = ?", catID)
}
if slug != "" {
qb = qb.Where("slug = ?", slug)
}
if pParam == "" || pParam == "true" { qb = qb.Where("published = ?", true) }
if featuredParam { qb = qb.Where("featured = ?", true) }
if catID > 0 { qb = qb.Where("category_id = ?", catID) }
if slug != "" { qb = qb.Where("slug = ?", slug) }
if q != "" {
like := "%" + strings.ToLower(q) + "%"
qb = qb.Where("LOWER(title) LIKE ? OR LOWER(content) LIKE ? OR LOWER(category_name) LIKE ?", like, like, like)
}
if matchID != "" {
qb = qb.Joins("JOIN article_match_links aml ON aml.article_id = articles.id").Where("aml.external_match_id = ?", matchID)
}
if matchID != "" { qb = qb.Joins("JOIN article_match_links aml ON aml.article_id = articles.id").Where("aml.external_match_id = ?", matchID) }
if monthStr != "" {
var y, m int
if len(monthStr) >= 7 {
if yy, err := strconv.Atoi(monthStr[0:4]); err == nil {
y = yy
}
if mm, err := strconv.Atoi(monthStr[5:7]); err == nil {
m = mm
}
if yy, err := strconv.Atoi(monthStr[0:4]); err == nil { y = yy }
if mm, err := strconv.Atoi(monthStr[5:7]); err == nil { m = mm }
}
if y > 0 && m >= 1 && m <= 12 {
start := time.Date(y, time.Month(m), 1, 0, 0, 0, 0, time.UTC)
nm := m + 1
ny := y
if nm == 13 {
nm = 1
ny = y + 1
}
nm := m + 1; ny := y
if nm == 13 { nm = 1; ny = y + 1 }
end := time.Date(ny, time.Month(nm), 1, 0, 0, 0, 0, time.UTC)
qb = qb.Where("COALESCE(published_at, created_at) >= ? AND COALESCE(published_at, created_at) < ?", start, end)
}
@@ -1237,23 +968,20 @@ func (bc *BaseController) GetArticles(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
if err := qb.Preload("Author").Preload("Category").
Order("COALESCE(published_at, created_at) DESC, created_at DESC").
Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil {
Limit(size).Offset((page-1)*size).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
for i := range items {
if items[i].ImageURL == "" {
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
}
if items[i].ReadTime == 0 {
items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content)
}
if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" }
if items[i].ReadTime == 0 { items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content) }
}
// Compute helper fields for list
var aliases []models.CompetitionAlias
_ = bc.DB.Find(&aliases).Error
for i := range items { bc.addArticleComputedFields(&items[i], aliases) }
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}
@@ -1262,16 +990,10 @@ func (bc *BaseController) GetFeaturedArticles(c *gin.Context) {
pageStr := strings.TrimSpace(c.DefaultQuery("page", "1"))
sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "6"))
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
if page < 1 { page = 1 }
size, _ := strconv.Atoi(sizeStr)
if size <= 0 {
size = 6
}
if size > 100 {
size = 100
}
if size <= 0 { size = 6 }
if size > 100 { size = 100 }
var items []models.Article
qb := bc.DB.Model(&models.Article{}).
@@ -1285,19 +1007,56 @@ func (bc *BaseController) GetFeaturedArticles(c *gin.Context) {
}
if err := qb.Preload("Author").Preload("Category").
Order("COALESCE(published_at, created_at) DESC, created_at DESC").
Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil {
Limit(size).Offset((page-1)*size).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
for i := range items {
if items[i].ImageURL == "" {
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" }
if items[i].ReadTime == 0 { items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content) }
}
// Compute helper fields for list
var aliases []models.CompetitionAlias
_ = bc.DB.Find(&aliases).Error
for i := range items { bc.addArticleComputedFields(&items[i], aliases) }
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}
// addArticleComputedFields populates non-persisted helper fields on the Article JSON
func (bc *BaseController) addArticleComputedFields(a *models.Article, aliases []models.CompetitionAlias) {
// Category slug
if a.Category != nil && strings.TrimSpace(a.Category.Slug) != "" {
a.CategorySlug = strings.TrimSpace(a.Category.Slug)
} else if strings.TrimSpace(a.CategoryName) != "" {
a.CategorySlug = makeSlug(a.CategoryName)
} else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" {
a.CategorySlug = makeSlug(a.Category.Name)
}
// Normalized category (for fast matching on FE)
if strings.TrimSpace(a.CategoryName) != "" {
a.NormalizedCategory = foldAccents(a.CategoryName)
} else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" {
a.NormalizedCategory = foldAccents(a.Category.Name)
}
// URL path
if strings.TrimSpace(a.Slug) != "" { a.URL = "/news/" + strings.TrimSpace(a.Slug) } else { a.URL = fmt.Sprintf("/articles/%d", a.ID) }
// Competition alias mapping (match category against alias or original name)
cat := strings.TrimSpace(a.CategoryName)
if cat == "" && a.Category != nil {
cat = strings.TrimSpace(a.Category.Name)
}
if cat == "" || len(aliases) == 0 { return }
ncat := foldAccents(cat)
for _, al := range aliases {
aliasNorm := foldAccents(al.Alias)
origNorm := foldAccents(al.OriginalName)
if aliasNorm != "" {
if strings.Contains(ncat, aliasNorm) || strings.Contains(aliasNorm, ncat) { a.CompetitionAlias = al.Alias; return }
}
if items[i].ReadTime == 0 {
items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content)
if origNorm != "" {
if strings.Contains(ncat, origNorm) || strings.Contains(origNorm, ncat) { a.CompetitionAlias = al.Alias; return }
}
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}
// UpdateArticle updates an existing article (protected)
@@ -1363,7 +1122,22 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
var cat models.Category
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
if err == gorm.ErrRecordNotFound {
cat = models.Category{Name: name}
// Create category with a unique slug derived from name
s := makeSlug(name)
if s == "" {
s = fmt.Sprintf("category-%d", time.Now().Unix())
}
orig := s
for i := 0; i < 50; i++ {
var sc int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 { break }
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat = models.Category{Name: name, Slug: s}
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
@@ -1473,7 +1247,7 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
}
if err := bc.DB.Save(&art).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek"})
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek", "detail": err.Error()})
return
}
@@ -2317,6 +2091,42 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
return
}
// Basic SSRF hardening: block internal/private destinations and unusual ports
host := u.Hostname()
port := u.Port()
if port != "" && port != "80" && port != "443" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported port"})
return
}
ips, err := net.LookupIP(host)
if err == nil {
blockedCIDRs := []string{
"127.0.0.0/8", // loopback
"10.0.0.0/8", // private
"172.16.0.0/12", // private
"192.168.0.0/16",// private
"169.254.0.0/16",// link-local
"::1/128", // IPv6 loopback
"fc00::/7", // IPv6 unique local
"fe80::/10", // IPv6 link-local
}
var nets []*net.IPNet
for _, cidr := range blockedCIDRs {
_, n, perr := net.ParseCIDR(cidr)
if perr == nil {
nets = append(nets, n)
}
}
for _, ip := range ips {
for _, n := range nets {
if n.Contains(ip) {
c.JSON(http.StatusBadRequest, gin.H{"error": "destination not allowed"})
return
}
}
}
}
// Fetch with a short timeout
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", u.String(), nil)
@@ -2367,6 +2177,16 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
}
}
// Enforce a reasonable maximum size when Content-Length is provided (8MB)
if cl := resp.Header.Get("Content-Length"); cl != "" {
if n, err := strconv.Atoi(cl); err == nil {
if n > 8*1024*1024 {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image too large"})
return
}
}
}
// Stream response
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Cache-Control", "public, max-age=86400")
@@ -4386,6 +4206,154 @@ func (bc *BaseController) DeleteBanner(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"zprava": "Banner byl smazán"})
}
func (bc *BaseController) GetCategories(c *gin.Context) {
var items []models.Category
if err := bc.DB.Order("name 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) CreateCategory(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Slug string `json:"slug"`
}
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 kategorie je povinný"})
return
}
var cnt int64
_ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ?", strings.ToLower(name)).Count(&cnt).Error
if cnt > 0 {
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
return
}
slug := strings.TrimSpace(body.Slug)
if slug == "" {
slug = makeSlug(name)
} else {
slug = makeSlug(slug)
}
orig := slug
for i := 0; i < 50; i++ {
var sc int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 {
break
}
slug = fmt.Sprintf("%s-%d", orig, i+1)
}
item := models.Category{
Name: name,
Description: strings.TrimSpace(body.Description),
Slug: slug,
}
if err := bc.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
}
c.JSON(http.StatusCreated, item)
}
func (bc *BaseController) UpdateCategory(c *gin.Context) {
id := c.Param("id")
var item models.Category
if err := bc.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
Slug *string `json:"slug"`
}
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 kategorie nemůže být prázdný"})
return
}
var cnt int64
_ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ? AND id != ?", strings.ToLower(v), item.ID).Count(&cnt).Error
if cnt > 0 {
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
return
}
item.Name = v
}
if body.Description != nil {
item.Description = strings.TrimSpace(*body.Description)
}
if body.Slug != nil {
s := strings.TrimSpace(*body.Slug)
if s == "" {
s = makeSlug(item.Name)
} else {
s = makeSlug(s)
}
orig := s
for i := 0; i < 50; i++ {
var sc int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ? AND id != ?", s, item.ID).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
item.Slug = s
}
if err := bc.DB.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"})
return
}
c.JSON(http.StatusOK, item)
}
func (bc *BaseController) DeleteCategory(c *gin.Context) {
id := c.Param("id")
var item models.Category
if err := bc.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
var cnt int64
if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", item.ID).Count(&cnt).Error; err == nil && cnt > 0 {
c.JSON(http.StatusConflict, gin.H{"chyba": "Nelze smazat kategorii s přiřazenými články", "detail": cnt})
return
}
if err := bc.DB.Delete(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
return
}
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
}
func (bc *BaseController) UploadImage(c *gin.Context) {
f, err := c.FormFile("file")
if err != nil {
@@ -4405,9 +4373,9 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
return
}
// Light content sniffing to ensure the uploaded payload matches the declared extension
// (helps mitigate mislabelled uploads). We only read the first few bytes.
// and basic SVG sanitization (reject obvious script/event patterns).
if src, err := f.Open(); err == nil {
buf := make([]byte, 512)
buf := make([]byte, 2048)
n, _ := src.Read(buf)
_ = src.Close()
detected := http.DetectContentType(buf[:n])
@@ -4416,11 +4384,8 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
case ".pdf":
validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream"
case ".svg":
// Many servers label SVGs inconsistently; allow svg+xml, xml, or text/plain
dl := strings.ToLower(detected)
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
validCT = strings.Contains(strings.ToLower(detected), "image/svg+xml") || strings.Contains(strings.ToLower(detected), "xml") || strings.HasPrefix(strings.ToLower(detected), "text/")
default:
// Common images should report image/*
validCT = strings.HasPrefix(detected, "image/")
}
if !validCT {
@@ -4428,6 +4393,19 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
return
}
}
// Additional SVG content check against common script vectors
if ext == ".svg" {
if src2, err := f.Open(); err == nil {
defer src2.Close()
check := make([]byte, 65536)
n, _ := io.ReadFull(src2, check)
lower := strings.ToLower(string(check[:n]))
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
return
}
}
}
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
+508
View File
@@ -0,0 +1,508 @@
package controllers
import (
"net/http"
"strings"
"time"
"encoding/json"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
)
type CommentController struct{ DB *gorm.DB }
// ReportComment allows a user to report a comment with an optional reason
func (cc *CommentController) ReportComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
var body struct{ Reason string `json:"reason"` }
_ = c.ShouldBindJSON(&body)
uid, _ := c.Get("userID")
// Prevent duplicate reports by same user
var exists models.CommentReport
if err := cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).First(&exists).Error; err == nil && exists.ID != 0 {
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
rep := models.CommentReport{ CommentID: cm.ID, UserID: uid.(uint), Reason: strings.TrimSpace(body.Reason) }
if err := cc.DB.Create(&rep).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// React to a comment (auth)
func (cc *CommentController) React(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
var body struct{ Type string `json:"type"` }
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Type) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
uid, _ := c.Get("userID")
// delete previous reaction for this user
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
// create new
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
if err := cc.DB.Create(&r).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Remove reaction (auth)
func (cc *CommentController) Unreact(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
uid, _ := c.Get("userID")
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: list all comments with filters
func (cc *CommentController) AdminList(c *gin.Context) {
var items []models.Comment
q := cc.DB.Preload("User").Model(&models.Comment{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
if v := strings.TrimSpace(c.Query("target_type")); v != "" { q = q.Where("target_type = ?", v) }
if v := strings.TrimSpace(c.Query("target_id")); v != "" { q = q.Where("target_id = ?", v) }
if v := strings.TrimSpace(c.Query("user_id")); v != "" { q = q.Where("user_id = ?", v) }
page := parseIntDefault(c.Query("page"), 1)
size := parseIntDefault(c.Query("page_size"), 50)
if size > 200 { size = 200 }
var total int64
_ = q.Count(&total).Error
_ = q.Order("created_at DESC").Offset((page-1)*size).Limit(size).Find(&items).Error
// Preload reports counts
ids := make([]uint, 0, len(items))
for _, it := range items { ids = append(ids, it.ID) }
repCounts := map[uint]int{}
if len(ids) > 0 {
type pr struct{ CommentID uint; Cnt int }
var rows []pr
_ = cc.DB.Table("comment_reports").Select("comment_id, COUNT(*) as cnt").Where("comment_id IN ?", ids).Group("comment_id").Scan(&rows).Error
for _, r := range rows { repCounts[r.CommentID] = r.Cnt }
}
out := make([]commentOutput, 0, len(items))
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; out = append(out, co) }
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
// Admin: update comment status (visible|hidden)
func (cc *CommentController) AdminUpdateStatus(c *gin.Context) {
id := c.Param("id")
var body struct{ Status string `json:"status"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
if body.Status != "visible" && body.Status != "hidden" { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid status"}); return }
if err := cc.DB.Model(&models.Comment{}).Where("id = ?", id).Update("status", body.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: ban user for period
func (cc *CommentController) AdminBanUser(c *gin.Context) {
var body struct { UserID uint `json:"user_id"`; Reason string `json:"reason"`; DurationHours int `json:"duration_hours"` }
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
var until *time.Time
if body.DurationHours > 0 { t := time.Now().Add(time.Duration(body.DurationHours) * time.Hour); until = &t }
uid, _ := c.Get("userID")
ban := models.CommentBan{ UserID: body.UserID, Reason: strings.TrimSpace(body.Reason), Until: until, CreatedByID: uid.(uint) }
if err := cc.DB.Create(&ban).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Create unban request (auth)
func (cc *CommentController) CreateUnbanRequest(c *gin.Context) {
var body struct { Message string `json:"message"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
uid, _ := c.Get("userID")
req := models.UnbanRequest{ UserID: uid.(uint), Message: strings.TrimSpace(body.Message) }
if err := cc.DB.Create(&req).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: list unban requests
func (cc *CommentController) AdminListUnban(c *gin.Context) {
var items []models.UnbanRequest
_ = cc.DB.Order("created_at DESC").Find(&items).Error
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: resolve unban request
func (cc *CommentController) AdminResolveUnban(c *gin.Context) {
id := c.Param("id")
var body struct { Action string `json:"action"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
uid, _ := c.Get("userID")
var req models.UnbanRequest
if err := cc.DB.First(&req, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
if body.Action != "approve" && body.Action != "reject" { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return }
status := map[string]string{"approve":"approved","reject":"rejected"}[body.Action]
now := time.Now()
if err := cc.DB.Model(&req).Updates(map[string]interface{}{"status": status, "resolved_by_id": uid.(uint), "resolved_at": &now}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
// If approved, remove bans (set until = now)
if status == "approved" {
_ = cc.DB.Model(&models.CommentBan{}).Where("user_id = ? AND (until IS NULL OR until > ?)", req.UserID, time.Now()).Update("until", now).Error
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func NewCommentController(db *gorm.DB) *CommentController { return &CommentController{DB: db} }
var allowedTargetTypes = map[string]bool{
"article": true,
"event": true,
"gallery_album": true,
"youtube_video": true,
}
type commentOutput struct {
ID uint `json:"id"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
ParentID *uint `json:"parent_id,omitempty"`
Content string `json:"content"`
Status string `json:"status"`
IsEdited bool `json:"is_edited"`
EditedAt *time.Time `json:"edited_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
User userSlim `json:"user"`
Reactions map[string]int `json:"reactions"`
MyReaction string `json:"my_reaction,omitempty"`
SpamScore float32 `json:"spam_score,omitempty"`
SpamRules []string `json:"spam_rules,omitempty"`
Reports int `json:"reports,omitempty"`
}
type userSlim struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
AvatarURL string `json:"avatar_url,omitempty"`
}
func toOutput(c models.Comment) commentOutput {
out := commentOutput{
ID: c.ID,
TargetType: c.TargetType,
TargetID: c.TargetID,
ParentID: c.ParentID,
Content: c.Content,
Status: c.Status,
IsEdited: c.IsEdited,
EditedAt: c.EditedAt,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
User: userSlim{
ID: c.User.ID,
FirstName: c.User.FirstName,
LastName: c.User.LastName,
Email: c.User.Email,
Role: c.User.Role,
},
SpamScore: c.SpamScore,
}
if strings.TrimSpace(c.SpamRules) != "" {
var arr []string
if err := json.Unmarshal([]byte(c.SpamRules), &arr); err == nil { out.SpamRules = arr }
}
return out
}
// GetComments (public): list comments for a target with pagination
// GET /comments?target_type=...&target_id=...&page=1&page_size=20
func (cc *CommentController) GetComments(c *gin.Context) {
// Ensure table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.Comment{})
targetType := strings.TrimSpace(c.Query("target_type"))
targetID := strings.TrimSpace(c.Query("target_id"))
if !allowedTargetTypes[targetType] || targetID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing or invalid target_type/target_id"})
return
}
page := parseIntDefault(c.Query("page"), 1)
pageSize := parseIntDefault(c.Query("page_size"), 20)
if pageSize > 100 { pageSize = 100 }
if page < 1 { page = 1 }
var total int64
q := cc.DB.Model(&models.Comment{}).Where("target_type = ? AND target_id = ? AND status = ?", targetType, targetID, "visible")
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var rows []models.Comment
if err := cc.DB.Preload("User").Where("target_type = ? AND target_id = ? AND status = ?", targetType, targetID, "visible").
Order("created_at ASC").
Offset((page-1)*pageSize).Limit(pageSize).
Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// Build reactions map per comment
out := make([]commentOutput, 0, len(rows))
ids := make([]uint, 0, len(rows))
userIDs := make([]uint, 0, len(rows))
for _, r := range rows { ids = append(ids, r.ID) }
seenU := map[uint]bool{}
for _, r := range rows { if r.UserID != 0 && !seenU[r.UserID] { userIDs = append(userIDs, r.UserID); seenU[r.UserID] = true } }
reactionCounts := make(map[uint]map[string]int)
if len(ids) > 0 {
type rc struct{ CommentID uint; Type string; Cnt int }
var agg []rc
// aggregate per type
if err := cc.DB.Table("comment_reactions").Select("comment_id, type, COUNT(*) as cnt").
Where("comment_id IN ?", ids).Group("comment_id, type").Scan(&agg).Error; err == nil {
for _, a := range agg {
if reactionCounts[a.CommentID] == nil { reactionCounts[a.CommentID] = map[string]int{} }
reactionCounts[a.CommentID][a.Type] = a.Cnt
}
}
}
var myReactions map[uint]string
if uid, ok := c.Get("userID"); ok {
var rs []models.CommentReaction
if err := cc.DB.Where("user_id = ? AND comment_id IN ?", uid, ids).Find(&rs).Error; err == nil {
myReactions = make(map[uint]string, len(rs))
for _, r := range rs { myReactions[r.CommentID] = r.Type }
}
}
// Preload user profiles for avatar (prefer animated when available)
avatarByUser := map[uint]string{}
if len(userIDs) > 0 {
type up struct{ UserID uint; AvatarURL string; AnimatedAvatarURL string }
var profs []up
_ = cc.DB.Table("user_profiles").Select("user_id, avatar_url, animated_avatar_url").Where("user_id IN ?", userIDs).Scan(&profs).Error
for _, p := range profs {
if strings.TrimSpace(p.AnimatedAvatarURL) != "" {
avatarByUser[p.UserID] = p.AnimatedAvatarURL
} else {
avatarByUser[p.UserID] = p.AvatarURL
}
}
}
for _, r := range rows {
co := toOutput(r)
if co.User.ID != 0 {
if av, ok := avatarByUser[co.User.ID]; ok { co.User.AvatarURL = av }
}
if rc, ok := reactionCounts[r.ID]; ok { co.Reactions = rc } else { co.Reactions = map[string]int{} }
if myReactions != nil { if t, ok := myReactions[r.ID]; ok { co.MyReaction = t } }
out = append(out, co)
}
c.JSON(http.StatusOK, gin.H{
"items": out,
"total": total,
"page": page,
"page_size": pageSize,
})
}
type createCommentInput struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Content string `json:"content"`
ParentID *uint `json:"parent_id"`
}
// CreateComment (auth required)
func (cc *CommentController) CreateComment(c *gin.Context) {
// Ensure table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.Comment{})
var in createCommentInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
in.TargetType = strings.TrimSpace(in.TargetType)
in.TargetID = strings.TrimSpace(in.TargetID)
content := strings.TrimSpace(in.Content)
if !allowedTargetTypes[in.TargetType] || in.TargetID == "" || len(content) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing or invalid fields"})
return
}
if len(content) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Komentář je příliš krátký (min. 6 znaků)"})
return
}
if len(content) > 2000 { // hard cap
c.JSON(http.StatusBadRequest, gin.H{"error": "Komentář je příliš dlouhý (max 2000 znaků)"})
return
}
userIDv, _ := c.Get("userID")
userID := userIDv.(uint)
// Check active bans
var activeBan models.CommentBan
if err := cc.DB.Where("user_id = ? AND (until IS NULL OR until > ?)", userID, time.Now()).Order("created_at DESC").First(&activeBan).Error; err == nil && activeBan.ID != 0 {
// User is banned
until := "trvale"
if activeBan.Until != nil { until = activeBan.Until.Format(time.RFC3339) }
c.JSON(http.StatusForbidden, gin.H{"error": "Váš účet má omezené komentování.", "until": until})
return
}
// Spam evaluation and bad words filtering
score, rules := services.EvaluateSpamScore(content)
filtered, _ := services.FilterBadWords(content)
status := "visible"
// Moderation only if sensitive terms detected
if ok, _ := services.ContainsSensitiveWords(filtered); ok {
status = "hidden"
}
rulesJSON, _ := json.Marshal(rules)
cm := models.Comment{
TargetType: in.TargetType,
TargetID: in.TargetID,
UserID: userID,
ParentID: in.ParentID,
Content: filtered,
Status: status,
SpamScore: float32(score),
SpamRules: string(rulesJSON),
}
if err := cc.DB.Create(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment"})
return
}
// Award engagement points for visible comment
if status == "visible" {
svc := services.NewEngagementService(cc.DB)
_, _ = svc.AwardPoints(userID, 5, "comment_create", map[string]interface{}{"comment_id": cm.ID})
_ = svc.CheckAndAwardAchievements(userID)
}
// Reload with user
var out models.Comment
_ = cc.DB.Preload("User").First(&out, cm.ID).Error
c.JSON(http.StatusCreated, toOutput(out))
}
type updateCommentInput struct {
Content string `json:"content"`
}
// UpdateComment (auth: owner or admin)
func (cc *CommentController) UpdateComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.Preload("User").First(&cm, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Permission: owner or admin
role, _ := c.Get("userRole")
uidv, _ := c.Get("userID")
if role != "admin" && uidv.(uint) != cm.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
var in updateCommentInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
content := strings.TrimSpace(in.Content)
if len(content) == 0 || len(content) > 2000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Neplatný obsah"})
return
}
// Re-check ban
var activeBan models.CommentBan
if err := cc.DB.Where("user_id = ? AND (until IS NULL OR until > ?)", cm.UserID, time.Now()).First(&activeBan).Error; err == nil && activeBan.ID != 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "Váš účet má omezené komentování."})
return
}
// Filter & re-evaluate basic spam (do not auto-hide unless sensitive)
score, rules := services.EvaluateSpamScore(content)
filtered, _ := services.FilterBadWords(content)
now := time.Now()
cm.Content = filtered
cm.IsEdited = true
cm.EditedAt = &now
cm.SpamScore = float32(score)
if b, err := json.Marshal(rules); err == nil { cm.SpamRules = string(b) }
if err := cc.DB.Save(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update comment"})
return
}
c.JSON(http.StatusOK, toOutput(cm))
}
// DeleteComment (auth: owner or admin)
func (cc *CommentController) DeleteComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Permission: owner or admin
role, _ := c.Get("userRole")
uidv, _ := c.Get("userID")
if role != "admin" && uidv.(uint) != cm.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
if err := cc.DB.Delete(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete comment"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// helpers
func parseIntDefault(s string, def int) int {
if s == "" { return def }
n := 0
for _, ch := range s { if ch < '0' || ch > '9' { return def } }
for i := 0; i < len(s); i++ { n = n*10 + int(s[i]-'0') }
if n <= 0 { return def }
return n
}
+133 -94
View File
@@ -28,6 +28,78 @@ type ContactController struct {
emailService email.EmailService
}
func NewContactController(db *gorm.DB, emailService email.EmailService) *ContactController {
return &ContactController{DB: db, emailService: emailService}
}
type NewsletterSubscriptionRequest struct {
Email string `json:"email" binding:"required,email"`
Preferences map[string]bool `json:"preferences"`
}
// SubmitContactForm handles public contact form submissions
// POST /api/v1/contact
func (cc *ContactController) SubmitContactForm(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required"`
Subject string `json:"subject" binding:"required"`
Message string `json:"message" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
name := strings.TrimSpace(input.Name)
emailStr := strings.TrimSpace(input.Email)
subject := strings.TrimSpace(input.Subject)
message := strings.TrimSpace(input.Message)
if name == "" || emailStr == "" || subject == "" || message == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "All fields are required"})
return
}
if !strings.Contains(emailStr, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email is required"})
return
}
if s, _ := services.FilterBadWords(subject); s != "" {
subject = s
}
if m, _ := services.FilterBadWords(message); m != "" {
message = m
}
ip := c.ClientIP()
ua := c.GetHeader("User-Agent")
msg := models.ContactMessage{
Name: name,
Email: emailStr,
Subject: subject,
Message: message,
Source: "contact",
IPAddress: ip,
UserAgent: ua,
}
if err := cc.DB.Create(&msg).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"})
return
}
_ = cc.emailService.SendContactForm(&email.ContactFormData{
Name: name,
Email: emailStr,
Subject: subject,
Message: message,
IPAddress: ip,
UserAgent: ua,
})
c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID})
}
func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
@@ -387,11 +459,24 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcomeBack(&email.NewsletterWelcomeBackData{Email: r}) }
case "setup":
for _, r := range recipients {
token, tErr := utils.GenerateSubscriberToken(r, 60*24)
if tErr != nil { logger.Error("Failed to generate token for setup test: %v", tErr); continue }
token, _ := utils.GenerateSubscriberToken(r, 60*24) // 1 day
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
setupEmail := &email.EmailData{Subject: "Test: Nastavte svůj newsletter", To: []string{r}, Template: "newsletter_setup", Data: struct{ SetupURL string }{SetupURL: setupURL}}
// Engagement: if a user already exists for this email, award points
var subUser models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", r).First(&subUser).Error; err == nil && subUser.ID != 0 {
es := services.NewEngagementService(cc.DB)
_, _ = es.AwardPoints(subUser.ID, 12, "newsletter_subscribe", map[string]interface{}{"email": r})
_ = es.CheckAndAwardAchievements(subUser.ID)
}
setupEmail := &email.EmailData{
Subject: "Nastavte svůj newsletter",
To: []string{r},
Template: "newsletter_setup",
Data: struct{ SetupURL string }{SetupURL: setupURL},
}
_ = cc.emailService.SendEmail(setupEmail)
}
case "blogs", "events", "matches", "scores", "weekly":
@@ -409,85 +494,6 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Test email(s) sent", "recipients": recipients, "type": t})
}
func NewContactController(db *gorm.DB, emailService email.EmailService) *ContactController {
return &ContactController{
DB: db,
emailService: emailService,
}
}
type ContactFormRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Subject string `json:"subject" binding:"required"`
Message string `json:"message" binding:"required"`
Source string `json:"source"` // e.g., "contact", "sponsor"
}
// SubmitContactForm handles contact form submissions
// @Summary Submit contact form
// @Description Handles contact form submissions and sends an email notification
// @Tags contact
// @Accept json
// @Produce json
// @Param input body ContactFormRequest true "Contact form data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /api/v1/contact [post]
func (cc *ContactController) SubmitContactForm(c *gin.Context) {
var input ContactFormRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Normalize source field
source := strings.TrimSpace(input.Source)
if source == "" {
source = "contact"
}
// Save to database
contactMessage := models.ContactMessage{
Name: input.Name,
Email: input.Email,
Subject: input.Subject,
Message: input.Message,
Source: source,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
IsRead: false,
}
if err := cc.DB.Create(&contactMessage).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save contact message"})
return
}
// Send email notification asynchronously to prevent frontend timeout
go func() {
emailData := &email.ContactFormData{
Name: input.Name,
Email: input.Email,
Subject: input.Subject,
Message: input.Message,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
if err := cc.emailService.SendContactForm(emailData); err != nil {
logger.Error("Failed to send contact form email: %v", err)
}
}()
c.JSON(http.StatusOK, gin.H{"message": "Your message has been sent successfully"})
}
type NewsletterSubscriptionRequest struct {
Email string `json:"email" binding:"required,email"`
Preferences map[string]bool `json:"preferences"`
}
// SubscribeToNewsletter handles newsletter subscriptions
// @Summary Subscribe to newsletter
// @Description Handles newsletter subscription requests
@@ -527,6 +533,14 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
return
}
// Engagement: award points to matching user (if exists)
var u models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&u).Error; err == nil && u.ID != 0 {
es := services.NewEngagementService(cc.DB)
_, _ = es.AwardPoints(u.ID, 12, "newsletter_subscribe", map[string]interface{}{"email": subscription.Email})
_ = es.CheckAndAwardAchievements(u.ID)
}
// Send welcome back email in a goroutine
go func(sub models.NewsletterSubscription) {
manageURL := ""
@@ -579,18 +593,20 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
UpdatedAt: time.Now(),
}
if err := cc.DB.Create(&subscription).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe to newsletter"})
return
}
// Generate a subscriber token to include in follow-up emails (preferences links)
token, _ := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
// Generate a subscriber token to include in follow-up emails (preferences links)
token, _ := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
// Auto-create fan user account if not exists
// Engagement: if a user already exists for this email, award points
var u models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&u).Error; err == nil && u.ID != 0 {
es := services.NewEngagementService(cc.DB)
_, _ = es.AwardPointsCapped(u.ID, 12, "newsletter_subscribe", map[string]interface{}{"email": subscription.Email})
_ = es.CheckAndAwardAchievements(u.ID)
}
// Auto-create fan user account if not exists
var existingUser models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&existingUser).Error; err == gorm.ErrRecordNotFound {
// Generate a strong random password (16 chars, mixed set)
@@ -643,6 +659,10 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
if err := cc.emailService.SendEmail(credEmail); err != nil {
logger.Error("Failed to send fan account created email: %v", err)
}
// Engagement: award points to new user
es := services.NewEngagementService(cc.DB)
_, _ = es.AwardPoints(u.ID, 12, "newsletter_subscribe", map[string]interface{}{"email": subscription.Email})
_ = es.CheckAndAwardAchievements(u.ID)
}
}
}
@@ -1233,7 +1253,26 @@ func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
}
if len(messages) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No messages to forward"})
// Even if there are no messages now, ensure auto-forward is configured
if !input.SaveDefault {
var set models.Settings
if err := cc.DB.First(&set).Error; err != nil {
if err == gorm.ErrRecordNotFound {
set = models.Settings{}
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Create(&set).Error
}
} else {
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Save(&set).Error
}
}
c.JSON(http.StatusOK, gin.H{
"message": "Automatické přeposílání je nastaveno. Zatím nejsou žádné zprávy k přeposlání.",
"count": 0,
})
return
}
@@ -117,7 +117,14 @@ func (c *EditorPreviewController) ApplyPreviewChanges(ctx *gin.Context) {
"created_at": time.Now(),
"updated_at": time.Now(),
}
// Persist custom styles into settings JSON under settings.styles for compatibility
if len(elem.CustomStyles) > 0 {
config["settings"] = map[string]interface{}{
"styles": elem.CustomStyles,
}
}
if err := tx.Table("page_element_configs").Create(config).Error; err != nil {
tx.Rollback()
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
@@ -0,0 +1,270 @@
package controllers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
)
type EngagementController struct{ DB *gorm.DB }
func NewEngagementController(db *gorm.DB) *EngagementController { return &EngagementController{DB: db} }
// GET /api/v1/engagement/profile (auth)
func (ec *EngagementController) GetProfile(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
svc := services.NewEngagementService(ec.DB)
up, err := svc.EnsureProfile(userID)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load profile"}); return }
// Achievements count
var achCount int64
_ = ec.DB.Model(&models.UserAchievement{}).Where("user_id = ?", userID).Count(&achCount).Error
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"points": up.Points,
"level": up.Level,
"xp": up.XP,
"avatar_url": up.AvatarURL,
"animated_avatar_url": up.AnimatedAvatarURL,
"achievements": achCount,
})
}
// PATCH /api/v1/engagement/avatar (auth)
func (ec *EngagementController) PatchAvatar(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var body struct{ AvatarURL *string `json:"avatar_url"`; AnimatedAvatarURL *string `json:"animated_avatar_url"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
updates := map[string]interface{}{}
if body.AvatarURL != nil { updates["avatar_url"] = strings.TrimSpace(*body.AvatarURL) }
if body.AnimatedAvatarURL != nil { updates["animated_avatar_url"] = strings.TrimSpace(*body.AnimatedAvatarURL) }
if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if err := ec.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update avatar"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/v1/engagement/rewards (public)
func (ec *EngagementController) GetRewards(c *gin.Context) {
var items []models.RewardItem
q := ec.DB.Where("active = ?", true)
if err := q.Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load rewards"}); return }
c.JSON(http.StatusOK, items)
}
// POST /api/v1/engagement/redeem (auth)
func (ec *EngagementController) Redeem(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var body struct{ RewardID uint `json:"reward_id"` }
if err := c.ShouldBindJSON(&body); err != nil || body.RewardID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
var item models.RewardItem
if err := ec.DB.First(&item, body.RewardID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Reward not found"}); return }
if !item.Active { c.JSON(http.StatusBadRequest, gin.H{"error":"Reward is not active"}); return }
if item.Stock == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Out of stock"}); return }
// Ensure profile
svc := services.NewEngagementService(ec.DB)
up, err := svc.EnsureProfile(userID)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load profile"}); return }
if up.Points < item.CostPoints { c.JSON(http.StatusBadRequest, gin.H{"error":"Nedostatek bodů"}); return }
// Transaction: deduct points, reduce stock, create redemption
tx := ec.DB.Begin()
if err := tx.Model(&models.UserProfile{}).Where("user_id = ? AND points >= ?", userID, item.CostPoints).UpdateColumn("points", gorm.Expr("points - ?", item.CostPoints)).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to deduct points"}); return }
if item.Stock > 0 {
if err := tx.Model(&models.RewardItem{}).Where("id = ? AND stock > 0", item.ID).UpdateColumn("stock", gorm.Expr("stock - 1")).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update stock"}); return }
}
red := models.RewardRedemption{ UserID: userID, RewardID: item.ID, Status: "approved" }
if strings.HasPrefix(item.Type, "merch_") || item.Type == "custom" { red.Status = "pending" }
if err := tx.Create(&red).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create redemption"}); return }
// If avatar reward, update profile immediately
if item.Type == "avatar_static" {
_ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("avatar_url", item.ImageURL).Error
}
if item.Type == "avatar_animated" {
_ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("animated_avatar_url", item.ImageURL).Error
}
if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to commit redemption"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true, "status": red.Status})
}
// GET /api/v1/engagement/achievements (auth)
func (ec *EngagementController) GetAchievements(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
// Ensure defaults and award any newly satisfied achievements
svc := services.NewEngagementService(ec.DB)
_ = svc.CheckAndAwardAchievements(userID)
// Load active achievement definitions
var defs []models.Achievement
_ = ec.DB.Where("active = ?", true).Order("id ASC").Find(&defs).Error
// Load user's completed achievements
var userAch []models.UserAchievement
_ = ec.DB.Where("user_id = ?", userID).Find(&userAch).Error
achieved := map[uint]models.UserAchievement{}
for _, ua := range userAch { achieved[ua.AchievementID] = ua }
// Counters for progress
var commentCount int64
_ = ec.DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&commentCount).Error
var voteCount int64
_ = ec.DB.Model(&models.PollVote{}).Where("user_id = ?", userID).Count(&voteCount).Error
hasNewsletter := false
_ = ec.DB.Model(&models.NewsletterSubscription{}).Select("1").Where("LOWER(email) = (SELECT LOWER(email) FROM users WHERE id = ?) AND is_active = ?", userID, true).Limit(1).Scan(&hasNewsletter).Error
// Build response
items := make([]gin.H, 0, len(defs))
for _, d := range defs {
if ua, ok := achieved[d.ID]; ok {
items = append(items, gin.H{
"id": d.ID,
"code": d.Code,
"title": d.Title,
"description": d.Description,
"points": d.Points,
"xp": d.XP,
"icon": d.Icon,
"achieved": true,
"achieved_at": ua.CreatedAt,
})
} else {
items = append(items, gin.H{
"id": d.ID,
"code": d.Code,
"title": d.Title,
"description": d.Description,
"points": d.Points,
"xp": d.XP,
"icon": d.Icon,
"achieved": false,
})
}
}
c.JSON(http.StatusOK, gin.H{
"achievements": items,
"counters": gin.H{
"comments": commentCount,
"votes": voteCount,
"newsletter": hasNewsletter,
},
})
}
// Admin: list rewards
// GET /api/v1/admin/engagement/rewards
func (ec *EngagementController) AdminListRewards(c *gin.Context) {
var items []models.RewardItem
q := ec.DB.Model(&models.RewardItem{})
if v := strings.TrimSpace(c.Query("active")); v != "" {
if v == "true" || v == "1" { q = q.Where("active = ?", true) }
if v == "false" || v == "0" { q = q.Where("active = ?", false) }
}
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load rewards"}); return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: create reward
// POST /api/v1/admin/engagement/rewards
func (ec *EngagementController) AdminCreateReward(c *gin.Context) {
var body struct{
Name string `json:"name"`
Type string `json:"type"`
CostPoints int64 `json:"cost_points"`
ImageURL string `json:"image_url"`
Stock int `json:"stock"`
Active *bool `json:"active"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Type) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
}
item := models.RewardItem{ Name: strings.TrimSpace(body.Name), Type: strings.TrimSpace(body.Type), CostPoints: body.CostPoints, ImageURL: strings.TrimSpace(body.ImageURL), Stock: body.Stock, Active: true }
if body.Active != nil { item.Active = *body.Active }
if body.Metadata != nil { item.Metadata = body.Metadata }
if err := ec.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create reward"}); return }
c.JSON(http.StatusOK, item)
}
// Admin: update reward
// PUT /api/v1/admin/engagement/rewards/:id
func (ec *EngagementController) AdminUpdateReward(c *gin.Context) {
id := c.Param("id")
var body struct{
Name *string `json:"name"`
Type *string `json:"type"`
CostPoints *int64 `json:"cost_points"`
ImageURL *string `json:"image_url"`
Stock *int `json:"stock"`
Active *bool `json:"active"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
updates := map[string]interface{}{}
if body.Name != nil { updates["name"] = strings.TrimSpace(*body.Name) }
if body.Type != nil { updates["type"] = strings.TrimSpace(*body.Type) }
if body.CostPoints != nil { updates["cost_points"] = *body.CostPoints }
if body.ImageURL != nil { updates["image_url"] = strings.TrimSpace(*body.ImageURL) }
if body.Stock != nil { updates["stock"] = *body.Stock }
if body.Active != nil { updates["active"] = *body.Active }
if body.Metadata != nil { updates["metadata"] = body.Metadata }
if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if err := ec.DB.Model(&models.RewardItem{}).Where("id = ?", id).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update reward"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: delete reward
// DELETE /api/v1/admin/engagement/rewards/:id
func (ec *EngagementController) AdminDeleteReward(c *gin.Context) {
id := c.Param("id")
if err := ec.DB.Delete(&models.RewardItem{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to delete reward"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: list redemptions
// GET /api/v1/admin/engagement/redemptions
func (ec *EngagementController) AdminListRedemptions(c *gin.Context) {
var items []models.RewardRedemption
q := ec.DB.Model(&models.RewardRedemption{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load redemptions"}); return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: update redemption status (approve/reject/fulfill)
// PATCH /api/v1/admin/engagement/redemptions/:id
func (ec *EngagementController) AdminUpdateRedemptionStatus(c *gin.Context) {
id := c.Param("id")
var body struct{ Action string `json:"action"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
action := strings.ToLower(strings.TrimSpace(body.Action))
var newStatus string
switch action {
case "approve": newStatus = "approved"
case "reject": newStatus = "rejected"
case "fulfill": newStatus = "fulfilled"
default:
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return
}
if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update status"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "status": newStatus})
}
+61
View File
@@ -23,6 +23,67 @@ type FilesController struct {
DB *gorm.DB
}
func (fc *FilesController) GetStorageUsage(c *gin.Context) {
var usedBytes int64
row := fc.DB.Model(&models.UploadedFile{}).Select("COALESCE(SUM(file_size), 0)").Row()
_ = row.Scan(&usedBytes)
var count int64
fc.DB.Model(&models.UploadedFile{}).Count(&count)
var settings models.Settings
_ = fc.DB.First(&settings).Error
quotaMB := settings.StorageQuotaMB
if quotaMB <= 0 {
quotaMB = 1024
}
warnPct := settings.StorageWarnThreshold
if warnPct <= 0 {
warnPct = 80
}
criticalPct := settings.StorageCriticalThreshold
if criticalPct <= 0 {
criticalPct = 95
}
if warnPct > criticalPct {
warnPct = criticalPct - 5
if warnPct < 0 {
warnPct = 0
}
}
quotaBytes := int64(quotaMB) * 1024 * 1024
percent := 0.0
if quotaBytes > 0 {
percent = float64(usedBytes) * 100.0 / float64(quotaBytes)
}
status := "ok"
if int(percent+0.5) >= criticalPct {
status = "critical"
} else if int(percent+0.5) >= warnPct {
status = "warn"
}
resp := StorageUsageResponse{
UsedBytes: usedBytes,
UsedCount: count,
QuotaMB: quotaMB,
QuotaBytes: quotaBytes,
Percent: percent,
WarnPercent: warnPct,
CriticalPercent: criticalPct,
Status: status,
}
c.JSON(http.StatusOK, resp)
}
type StorageUsageResponse struct {
UsedBytes int64 `json:"used_bytes"`
UsedCount int64 `json:"used_count"`
QuotaMB int `json:"quota_mb"`
QuotaBytes int64 `json:"quota_bytes"`
Percent float64 `json:"percent"`
WarnPercent int `json:"warn_percent"`
CriticalPercent int `json:"critical_percent"`
Status string `json:"status"`
}
// FileInfo represents detailed file information with usage tracking
type FileInfo struct {
ID uint `json:"id"`
+18 -7
View File
@@ -110,7 +110,7 @@ func (ctrl *MyUIbrixController) OptimizePageLayout(c *gin.Context) {
var configs []map[string]interface{}
query := `
SELECT element_name, variant, visible, display_order, custom_styles
SELECT element_name, variant, visible, display_order, settings
FROM page_element_configs
WHERE page_type = ?
ORDER BY display_order ASC
@@ -127,22 +127,33 @@ func (ctrl *MyUIbrixController) OptimizePageLayout(c *gin.Context) {
var elementName, variant string
var visible bool
var displayOrder int
var customStylesJSON []byte
var settingsJSON []byte
if err := rows.Scan(&elementName, &variant, &visible, &displayOrder, &customStylesJSON); err != nil {
if err := rows.Scan(&elementName, &variant, &visible, &displayOrder, &settingsJSON); err != nil {
continue
}
// settings is a JSON object; we expect optional nested key "styles"
var settings map[string]interface{}
if len(settingsJSON) > 0 {
_ = json.Unmarshal(settingsJSON, &settings)
}
var customStyles map[string]interface{}
if len(customStylesJSON) > 0 {
json.Unmarshal(customStylesJSON, &customStyles)
if settings != nil {
if st, ok := settings["styles"]; ok {
if m, ok2 := st.(map[string]interface{}); ok2 {
customStyles = m
}
}
}
configs = append(configs, map[string]interface{}{
"element_name": elementName,
"variant": variant,
"visible": visible,
"variant": variant,
"visible": visible,
"display_order": displayOrder,
// Keep API field name as custom_styles for compatibility
"custom_styles": customStyles,
})
}
@@ -30,7 +30,9 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
// Get only top-level items (no parent) that are visible and NOT admin-only
if err := nc.DB.Where("parent_id IS NULL AND visible = ? AND requires_admin = ?", true, false).
Order("display_order ASC").
Preload("Children", "visible = ? AND requires_admin = ?", true, false).
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("visible = ? AND requires_admin = ?", true, false).Order("display_order ASC")
}).
Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch navigation items"})
return
@@ -63,7 +65,7 @@ func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
var items []models.NavigationItem
if err := nc.DB.Where("parent_id IS NULL").
Order("display_order ASC").
Order("requires_admin ASC, display_order ASC").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Order("display_order ASC")
}).
@@ -1,6 +1,7 @@
package controllers
import (
"fmt"
"fotbal-club/internal/models"
"net/http"
"strconv"
@@ -187,13 +188,38 @@ func (pc *PageElementConfigController) BatchUpdatePageElementConfigs(c *gin.Cont
updated := 0
created := 0
// Validate styles before saving
validator := &StyleValidator{}
for i := range configs {
if len(configs[i].Settings) > 0 {
// ElementSettings is already a map[string]interface{} type
settingsMap := map[string]interface{}(configs[i].Settings)
// Validate and normalize
if err := validator.ValidateAndNormalizeStyles(settingsMap); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Style validation failed for %s: %v", configs[i].ElementName, err)})
return
}
// Update back
configs[i].Settings = models.ElementSettings(settingsMap)
}
}
err := pc.DB.Transaction(func(tx *gorm.DB) error {
for _, cfg := range configs {
var existing models.PageElementConfig
result := tx.Where("page_type = ? AND element_name = ?", cfg.PageType, cfg.ElementName).First(&existing)
if result.Error == nil {
// Update
// Update - merge styles to preserve other settings
if len(cfg.Settings) > 0 && len(existing.Settings) > 0 {
existingMap := map[string]interface{}(existing.Settings)
newMap := map[string]interface{}(cfg.Settings)
mergedMap := validator.MergeStyles(existingMap, newMap)
cfg.Settings = models.ElementSettings(mergedMap)
}
existing.Variant = cfg.Variant
existing.Visible = cfg.Visible
existing.DisplayOrder = cfg.DisplayOrder
@@ -0,0 +1,124 @@
package controllers
import (
"encoding/json"
"fmt"
)
// StyleValidator validates and normalizes style objects before saving
type StyleValidator struct{}
// ValidateAndNormalizeStyles ensures style data is properly structured
func (sv *StyleValidator) ValidateAndNormalizeStyles(settings map[string]interface{}) error {
if settings == nil {
return nil
}
// If styles exist as a top-level key, ensure it's a valid object
if stylesRaw, exists := settings["styles"]; exists {
switch styles := stylesRaw.(type) {
case map[string]interface{}:
// Valid object - ensure all values are valid CSS values
for key, val := range styles {
if !sv.isValidCSSValue(val) {
return fmt.Errorf("invalid CSS value for property '%s': %v", key, val)
}
}
case string:
// Try to parse as JSON
var styleMap map[string]interface{}
if err := json.Unmarshal([]byte(styles), &styleMap); err != nil {
return fmt.Errorf("styles must be a valid JSON object or map")
}
// Replace with parsed object
settings["styles"] = styleMap
default:
return fmt.Errorf("styles must be an object, got %T", styles)
}
}
// Validate customCSS if present
if customCSS, exists := settings["customCSS"]; exists {
if _, ok := customCSS.(string); !ok {
return fmt.Errorf("customCSS must be a string, got %T", customCSS)
}
}
return nil
}
// isValidCSSValue checks if a value is a valid CSS value type
func (sv *StyleValidator) isValidCSSValue(val interface{}) bool {
switch val.(type) {
case string, int, int64, float64, bool:
return true
default:
return false
}
}
// MergeStyles merges new styles into existing settings preserving other fields
func (sv *StyleValidator) MergeStyles(existingSettings map[string]interface{}, newStyles map[string]interface{}) map[string]interface{} {
if existingSettings == nil {
existingSettings = make(map[string]interface{})
}
// If newStyles contains a "styles" key, merge it
if newStylesMap, exists := newStyles["styles"]; exists {
if existingStylesRaw, exists := existingSettings["styles"]; exists {
// Merge with existing styles
if existingStyles, ok := existingStylesRaw.(map[string]interface{}); ok {
if newStylesObj, ok := newStylesMap.(map[string]interface{}); ok {
// Merge newStylesObj into existingStyles
for k, v := range newStylesObj {
existingStyles[k] = v
}
existingSettings["styles"] = existingStyles
} else {
// Replace entirely if type mismatch
existingSettings["styles"] = newStylesMap
}
} else {
// Replace if existing was not a map
existingSettings["styles"] = newStylesMap
}
} else {
// No existing styles - set directly
existingSettings["styles"] = newStyles["styles"]
}
}
// Merge other top-level keys
for k, v := range newStyles {
if k != "styles" {
existingSettings[k] = v
}
}
return existingSettings
}
// ExtractStylesForSave prepares settings object for database save
// Ensures styles are properly nested under settings.styles
func (sv *StyleValidator) ExtractStylesForSave(input map[string]interface{}) map[string]interface{} {
settings := make(map[string]interface{})
// Copy all non-style fields
for k, v := range input {
if k != "styles" && k != "customCSS" {
settings[k] = v
}
}
// Nest styles under styles key
if stylesRaw, exists := input["styles"]; exists {
settings["styles"] = stylesRaw
}
// Also nest customCSS if present
if customCSS, exists := input["customCSS"]; exists {
settings["customCSS"] = customCSS
}
return settings
}
+92 -8
View File
@@ -9,6 +9,7 @@ import (
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -421,11 +422,30 @@ func (pc *PollController) Vote(c *gin.Context) {
return
}
// If not authenticated, don't persist personal info even if provided
if !hasUser {
input.VoterName = ""
input.VoterEmail = ""
}
// Normalize voter info. Allow guests to optionally provide name/email.
// If authenticated and no voter_name/email provided, fallback to user's profile.
var derivedName string
var derivedEmail string
if hasUser {
if uID, ok := userID.(uint); ok {
var u models.User
if err := pc.DB.First(&u, uID).Error; err == nil {
if u.FirstName != "" || u.LastName != "" {
derivedName = fmt.Sprintf("%s %s", u.FirstName, u.LastName)
}
derivedEmail = u.Email
}
}
}
// Final values to persist
voterName := input.VoterName
voterEmail := input.VoterEmail
if voterName == "" && derivedName != "" {
voterName = derivedName
}
if voterEmail == "" && derivedEmail != "" {
voterEmail = derivedEmail
}
// Check if already voted
ipHash := pc.hashIP(c.ClientIP())
@@ -477,8 +497,8 @@ func (pc *PollController) Vote(c *gin.Context) {
IPHash: ipHash,
UserAgent: userAgent,
SessionToken: sessionToken,
VoterName: input.VoterName,
VoterEmail: input.VoterEmail,
VoterName: voterName,
VoterEmail: voterEmail,
}
if hasUser {
@@ -513,7 +533,15 @@ func (pc *PollController) Vote(c *gin.Context) {
pc.DB.Preload("Options", func(db *gorm.DB) *gorm.DB {
return db.Order("display_order ASC, id ASC")
}).First(&poll, poll.ID)
// Engagement: award points to authenticated user
if hasUser {
uid := userID.(uint)
svc := services.NewEngagementService(pc.DB)
_, _ = svc.AwardPointsCapped(uid, 3, "poll_vote", map[string]interface{}{"poll_id": poll.ID})
_ = svc.CheckAndAwardAchievements(uid)
}
c.JSON(http.StatusOK, gin.H{
"message": "Vote recorded successfully",
"poll": poll,
@@ -637,3 +665,59 @@ func (pc *PollController) GetPollStats(c *gin.Context) {
"guest_votes": guestVotes,
})
}
// AdminListVotes returns detailed list of votes for a poll (admin only)
func (pc *PollController) AdminListVotes(c *gin.Context) {
id := c.Param("id")
var votes []models.PollVote
if err := pc.DB.Preload("Option").Preload("User").Where("poll_id = ?", id).Order("created_at DESC").Find(&votes).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch votes"})
return
}
type VoteDTO struct {
ID uint `json:"id"`
PollID uint `json:"poll_id"`
OptionID uint `json:"option_id"`
OptionText string `json:"option_text"`
UserID *uint `json:"user_id"`
UserEmail string `json:"user_email"`
UserFirstName string `json:"user_first_name"`
UserLastName string `json:"user_last_name"`
VoterName string `json:"voter_name"`
VoterEmail string `json:"voter_email"`
SessionToken string `json:"session_token"`
CreatedAt time.Time `json:"created_at"`
}
result := make([]VoteDTO, 0, len(votes))
for _, v := range votes {
optionText := ""
if v.Option != nil {
optionText = v.Option.Text
}
var userEmail, firstName, lastName string
if v.User != nil {
userEmail = v.User.Email
firstName = v.User.FirstName
lastName = v.User.LastName
}
result = append(result, VoteDTO{
ID: v.ID,
PollID: v.PollID,
OptionID: v.OptionID,
OptionText: optionText,
UserID: v.UserID,
UserEmail: userEmail,
UserFirstName: firstName,
UserLastName: lastName,
VoterName: v.VoterName,
VoterEmail: v.VoterEmail,
SessionToken: v.SessionToken,
CreatedAt: v.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"votes": result})
}
+94 -4
View File
@@ -18,12 +18,79 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ShortLinkController struct {
DB *gorm.DB
}
// PublicCreateShortLink creates (or upserts) a short link for a given target URL.
// Restrictions: only allows shortening links pointing to this site (request host)
// or to the configured FrontendBaseURL. Intended for visitor share/copy flows.
func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) {
var body struct {
TargetURL string `json:"target_url"`
Title string `json:"title"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
target, err := parseTarget(body.TargetURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return
}
tu, _ := url.Parse(target)
if tu == nil || tu.Host == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return
}
// Allow only same-site or configured frontend host
reqHost := c.Request.Host
stripPort := func(h string) string {
if i := strings.IndexByte(h, ':'); i >= 0 { return h[:i] }
return h
}
allowed := stripPort(tu.Host) == stripPort(reqHost)
if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" {
if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" {
if stripPort(fu.Host) == stripPort(tu.Host) {
allowed = true
}
}
}
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"})
return
}
// Deterministic code from URL so repeated calls return same shortlink
code := "p-" + codeFromHash(target, 7)
link := models.ShortLink{
Code: code,
TargetURL: target,
Title: strings.TrimSpace(body.Title),
Active: true,
}
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "code"}},
DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}),
}).Create(&link).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()})
return
}
var saved models.ShortLink
if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil {
saved = link
}
scheme := getScheme(c)
host := c.Request.Host
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code)
c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved})
}
func NewShortLinkController(db *gorm.DB) *ShortLinkController {
return &ShortLinkController{DB: db}
}
@@ -57,6 +124,18 @@ func hashIPShort(ip string) string {
return hex.EncodeToString(h[:])
}
func codeFromHash(s string, n int) string {
if n <= 0 { n = 7 }
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
sum := sha256.Sum256([]byte(s))
out := make([]byte, n)
for i := 0; i < n; i++ {
b := sum[i%len(sum)]
out[i] = alphabet[int(b)%len(alphabet)]
}
return string(out)
}
func getScheme(c *gin.Context) string {
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
return p
@@ -204,14 +283,25 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
Active: active,
ExpiresAt: body.ExpiresAt,
}
if err := s.DB.Create(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
// Upsert on code to avoid duplicate errors and keep link stable across regenerations
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "code"}},
DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "source_type", "source_id", "active", "expires_at", "updated_at"}),
}).Create(&link).Error; err != nil {
// Return database error message for easier debugging (non-sensitive)
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()})
return
}
// Ensure we return the saved record (ID will be empty on update path)
var saved models.ShortLink
if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil {
// Fallback to in-memory link if fetch fails
saved = link
}
scheme := getScheme(c)
host := c.Request.Host
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, link.Code)
c.JSON(http.StatusOK, gin.H{"id": link.ID, "code": link.Code, "short_url": shortURL, "link": link})
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code)
c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved})
}
func (s *ShortLinkController) ListShortLinks(c *gin.Context) {
+70
View File
@@ -0,0 +1,70 @@
package middleware
import (
"net/http"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
// AssetCacheControl sets optimal Cache-Control headers for static assets served by this server.
// It only affects GET requests for well-known static prefixes and does not override
// cache headers explicitly set by handlers downstream (they can still modify after c.Next if needed).
func AssetCacheControl() gin.HandlerFunc {
return func(c *gin.Context) {
// Only apply to GET requests
if c.Request.Method == http.MethodGet {
p := c.Request.URL.Path
lower := strings.ToLower(p)
switch {
// Specific: YouTube channel static cache can be cached longer (content changes infrequently)
case strings.HasPrefix(lower, "/cache/prefetch/") && strings.HasSuffix(lower, "youtube_channel.json"):
c.Header("Cache-Control", "public, max-age=3600") // 1 hour
case strings.HasPrefix(lower, "/dist/"):
// Fingerprinted build assets should be cached for a year and immutable
c.Header("Cache-Control", "public, max-age=31536000, immutable")
case strings.HasPrefix(lower, "/uploads/"):
// User uploads: cache for a week; allow clients to revalidate if replaced
// Heuristic: if file name appears fingerprinted (e.g., .<hash>.ext), use longer cache
base := filepath.Base(lower)
if looksFingerprinted(base) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
c.Header("Cache-Control", "public, max-age=604800") // 7 days
}
case strings.HasPrefix(lower, "/cache/"):
// Prefetched JSON and other generated cache files: short to medium cache
c.Header("Cache-Control", "public, max-age=300") // 5 minutes
}
}
c.Next()
}
}
// looksFingerprinted checks if a filename contains a long hex-like segment before the extension
// e.g. logo.7eacd9f0bfa04928a9b6936140168f58.png
func looksFingerprinted(name string) bool {
dot := strings.LastIndexByte(name, '.')
if dot <= 0 || dot >= len(name)-1 {
return false
}
core := name[:dot]
// Find final segment after last dot/underscore/hyphen in core
lastSep := strings.LastIndexAny(core, "._-")
if lastSep < 0 || lastSep+1 >= len(core) {
return false
}
seg := core[lastSep+1:]
if len(seg) < 16 { // require at least 16 chars to treat as a hash
return false
}
// hex-like check
for i := 0; i < len(seg); i++ {
ch := seg[i]
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f')) {
return false
}
}
return true
}
+2 -2
View File
@@ -15,8 +15,8 @@ import (
// JWTAuth is a middleware that checks for a valid JWT token
func JWTAuth(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// Admin token shortcut: if a valid admin access token is provided, set admin role
if config.AppConfig != nil && config.AppConfig.AdminAccessToken != "" {
// Admin token shortcut (DEV/TEST ONLY): allow only outside production
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" && config.AppConfig.AdminAccessToken != "" {
header := c.GetHeader("X-Admin-Token")
if header != "" && header == config.AppConfig.AdminAccessToken {
c.Set("userRole", "admin")
+37
View File
@@ -0,0 +1,37 @@
package middleware
import (
"context"
"time"
"github.com/gin-gonic/gin"
)
// DBContext adds a context with timeout to all database operations
// This prevents queries from hanging indefinitely and exhausting connections
func DBContext() gin.HandlerFunc {
return func(c *gin.Context) {
// Create a context with timeout for this request's database operations
// 15 seconds is generous for most queries while preventing indefinite hangs
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
// Store the context so controllers can use it with db.WithContext(ctx)
c.Set("dbCtx", ctx)
c.Next()
}
}
// GetDBContext retrieves the database context from gin.Context
// Returns a background context with timeout if not found
func GetDBContext(c *gin.Context) context.Context {
if ctx, exists := c.Get("dbCtx"); exists {
if dbCtx, ok := ctx.(context.Context); ok {
return dbCtx
}
}
// Fallback with timeout
ctx, _ := context.WithTimeout(context.Background(), 15*time.Second)
return ctx
}
+43
View File
@@ -0,0 +1,43 @@
package middleware
import (
"fmt"
"net/http"
"runtime/debug"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
)
// CustomRecovery returns a middleware that recovers from panics and logs them
func CustomRecovery() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// Get stack trace
stack := string(debug.Stack())
// Log the panic
requestID := GetRequestID(c)
logger.Error("Panic recovered",
"request_id", requestID,
"error", fmt.Sprintf("%v", err),
"stack", stack,
"path", c.Request.URL.Path,
"method", c.Request.Method,
)
// Return error response
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
"request_id": requestID,
})
c.Abort()
}
}()
c.Next()
}
}
+36
View File
@@ -0,0 +1,36 @@
package middleware
import (
"time"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
)
// RequestLogger logs a concise access log line per request with latency and identifiers.
// It is lightweight and safe for production usage.
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
method := c.Request.Method
// Continue
c.Next()
// After handler
status := c.Writer.Status()
latency := time.Since(start)
rid := c.GetString("request_id")
// Try both userID keys used across codebase
var uid any
if v, ok := c.Get("userID"); ok {
uid = v
} else if v, ok := c.Get("user_id"); ok {
uid = v
}
if uid != nil {
logger.Info("%s %s => %d (%s) rid=%s uid=%v", method, path, status, latency, rid, uid)
} else {
logger.Info("%s %s => %d (%s) rid=%s", method, path, status, latency, rid)
}
}
}
+17 -10
View File
@@ -1,12 +1,11 @@
package middleware
import (
"bytes"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// RequestSizeLimit limits the size of request bodies
@@ -39,14 +38,15 @@ func ValidateContentType() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
contentType := c.GetHeader("Content-Type")
// Allow multipart for file uploads
if strings.Contains(c.Request.URL.Path, "/upload") {
path := c.Request.URL.Path
// Allow multipart for uploads and image processing crop upload
if strings.Contains(path, "/upload") || strings.Contains(path, "/image-processing/crop-upload") {
c.Next()
return
}
// Require JSON for API endpoints
// Require JSON for other API endpoints
if !strings.Contains(contentType, "application/json") {
c.JSON(http.StatusUnsupportedMediaType, gin.H{
"error": "Content-Type must be application/json",
@@ -75,10 +75,17 @@ func RequestID() gin.HandlerFunc {
}
func generateRequestID() string {
// Simple request ID generation
b := make([]byte, 16)
_, _ = io.ReadFull(bytes.NewReader([]byte(strings.Repeat("0123456789abcdef", 2))), b)
return string(b)
return uuid.New().String()
}
// GetRequestID retrieves the request ID from context
func GetRequestID(c *gin.Context) string {
if id, exists := c.Get("request_id"); exists {
if requestID, ok := id.(string); ok {
return requestID
}
}
return ""
}
// SecurityAuditLog logs security-relevant events
@@ -0,0 +1,52 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
// SmartCompression applies gzip compression intelligently
// Skips compression for already compressed formats and small responses
func SmartCompression() gin.HandlerFunc {
return func(c *gin.Context) {
// Skip compression for already compressed formats
contentType := c.GetHeader("Content-Type")
if shouldSkipCompression(contentType) {
c.Next()
return
}
// Skip compression for small responses (< 1KB overhead not worth it)
// This is handled by checking response size in writer
c.Next()
}
}
// shouldSkipCompression checks if content type should skip compression
func shouldSkipCompression(contentType string) bool {
// Already compressed formats
skipTypes := []string{
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"video/",
"audio/",
"application/zip",
"application/x-zip",
"application/x-gzip",
"application/gzip",
"application/x-compress",
"application/pdf", // PDFs are already compressed
}
for _, skip := range skipTypes {
if strings.Contains(strings.ToLower(contentType), skip) {
return true
}
}
return false
}
+11 -7
View File
@@ -29,7 +29,11 @@ func SecurityHeaders() gin.HandlerFunc {
}
// Strict Content-Security-Policy
csp := buildCSP(config.AppConfig.AppEnv == "production")
// Prefer configured CSP from environment/config, otherwise build a safe default
csp := config.AppConfig.ContentSecurityPolicy
if csp == "" {
csp = buildCSP(config.AppConfig.AppEnv == "production")
}
c.Header("Content-Security-Policy", csp)
// Additional security headers
@@ -46,13 +50,13 @@ func SecurityHeaders() gin.HandlerFunc {
// buildCSP creates a strict Content-Security-Policy
func buildCSP(production bool) string {
if production {
// Strict production CSP
// Generic production CSP without hardcoded domains
return "default-src 'self'; " +
"script-src 'self' https://fonts.googleapis.com https://umami.tdvorak.dev; " +
"style-src 'self' https://fonts.googleapis.com; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com data:; " +
"img-src 'self' data: https: blob:; " +
"connect-src 'self' https://umami.tdvorak.dev https://zonerama.tdvorak.dev; " +
"connect-src 'self' https:; " +
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com; " +
"object-src 'none'; " +
"base-uri 'self'; " +
@@ -61,9 +65,9 @@ func buildCSP(production bool) string {
"upgrade-insecure-requests;"
}
// Development CSP - slightly relaxed for local development
// Development CSP - relaxed for local development
return "default-src 'self'; " +
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://fonts.googleapis.com https://umami.tdvorak.dev; " +
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://fonts.googleapis.com; " +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
"font-src 'self' https://fonts.gstatic.com data:; " +
"img-src 'self' data: https: http: blob:; " +
+22
View File
@@ -0,0 +1,22 @@
package models
import (
"time"
)
type Comment struct {
BaseModel
TargetType string `json:"target_type" gorm:"size:30;index:idx_target"`
TargetID string `json:"target_id" gorm:"size:128;index:idx_target"`
UserID uint `json:"user_id" gorm:"index"`
User User `json:"user" gorm:"foreignKey:UserID"`
ParentID *uint `json:"parent_id,omitempty" gorm:"index"`
Content string `json:"content" gorm:"type:text;not null"`
Status string `json:"status" gorm:"size:20;default:'visible';index"`
SpamScore float32 `json:"spam_score" gorm:"type:real;default:0"`
SpamRules string `json:"spam_rules" gorm:"type:text"`
IsEdited bool `json:"is_edited" gorm:"default:false"`
EditedAt *time.Time `json:"edited_at"`
}
func (Comment) TableName() string { return "comments" }
+24
View File
@@ -0,0 +1,24 @@
package models
import "time"
type CommentBan struct {
BaseModel
UserID uint `json:"user_id" gorm:"index;not null"`
Reason string `json:"reason" gorm:"type:text"`
Until *time.Time `json:"until" gorm:"index"` // nil = permanent
CreatedByID uint `json:"created_by_id" gorm:"index"`
}
func (CommentBan) TableName() string { return "comment_bans" }
type UnbanRequest struct {
BaseModel
UserID uint `json:"user_id" gorm:"index;not null"`
Message string `json:"message" gorm:"type:text"`
Status string `json:"status" gorm:"size:20;default:'pending';index"` // pending|approved|rejected
ResolvedByID *uint `json:"resolved_by_id" gorm:"index"`
ResolvedAt *time.Time `json:"resolved_at"`
}
func (UnbanRequest) TableName() string { return "unban_requests" }
+10
View File
@@ -0,0 +1,10 @@
package models
type CommentReaction struct {
BaseModel
CommentID uint `json:"comment_id" gorm:"index;not null"`
UserID uint `json:"user_id" gorm:"index;not null"`
Type string `json:"type" gorm:"size:24;not null;index"` // like|heart|smile|laugh|thumbs_up|thumbs_down|sad|angry
}
func (CommentReaction) TableName() string { return "comment_reactions" }
+10
View File
@@ -0,0 +1,10 @@
package models
type CommentReport struct {
BaseModel
CommentID uint `json:"comment_id" gorm:"index;not null"`
UserID uint `json:"user_id" gorm:"index;not null"`
Reason string `json:"reason" gorm:"size:255"`
}
func (CommentReport) TableName() string { return "comment_reports" }
+68
View File
@@ -0,0 +1,68 @@
package models
import "gorm.io/datatypes"
// PointsTransaction logs changes in points/xp
// Reason examples: comment_create, poll_vote, newsletter_subscribe, redeem, admin_adjust
// Meta can hold ids like {"comment_id":123}
type PointsTransaction struct {
BaseModel
UserID uint `json:"user_id" gorm:"index;not null"`
Delta int64 `json:"delta"`
XPDelta int64 `json:"xp_delta"`
Reason string `json:"reason" gorm:"size:64;index"`
Meta datatypes.JSONMap `json:"meta" gorm:"type:jsonb"`
}
func (PointsTransaction) TableName() string { return "points_transactions" }
// Achievement definition managed in DB for flexibility
// Condition is a code string handled by service (e.g., first_comment, first_vote, votes_10, comments_25)
type Achievement struct {
BaseModel
Code string `json:"code" gorm:"size:64;uniqueIndex"`
Title string `json:"title"`
Description string `json:"description"`
Points int64 `json:"points"`
XP int64 `json:"xp"`
Icon string `json:"icon"`
Active bool `json:"active" gorm:"default:true"`
}
func (Achievement) TableName() string { return "achievements" }
type UserAchievement struct {
BaseModel
UserID uint `json:"user_id" gorm:"index;not null"`
AchievementID uint `json:"achievement_id" gorm:"index;not null"`
}
func (UserAchievement) TableName() string { return "user_achievements" }
// Reward items configured by admin (e.g., avatars, merch vouchers)
type RewardItem struct {
BaseModel
Name string `json:"name"`
Type string `json:"type" gorm:"size:32;index"` // avatar_static, avatar_animated, merch_coupon, custom
CostPoints int64 `json:"cost_points"`
ImageURL string `json:"image_url"`
Stock int `json:"stock"`
Active bool `json:"active" gorm:"default:true"`
Metadata datatypes.JSONMap `json:"metadata" gorm:"type:jsonb"`
}
func (RewardItem) TableName() string { return "reward_items" }
// Redemption log for rewards
type RewardRedemption struct {
BaseModel
UserID uint `json:"user_id" gorm:"index;not null"`
RewardID uint `json:"reward_id" gorm:"index;not null"`
Status string `json:"status" gorm:"size:24;default:'pending';index"`
}
func (RewardRedemption) TableName() string { return "reward_redemptions" }
+10 -2
View File
@@ -60,6 +60,11 @@ type Article struct {
// Match link (loaded separately, not stored in this table)
// Removed omitempty to always include in JSON (even if null)
MatchLink *ArticleMatchLink `gorm:"-" json:"match_link"`
// Computed helpers (not persisted)
CategorySlug string `gorm:"-" json:"category_slug,omitempty"`
CompetitionAlias string `gorm:"-" json:"competition_alias,omitempty"`
NormalizedCategory string `gorm:"-" json:"normalized_category,omitempty"`
URL string `gorm:"-" json:"url,omitempty"`
}
// ArticleTeamLink represents a link from an article to a team identified by an external FACR ID
@@ -253,11 +258,14 @@ type Settings struct {
LocationLatitude float64 `json:"location_latitude"`
LocationLongitude float64 `json:"location_longitude"`
MapZoomLevel int `gorm:"default:15" json:"map_zoom_level"`
MapStyle string `json:"map_style"` // OpenStreetMap style URL or preset: default, dark, satellite
MapStyle string `json:"map_style"`
ShowMapOnHomepage bool `json:"show_map_on_homepage"`
// Homepage matches display configuration
FinishedMatchDisplayDays int `gorm:"default:2" json:"finished_match_display_days"` // Number of days to show finished matches with scores on homepage
FinishedMatchDisplayDays int `gorm:"default:2" json:"finished_match_display_days"`
StorageQuotaMB int `json:"storage_quota_mb"`
StorageWarnThreshold int `json:"storage_warn_threshold"`
StorageCriticalThreshold int `json:"storage_critical_threshold"`
}
// TableName specifies table name for Settings model
+13
View File
@@ -0,0 +1,13 @@
package models
type UserProfile struct {
BaseModel
UserID uint `json:"user_id" gorm:"uniqueIndex;not null"`
Points int64 `json:"points" gorm:"default:0;index"`
Level int `json:"level" gorm:"default:1"`
XP int64 `json:"xp" gorm:"default:0"`
AvatarURL string `json:"avatar_url" gorm:"type:varchar(500)"`
AnimatedAvatarURL string `json:"animated_avatar_url" gorm:"type:varchar(500)"`
}
func (UserProfile) TableName() string { return "user_profiles" }
+77 -3
View File
@@ -53,13 +53,19 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
imageProcessingController := &controllers.ImageProcessingController{}
articleController := controllers.NewArticleController(db)
myuibrixController := &controllers.MyUIbrixController{DB: db}
editorPreviewController := controllers.NewEditorPreviewController(db)
shortLinkController := controllers.NewShortLinkController(db)
commentController := controllers.NewCommentController(db)
engagementController := controllers.NewEngagementController(db)
// API v1 group
{
// Health check
api.GET("/health", baseController.HealthCheck)
// CSRF token for cookie-based clients
api.GET("/csrf-token", middleware.GetCSRFToken)
// Image proxy (public) to work around CORS when reading images in Canvas on the frontend
api.GET("/proxy/image", baseController.ProxyImage)
@@ -73,6 +79,9 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Public page element configurations
api.GET("/page-elements", pageElementConfigController.GetPageElementConfigs)
// Public shortlink creation for visitors (same-site only)
api.POST("/shortlinks/public", middleware.RateLimit(30, time.Minute), shortLinkController.PublicCreateShortLink)
// Email tracking (public)
api.GET("/email/open.gif", emailController.OpenPixel)
api.GET("/email/click", emailController.ClickRedirect)
@@ -118,10 +127,47 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
events.GET("/:id", eventController.GetEventByID)
}
// Comments (public list)
api.GET("/comments", commentController.GetComments)
// Engagement (public + protected)
api.GET("/engagement/rewards", engagementController.GetRewards)
// Protected routes (require authentication)
protected := api.Group("")
protected.Use(middleware.JWTAuth(db))
// CSRF protect state-changing requests when relying on cookies (Bearer tokens are auto-exempt)
protected.Use(middleware.CSRFProtection())
{
// Engagement profile & actions
protected.GET("/engagement/profile", engagementController.GetProfile)
protected.PATCH("/engagement/avatar", engagementController.PatchAvatar)
protected.POST("/engagement/redeem", engagementController.Redeem)
protected.GET("/engagement/achievements", engagementController.GetAchievements)
// Comments (create/update/delete)
protected.POST("/comments", middleware.RateLimit(20, time.Minute), commentController.CreateComment)
protected.PUT("/comments/:id", commentController.UpdateComment)
protected.DELETE("/comments/:id", commentController.DeleteComment)
// Comment reactions and unban request
protected.POST("/comments/:id/react", middleware.RateLimit(60, time.Minute), commentController.React)
protected.DELETE("/comments/:id/react", commentController.Unreact)
protected.POST("/comments/unban-request", middleware.RateLimit(5, time.Hour), commentController.CreateUnbanRequest)
protected.POST("/comments/:id/report", middleware.RateLimit(10, time.Hour), commentController.ReportComment)
// Editor preview endpoints (authenticated editors)
editor := protected.Group("/editor")
editor.Use(middleware.RoleAuth("editor"))
{
// Real-time preview state
editor.GET("/preview/:session_id", editorPreviewController.GetPreviewState)
editor.POST("/preview/:session_id", editorPreviewController.UpdatePreviewState)
editor.POST("/preview/:session_id/apply", editorPreviewController.ApplyPreviewChanges)
editor.DELETE("/preview/:session_id", editorPreviewController.DiscardPreviewChanges)
// Validation and variants
editor.POST("/preview/validate", editorPreviewController.ValidatePreviewConfig)
editor.GET("/variants/:element_name", editorPreviewController.GetAvailableVariants)
}
// Newsletter preferences token for current user
protected.GET("/newsletter/token/me", contactController.GetNewsletterTokenForUser)
@@ -134,6 +180,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
ai.POST("/blog/generate", aiController.GenerateBlog)
ai.POST("/about/generate", aiController.GenerateAboutPage)
ai.POST("/css/generate", aiController.GenerateCSS)
ai.POST("/instagram/generate", aiController.GenerateInstagram)
}
// User profile
@@ -212,6 +259,15 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
admin := protected.Group("/admin")
admin.Use(middleware.RoleAuth("admin"))
{
// Comments moderation
commentsAdmin := admin.Group("/comments")
{
commentsAdmin.GET("", commentController.AdminList)
commentsAdmin.PATCH("/:id/status", commentController.AdminUpdateStatus)
commentsAdmin.POST("/ban", commentController.AdminBanUser)
commentsAdmin.GET("/unban-requests", commentController.AdminListUnban)
commentsAdmin.POST("/unban-requests/:id/resolve", commentController.AdminResolveUnban)
}
// Admin-only endpoints for managing sponsors, etc. (user CRUD removed; no handlers defined)
// Competition aliases (admin)
@@ -357,6 +413,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
files.GET("", filesController.GetAllFiles)
files.GET("/unused", filesController.GetUnusedFiles)
files.GET("/duplicates", filesController.GetDuplicateFiles)
files.GET("/usage", filesController.GetStorageUsage)
files.GET("/:id/usages", filesController.GetFileUsages)
files.DELETE("/:id", filesController.DeleteFile)
files.POST("/scan", filesController.ScanAndSyncFiles)
@@ -404,6 +461,18 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
polls.PUT("/:id", pollController.UpdatePoll)
polls.DELETE("/:id", pollController.DeletePoll)
polls.GET("/:id/stats", pollController.GetPollStats)
polls.GET("/:id/votes", pollController.AdminListVotes)
}
// Engagement management (admin)
engagement := admin.Group("/engagement")
{
engagement.GET("/rewards", engagementController.AdminListRewards)
engagement.POST("/rewards", engagementController.AdminCreateReward)
engagement.PUT("/rewards/:id", engagementController.AdminUpdateReward)
engagement.DELETE("/rewards/:id", engagementController.AdminDeleteReward)
engagement.GET("/redemptions", engagementController.AdminListRedemptions)
engagement.PATCH("/redemptions/:id", engagementController.AdminUpdateRedemptionStatus)
}
// Page element configurations management (admin)
@@ -460,8 +529,13 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Allow uploads publicly so initial setup can upload a club logo before an admin exists.
api.POST("/upload", middleware.RateLimit(30, time.Minute), baseController.UploadImage)
// Image processing endpoints (protected)
imageProcessing := protected.Group("/image-processing")
// Image processing endpoints (protected for editors)
// Note: Define a dedicated group with required middleware to avoid referencing
// the out-of-scope `protected` variable from above.
imageProcessing := api.Group("/image-processing")
imageProcessing.Use(middleware.JWTAuth(db))
imageProcessing.Use(middleware.CSRFProtection())
imageProcessing.Use(middleware.RoleAuth("editor"))
{
imageProcessing.POST("/process", imageProcessingController.ProcessImage)
imageProcessing.POST("/crop-upload", imageProcessingController.CropAndUpload)
@@ -472,7 +546,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
api.GET("/scoreboard", scoreboardController.GetPublic)
api.GET("/scoreboard/colors/derive", scoreboardController.DeriveColors)
// Public core endpoints
// ... (rest of the code remains the same)
api.GET("/settings", baseController.GetPublicSettings)
api.GET("/competition-aliases", baseController.GetPublicCompetitionAliases)
api.GET("/public/team-logo-overrides", baseController.GetPublicTeamLogoOverrides)
+197
View File
@@ -0,0 +1,197 @@
package services
import (
"regexp"
"strings"
)
// A compact list of Czech and English bad words with family-friendly replacements.
// Note: This is a lightweight, non-exhaustive list intended for community sites.
var badWordMap = map[string]string{
// Czech
"kráva": "osobo",
"debil": "nezdvořák",
"idiot": "nešika",
"blbec": "popleta",
"pitomec": "nezbeda",
"trouba": "popleta",
"sprostý": "nevhodný",
"sráč": "strašpytel",
"čůrák": "šibal",
"kokot": "popleta",
"kretén": "nešika",
"hovno": "ťuťo",
"nasrat": "naštvat",
"nasr**": "naštv**",
"prdel": "zadek",
"píča": "potížistka",
"piča": "potížistka",
"zmrd": "nezbeda",
"sračka": "nepěknost",
"sračky": "nepěknosti",
"posrat": "pokazit",
"posranej": "zkalený",
"šukat": "láskovat",
"mrdat": "lumpačit",
"mrdka": "neplecha",
"kurva": "mrška",
"zasran": "nepříjemn",
"do prdele": "sakryš",
"čubka": "neposedná",
"svině": "nezdárná",
// English
"shit": "shoot",
"fuck": "flip",
"fucking": "flipping",
"asshole": "meanie",
"bitch": "rascal",
"bastard": "rascal",
"dick": "goof",
"dickhead": "goof",
"cock": "goof",
"pussy": "rascal",
"cunt": "rascal",
"crap": "crud",
"damn": "darn",
}
// Compiled replacement patterns and sensitive patterns
type compiledReplacement struct {
re *regexp.Regexp
replacement string
}
var compiledRepls []compiledReplacement
var sensitiveRegexps []*regexp.Regexp
func init() {
// Build compiled replacements from explicit words/phrases
for w, rep := range badWordMap {
var pat string
if strings.Contains(w, " ") {
// phrase: allow flexible spacing
pat = "(?i)\\b" + strings.ReplaceAll(regexp.QuoteMeta(w), " ", "\\s+") + "\\b"
} else {
pat = "(?i)\\b" + regexp.QuoteMeta(w) + "[a-zá-ž0-9]*\\b"
}
compiledRepls = append(compiledRepls, compiledReplacement{ re: regexp.MustCompile(pat), replacement: rep })
}
// Add Czech stems with diacritic + leet tolerant patterns
czStems := []struct{ stem, rep string }{
{"kurv", "mrška"}, {"píc", "potížistka"}, {"pic", "potížistka"}, {"mrd", "lumpačit"}, {"šuk", "láskovat"}, {"srač", "nepěknost"}, {"hovn", "ťuťo"}, {"zmrd", "nezbeda"}, {"čubk", "neposedná"}, {"svin", "nezdárná"}, {"kokot", "popleta"}, {"čur", "šibal"}, {"cur", "šibal"},
{"debil", "nezdvořák"}, {"idiot", "nešika"}, {"kretén", "nešika"}, {"blbec", "popleta"}, {"prdel", "zadek"},
}
for _, it := range czStems {
pat := "(?i)\\b" + diacriticLeetPattern(it.stem) + "[a-zá-ž0-9]*\\b"
compiledRepls = append(compiledRepls, compiledReplacement{ re: regexp.MustCompile(pat), replacement: it.rep })
}
// English stems (simple suffix handling)
en := []struct{ rawPattern, rep string }{
{`(?i)\bshit(ty|head|s|ting)?\b`, "shoot"},
{`(?i)\bfuck(ing|er|ers|ed|s)?\b`, "flip"},
{`(?i)\bass(hole|hat|es)?\b`, "meanie"},
{`(?i)\bbitch(es|y)?\b`, "rascal"},
{`(?i)\bbastard(s)?\b`, "rascal"},
{`(?i)\bdick(head|s)?\b`, "goof"},
{`(?i)\bcock(s|ing)?\b`, "goof"},
{`(?i)\bpussy\b`, "rascal"},
{`(?i)\bcunt(s)?\b`, "rascal"},
{`(?i)\bcrap(py|s)?\b`, "crud"},
{`(?i)\bdamn(ed|s|ing)?\b`, "darn"},
}
for _, e := range en {
compiledRepls = append(compiledRepls, compiledReplacement{ re: regexp.MustCompile(e.rawPattern), replacement: e.rep })
}
// Sensitive stems (trigger moderation)
sensStems := []string{"kurv", "píc", "pic", "mrd", "šuk", "čur", "cur", "kokot", "cunt", "fuck"}
for _, s := range sensStems {
// Czech stems get diacritic+leet tolerant pattern; English raw
var re *regexp.Regexp
if isASCII(s) {
re = regexp.MustCompile("(?i)\\b" + regexp.QuoteMeta(s) + "[a-z0-9]*\\b")
} else {
re = regexp.MustCompile("(?i)\\b" + diacriticLeetPattern(s) + "[a-zá-ž0-9]*\\b")
}
sensitiveRegexps = append(sensitiveRegexps, re)
}
}
// FilterBadWords replaces bad words with friendlier counterparts while preserving approximate case.
func FilterBadWords(s string) (string, bool) {
if strings.TrimSpace(s) == "" { return s, false }
out := s
replaced := false
for _, cr := range compiledRepls {
out2 := cr.re.ReplaceAllStringFunc(out, func(m string) string {
replaced = true
// preserve basic case style
if isTitle(m) { return title(cr.replacement) }
if isUpper(m) { return strings.ToUpper(cr.replacement) }
return cr.replacement
})
out = out2
}
return out, replaced
}
// ContainsSensitiveWords returns true and the matched words if content contains strong/explicit terms.
func ContainsSensitiveWords(s string) (bool, []string) {
if strings.TrimSpace(s) == "" { return false, nil }
found := []string{}
for _, re := range sensitiveRegexps {
if loc := re.FindStringIndex(s); loc != nil {
found = append(found, s[loc[0]:loc[1]])
}
}
if len(found) == 0 { return false, nil }
return true, found
}
func isUpper(s string) bool { return s == strings.ToUpper(s) }
func isTitle(s string) bool { return len(s) > 0 && strings.ToUpper(s[:1]) == s[:1] && strings.ToLower(s[1:]) == s[1:] }
func title(s string) string { if len(s)==0 {return s}; return strings.ToUpper(s[:1]) + s[1:] }
// Helpers for Czech diacritics + simple leetspeak
func diacriticLeetPattern(stem string) string {
var b strings.Builder
for _, r := range stem {
b.WriteString(expandRune(r))
}
return b.String()
}
func expandRune(r rune) string {
switch r {
case 'a', 'A': return "[aá@4]"
case 'e', 'E': return "[eéě3]"
case 'i', 'I', 'l', 'L': return "[iíl1!]"
case 'o', 'O': return "[oó0]"
case 'u', 'U': return "[uúů]"
case 'y', 'Y': return "[yý]"
case 'c', 'C': return "[cč]"
case 's', 'S': return "[sš5]"
case 'z', 'Z': return "[zž2]"
case 'r', 'R': return "[rř]"
case 't', 'T': return "[tť7]"
case 'n', 'N': return "[nň]"
case 'd', 'D': return "[dď]"
case 'p', 'P': return "[p]"
case 'k', 'K': return "[k]"
case 'm', 'M': return "[m]"
case 'v', 'V': return "[v]"
case 'h', 'H': return "[h]"
case 'g', 'G': return "[g]"
default:
// escape everything else
return regexp.QuoteMeta(string(r))
}
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ { if s[i] >= 128 { return false } }
return true
}
+166
View File
@@ -0,0 +1,166 @@
package services
import (
"errors"
"strings"
"time"
"gorm.io/gorm"
"fotbal-club/internal/models"
)
// EngagementService encapsulates points, XP, achievements, and rewards
type EngagementService struct {
DB *gorm.DB
}
// AwardPointsCapped applies simple anti-abuse caps per reason.
// - poll_vote: max 1 award per day
// - comment_create: max 10 awards per day
// - newsletter_subscribe: once per lifetime
func (s *EngagementService) AwardPointsCapped(userID uint, delta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) {
if userID == 0 || delta == 0 { return nil, nil }
if !s.canAwardMore(userID, strings.TrimSpace(reason)) {
return s.EnsureProfile(userID) // return current profile without adding
}
return s.AwardPoints(userID, delta, reason, meta)
}
func (s *EngagementService) canAwardMore(userID uint, reason string) bool {
now := time.Now()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
switch reason {
case "poll_vote":
var cnt int64
_ = s.DB.Model(&models.PointsTransaction{}).
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "poll_vote", startOfDay).
Count(&cnt).Error
return cnt < 1
case "comment_create":
var cnt int64
_ = s.DB.Model(&models.PointsTransaction{}).
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "comment_create", startOfDay).
Count(&cnt).Error
return cnt < 10
case "newsletter_subscribe":
var cnt int64
_ = s.DB.Model(&models.PointsTransaction{}).
Where("user_id = ? AND reason = ?", userID, "newsletter_subscribe").
Count(&cnt).Error
return cnt == 0
default:
return true
}
}
func NewEngagementService(db *gorm.DB) *EngagementService { return &EngagementService{DB: db} }
// EnsureProfile creates a profile if missing
func (s *EngagementService) EnsureProfile(userID uint) (*models.UserProfile, error) {
var up models.UserProfile
if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
up = models.UserProfile{UserID: userID, Points: 0, Level: 1, XP: 0}
if err := s.DB.Create(&up).Error; err != nil { return nil, err }
return &up, nil
}
return nil, err
}
return &up, nil
}
// AwardPoints adds points/xp and logs transaction; returns updated profile
func (s *EngagementService) AwardPoints(userID uint, delta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) {
if userID == 0 || delta == 0 { return nil, nil }
if _, err := s.EnsureProfile(userID); err != nil { return nil, err }
pt := models.PointsTransaction{ UserID: userID, Delta: delta, XPDelta: delta, Reason: strings.TrimSpace(reason) }
if meta != nil { pt.Meta = meta }
if err := s.DB.Create(&pt).Error; err != nil { return nil, err }
// Update profile atomically
if err := s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).
Updates(map[string]interface{}{
"points": gorm.Expr("points + ?", delta),
"xp": gorm.Expr("xp + ?", delta),
"updated_at": time.Now(),
}).Error; err != nil { return nil, err }
// Recompute level
var up models.UserProfile
if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil { return nil, err }
lvl := ComputeLevel(up.XP)
if lvl != up.Level {
_ = s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("level", lvl).Error
up.Level = lvl
}
return &up, nil
}
// ComputeLevel returns level for given XP (simple quadratic growth)
func ComputeLevel(xp int64) int {
// Level 1 at 0 xp, each level requires +100 * level xp increment approximately
lvl := 1
threshold := int64(100)
remaining := xp
for remaining >= threshold {
remaining -= threshold
lvl++
threshold += int64(100)
if lvl > 200 { break }
}
if lvl < 1 { lvl = 1 }
return lvl
}
// CheckAndAwardAchievements evaluates basic achievements and awards when reached
func (s *EngagementService) CheckAndAwardAchievements(userID uint) error {
if userID == 0 { return nil }
// Preload completed achievements for user
var done []models.UserAchievement
_ = s.DB.Where("user_id = ?", userID).Find(&done).Error
doneSet := map[uint]bool{}
for _, ua := range done { doneSet[ua.AchievementID] = true }
// Ensure default achievements exist
defaults := []models.Achievement{
{Code: "first_comment", Title: "První komentář", Description: "Napsal/a jste první komentář.", Points: 10, XP: 10, Active: true},
{Code: "first_vote", Title: "První hlasování", Description: "Poprvé jste hlasoval/a v anketě.", Points: 8, XP: 8, Active: true},
{Code: "newsletter_sub", Title: "Odběr novinek", Description: "Přihlášení k odběru newsletteru.", Points: 12, XP: 12, Active: true},
{Code: "comments_10", Title: "Komentátor", Description: "10 komentářů!", Points: 20, XP: 20, Active: true},
{Code: "votes_10", Title: "Hlasující", Description: "10 hlasování!", Points: 20, XP: 20, Active: true},
}
for _, a := range defaults {
var existing models.Achievement
if err := s.DB.Where("code = ?", a.Code).First(&existing).Error; err != nil {
_ = s.DB.Create(&a).Error
}
}
// Compute counts
var commentCount int64
_ = s.DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&commentCount).Error
var voteCount int64
_ = s.DB.Model(&models.PollVote{}).Where("user_id = ?", userID).Count(&voteCount).Error
var hasNewsletter bool
if err := s.DB.Model(&models.NewsletterSubscription{}).Select("1").Where("LOWER(email) = (SELECT LOWER(email) FROM users WHERE id = ?) AND is_active = ?", userID, true).Limit(1).Scan(&hasNewsletter).Error; err != nil {
// ignore
}
awardByCode := func(code string) {
var a models.Achievement
if err := s.DB.Where("code = ? AND active = ?", code, true).First(&a).Error; err == nil {
var existing models.UserAchievement
if err := s.DB.Where("user_id = ? AND achievement_id = ?", userID, a.ID).First(&existing).Error; errors.Is(err, gorm.ErrRecordNotFound) {
_ = s.DB.Create(&models.UserAchievement{UserID: userID, AchievementID: a.ID}).Error
_, _ = s.AwardPoints(userID, a.Points, "achievement:"+code, map[string]interface{}{"achievement_id": a.ID})
}
}
}
if commentCount >= 1 { awardByCode("first_comment") }
if voteCount >= 1 { awardByCode("first_vote") }
if hasNewsletter { awardByCode("newsletter_sub") }
if commentCount >= 10 { awardByCode("comments_10") }
if voteCount >= 10 { awardByCode("votes_10") }
return nil
}
+25 -1
View File
@@ -54,7 +54,7 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
}
// Upcoming events
if want["events"] || want["matches"] {
if want["events"] {
items := pickUpcomingEvents(ev, 6)
if len(items) > 0 {
sections = append(sections, renderEventsSection(items))
@@ -140,6 +140,30 @@ func pickUpcomingEvents(v any, n int) []Event {
}
}
}
// Fallback URL to internal activity detail when not provided
if strings.TrimSpace(e.Url) == "" {
// Try to read numeric id from generic JSON number (float64)
if idv, ok := m["id"]; ok {
switch t := idv.(type) {
case float64:
if t > 0 {
e.Url = "/aktivita/" + fmt.Sprintf("%d", int64(t))
}
case int:
if t > 0 {
e.Url = "/aktivita/" + fmt.Sprintf("%d", t)
}
case int64:
if t > 0 {
e.Url = "/aktivita/" + fmt.Sprintf("%d", t)
}
case string:
if strings.TrimSpace(t) != "" {
e.Url = "/aktivita/" + strings.TrimSpace(t)
}
}
}
}
out = append(out, e)
}
return out
+55
View File
@@ -0,0 +1,55 @@
package services
import (
"regexp"
"strings"
)
// Simple heuristics to evaluate spammy text. Returns score 0..1 and triggered rules.
func EvaluateSpamScore(s string) (float64, []string) {
var rules []string
content := strings.TrimSpace(s)
if content == "" {
return 1.0, []string{"empty"}
}
// Too short
if len([]rune(content)) < 6 {
rules = append(rules, "too_short")
}
// Excessive repeated characters like 'aaaaaa' or '!!!!'
repeatRe := regexp.MustCompile(`([a-zA-Z!?.])\1{4,}`)
if repeatRe.MatchString(content) {
rules = append(rules, "repeated_chars")
}
// Low vowel ratio suggests gibberish in Czech/English latin text
letters := regexp.MustCompile(`[A-Za-zÁáÉéĚěÍíÓóÚúŮůÝýŽžŠšČčŘřŤťŇňĎď]`).FindAllString(content, -1)
if len(letters) >= 8 {
vowels := regexp.MustCompile(`[AaEeIiOoUuYyÁáÉéĚěÍíÓóÚúŮůÝý]`).FindAllString(content, -1)
ratio := float64(len(vowels)) / float64(len(letters))
if ratio < 0.18 { // very low vowel ratio
rules = append(rules, "low_vowel_ratio")
}
}
// Too many links
linkCount := len(regexp.MustCompile(`https?://`).FindAllStringIndex(content, -1))
if linkCount >= 3 {
rules = append(rules, "too_many_links")
}
// All-caps shouting
if content == strings.ToUpper(content) && len(content) >= 8 {
rules = append(rules, "all_caps")
}
// Compute score by rules weight
weights := map[string]float64{
"empty": 1.0,
"too_short": 0.4,
"repeated_chars": 0.3,
"low_vowel_ratio": 0.3,
"too_many_links": 0.5,
"all_caps": 0.2,
}
score := 0.0
for _, r := range rules { score += weights[r] }
if score > 1.0 { score = 1.0 }
return score, rules
}