mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #79
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 80–140 slov, rozdělit do 2–3 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 4–6 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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:; " +
|
||||
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user