This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
+148
View File
@@ -298,6 +298,154 @@ type aiCSSResponse struct {
CSS string `json:"css"`
}
// Instagram caption generation
type aiInstaMatch struct {
Home string `json:"home"`
Away string `json:"away"`
Competition string `json:"competition"`
DateTime string `json:"date_time"`
Venue string `json:"venue"`
Score string `json:"score"`
}
type aiInstagramRequest struct {
Type string `json:"type"` // "article" | "event" | "generic"
Title string `json:"title"`
Content string `json:"content"` // plain text, HTML will be ignored
ClubName string `json:"club_name"`
Link string `json:"link"`
Hashtags []string `json:"hashtags"`
Audience string `json:"audience"`
Tone string `json:"tone"`
Match *aiInstaMatch `json:"match"`
}
type aiInstagramResponse struct {
Text string `json:"text"`
}
// GenerateInstagram creates an Instagram caption in Czech using OpenRouter
func (ac *AIController) GenerateInstagram(c *gin.Context) {
var req aiInstagramRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Normalize
t := strings.ToLower(strings.TrimSpace(req.Type))
if t == "" { t = "article" }
club := strings.TrimSpace(req.ClubName)
if club == "" { club = "Náš klub" }
audience := strings.TrimSpace(req.Audience)
if audience == "" { audience = "fanoušci klubu" }
tone := strings.TrimSpace(req.Tone)
if tone == "" { tone = "informativní, přátelský" }
// Build system and user messages
system := "Jsi zkušený český social media copywriter pro fotbalový klub. Píšeš poutavé, ale profesionální popisky na Instagram v gramaticky správné češtině (bez neologismů). Buď konkrétní, z textu vyber to nejdůležitější, vyhni se klišé. Výsledek vrať POUZE JSON: {\"text\": \"...\"}."
// Compose contextual notes
var notes []string
if req.Title != "" { notes = append(notes, "Titulek: "+req.Title) }
if strings.TrimSpace(req.Content) != "" { notes = append(notes, "Obsah (zkrácený): "+strings.TrimSpace(req.Content)) }
if req.Match != nil {
m := req.Match
line := []string{}
if m.Home != "" || m.Away != "" { line = append(line, fmt.Sprintf("%s vs %s", strings.TrimSpace(m.Home), strings.TrimSpace(m.Away))) }
if strings.TrimSpace(m.Score) != "" { line = append(line, "Výsledek: "+strings.TrimSpace(m.Score)) }
if strings.TrimSpace(m.Competition) != "" { line = append(line, strings.TrimSpace(m.Competition)) }
if strings.TrimSpace(m.DateTime) != "" { line = append(line, strings.TrimSpace(m.DateTime)) }
if strings.TrimSpace(m.Venue) != "" { line = append(line, "Místo: "+strings.TrimSpace(m.Venue)) }
if len(line) > 0 { notes = append(notes, "Zápas: "+strings.Join(line, " • ")) }
}
if strings.TrimSpace(req.Link) != "" { notes = append(notes, "Krátký odkaz: "+strings.TrimSpace(req.Link)) }
if len(req.Hashtags) > 0 { notes = append(notes, "Preferované hashtagy: "+strings.Join(req.Hashtags, ", ")) }
// Hard requirements
requirements := []string{
"Délka 80140 slov, rozdělit do 23 krátkých odstavců.",
"Použij maximálně 6 emotikonů (žádné dlouhé řetězy).",
"Na konec vlož oddělovač a řádek '🔗 ' následovaný přesně poskytnutým krátkým odkazem.",
"Přidej 46 relevantních českých hashtagů (včetně klubového), přirozeně na konci.",
"Drž se zadaného obsahu. Bez vymýšlení neexistujících informací.",
fmt.Sprintf("Tón: %s. Publikum: %s.", tone, audience),
}
// Build user prompt
user := fmt.Sprintf("Typ: %s\nKlub: %s\n\nPoznámky:\n- %s\n\nPožadavky:\n- %s\n\nVrať POUZE JSON bez formátování.", t, club, strings.Join(notes, "\n- "), strings.Join(requirements, "\n- "))
baseURL := getOpenRouterBaseURL()
apiKey := getOpenRouterAPIKey()
if strings.TrimSpace(apiKey) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OPENROUTER_API_KEY není nastaven"})
return
}
model := getOpenRouterModel()
if model == "" { model = "mistralai/mistral-small-3.2-24b-instruct:free" }
fallbackModel := getOpenRouterFallbackModel()
if fallbackModel == "" { fallbackModel = "mistralai/mistral-nemo:free" }
callModel := func(modelName string) (string, int, error) {
payload := map[string]interface{}{
"model": modelName,
"messages": []map[string]string{
{"role": "system", "content": system},
{"role": "user", "content": user},
},
"temperature": 0.5,
"max_tokens": 800,
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(baseURL, "/") + "/chat/completions"
reqHTTP, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil { return "", http.StatusInternalServerError, err }
reqHTTP.Header.Set("Authorization", "Bearer "+apiKey)
reqHTTP.Header.Set("Content-Type", "application/json")
if ref := strings.TrimSpace(getenv("OPENROUTER_SITE_URL")); ref != "" { reqHTTP.Header.Set("HTTP-Referer", ref) }
if ttl := strings.TrimSpace(getenv("OPENROUTER_APP_NAME")); ttl != "" { reqHTTP.Header.Set("X-Title", ttl) }
client := &http.Client{Timeout: 45 * time.Second}
resp, err := client.Do(reqHTTP)
if err != nil { return "", http.StatusBadGateway, err }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var e map[string]interface{}
_ = json.NewDecoder(resp.Body).Decode(&e)
return "", resp.StatusCode, fmt.Errorf("OpenRouter API error: %v", e)
}
var or struct { Choices []struct { Message struct{ Content string `json:"content"` } `json:"message"` } `json:"choices"` }
if err := json.NewDecoder(resp.Body).Decode(&or); err != nil { return "", http.StatusBadGateway, err }
if len(or.Choices) == 0 { return "", http.StatusBadGateway, fmt.Errorf("empty choices") }
return strings.TrimSpace(or.Choices[0].Message.Content), http.StatusOK, nil
}
content, _, err := callModel(model)
if err != nil || strings.TrimSpace(content) == "" {
if fbContent, _, fbErr := callModel(fallbackModel); fbErr == nil && strings.TrimSpace(fbContent) != "" {
content = fbContent
} else {
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter selhal (včetně fallbacku)", "details": err.Error()}); return }
if fbErr != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter fallback selhal", "details": fbErr.Error()}); return }
c.JSON(http.StatusBadGateway, gin.H{"error": "OpenRouter vrátil prázdnou odpověď"}); return
}
}
sanitized := sanitizeAIResponse(content)
var out aiInstagramResponse
if err := json.Unmarshal([]byte(sanitized), &out); err != nil {
re := regexp.MustCompile(`(?s)\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}`)
if m := re.FindString(sanitized); m != "" {
_ = json.Unmarshal([]byte(m), &out)
}
}
if strings.TrimSpace(out.Text) == "" {
// minimal fallback
txt := req.Title
if txt == "" { txt = "Novinky z klubu" }
out.Text = fmt.Sprintf("%s\n\n🔗 %s", txt, strings.TrimSpace(req.Link))
}
c.JSON(http.StatusOK, out)
}
// GenerateBlog creates a blog article using the OpenRouter API (with Mistral models)
func (ac *AIController) GenerateBlog(c *gin.Context) {
var req aiBlogRequest
+16 -2
View File
@@ -116,8 +116,22 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
var category models.Category
err := ac.DB.Where("name = ?", categoryName).First(&category).Error
if err == gorm.ErrRecordNotFound {
// Create new category
category = models.Category{Name: categoryName}
// Create new category with unique slug derived from name
s := makeSlug(categoryName)
if s == "" {
s = fmt.Sprintf("category-%d", time.Now().Unix())
}
orig := s
for i := 0; i < 50; i++ {
var sc int64
if err := ac.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 { break }
s = fmt.Sprintf("%s-%d", orig, i+1)
}
category = models.Category{Name: categoryName, Slug: s}
if err := ac.DB.Create(&category).Error; err != nil {
logger.Error("CreateArticle: Error creating category '%s': %v", categoryName, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit kategorii"})
+371 -393
View File
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"net/url"
"net"
"os"
"path/filepath"
"regexp"
@@ -39,6 +40,75 @@ type BaseController struct {
DB *gorm.DB
}
// GetArticleBySlug returns a single article by slug (public)
func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
slug := strings.TrimSpace(c.Param("slug"))
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"})
return
}
var art models.Article
if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
if art.ImageURL == "" { art.ImageURL = "/dist/img/logo-club-empty.svg" }
if art.ReadTime == 0 { art.ReadTime = computeEstimatedReadMinutes(art.Content) }
var matchLink models.ArticleMatchLink
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
art.MatchLink = &matchLink
}
var aliases []models.CompetitionAlias
_ = bc.DB.Find(&aliases).Error
bc.addArticleComputedFields(&art, aliases)
c.JSON(http.StatusOK, art)
}
// respondArticlesFromCache tries to serve articles from on-disk cache and returns true if it did.
func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool {
// Helper: read JSON file and respond with pagination
readAndRespond := func(path string) bool {
b, err := os.ReadFile(path)
if err != nil { return false }
// Try payload {items: [...]} first
var wrap struct{ Items []models.Article `json:"items"` }
if json.Unmarshal(b, &wrap) == nil && len(wrap.Items) > 0 {
items := wrap.Items
total := len(items)
start := (page - 1) * size
if start < 0 { start = 0 }
if start > total { start = total }
end := start + size
if end > total { end = total }
paged := items[start:end]
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
return true
}
// Fallback: raw array of articles
var arr []models.Article
if json.Unmarshal(b, &arr) == nil && len(arr) > 0 {
total := len(arr)
start := (page - 1) * size
if start < 0 { start = 0 }
if start > total { start = total }
end := start + size
if end > total { end = total }
paged := arr[start:end]
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
return true
}
return false
}
// Try blogs cache first, then prefetch
if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) { return true }
if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) { return true }
return false
}
func makeSlug(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
@@ -832,329 +902,22 @@ func (bc *BaseController) GetArticle(c *gin.Context) {
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
art.MatchLink = &matchLink
}
// Compute helper fields (category_slug, competition_alias, normalized_category, url)
var aliases []models.CompetitionAlias
_ = bc.DB.Find(&aliases).Error
bc.addArticleComputedFields(&art, aliases)
c.JSON(http.StatusOK, art)
}
// GetCategories returns a list of all categories (public)
func (bc *BaseController) GetCategories(c *gin.Context) {
var items []models.Category
if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
c.JSON(http.StatusOK, items)
}
// CreateCategory creates a new category (admin only)
func (bc *BaseController) CreateCategory(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie je povinný"})
return
}
// Check if category with same name already exists
var existing models.Category
if err := bc.DB.Where("name = ?", name).First(&existing).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
return
}
cat := models.Category{
Name: name,
Description: strings.TrimSpace(body.Description),
}
// Ensure category slug is set and unique
s := makeSlug(cat.Name)
if s == "" {
s = "category"
}
orig := s
for i := 0; i < 50; i++ {
var cnt int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&cnt).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if cnt == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat.Slug = s
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
}
c.JSON(http.StatusCreated, cat)
}
// UpdateCategory updates an existing category (admin only)
func (bc *BaseController) UpdateCategory(c *gin.Context) {
id := c.Param("id")
var cat models.Category
if err := bc.DB.First(&cat, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
if body.Name != nil {
name := strings.TrimSpace(*body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie nemůže být prázdný"})
return
}
// Check if another category with same name exists
var existing models.Category
if err := bc.DB.Where("name = ? AND id != ?", name, id).First(&existing).Error; err == nil {
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
return
}
cat.Name = name
}
if body.Description != nil {
cat.Description = strings.TrimSpace(*body.Description)
}
if err := bc.DB.Save(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"})
return
}
c.JSON(http.StatusOK, cat)
}
// DeleteCategory deletes a category (admin only)
func (bc *BaseController) DeleteCategory(c *gin.Context) {
id := c.Param("id")
var cat models.Category
if err := bc.DB.First(&cat, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
// Check if any articles are using this category
var articleCount int64
if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", id).Count(&articleCount).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole článků"})
return
}
if articleCount > 0 {
c.JSON(http.StatusConflict, gin.H{
"chyba": "Nelze smazat kategorii, která obsahuje články",
"detail": fmt.Sprintf("Kategorie obsahuje %d článků", articleCount),
})
return
}
if err := bc.DB.Delete(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
return
}
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
}
// GetArticleBySlug returns a single article by slug (public)
func (bc *BaseController) GetArticleBySlug(c *gin.Context) {
slug := strings.TrimSpace(c.Param("slug"))
if slug == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Chyba slug"})
return
}
var art models.Article
if err := bc.DB.Preload("Author").Preload("Category").Where("slug = ?", slug).First(&art).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Článek nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
if art.ImageURL == "" {
art.ImageURL = "/dist/img/logo-club-empty.svg"
}
if art.ReadTime == 0 {
art.ReadTime = computeEstimatedReadMinutes(art.Content)
}
// Load match link if exists
var matchLink models.ArticleMatchLink
if err := bc.DB.Where("article_id = ?", art.ID).First(&matchLink).Error; err == nil {
art.MatchLink = &matchLink
}
c.JSON(http.StatusOK, art)
}
// writeArticlesCache writes a JSON snapshot of PUBLISHED articles to cache/blogs/articles.json
// Shape: { "items": [Article], "total": N, "page": 1, "page_size": N }
func (bc *BaseController) writeArticlesCache() {
// Load only published articles ordered by published_at desc, created_at desc
var items []models.Article
if err := bc.DB.Where("published = ?", true).Order("published_at DESC, created_at DESC").Find(&items).Error; err != nil {
return
}
// Ensure image fallback
for i := range items {
if items[i].ImageURL == "" {
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
}
}
payload := map[string]any{
"items": items,
"total": len(items),
"page": 1,
"page_size": len(items),
}
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return
}
dir := filepath.Join("cache", "blogs")
_ = os.MkdirAll(dir, 0o755)
tmp := filepath.Join(dir, "articles.json.tmp")
dst := filepath.Join(dir, "articles.json")
if err := os.WriteFile(tmp, b, 0o644); err == nil {
_ = os.Rename(tmp, dst)
}
}
// respondArticlesFromCache attempts to read cache/blogs/articles.json (or cache/prefetch/articles.json) and respond.
// Returns true if a response was written.
func (bc *BaseController) respondArticlesFromCache(c *gin.Context, page, size int) bool {
// Helper to page slice safely
pageSlice := func(arr []map[string]any, page, size int) []map[string]any {
if size <= 0 {
size = len(arr)
}
if page <= 0 {
page = 1
}
start := (page - 1) * size
if start >= len(arr) {
return []map[string]any{}
}
end := start + size
if end > len(arr) {
end = len(arr)
}
return arr[start:end]
}
readAndRespond := func(p string) bool {
f, err := os.Open(p)
if err != nil {
return false
}
defer f.Close()
var raw map[string]any
if err := json.NewDecoder(f).Decode(&raw); err != nil {
return false
}
// Normalize items array
var arr []map[string]any
if its, ok := raw["items"].([]any); ok {
for _, it := range its {
if m, ok := it.(map[string]any); ok {
arr = append(arr, m)
}
}
} else if its, ok := raw["data"].([]any); ok {
for _, it := range its {
if m, ok := it.(map[string]any); ok {
arr = append(arr, m)
}
}
}
if len(arr) == 0 {
return false
}
// Optional filters from query
publishedOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("published", "false"))) == "true"
featuredOnly := strings.ToLower(strings.TrimSpace(c.DefaultQuery("featured", "false"))) == "true"
if publishedOnly || featuredOnly {
filtered := make([]map[string]any, 0, len(arr))
for _, m := range arr {
if publishedOnly {
if v, ok := m["published"].(bool); !ok || !v {
continue
}
}
if featuredOnly {
if v, ok := m["featured"].(bool); !ok || !v {
continue
}
}
filtered = append(filtered, m)
}
arr = filtered
}
if len(arr) == 0 {
return false
}
total := len(arr)
paged := pageSlice(arr, page, size)
c.JSON(http.StatusOK, gin.H{"items": paged, "total": total, "page": page, "page_size": size})
return true
}
// Try blogs cache first
if readAndRespond(filepath.Join("cache", "blogs", "articles.json")) {
return true
}
// Fallback to prefetch cache if available
if readAndRespond(filepath.Join("cache", "prefetch", "articles.json")) {
return true
}
return false
}
// GetArticles returns a paginated list of articles (public by default, admin can request all with published=false)
func (bc *BaseController) GetArticles(c *gin.Context) {
pageStr := strings.TrimSpace(c.DefaultQuery("page", "1"))
sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "10"))
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
if page < 1 { page = 1 }
size, _ := strconv.Atoi(sizeStr)
if size <= 0 {
size = 10
}
if size > 100 {
size = 100
}
if size <= 0 { size = 10 }
if size > 100 { size = 100 }
pParam := strings.ToLower(strings.TrimSpace(c.Query("published")))
featuredParam := strings.ToLower(strings.TrimSpace(c.Query("featured"))) == "true"
@@ -1163,70 +926,38 @@ func (bc *BaseController) GetArticles(c *gin.Context) {
catRaw := strings.TrimSpace(c.Query("category_id"))
matchID := strings.TrimSpace(c.Query("match_id"))
monthStr := strings.TrimSpace(c.Query("month"))
if monthStr == "" {
monthStr = strings.TrimSpace(c.Query("date"))
}
if monthStr == "" { monthStr = strings.TrimSpace(c.Query("date")) }
catID := 0
if catRaw != "" {
if v, err := strconv.Atoi(catRaw); err == nil {
catID = v
}
}
if catRaw != "" { if v, err := strconv.Atoi(catRaw); err == nil { catID = v } }
skipCache := false
if pParam == "false" {
skipCache = true
}
if q != "" || slug != "" || catID > 0 || matchID != "" || monthStr != "" {
skipCache = true
}
if pParam == "false" { skipCache = true }
if q != "" || slug != "" || catID > 0 || matchID != "" || monthStr != "" { skipCache = true }
if !skipCache {
if bc.respondArticlesFromCache(c, page, size) {
return
}
if bc.respondArticlesFromCache(c, page, size) { return }
}
var items []models.Article
qb := bc.DB.Model(&models.Article{})
if pParam == "" || pParam == "true" {
qb = qb.Where("published = ?", true)
}
if featuredParam {
qb = qb.Where("featured = ?", true)
}
if catID > 0 {
qb = qb.Where("category_id = ?", catID)
}
if slug != "" {
qb = qb.Where("slug = ?", slug)
}
if pParam == "" || pParam == "true" { qb = qb.Where("published = ?", true) }
if featuredParam { qb = qb.Where("featured = ?", true) }
if catID > 0 { qb = qb.Where("category_id = ?", catID) }
if slug != "" { qb = qb.Where("slug = ?", slug) }
if q != "" {
like := "%" + strings.ToLower(q) + "%"
qb = qb.Where("LOWER(title) LIKE ? OR LOWER(content) LIKE ? OR LOWER(category_name) LIKE ?", like, like, like)
}
if matchID != "" {
qb = qb.Joins("JOIN article_match_links aml ON aml.article_id = articles.id").Where("aml.external_match_id = ?", matchID)
}
if matchID != "" { qb = qb.Joins("JOIN article_match_links aml ON aml.article_id = articles.id").Where("aml.external_match_id = ?", matchID) }
if monthStr != "" {
var y, m int
if len(monthStr) >= 7 {
if yy, err := strconv.Atoi(monthStr[0:4]); err == nil {
y = yy
}
if mm, err := strconv.Atoi(monthStr[5:7]); err == nil {
m = mm
}
if yy, err := strconv.Atoi(monthStr[0:4]); err == nil { y = yy }
if mm, err := strconv.Atoi(monthStr[5:7]); err == nil { m = mm }
}
if y > 0 && m >= 1 && m <= 12 {
start := time.Date(y, time.Month(m), 1, 0, 0, 0, 0, time.UTC)
nm := m + 1
ny := y
if nm == 13 {
nm = 1
ny = y + 1
}
nm := m + 1; ny := y
if nm == 13 { nm = 1; ny = y + 1 }
end := time.Date(ny, time.Month(nm), 1, 0, 0, 0, 0, time.UTC)
qb = qb.Where("COALESCE(published_at, created_at) >= ? AND COALESCE(published_at, created_at) < ?", start, end)
}
@@ -1237,23 +968,20 @@ func (bc *BaseController) GetArticles(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
if err := qb.Preload("Author").Preload("Category").
Order("COALESCE(published_at, created_at) DESC, created_at DESC").
Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil {
Limit(size).Offset((page-1)*size).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
for i := range items {
if items[i].ImageURL == "" {
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
}
if items[i].ReadTime == 0 {
items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content)
}
if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" }
if items[i].ReadTime == 0 { items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content) }
}
// Compute helper fields for list
var aliases []models.CompetitionAlias
_ = bc.DB.Find(&aliases).Error
for i := range items { bc.addArticleComputedFields(&items[i], aliases) }
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}
@@ -1262,16 +990,10 @@ func (bc *BaseController) GetFeaturedArticles(c *gin.Context) {
pageStr := strings.TrimSpace(c.DefaultQuery("page", "1"))
sizeStr := strings.TrimSpace(c.DefaultQuery("page_size", "6"))
page, _ := strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
if page < 1 { page = 1 }
size, _ := strconv.Atoi(sizeStr)
if size <= 0 {
size = 6
}
if size > 100 {
size = 100
}
if size <= 0 { size = 6 }
if size > 100 { size = 100 }
var items []models.Article
qb := bc.DB.Model(&models.Article{}).
@@ -1285,19 +1007,56 @@ func (bc *BaseController) GetFeaturedArticles(c *gin.Context) {
}
if err := qb.Preload("Author").Preload("Category").
Order("COALESCE(published_at, created_at) DESC, created_at DESC").
Limit(size).Offset((page - 1) * size).Find(&items).Error; err != nil {
Limit(size).Offset((page-1)*size).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
for i := range items {
if items[i].ImageURL == "" {
items[i].ImageURL = "/dist/img/logo-club-empty.svg"
if items[i].ImageURL == "" { items[i].ImageURL = "/dist/img/logo-club-empty.svg" }
if items[i].ReadTime == 0 { items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content) }
}
// Compute helper fields for list
var aliases []models.CompetitionAlias
_ = bc.DB.Find(&aliases).Error
for i := range items { bc.addArticleComputedFields(&items[i], aliases) }
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}
// addArticleComputedFields populates non-persisted helper fields on the Article JSON
func (bc *BaseController) addArticleComputedFields(a *models.Article, aliases []models.CompetitionAlias) {
// Category slug
if a.Category != nil && strings.TrimSpace(a.Category.Slug) != "" {
a.CategorySlug = strings.TrimSpace(a.Category.Slug)
} else if strings.TrimSpace(a.CategoryName) != "" {
a.CategorySlug = makeSlug(a.CategoryName)
} else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" {
a.CategorySlug = makeSlug(a.Category.Name)
}
// Normalized category (for fast matching on FE)
if strings.TrimSpace(a.CategoryName) != "" {
a.NormalizedCategory = foldAccents(a.CategoryName)
} else if a.Category != nil && strings.TrimSpace(a.Category.Name) != "" {
a.NormalizedCategory = foldAccents(a.Category.Name)
}
// URL path
if strings.TrimSpace(a.Slug) != "" { a.URL = "/news/" + strings.TrimSpace(a.Slug) } else { a.URL = fmt.Sprintf("/articles/%d", a.ID) }
// Competition alias mapping (match category against alias or original name)
cat := strings.TrimSpace(a.CategoryName)
if cat == "" && a.Category != nil {
cat = strings.TrimSpace(a.Category.Name)
}
if cat == "" || len(aliases) == 0 { return }
ncat := foldAccents(cat)
for _, al := range aliases {
aliasNorm := foldAccents(al.Alias)
origNorm := foldAccents(al.OriginalName)
if aliasNorm != "" {
if strings.Contains(ncat, aliasNorm) || strings.Contains(aliasNorm, ncat) { a.CompetitionAlias = al.Alias; return }
}
if items[i].ReadTime == 0 {
items[i].ReadTime = computeEstimatedReadMinutes(items[i].Content)
if origNorm != "" {
if strings.Contains(ncat, origNorm) || strings.Contains(origNorm, ncat) { a.CompetitionAlias = al.Alias; return }
}
}
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": size})
}
// UpdateArticle updates an existing article (protected)
@@ -1363,7 +1122,22 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
var cat models.Category
if err := bc.DB.Where("name = ?", name).First(&cat).Error; err != nil {
if err == gorm.ErrRecordNotFound {
cat = models.Category{Name: name}
// Create category with a unique slug derived from name
s := makeSlug(name)
if s == "" {
s = fmt.Sprintf("category-%d", time.Now().Unix())
}
orig := s
for i := 0; i < 50; i++ {
var sc int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", s).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 { break }
s = fmt.Sprintf("%s-%d", orig, i+1)
}
cat = models.Category{Name: name, Slug: s}
if err := bc.DB.Create(&cat).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
@@ -1473,7 +1247,7 @@ func (bc *BaseController) UpdateArticle(c *gin.Context) {
}
if err := bc.DB.Save(&art).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek"})
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze uložit článek", "detail": err.Error()})
return
}
@@ -2317,6 +2091,42 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
return
}
// Basic SSRF hardening: block internal/private destinations and unusual ports
host := u.Hostname()
port := u.Port()
if port != "" && port != "80" && port != "443" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported port"})
return
}
ips, err := net.LookupIP(host)
if err == nil {
blockedCIDRs := []string{
"127.0.0.0/8", // loopback
"10.0.0.0/8", // private
"172.16.0.0/12", // private
"192.168.0.0/16",// private
"169.254.0.0/16",// link-local
"::1/128", // IPv6 loopback
"fc00::/7", // IPv6 unique local
"fe80::/10", // IPv6 link-local
}
var nets []*net.IPNet
for _, cidr := range blockedCIDRs {
_, n, perr := net.ParseCIDR(cidr)
if perr == nil {
nets = append(nets, n)
}
}
for _, ip := range ips {
for _, n := range nets {
if n.Contains(ip) {
c.JSON(http.StatusBadRequest, gin.H{"error": "destination not allowed"})
return
}
}
}
}
// Fetch with a short timeout
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", u.String(), nil)
@@ -2367,6 +2177,16 @@ func (bc *BaseController) ProxyImage(c *gin.Context) {
}
}
// Enforce a reasonable maximum size when Content-Length is provided (8MB)
if cl := resp.Header.Get("Content-Length"); cl != "" {
if n, err := strconv.Atoi(cl); err == nil {
if n > 8*1024*1024 {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "image too large"})
return
}
}
}
// Stream response
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Cache-Control", "public, max-age=86400")
@@ -4386,6 +4206,154 @@ func (bc *BaseController) DeleteBanner(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"zprava": "Banner byl smazán"})
}
func (bc *BaseController) GetCategories(c *gin.Context) {
var items []models.Category
if err := bc.DB.Order("name ASC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
c.JSON(http.StatusOK, items)
}
func (bc *BaseController) CreateCategory(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Slug string `json:"slug"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie je povinný"})
return
}
var cnt int64
_ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ?", strings.ToLower(name)).Count(&cnt).Error
if cnt > 0 {
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
return
}
slug := strings.TrimSpace(body.Slug)
if slug == "" {
slug = makeSlug(name)
} else {
slug = makeSlug(slug)
}
orig := slug
for i := 0; i < 50; i++ {
var sc int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ?", slug).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 {
break
}
slug = fmt.Sprintf("%s-%d", orig, i+1)
}
item := models.Category{
Name: name,
Description: strings.TrimSpace(body.Description),
Slug: slug,
}
if err := bc.DB.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze vytvořit kategorii"})
return
}
c.JSON(http.StatusCreated, item)
}
func (bc *BaseController) UpdateCategory(c *gin.Context) {
id := c.Param("id")
var item models.Category
if err := bc.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
Slug *string `json:"slug"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Neplatná data", "detail": err.Error()})
return
}
if body.Name != nil {
v := strings.TrimSpace(*body.Name)
if v == "" {
c.JSON(http.StatusBadRequest, gin.H{"chyba": "Název kategorie nemůže být prázdný"})
return
}
var cnt int64
_ = bc.DB.Model(&models.Category{}).Where("LOWER(name) = ? AND id != ?", strings.ToLower(v), item.ID).Count(&cnt).Error
if cnt > 0 {
c.JSON(http.StatusConflict, gin.H{"chyba": "Kategorie s tímto názvem již existuje"})
return
}
item.Name = v
}
if body.Description != nil {
item.Description = strings.TrimSpace(*body.Description)
}
if body.Slug != nil {
s := strings.TrimSpace(*body.Slug)
if s == "" {
s = makeSlug(item.Name)
} else {
s = makeSlug(s)
}
orig := s
for i := 0; i < 50; i++ {
var sc int64
if err := bc.DB.Model(&models.Category{}).Where("slug = ? AND id != ?", s, item.ID).Count(&sc).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba při kontrole jedinečnosti URL"})
return
}
if sc == 0 {
break
}
s = fmt.Sprintf("%s-%d", orig, i+1)
}
item.Slug = s
}
if err := bc.DB.Save(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze aktualizovat kategorii"})
return
}
c.JSON(http.StatusOK, item)
}
func (bc *BaseController) DeleteCategory(c *gin.Context) {
id := c.Param("id")
var item models.Category
if err := bc.DB.First(&item, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"chyba": "Kategorie nenalezena"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Chyba databáze"})
return
}
var cnt int64
if err := bc.DB.Model(&models.Article{}).Where("category_id = ?", item.ID).Count(&cnt).Error; err == nil && cnt > 0 {
c.JSON(http.StatusConflict, gin.H{"chyba": "Nelze smazat kategorii s přiřazenými články", "detail": cnt})
return
}
if err := bc.DB.Delete(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
return
}
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
}
func (bc *BaseController) UploadImage(c *gin.Context) {
f, err := c.FormFile("file")
if err != nil {
@@ -4405,9 +4373,9 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
return
}
// Light content sniffing to ensure the uploaded payload matches the declared extension
// (helps mitigate mislabelled uploads). We only read the first few bytes.
// and basic SVG sanitization (reject obvious script/event patterns).
if src, err := f.Open(); err == nil {
buf := make([]byte, 512)
buf := make([]byte, 2048)
n, _ := src.Read(buf)
_ = src.Close()
detected := http.DetectContentType(buf[:n])
@@ -4416,11 +4384,8 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
case ".pdf":
validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream"
case ".svg":
// Many servers label SVGs inconsistently; allow svg+xml, xml, or text/plain
dl := strings.ToLower(detected)
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
validCT = strings.Contains(strings.ToLower(detected), "image/svg+xml") || strings.Contains(strings.ToLower(detected), "xml") || strings.HasPrefix(strings.ToLower(detected), "text/")
default:
// Common images should report image/*
validCT = strings.HasPrefix(detected, "image/")
}
if !validCT {
@@ -4428,6 +4393,19 @@ func (bc *BaseController) UploadImage(c *gin.Context) {
return
}
}
// Additional SVG content check against common script vectors
if ext == ".svg" {
if src2, err := f.Open(); err == nil {
defer src2.Close()
check := make([]byte, 65536)
n, _ := io.ReadFull(src2, check)
lower := strings.ToLower(string(check[:n]))
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
return
}
}
}
dir := config.AppConfig.UploadDir
if strings.TrimSpace(dir) == "" {
dir = "./uploads"
+508
View File
@@ -0,0 +1,508 @@
package controllers
import (
"net/http"
"strings"
"time"
"encoding/json"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
)
type CommentController struct{ DB *gorm.DB }
// ReportComment allows a user to report a comment with an optional reason
func (cc *CommentController) ReportComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
var body struct{ Reason string `json:"reason"` }
_ = c.ShouldBindJSON(&body)
uid, _ := c.Get("userID")
// Prevent duplicate reports by same user
var exists models.CommentReport
if err := cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).First(&exists).Error; err == nil && exists.ID != 0 {
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
rep := models.CommentReport{ CommentID: cm.ID, UserID: uid.(uint), Reason: strings.TrimSpace(body.Reason) }
if err := cc.DB.Create(&rep).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// React to a comment (auth)
func (cc *CommentController) React(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
var body struct{ Type string `json:"type"` }
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Type) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
uid, _ := c.Get("userID")
// delete previous reaction for this user
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
// create new
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
if err := cc.DB.Create(&r).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Remove reaction (auth)
func (cc *CommentController) Unreact(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
return
}
uid, _ := c.Get("userID")
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: list all comments with filters
func (cc *CommentController) AdminList(c *gin.Context) {
var items []models.Comment
q := cc.DB.Preload("User").Model(&models.Comment{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
if v := strings.TrimSpace(c.Query("target_type")); v != "" { q = q.Where("target_type = ?", v) }
if v := strings.TrimSpace(c.Query("target_id")); v != "" { q = q.Where("target_id = ?", v) }
if v := strings.TrimSpace(c.Query("user_id")); v != "" { q = q.Where("user_id = ?", v) }
page := parseIntDefault(c.Query("page"), 1)
size := parseIntDefault(c.Query("page_size"), 50)
if size > 200 { size = 200 }
var total int64
_ = q.Count(&total).Error
_ = q.Order("created_at DESC").Offset((page-1)*size).Limit(size).Find(&items).Error
// Preload reports counts
ids := make([]uint, 0, len(items))
for _, it := range items { ids = append(ids, it.ID) }
repCounts := map[uint]int{}
if len(ids) > 0 {
type pr struct{ CommentID uint; Cnt int }
var rows []pr
_ = cc.DB.Table("comment_reports").Select("comment_id, COUNT(*) as cnt").Where("comment_id IN ?", ids).Group("comment_id").Scan(&rows).Error
for _, r := range rows { repCounts[r.CommentID] = r.Cnt }
}
out := make([]commentOutput, 0, len(items))
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; out = append(out, co) }
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
// Admin: update comment status (visible|hidden)
func (cc *CommentController) AdminUpdateStatus(c *gin.Context) {
id := c.Param("id")
var body struct{ Status string `json:"status"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
if body.Status != "visible" && body.Status != "hidden" { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid status"}); return }
if err := cc.DB.Model(&models.Comment{}).Where("id = ?", id).Update("status", body.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: ban user for period
func (cc *CommentController) AdminBanUser(c *gin.Context) {
var body struct { UserID uint `json:"user_id"`; Reason string `json:"reason"`; DurationHours int `json:"duration_hours"` }
if err := c.ShouldBindJSON(&body); err != nil || body.UserID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
var until *time.Time
if body.DurationHours > 0 { t := time.Now().Add(time.Duration(body.DurationHours) * time.Hour); until = &t }
uid, _ := c.Get("userID")
ban := models.CommentBan{ UserID: body.UserID, Reason: strings.TrimSpace(body.Reason), Until: until, CreatedByID: uid.(uint) }
if err := cc.DB.Create(&ban).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Create unban request (auth)
func (cc *CommentController) CreateUnbanRequest(c *gin.Context) {
var body struct { Message string `json:"message"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
uid, _ := c.Get("userID")
req := models.UnbanRequest{ UserID: uid.(uint), Message: strings.TrimSpace(body.Message) }
if err := cc.DB.Create(&req).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: list unban requests
func (cc *CommentController) AdminListUnban(c *gin.Context) {
var items []models.UnbanRequest
_ = cc.DB.Order("created_at DESC").Find(&items).Error
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: resolve unban request
func (cc *CommentController) AdminResolveUnban(c *gin.Context) {
id := c.Param("id")
var body struct { Action string `json:"action"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid"}); return }
uid, _ := c.Get("userID")
var req models.UnbanRequest
if err := cc.DB.First(&req, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
if body.Action != "approve" && body.Action != "reject" { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return }
status := map[string]string{"approve":"approved","reject":"rejected"}[body.Action]
now := time.Now()
if err := cc.DB.Model(&req).Updates(map[string]interface{}{"status": status, "resolved_by_id": uid.(uint), "resolved_at": &now}).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
// If approved, remove bans (set until = now)
if status == "approved" {
_ = cc.DB.Model(&models.CommentBan{}).Where("user_id = ? AND (until IS NULL OR until > ?)", req.UserID, time.Now()).Update("until", now).Error
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func NewCommentController(db *gorm.DB) *CommentController { return &CommentController{DB: db} }
var allowedTargetTypes = map[string]bool{
"article": true,
"event": true,
"gallery_album": true,
"youtube_video": true,
}
type commentOutput struct {
ID uint `json:"id"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
ParentID *uint `json:"parent_id,omitempty"`
Content string `json:"content"`
Status string `json:"status"`
IsEdited bool `json:"is_edited"`
EditedAt *time.Time `json:"edited_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
User userSlim `json:"user"`
Reactions map[string]int `json:"reactions"`
MyReaction string `json:"my_reaction,omitempty"`
SpamScore float32 `json:"spam_score,omitempty"`
SpamRules []string `json:"spam_rules,omitempty"`
Reports int `json:"reports,omitempty"`
}
type userSlim struct {
ID uint `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Role string `json:"role"`
AvatarURL string `json:"avatar_url,omitempty"`
}
func toOutput(c models.Comment) commentOutput {
out := commentOutput{
ID: c.ID,
TargetType: c.TargetType,
TargetID: c.TargetID,
ParentID: c.ParentID,
Content: c.Content,
Status: c.Status,
IsEdited: c.IsEdited,
EditedAt: c.EditedAt,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
User: userSlim{
ID: c.User.ID,
FirstName: c.User.FirstName,
LastName: c.User.LastName,
Email: c.User.Email,
Role: c.User.Role,
},
SpamScore: c.SpamScore,
}
if strings.TrimSpace(c.SpamRules) != "" {
var arr []string
if err := json.Unmarshal([]byte(c.SpamRules), &arr); err == nil { out.SpamRules = arr }
}
return out
}
// GetComments (public): list comments for a target with pagination
// GET /comments?target_type=...&target_id=...&page=1&page_size=20
func (cc *CommentController) GetComments(c *gin.Context) {
// Ensure table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.Comment{})
targetType := strings.TrimSpace(c.Query("target_type"))
targetID := strings.TrimSpace(c.Query("target_id"))
if !allowedTargetTypes[targetType] || targetID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing or invalid target_type/target_id"})
return
}
page := parseIntDefault(c.Query("page"), 1)
pageSize := parseIntDefault(c.Query("page_size"), 20)
if pageSize > 100 { pageSize = 100 }
if page < 1 { page = 1 }
var total int64
q := cc.DB.Model(&models.Comment{}).Where("target_type = ? AND target_id = ? AND status = ?", targetType, targetID, "visible")
if err := q.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
var rows []models.Comment
if err := cc.DB.Preload("User").Where("target_type = ? AND target_id = ? AND status = ?", targetType, targetID, "visible").
Order("created_at ASC").
Offset((page-1)*pageSize).Limit(pageSize).
Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
}
// Build reactions map per comment
out := make([]commentOutput, 0, len(rows))
ids := make([]uint, 0, len(rows))
userIDs := make([]uint, 0, len(rows))
for _, r := range rows { ids = append(ids, r.ID) }
seenU := map[uint]bool{}
for _, r := range rows { if r.UserID != 0 && !seenU[r.UserID] { userIDs = append(userIDs, r.UserID); seenU[r.UserID] = true } }
reactionCounts := make(map[uint]map[string]int)
if len(ids) > 0 {
type rc struct{ CommentID uint; Type string; Cnt int }
var agg []rc
// aggregate per type
if err := cc.DB.Table("comment_reactions").Select("comment_id, type, COUNT(*) as cnt").
Where("comment_id IN ?", ids).Group("comment_id, type").Scan(&agg).Error; err == nil {
for _, a := range agg {
if reactionCounts[a.CommentID] == nil { reactionCounts[a.CommentID] = map[string]int{} }
reactionCounts[a.CommentID][a.Type] = a.Cnt
}
}
}
var myReactions map[uint]string
if uid, ok := c.Get("userID"); ok {
var rs []models.CommentReaction
if err := cc.DB.Where("user_id = ? AND comment_id IN ?", uid, ids).Find(&rs).Error; err == nil {
myReactions = make(map[uint]string, len(rs))
for _, r := range rs { myReactions[r.CommentID] = r.Type }
}
}
// Preload user profiles for avatar (prefer animated when available)
avatarByUser := map[uint]string{}
if len(userIDs) > 0 {
type up struct{ UserID uint; AvatarURL string; AnimatedAvatarURL string }
var profs []up
_ = cc.DB.Table("user_profiles").Select("user_id, avatar_url, animated_avatar_url").Where("user_id IN ?", userIDs).Scan(&profs).Error
for _, p := range profs {
if strings.TrimSpace(p.AnimatedAvatarURL) != "" {
avatarByUser[p.UserID] = p.AnimatedAvatarURL
} else {
avatarByUser[p.UserID] = p.AvatarURL
}
}
}
for _, r := range rows {
co := toOutput(r)
if co.User.ID != 0 {
if av, ok := avatarByUser[co.User.ID]; ok { co.User.AvatarURL = av }
}
if rc, ok := reactionCounts[r.ID]; ok { co.Reactions = rc } else { co.Reactions = map[string]int{} }
if myReactions != nil { if t, ok := myReactions[r.ID]; ok { co.MyReaction = t } }
out = append(out, co)
}
c.JSON(http.StatusOK, gin.H{
"items": out,
"total": total,
"page": page,
"page_size": pageSize,
})
}
type createCommentInput struct {
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
Content string `json:"content"`
ParentID *uint `json:"parent_id"`
}
// CreateComment (auth required)
func (cc *CommentController) CreateComment(c *gin.Context) {
// Ensure table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.Comment{})
var in createCommentInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
in.TargetType = strings.TrimSpace(in.TargetType)
in.TargetID = strings.TrimSpace(in.TargetID)
content := strings.TrimSpace(in.Content)
if !allowedTargetTypes[in.TargetType] || in.TargetID == "" || len(content) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing or invalid fields"})
return
}
if len(content) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Komentář je příliš krátký (min. 6 znaků)"})
return
}
if len(content) > 2000 { // hard cap
c.JSON(http.StatusBadRequest, gin.H{"error": "Komentář je příliš dlouhý (max 2000 znaků)"})
return
}
userIDv, _ := c.Get("userID")
userID := userIDv.(uint)
// Check active bans
var activeBan models.CommentBan
if err := cc.DB.Where("user_id = ? AND (until IS NULL OR until > ?)", userID, time.Now()).Order("created_at DESC").First(&activeBan).Error; err == nil && activeBan.ID != 0 {
// User is banned
until := "trvale"
if activeBan.Until != nil { until = activeBan.Until.Format(time.RFC3339) }
c.JSON(http.StatusForbidden, gin.H{"error": "Váš účet má omezené komentování.", "until": until})
return
}
// Spam evaluation and bad words filtering
score, rules := services.EvaluateSpamScore(content)
filtered, _ := services.FilterBadWords(content)
status := "visible"
// Moderation only if sensitive terms detected
if ok, _ := services.ContainsSensitiveWords(filtered); ok {
status = "hidden"
}
rulesJSON, _ := json.Marshal(rules)
cm := models.Comment{
TargetType: in.TargetType,
TargetID: in.TargetID,
UserID: userID,
ParentID: in.ParentID,
Content: filtered,
Status: status,
SpamScore: float32(score),
SpamRules: string(rulesJSON),
}
if err := cc.DB.Create(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create comment"})
return
}
// Award engagement points for visible comment
if status == "visible" {
svc := services.NewEngagementService(cc.DB)
_, _ = svc.AwardPoints(userID, 5, "comment_create", map[string]interface{}{"comment_id": cm.ID})
_ = svc.CheckAndAwardAchievements(userID)
}
// Reload with user
var out models.Comment
_ = cc.DB.Preload("User").First(&out, cm.ID).Error
c.JSON(http.StatusCreated, toOutput(out))
}
type updateCommentInput struct {
Content string `json:"content"`
}
// UpdateComment (auth: owner or admin)
func (cc *CommentController) UpdateComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.Preload("User").First(&cm, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Permission: owner or admin
role, _ := c.Get("userRole")
uidv, _ := c.Get("userID")
if role != "admin" && uidv.(uint) != cm.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
var in updateCommentInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
content := strings.TrimSpace(in.Content)
if len(content) == 0 || len(content) > 2000 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Neplatný obsah"})
return
}
// Re-check ban
var activeBan models.CommentBan
if err := cc.DB.Where("user_id = ? AND (until IS NULL OR until > ?)", cm.UserID, time.Now()).First(&activeBan).Error; err == nil && activeBan.ID != 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "Váš účet má omezené komentování."})
return
}
// Filter & re-evaluate basic spam (do not auto-hide unless sensitive)
score, rules := services.EvaluateSpamScore(content)
filtered, _ := services.FilterBadWords(content)
now := time.Now()
cm.Content = filtered
cm.IsEdited = true
cm.EditedAt = &now
cm.SpamScore = float32(score)
if b, err := json.Marshal(rules); err == nil { cm.SpamRules = string(b) }
if err := cc.DB.Save(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update comment"})
return
}
c.JSON(http.StatusOK, toOutput(cm))
}
// DeleteComment (auth: owner or admin)
func (cc *CommentController) DeleteComment(c *gin.Context) {
id := c.Param("id")
var cm models.Comment
if err := cc.DB.First(&cm, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Comment not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
}
return
}
// Permission: owner or admin
role, _ := c.Get("userRole")
uidv, _ := c.Get("userID")
if role != "admin" && uidv.(uint) != cm.UserID {
c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"})
return
}
if err := cc.DB.Delete(&cm).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete comment"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// helpers
func parseIntDefault(s string, def int) int {
if s == "" { return def }
n := 0
for _, ch := range s { if ch < '0' || ch > '9' { return def } }
for i := 0; i < len(s); i++ { n = n*10 + int(s[i]-'0') }
if n <= 0 { return def }
return n
}
+133 -94
View File
@@ -28,6 +28,78 @@ type ContactController struct {
emailService email.EmailService
}
func NewContactController(db *gorm.DB, emailService email.EmailService) *ContactController {
return &ContactController{DB: db, emailService: emailService}
}
type NewsletterSubscriptionRequest struct {
Email string `json:"email" binding:"required,email"`
Preferences map[string]bool `json:"preferences"`
}
// SubmitContactForm handles public contact form submissions
// POST /api/v1/contact
func (cc *ContactController) SubmitContactForm(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required"`
Subject string `json:"subject" binding:"required"`
Message string `json:"message" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
return
}
name := strings.TrimSpace(input.Name)
emailStr := strings.TrimSpace(input.Email)
subject := strings.TrimSpace(input.Subject)
message := strings.TrimSpace(input.Message)
if name == "" || emailStr == "" || subject == "" || message == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "All fields are required"})
return
}
if !strings.Contains(emailStr, "@") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Valid email is required"})
return
}
if s, _ := services.FilterBadWords(subject); s != "" {
subject = s
}
if m, _ := services.FilterBadWords(message); m != "" {
message = m
}
ip := c.ClientIP()
ua := c.GetHeader("User-Agent")
msg := models.ContactMessage{
Name: name,
Email: emailStr,
Subject: subject,
Message: message,
Source: "contact",
IPAddress: ip,
UserAgent: ua,
}
if err := cc.DB.Create(&msg).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save message"})
return
}
_ = cc.emailService.SendContactForm(&email.ContactFormData{
Name: name,
Email: emailStr,
Subject: subject,
Message: message,
IPAddress: ip,
UserAgent: ua,
})
c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID})
}
func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
@@ -387,11 +459,24 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
for _, r := range recipients { _ = cc.emailService.SendNewsletterWelcomeBack(&email.NewsletterWelcomeBackData{Email: r}) }
case "setup":
for _, r := range recipients {
token, tErr := utils.GenerateSubscriberToken(r, 60*24)
if tErr != nil { logger.Error("Failed to generate token for setup test: %v", tErr); continue }
token, _ := utils.GenerateSubscriberToken(r, 60*24) // 1 day
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
setupEmail := &email.EmailData{Subject: "Test: Nastavte svůj newsletter", To: []string{r}, Template: "newsletter_setup", Data: struct{ SetupURL string }{SetupURL: setupURL}}
// Engagement: if a user already exists for this email, award points
var subUser models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", r).First(&subUser).Error; err == nil && subUser.ID != 0 {
es := services.NewEngagementService(cc.DB)
_, _ = es.AwardPoints(subUser.ID, 12, "newsletter_subscribe", map[string]interface{}{"email": r})
_ = es.CheckAndAwardAchievements(subUser.ID)
}
setupEmail := &email.EmailData{
Subject: "Nastavte svůj newsletter",
To: []string{r},
Template: "newsletter_setup",
Data: struct{ SetupURL string }{SetupURL: setupURL},
}
_ = cc.emailService.SendEmail(setupEmail)
}
case "blogs", "events", "matches", "scores", "weekly":
@@ -409,85 +494,6 @@ func (cc *ContactController) SendNewsletterTest(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Test email(s) sent", "recipients": recipients, "type": t})
}
func NewContactController(db *gorm.DB, emailService email.EmailService) *ContactController {
return &ContactController{
DB: db,
emailService: emailService,
}
}
type ContactFormRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Subject string `json:"subject" binding:"required"`
Message string `json:"message" binding:"required"`
Source string `json:"source"` // e.g., "contact", "sponsor"
}
// SubmitContactForm handles contact form submissions
// @Summary Submit contact form
// @Description Handles contact form submissions and sends an email notification
// @Tags contact
// @Accept json
// @Produce json
// @Param input body ContactFormRequest true "Contact form data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /api/v1/contact [post]
func (cc *ContactController) SubmitContactForm(c *gin.Context) {
var input ContactFormRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Normalize source field
source := strings.TrimSpace(input.Source)
if source == "" {
source = "contact"
}
// Save to database
contactMessage := models.ContactMessage{
Name: input.Name,
Email: input.Email,
Subject: input.Subject,
Message: input.Message,
Source: source,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
IsRead: false,
}
if err := cc.DB.Create(&contactMessage).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save contact message"})
return
}
// Send email notification asynchronously to prevent frontend timeout
go func() {
emailData := &email.ContactFormData{
Name: input.Name,
Email: input.Email,
Subject: input.Subject,
Message: input.Message,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
if err := cc.emailService.SendContactForm(emailData); err != nil {
logger.Error("Failed to send contact form email: %v", err)
}
}()
c.JSON(http.StatusOK, gin.H{"message": "Your message has been sent successfully"})
}
type NewsletterSubscriptionRequest struct {
Email string `json:"email" binding:"required,email"`
Preferences map[string]bool `json:"preferences"`
}
// SubscribeToNewsletter handles newsletter subscriptions
// @Summary Subscribe to newsletter
// @Description Handles newsletter subscription requests
@@ -527,6 +533,14 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
return
}
// Engagement: award points to matching user (if exists)
var u models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&u).Error; err == nil && u.ID != 0 {
es := services.NewEngagementService(cc.DB)
_, _ = es.AwardPoints(u.ID, 12, "newsletter_subscribe", map[string]interface{}{"email": subscription.Email})
_ = es.CheckAndAwardAchievements(u.ID)
}
// Send welcome back email in a goroutine
go func(sub models.NewsletterSubscription) {
manageURL := ""
@@ -579,18 +593,20 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
UpdatedAt: time.Now(),
}
if err := cc.DB.Create(&subscription).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe to newsletter"})
return
}
// Generate a subscriber token to include in follow-up emails (preferences links)
token, _ := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
// Generate a subscriber token to include in follow-up emails (preferences links)
token, _ := utils.GenerateSubscriberToken(subscription.Email, 60*24) // 1 day
baseFE := strings.TrimSuffix(config.AppConfig.FrontendBaseURL, "/")
setupURL := baseFE + "/newsletter/setup?token=" + url.QueryEscape(token)
unsubscribeURL := baseFE + "/newsletter/preferences?token=" + url.QueryEscape(token)
// Auto-create fan user account if not exists
// Engagement: if a user already exists for this email, award points
var u models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&u).Error; err == nil && u.ID != 0 {
es := services.NewEngagementService(cc.DB)
_, _ = es.AwardPointsCapped(u.ID, 12, "newsletter_subscribe", map[string]interface{}{"email": subscription.Email})
_ = es.CheckAndAwardAchievements(u.ID)
}
// Auto-create fan user account if not exists
var existingUser models.User
if err := cc.DB.Where("LOWER(email) = LOWER(?)", subscription.Email).First(&existingUser).Error; err == gorm.ErrRecordNotFound {
// Generate a strong random password (16 chars, mixed set)
@@ -643,6 +659,10 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
if err := cc.emailService.SendEmail(credEmail); err != nil {
logger.Error("Failed to send fan account created email: %v", err)
}
// Engagement: award points to new user
es := services.NewEngagementService(cc.DB)
_, _ = es.AwardPoints(u.ID, 12, "newsletter_subscribe", map[string]interface{}{"email": subscription.Email})
_ = es.CheckAndAwardAchievements(u.ID)
}
}
}
@@ -1233,7 +1253,26 @@ func (cc *ContactController) ForwardAllContactMessages(c *gin.Context) {
}
if len(messages) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "No messages to forward"})
// Even if there are no messages now, ensure auto-forward is configured
if !input.SaveDefault {
var set models.Settings
if err := cc.DB.First(&set).Error; err != nil {
if err == gorm.ErrRecordNotFound {
set = models.Settings{}
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Create(&set).Error
}
} else {
set.ContactForwardEnabled = true
set.ContactForwardList = strings.Join(recipients, ", ")
_ = cc.DB.Save(&set).Error
}
}
c.JSON(http.StatusOK, gin.H{
"message": "Automatické přeposílání je nastaveno. Zatím nejsou žádné zprávy k přeposlání.",
"count": 0,
})
return
}
@@ -117,7 +117,14 @@ func (c *EditorPreviewController) ApplyPreviewChanges(ctx *gin.Context) {
"created_at": time.Now(),
"updated_at": time.Now(),
}
// Persist custom styles into settings JSON under settings.styles for compatibility
if len(elem.CustomStyles) > 0 {
config["settings"] = map[string]interface{}{
"styles": elem.CustomStyles,
}
}
if err := tx.Table("page_element_configs").Create(config).Error; err != nil {
tx.Rollback()
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save configuration"})
@@ -0,0 +1,270 @@
package controllers
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
)
type EngagementController struct{ DB *gorm.DB }
func NewEngagementController(db *gorm.DB) *EngagementController { return &EngagementController{DB: db} }
// GET /api/v1/engagement/profile (auth)
func (ec *EngagementController) GetProfile(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
svc := services.NewEngagementService(ec.DB)
up, err := svc.EnsureProfile(userID)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load profile"}); return }
// Achievements count
var achCount int64
_ = ec.DB.Model(&models.UserAchievement{}).Where("user_id = ?", userID).Count(&achCount).Error
c.JSON(http.StatusOK, gin.H{
"user_id": userID,
"points": up.Points,
"level": up.Level,
"xp": up.XP,
"avatar_url": up.AvatarURL,
"animated_avatar_url": up.AnimatedAvatarURL,
"achievements": achCount,
})
}
// PATCH /api/v1/engagement/avatar (auth)
func (ec *EngagementController) PatchAvatar(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var body struct{ AvatarURL *string `json:"avatar_url"`; AnimatedAvatarURL *string `json:"animated_avatar_url"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
updates := map[string]interface{}{}
if body.AvatarURL != nil { updates["avatar_url"] = strings.TrimSpace(*body.AvatarURL) }
if body.AnimatedAvatarURL != nil { updates["animated_avatar_url"] = strings.TrimSpace(*body.AnimatedAvatarURL) }
if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if err := ec.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update avatar"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GET /api/v1/engagement/rewards (public)
func (ec *EngagementController) GetRewards(c *gin.Context) {
var items []models.RewardItem
q := ec.DB.Where("active = ?", true)
if err := q.Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load rewards"}); return }
c.JSON(http.StatusOK, items)
}
// POST /api/v1/engagement/redeem (auth)
func (ec *EngagementController) Redeem(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var body struct{ RewardID uint `json:"reward_id"` }
if err := c.ShouldBindJSON(&body); err != nil || body.RewardID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
var item models.RewardItem
if err := ec.DB.First(&item, body.RewardID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Reward not found"}); return }
if !item.Active { c.JSON(http.StatusBadRequest, gin.H{"error":"Reward is not active"}); return }
if item.Stock == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"Out of stock"}); return }
// Ensure profile
svc := services.NewEngagementService(ec.DB)
up, err := svc.EnsureProfile(userID)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load profile"}); return }
if up.Points < item.CostPoints { c.JSON(http.StatusBadRequest, gin.H{"error":"Nedostatek bodů"}); return }
// Transaction: deduct points, reduce stock, create redemption
tx := ec.DB.Begin()
if err := tx.Model(&models.UserProfile{}).Where("user_id = ? AND points >= ?", userID, item.CostPoints).UpdateColumn("points", gorm.Expr("points - ?", item.CostPoints)).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to deduct points"}); return }
if item.Stock > 0 {
if err := tx.Model(&models.RewardItem{}).Where("id = ? AND stock > 0", item.ID).UpdateColumn("stock", gorm.Expr("stock - 1")).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update stock"}); return }
}
red := models.RewardRedemption{ UserID: userID, RewardID: item.ID, Status: "approved" }
if strings.HasPrefix(item.Type, "merch_") || item.Type == "custom" { red.Status = "pending" }
if err := tx.Create(&red).Error; err != nil { tx.Rollback(); c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create redemption"}); return }
// If avatar reward, update profile immediately
if item.Type == "avatar_static" {
_ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("avatar_url", item.ImageURL).Error
}
if item.Type == "avatar_animated" {
_ = tx.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("animated_avatar_url", item.ImageURL).Error
}
if err := tx.Commit().Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to commit redemption"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true, "status": red.Status})
}
// GET /api/v1/engagement/achievements (auth)
func (ec *EngagementController) GetAchievements(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
// Ensure defaults and award any newly satisfied achievements
svc := services.NewEngagementService(ec.DB)
_ = svc.CheckAndAwardAchievements(userID)
// Load active achievement definitions
var defs []models.Achievement
_ = ec.DB.Where("active = ?", true).Order("id ASC").Find(&defs).Error
// Load user's completed achievements
var userAch []models.UserAchievement
_ = ec.DB.Where("user_id = ?", userID).Find(&userAch).Error
achieved := map[uint]models.UserAchievement{}
for _, ua := range userAch { achieved[ua.AchievementID] = ua }
// Counters for progress
var commentCount int64
_ = ec.DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&commentCount).Error
var voteCount int64
_ = ec.DB.Model(&models.PollVote{}).Where("user_id = ?", userID).Count(&voteCount).Error
hasNewsletter := false
_ = ec.DB.Model(&models.NewsletterSubscription{}).Select("1").Where("LOWER(email) = (SELECT LOWER(email) FROM users WHERE id = ?) AND is_active = ?", userID, true).Limit(1).Scan(&hasNewsletter).Error
// Build response
items := make([]gin.H, 0, len(defs))
for _, d := range defs {
if ua, ok := achieved[d.ID]; ok {
items = append(items, gin.H{
"id": d.ID,
"code": d.Code,
"title": d.Title,
"description": d.Description,
"points": d.Points,
"xp": d.XP,
"icon": d.Icon,
"achieved": true,
"achieved_at": ua.CreatedAt,
})
} else {
items = append(items, gin.H{
"id": d.ID,
"code": d.Code,
"title": d.Title,
"description": d.Description,
"points": d.Points,
"xp": d.XP,
"icon": d.Icon,
"achieved": false,
})
}
}
c.JSON(http.StatusOK, gin.H{
"achievements": items,
"counters": gin.H{
"comments": commentCount,
"votes": voteCount,
"newsletter": hasNewsletter,
},
})
}
// Admin: list rewards
// GET /api/v1/admin/engagement/rewards
func (ec *EngagementController) AdminListRewards(c *gin.Context) {
var items []models.RewardItem
q := ec.DB.Model(&models.RewardItem{})
if v := strings.TrimSpace(c.Query("active")); v != "" {
if v == "true" || v == "1" { q = q.Where("active = ?", true) }
if v == "false" || v == "0" { q = q.Where("active = ?", false) }
}
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load rewards"}); return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: create reward
// POST /api/v1/admin/engagement/rewards
func (ec *EngagementController) AdminCreateReward(c *gin.Context) {
var body struct{
Name string `json:"name"`
Type string `json:"type"`
CostPoints int64 `json:"cost_points"`
ImageURL string `json:"image_url"`
Stock int `json:"stock"`
Active *bool `json:"active"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" || strings.TrimSpace(body.Type) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
}
item := models.RewardItem{ Name: strings.TrimSpace(body.Name), Type: strings.TrimSpace(body.Type), CostPoints: body.CostPoints, ImageURL: strings.TrimSpace(body.ImageURL), Stock: body.Stock, Active: true }
if body.Active != nil { item.Active = *body.Active }
if body.Metadata != nil { item.Metadata = body.Metadata }
if err := ec.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create reward"}); return }
c.JSON(http.StatusOK, item)
}
// Admin: update reward
// PUT /api/v1/admin/engagement/rewards/:id
func (ec *EngagementController) AdminUpdateReward(c *gin.Context) {
id := c.Param("id")
var body struct{
Name *string `json:"name"`
Type *string `json:"type"`
CostPoints *int64 `json:"cost_points"`
ImageURL *string `json:"image_url"`
Stock *int `json:"stock"`
Active *bool `json:"active"`
Metadata map[string]interface{} `json:"metadata"`
}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
updates := map[string]interface{}{}
if body.Name != nil { updates["name"] = strings.TrimSpace(*body.Name) }
if body.Type != nil { updates["type"] = strings.TrimSpace(*body.Type) }
if body.CostPoints != nil { updates["cost_points"] = *body.CostPoints }
if body.ImageURL != nil { updates["image_url"] = strings.TrimSpace(*body.ImageURL) }
if body.Stock != nil { updates["stock"] = *body.Stock }
if body.Active != nil { updates["active"] = *body.Active }
if body.Metadata != nil { updates["metadata"] = body.Metadata }
if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if err := ec.DB.Model(&models.RewardItem{}).Where("id = ?", id).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update reward"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: delete reward
// DELETE /api/v1/admin/engagement/rewards/:id
func (ec *EngagementController) AdminDeleteReward(c *gin.Context) {
id := c.Param("id")
if err := ec.DB.Delete(&models.RewardItem{}, "id = ?", id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to delete reward"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: list redemptions
// GET /api/v1/admin/engagement/redemptions
func (ec *EngagementController) AdminListRedemptions(c *gin.Context) {
var items []models.RewardRedemption
q := ec.DB.Model(&models.RewardRedemption{})
if v := strings.TrimSpace(c.Query("status")); v != "" { q = q.Where("status = ?", v) }
if err := q.Order("created_at DESC").Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load redemptions"}); return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: update redemption status (approve/reject/fulfill)
// PATCH /api/v1/admin/engagement/redemptions/:id
func (ec *EngagementController) AdminUpdateRedemptionStatus(c *gin.Context) {
id := c.Param("id")
var body struct{ Action string `json:"action"` }
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
action := strings.ToLower(strings.TrimSpace(body.Action))
var newStatus string
switch action {
case "approve": newStatus = "approved"
case "reject": newStatus = "rejected"
case "fulfill": newStatus = "fulfilled"
default:
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid action"}); return
}
if err := ec.DB.Model(&models.RewardRedemption{}).Where("id = ?", id).Update("status", newStatus).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to update status"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "status": newStatus})
}
+61
View File
@@ -23,6 +23,67 @@ type FilesController struct {
DB *gorm.DB
}
func (fc *FilesController) GetStorageUsage(c *gin.Context) {
var usedBytes int64
row := fc.DB.Model(&models.UploadedFile{}).Select("COALESCE(SUM(file_size), 0)").Row()
_ = row.Scan(&usedBytes)
var count int64
fc.DB.Model(&models.UploadedFile{}).Count(&count)
var settings models.Settings
_ = fc.DB.First(&settings).Error
quotaMB := settings.StorageQuotaMB
if quotaMB <= 0 {
quotaMB = 1024
}
warnPct := settings.StorageWarnThreshold
if warnPct <= 0 {
warnPct = 80
}
criticalPct := settings.StorageCriticalThreshold
if criticalPct <= 0 {
criticalPct = 95
}
if warnPct > criticalPct {
warnPct = criticalPct - 5
if warnPct < 0 {
warnPct = 0
}
}
quotaBytes := int64(quotaMB) * 1024 * 1024
percent := 0.0
if quotaBytes > 0 {
percent = float64(usedBytes) * 100.0 / float64(quotaBytes)
}
status := "ok"
if int(percent+0.5) >= criticalPct {
status = "critical"
} else if int(percent+0.5) >= warnPct {
status = "warn"
}
resp := StorageUsageResponse{
UsedBytes: usedBytes,
UsedCount: count,
QuotaMB: quotaMB,
QuotaBytes: quotaBytes,
Percent: percent,
WarnPercent: warnPct,
CriticalPercent: criticalPct,
Status: status,
}
c.JSON(http.StatusOK, resp)
}
type StorageUsageResponse struct {
UsedBytes int64 `json:"used_bytes"`
UsedCount int64 `json:"used_count"`
QuotaMB int `json:"quota_mb"`
QuotaBytes int64 `json:"quota_bytes"`
Percent float64 `json:"percent"`
WarnPercent int `json:"warn_percent"`
CriticalPercent int `json:"critical_percent"`
Status string `json:"status"`
}
// FileInfo represents detailed file information with usage tracking
type FileInfo struct {
ID uint `json:"id"`
+18 -7
View File
@@ -110,7 +110,7 @@ func (ctrl *MyUIbrixController) OptimizePageLayout(c *gin.Context) {
var configs []map[string]interface{}
query := `
SELECT element_name, variant, visible, display_order, custom_styles
SELECT element_name, variant, visible, display_order, settings
FROM page_element_configs
WHERE page_type = ?
ORDER BY display_order ASC
@@ -127,22 +127,33 @@ func (ctrl *MyUIbrixController) OptimizePageLayout(c *gin.Context) {
var elementName, variant string
var visible bool
var displayOrder int
var customStylesJSON []byte
var settingsJSON []byte
if err := rows.Scan(&elementName, &variant, &visible, &displayOrder, &customStylesJSON); err != nil {
if err := rows.Scan(&elementName, &variant, &visible, &displayOrder, &settingsJSON); err != nil {
continue
}
// settings is a JSON object; we expect optional nested key "styles"
var settings map[string]interface{}
if len(settingsJSON) > 0 {
_ = json.Unmarshal(settingsJSON, &settings)
}
var customStyles map[string]interface{}
if len(customStylesJSON) > 0 {
json.Unmarshal(customStylesJSON, &customStyles)
if settings != nil {
if st, ok := settings["styles"]; ok {
if m, ok2 := st.(map[string]interface{}); ok2 {
customStyles = m
}
}
}
configs = append(configs, map[string]interface{}{
"element_name": elementName,
"variant": variant,
"visible": visible,
"variant": variant,
"visible": visible,
"display_order": displayOrder,
// Keep API field name as custom_styles for compatibility
"custom_styles": customStyles,
})
}
@@ -30,7 +30,9 @@ func (nc *NavigationController) GetNavigationItems(c *gin.Context) {
// Get only top-level items (no parent) that are visible and NOT admin-only
if err := nc.DB.Where("parent_id IS NULL AND visible = ? AND requires_admin = ?", true, false).
Order("display_order ASC").
Preload("Children", "visible = ? AND requires_admin = ?", true, false).
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("visible = ? AND requires_admin = ?", true, false).Order("display_order ASC")
}).
Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch navigation items"})
return
@@ -63,7 +65,7 @@ func (nc *NavigationController) GetAllNavigationItems(c *gin.Context) {
var items []models.NavigationItem
if err := nc.DB.Where("parent_id IS NULL").
Order("display_order ASC").
Order("requires_admin ASC, display_order ASC").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Order("display_order ASC")
}).
@@ -1,6 +1,7 @@
package controllers
import (
"fmt"
"fotbal-club/internal/models"
"net/http"
"strconv"
@@ -187,13 +188,38 @@ func (pc *PageElementConfigController) BatchUpdatePageElementConfigs(c *gin.Cont
updated := 0
created := 0
// Validate styles before saving
validator := &StyleValidator{}
for i := range configs {
if len(configs[i].Settings) > 0 {
// ElementSettings is already a map[string]interface{} type
settingsMap := map[string]interface{}(configs[i].Settings)
// Validate and normalize
if err := validator.ValidateAndNormalizeStyles(settingsMap); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Style validation failed for %s: %v", configs[i].ElementName, err)})
return
}
// Update back
configs[i].Settings = models.ElementSettings(settingsMap)
}
}
err := pc.DB.Transaction(func(tx *gorm.DB) error {
for _, cfg := range configs {
var existing models.PageElementConfig
result := tx.Where("page_type = ? AND element_name = ?", cfg.PageType, cfg.ElementName).First(&existing)
if result.Error == nil {
// Update
// Update - merge styles to preserve other settings
if len(cfg.Settings) > 0 && len(existing.Settings) > 0 {
existingMap := map[string]interface{}(existing.Settings)
newMap := map[string]interface{}(cfg.Settings)
mergedMap := validator.MergeStyles(existingMap, newMap)
cfg.Settings = models.ElementSettings(mergedMap)
}
existing.Variant = cfg.Variant
existing.Visible = cfg.Visible
existing.DisplayOrder = cfg.DisplayOrder
@@ -0,0 +1,124 @@
package controllers
import (
"encoding/json"
"fmt"
)
// StyleValidator validates and normalizes style objects before saving
type StyleValidator struct{}
// ValidateAndNormalizeStyles ensures style data is properly structured
func (sv *StyleValidator) ValidateAndNormalizeStyles(settings map[string]interface{}) error {
if settings == nil {
return nil
}
// If styles exist as a top-level key, ensure it's a valid object
if stylesRaw, exists := settings["styles"]; exists {
switch styles := stylesRaw.(type) {
case map[string]interface{}:
// Valid object - ensure all values are valid CSS values
for key, val := range styles {
if !sv.isValidCSSValue(val) {
return fmt.Errorf("invalid CSS value for property '%s': %v", key, val)
}
}
case string:
// Try to parse as JSON
var styleMap map[string]interface{}
if err := json.Unmarshal([]byte(styles), &styleMap); err != nil {
return fmt.Errorf("styles must be a valid JSON object or map")
}
// Replace with parsed object
settings["styles"] = styleMap
default:
return fmt.Errorf("styles must be an object, got %T", styles)
}
}
// Validate customCSS if present
if customCSS, exists := settings["customCSS"]; exists {
if _, ok := customCSS.(string); !ok {
return fmt.Errorf("customCSS must be a string, got %T", customCSS)
}
}
return nil
}
// isValidCSSValue checks if a value is a valid CSS value type
func (sv *StyleValidator) isValidCSSValue(val interface{}) bool {
switch val.(type) {
case string, int, int64, float64, bool:
return true
default:
return false
}
}
// MergeStyles merges new styles into existing settings preserving other fields
func (sv *StyleValidator) MergeStyles(existingSettings map[string]interface{}, newStyles map[string]interface{}) map[string]interface{} {
if existingSettings == nil {
existingSettings = make(map[string]interface{})
}
// If newStyles contains a "styles" key, merge it
if newStylesMap, exists := newStyles["styles"]; exists {
if existingStylesRaw, exists := existingSettings["styles"]; exists {
// Merge with existing styles
if existingStyles, ok := existingStylesRaw.(map[string]interface{}); ok {
if newStylesObj, ok := newStylesMap.(map[string]interface{}); ok {
// Merge newStylesObj into existingStyles
for k, v := range newStylesObj {
existingStyles[k] = v
}
existingSettings["styles"] = existingStyles
} else {
// Replace entirely if type mismatch
existingSettings["styles"] = newStylesMap
}
} else {
// Replace if existing was not a map
existingSettings["styles"] = newStylesMap
}
} else {
// No existing styles - set directly
existingSettings["styles"] = newStyles["styles"]
}
}
// Merge other top-level keys
for k, v := range newStyles {
if k != "styles" {
existingSettings[k] = v
}
}
return existingSettings
}
// ExtractStylesForSave prepares settings object for database save
// Ensures styles are properly nested under settings.styles
func (sv *StyleValidator) ExtractStylesForSave(input map[string]interface{}) map[string]interface{} {
settings := make(map[string]interface{})
// Copy all non-style fields
for k, v := range input {
if k != "styles" && k != "customCSS" {
settings[k] = v
}
}
// Nest styles under styles key
if stylesRaw, exists := input["styles"]; exists {
settings["styles"] = stylesRaw
}
// Also nest customCSS if present
if customCSS, exists := input["customCSS"]; exists {
settings["customCSS"] = customCSS
}
return settings
}
+92 -8
View File
@@ -9,6 +9,7 @@ import (
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -421,11 +422,30 @@ func (pc *PollController) Vote(c *gin.Context) {
return
}
// If not authenticated, don't persist personal info even if provided
if !hasUser {
input.VoterName = ""
input.VoterEmail = ""
}
// Normalize voter info. Allow guests to optionally provide name/email.
// If authenticated and no voter_name/email provided, fallback to user's profile.
var derivedName string
var derivedEmail string
if hasUser {
if uID, ok := userID.(uint); ok {
var u models.User
if err := pc.DB.First(&u, uID).Error; err == nil {
if u.FirstName != "" || u.LastName != "" {
derivedName = fmt.Sprintf("%s %s", u.FirstName, u.LastName)
}
derivedEmail = u.Email
}
}
}
// Final values to persist
voterName := input.VoterName
voterEmail := input.VoterEmail
if voterName == "" && derivedName != "" {
voterName = derivedName
}
if voterEmail == "" && derivedEmail != "" {
voterEmail = derivedEmail
}
// Check if already voted
ipHash := pc.hashIP(c.ClientIP())
@@ -477,8 +497,8 @@ func (pc *PollController) Vote(c *gin.Context) {
IPHash: ipHash,
UserAgent: userAgent,
SessionToken: sessionToken,
VoterName: input.VoterName,
VoterEmail: input.VoterEmail,
VoterName: voterName,
VoterEmail: voterEmail,
}
if hasUser {
@@ -513,7 +533,15 @@ func (pc *PollController) Vote(c *gin.Context) {
pc.DB.Preload("Options", func(db *gorm.DB) *gorm.DB {
return db.Order("display_order ASC, id ASC")
}).First(&poll, poll.ID)
// Engagement: award points to authenticated user
if hasUser {
uid := userID.(uint)
svc := services.NewEngagementService(pc.DB)
_, _ = svc.AwardPointsCapped(uid, 3, "poll_vote", map[string]interface{}{"poll_id": poll.ID})
_ = svc.CheckAndAwardAchievements(uid)
}
c.JSON(http.StatusOK, gin.H{
"message": "Vote recorded successfully",
"poll": poll,
@@ -637,3 +665,59 @@ func (pc *PollController) GetPollStats(c *gin.Context) {
"guest_votes": guestVotes,
})
}
// AdminListVotes returns detailed list of votes for a poll (admin only)
func (pc *PollController) AdminListVotes(c *gin.Context) {
id := c.Param("id")
var votes []models.PollVote
if err := pc.DB.Preload("Option").Preload("User").Where("poll_id = ?", id).Order("created_at DESC").Find(&votes).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch votes"})
return
}
type VoteDTO struct {
ID uint `json:"id"`
PollID uint `json:"poll_id"`
OptionID uint `json:"option_id"`
OptionText string `json:"option_text"`
UserID *uint `json:"user_id"`
UserEmail string `json:"user_email"`
UserFirstName string `json:"user_first_name"`
UserLastName string `json:"user_last_name"`
VoterName string `json:"voter_name"`
VoterEmail string `json:"voter_email"`
SessionToken string `json:"session_token"`
CreatedAt time.Time `json:"created_at"`
}
result := make([]VoteDTO, 0, len(votes))
for _, v := range votes {
optionText := ""
if v.Option != nil {
optionText = v.Option.Text
}
var userEmail, firstName, lastName string
if v.User != nil {
userEmail = v.User.Email
firstName = v.User.FirstName
lastName = v.User.LastName
}
result = append(result, VoteDTO{
ID: v.ID,
PollID: v.PollID,
OptionID: v.OptionID,
OptionText: optionText,
UserID: v.UserID,
UserEmail: userEmail,
UserFirstName: firstName,
UserLastName: lastName,
VoterName: v.VoterName,
VoterEmail: v.VoterEmail,
SessionToken: v.SessionToken,
CreatedAt: v.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"votes": result})
}
+94 -4
View File
@@ -18,12 +18,79 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ShortLinkController struct {
DB *gorm.DB
}
// PublicCreateShortLink creates (or upserts) a short link for a given target URL.
// Restrictions: only allows shortening links pointing to this site (request host)
// or to the configured FrontendBaseURL. Intended for visitor share/copy flows.
func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) {
var body struct {
TargetURL string `json:"target_url"`
Title string `json:"title"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
target, err := parseTarget(body.TargetURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return
}
tu, _ := url.Parse(target)
if tu == nil || tu.Host == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"})
return
}
// Allow only same-site or configured frontend host
reqHost := c.Request.Host
stripPort := func(h string) string {
if i := strings.IndexByte(h, ':'); i >= 0 { return h[:i] }
return h
}
allowed := stripPort(tu.Host) == stripPort(reqHost)
if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" {
if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" {
if stripPort(fu.Host) == stripPort(tu.Host) {
allowed = true
}
}
}
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"})
return
}
// Deterministic code from URL so repeated calls return same shortlink
code := "p-" + codeFromHash(target, 7)
link := models.ShortLink{
Code: code,
TargetURL: target,
Title: strings.TrimSpace(body.Title),
Active: true,
}
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "code"}},
DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}),
}).Create(&link).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()})
return
}
var saved models.ShortLink
if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil {
saved = link
}
scheme := getScheme(c)
host := c.Request.Host
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code)
c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved})
}
func NewShortLinkController(db *gorm.DB) *ShortLinkController {
return &ShortLinkController{DB: db}
}
@@ -57,6 +124,18 @@ func hashIPShort(ip string) string {
return hex.EncodeToString(h[:])
}
func codeFromHash(s string, n int) string {
if n <= 0 { n = 7 }
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
sum := sha256.Sum256([]byte(s))
out := make([]byte, n)
for i := 0; i < n; i++ {
b := sum[i%len(sum)]
out[i] = alphabet[int(b)%len(alphabet)]
}
return string(out)
}
func getScheme(c *gin.Context) string {
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
return p
@@ -204,14 +283,25 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
Active: active,
ExpiresAt: body.ExpiresAt,
}
if err := s.DB.Create(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
// Upsert on code to avoid duplicate errors and keep link stable across regenerations
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "code"}},
DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "source_type", "source_id", "active", "expires_at", "updated_at"}),
}).Create(&link).Error; err != nil {
// Return database error message for easier debugging (non-sensitive)
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()})
return
}
// Ensure we return the saved record (ID will be empty on update path)
var saved models.ShortLink
if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil {
// Fallback to in-memory link if fetch fails
saved = link
}
scheme := getScheme(c)
host := c.Request.Host
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, link.Code)
c.JSON(http.StatusOK, gin.H{"id": link.ID, "code": link.Code, "short_url": shortURL, "link": link})
shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code)
c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved})
}
func (s *ShortLinkController) ListShortLinks(c *gin.Context) {