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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user