This commit is contained in:
Tomas Dvorak
2025-11-11 10:29:30 +01:00
parent d5b4faea61
commit 8762bde4bf
139 changed files with 7240 additions and 2870 deletions
+7 -1
View File
@@ -76,7 +76,10 @@ type Config struct {
UmamiURL string
UmamiUsername string
UmamiPassword string
UmamiWebsiteID string // If empty, will auto-create on production
UmamiWebsiteID string
ErrorIngestURL string
ErrorIngestToken string
// Antivirus (optional)
ClamAVEnabled bool
@@ -182,6 +185,9 @@ func LoadConfig() {
UmamiPassword: getEnv("UMAMI_PASSWORD", ""),
UmamiWebsiteID: getEnv("UMAMI_WEBSITE_ID", ""),
ErrorIngestURL: getEnv("ERROR_INGEST_URL", ""),
ErrorIngestToken: getEnv("ERROR_INGEST_TOKEN", ""),
// Antivirus (optional)
ClamAVEnabled: getEnvAsBool("CLAMAV_ENABLED", false),
ClamAVHost: getEnv("CLAMAV_HOST", "127.0.0.1"),
@@ -1,6 +1,7 @@
package controllers
import (
"encoding/json"
"fmt"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -45,6 +46,15 @@ type CreateArticleRequest struct {
YouTubeVideoTitle string `json:"youtube_video_title"`
YouTubeVideoURL string `json:"youtube_video_url"`
YouTubeVideoThumbnail string `json:"youtube_video_thumbnail"`
Attachments []AttachmentItem `json:"attachments"`
}
// AttachmentItem represents a single attachment entry for an article
type AttachmentItem struct {
Name string `json:"name"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size *int `json:"size,omitempty"`
}
// CreateArticle creates a new article with comprehensive error handling
@@ -243,6 +253,13 @@ func (ac *ArticleController) CreateArticle(c *gin.Context) {
article.YouTubeVideoThumbnail = trimmed
}
// 15. Set attachments (serialize to JSON string)
if len(req.Attachments) > 0 {
if b, err := json.Marshal(req.Attachments); err == nil {
article.Attachments = string(b)
}
}
// 15. Save to database
if err := ac.DB.Create(&article).Error; err != nil {
logger.Error("CreateArticle: Database error: %v", err)
File diff suppressed because it is too large Load Diff
+63 -3
View File
@@ -103,6 +103,8 @@ func (cc *CommentController) Unreact(c *gin.Context) {
// Admin: list all comments with filters
func (cc *CommentController) AdminList(c *gin.Context) {
// Ensure tables exist (best-effort)
_ = cc.DB.AutoMigrate(&models.Comment{}, &models.CommentReport{}, &models.CommentReaction{})
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) }
@@ -125,8 +127,21 @@ func (cc *CommentController) AdminList(c *gin.Context) {
_ = 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 }
}
// Compute admin likes (thumbs_up/like) per comment
adminLiked := map[uint]bool{}
if len(ids) > 0 {
type al struct{ CommentID uint }
var rows []al
_ = cc.DB.Table("comment_reactions as cr").
Select("cr.comment_id").
Joins("JOIN users u ON u.id = cr.user_id").
Where("cr.comment_id IN ? AND u.role = ? AND cr.type IN ?", ids, "admin", []string{"thumbs_up", "like"}).
Group("cr.comment_id").
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
}
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) }
for _, r := range items { co := toOutput(r); if v, ok := repCounts[r.ID]; ok { co.Reports = v }; if adminLiked[r.ID] { co.AdminLiked = true }; out = append(out, co) }
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
}
@@ -216,6 +231,7 @@ type commentOutput struct {
SpamScore float32 `json:"spam_score,omitempty"`
SpamRules []string `json:"spam_rules,omitempty"`
Reports int `json:"reports,omitempty"`
AdminLiked bool `json:"admin_liked,omitempty"`
}
type userSlim struct {
@@ -273,14 +289,32 @@ func (cc *CommentController) GetComments(c *gin.Context) {
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")
// Visibility rules:
// - Public/unauthenticated: only visible
// - Authenticated non-admin: visible + own hidden (awaiting moderation)
// - Admin: all
role, _ := c.Get("userRole")
uid, _ := c.Get("userID")
var where string
var args []interface{}
if role == "admin" {
where = "target_type = ? AND target_id = ?"
args = []interface{}{targetType, targetID}
} else if uid != nil {
where = "target_type = ? AND target_id = ? AND (status = 'visible' OR (status = 'hidden' AND user_id = ?))"
args = []interface{}{targetType, targetID, uid}
} else {
where = "target_type = ? AND target_id = ? AND status = 'visible'"
args = []interface{}{targetType, targetID}
}
q := cc.DB.Model(&models.Comment{}).Where(where, args...)
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").
if err := cc.DB.Preload("User").Where(where, args...).
Order("created_at ASC").
Offset((page-1)*pageSize).Limit(pageSize).
Find(&rows).Error; err != nil {
@@ -316,6 +350,19 @@ func (cc *CommentController) GetComments(c *gin.Context) {
for _, r := range rs { myReactions[r.CommentID] = r.Type }
}
}
// Admin liked map
adminLiked := map[uint]bool{}
if len(ids) > 0 {
type al struct{ CommentID uint }
var rows []al
_ = cc.DB.Table("comment_reactions as cr").
Select("cr.comment_id").
Joins("JOIN users u ON u.id = cr.user_id").
Where("cr.comment_id IN ? AND u.role = ? AND cr.type IN ?", ids, "admin", []string{"thumbs_up", "like"}).
Group("cr.comment_id").
Scan(&rows).Error
for _, r := range rows { adminLiked[r.CommentID] = true }
}
// Preload user profiles for username + avatar (prefer animated when available)
type up struct{ UserID uint; AvatarURL string; AnimatedAvatarURL string; Username string }
profByUser := map[uint]up{}
@@ -337,6 +384,7 @@ func (cc *CommentController) GetComments(c *gin.Context) {
}
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 } }
if adminLiked[r.ID] { co.AdminLiked = true }
out = append(out, co)
}
@@ -381,6 +429,18 @@ func (cc *CommentController) CreateComment(c *gin.Context) {
return
}
if in.ParentID != nil {
var parent models.Comment
if err := cc.DB.First(&parent, *in.ParentID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Rodičovský komentář nenalezen"})
return
}
if parent.TargetType != in.TargetType || parent.TargetID != in.TargetID {
c.JSON(http.StatusBadRequest, gin.H{"error": "Rodičovský komentář neodpovídá cíli"})
return
}
}
userIDv, _ := c.Get("userID")
userID := userIDv.(uint)
File diff suppressed because it is too large Load Diff
+51 -24
View File
@@ -196,29 +196,35 @@ func (ec *EngagementController) PatchAvatar(c *gin.Context) {
// GET /api/v1/engagement/rewards (public)
func (ec *EngagementController) GetRewards(c *gin.Context) {
var unlock models.RewardItem
if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil {
unlock = models.RewardItem{
Name: "Odemknout vlastní avatar (upload)",
Type: "avatar_upload_unlock",
CostPoints: 250,
ImageURL: "",
Stock: -1,
Active: true,
}
_ = ec.DB.Create(&unlock).Error
} else {
if !unlock.Active {
_ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Update("active", true).Error
}
}
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)
var unlock models.RewardItem
if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil {
unlock = models.RewardItem{
Name: "Odemknout vlastní avatar (upload)",
Type: "avatar_upload_unlock",
CostPoints: 50,
ImageURL: "",
Stock: -1,
Active: true,
}
_ = ec.DB.Create(&unlock).Error
} else {
updates := map[string]interface{}{}
if !unlock.Active { updates["active"] = true }
if unlock.Stock != -1 { updates["stock"] = -1 }
if strings.TrimSpace(unlock.Name) == "" || unlock.Name != "Odemknout vlastní avatar (upload)" {
updates["name"] = "Odemknout vlastní avatar (upload)"
}
if len(updates) > 0 {
_ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Updates(updates).Error
}
}
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)
@@ -419,6 +425,24 @@ func (ec *EngagementController) GetAchievements(c *gin.Context) {
// GET /api/v1/admin/engagement/rewards
func (ec *EngagementController) AdminListRewards(c *gin.Context) {
var items []models.RewardItem
var unlock models.RewardItem
if err := ec.DB.Where("type = ?", "avatar_upload_unlock").First(&unlock).Error; err != nil {
unlock = models.RewardItem{
Name: "Odemknout vlastní avatar (upload)",
Type: "avatar_upload_unlock",
CostPoints: 50,
ImageURL: "",
Stock: -1,
Active: true,
}
_ = ec.DB.Create(&unlock).Error
} else {
updates := map[string]interface{}{}
if !unlock.Active { updates["active"] = true }
if unlock.Stock != -1 { updates["stock"] = -1 }
if strings.TrimSpace(unlock.Name) == "" || unlock.Name != "Odemknout vlastní avatar (upload)" { updates["name"] = "Odemknout vlastní avatar (upload)" }
if len(updates) > 0 { _ = ec.DB.Model(&models.RewardItem{}).Where("id = ?", unlock.ID).Updates(updates).Error }
}
q := ec.DB.Model(&models.RewardItem{})
if v := strings.TrimSpace(c.Query("active")); v != "" {
if v == "true" || v == "1" { q = q.Where("active = ?", true) }
@@ -470,13 +494,16 @@ func (ec *EngagementController) AdminUpdateReward(c *gin.Context) {
var existing models.RewardItem
_ = ec.DB.First(&existing, id).Error
if strings.EqualFold(existing.Type, "avatar_upload_unlock") {
// Disallow disabling or changing type of the mandatory reward
// Disallow disabling or changing type, and restrict updates to cost_points only
if body.Active != nil && *body.Active == false {
c.JSON(http.StatusBadRequest, gin.H{"error": "This reward cannot be deactivated"}); return
}
if body.Type != nil && strings.ToLower(strings.TrimSpace(*body.Type)) != existing.Type {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type cannot be changed for this reward"}); return
}
if body.Name != nil || body.ImageURL != nil || body.Stock != nil || body.Active != nil || body.Metadata != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Only price (cost_points) can be edited for this reward"}); return
}
}
updates := map[string]interface{}{}
if body.Name != nil { updates["name"] = strings.TrimSpace(*body.Name) }
+287
View File
@@ -0,0 +1,287 @@
package controllers
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"fotbal-club/internal/models"
"fotbal-club/internal/config"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"gorm.io/gorm"
)
type ErrorController struct {
DB *gorm.DB
}
func NewErrorController(db *gorm.DB) *ErrorController { return &ErrorController{DB: db} }
type ingestPayload struct {
Origin string `json:"origin"`
Language string `json:"language"`
Severity string `json:"severity"`
Message string `json:"message"`
Stack string `json:"stack"`
Component string `json:"component"`
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column"`
URL string `json:"url"`
Method string `json:"method"`
Status int `json:"status"`
RequestID string `json:"request_id"`
UserID *uint `json:"user_id"`
SessionToken string `json:"session_token"`
Tags map[string]string `json:"tags"`
Context map[string]interface{} `json:"context"`
Env string `json:"env"`
Version string `json:"version"`
Hostname string `json:"hostname"`
OccurredAt *time.Time `json:"occurred_at"`
}
func (ec *ErrorController) Ingest(c *gin.Context) {
var p ingestPayload
if err := c.ShouldBindJSON(&p); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
var s models.Settings
_ = ec.DB.First(&s).Error
if p.Tags == nil { p.Tags = map[string]string{} }
if strings.TrimSpace(p.Origin) != "" {
if _, ok := p.Tags["service"]; !ok { p.Tags["service"] = strings.TrimSpace(p.Origin) }
} else {
if _, ok := p.Tags["service"]; !ok { p.Tags["service"] = "backend" }
}
if _, ok := p.Tags["instance_env"]; !ok {
if config.AppConfig != nil { p.Tags["instance_env"] = strings.TrimSpace(config.AppConfig.AppEnv) }
}
if p.Hostname == "" {
host := c.Request.Host
if idx := strings.Index(host, ":"); idx >= 0 { host = host[:idx] }
p.Hostname = host
}
if _, ok := p.Tags["instance_host"]; !ok && strings.TrimSpace(p.Hostname) != "" {
p.Tags["instance_host"] = strings.TrimSpace(p.Hostname)
}
if v := strings.TrimSpace(s.ClubName); v != "" { p.Tags["club_name"] = v }
if v := strings.TrimSpace(s.ClubID); v != "" { p.Tags["club_id"] = v }
if v := strings.TrimSpace(s.ClubLogoURL); v != "" { p.Tags["club_logo_url"] = v }
if p.Env == "" && config.AppConfig != nil { p.Env = config.AppConfig.AppEnv }
if p.Version == "" {
v := strings.TrimSpace(os.Getenv("APP_VERSION"))
if v != "" { p.Version = v }
}
if _, ok := p.Tags["container_id"]; !ok {
if h, err := os.Hostname(); err == nil {
if hv := strings.TrimSpace(h); hv != "" { p.Tags["container_id"] = hv }
}
}
var tags datatypes.JSON
var ctx datatypes.JSON
if p.Tags != nil {
if b, err := json.Marshal(p.Tags); err == nil { tags = datatypes.JSON(b) }
}
if p.Context != nil {
if b, err := json.Marshal(p.Context); err == nil { ctx = datatypes.JSON(b) }
}
occurred := time.Now()
if p.OccurredAt != nil && !p.OccurredAt.IsZero() {
occurred = *p.OccurredAt
}
rec := models.ErrorEvent{
Origin: p.Origin,
Language: p.Language,
Severity: p.Severity,
Message: p.Message,
Stack: p.Stack,
Component: p.Component,
File: p.File,
Line: p.Line,
Column: p.Column,
URL: p.URL,
Method: p.Method,
Status: p.Status,
RequestID: p.RequestID,
UserID: p.UserID,
SessionToken: p.SessionToken,
Tags: tags,
Context: ctx,
Env: p.Env,
Version: p.Version,
Hostname: p.Hostname,
OccurredAt: occurred,
}
if err := ec.DB.Create(&rec).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store"})
return
}
go func(p ingestPayload) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Prefer environment (config) for ingest URL and token; domain managed solely via .env
extURL := strings.TrimSpace(config.AppConfig.ErrorIngestURL)
token := strings.TrimSpace(config.AppConfig.ErrorIngestToken)
if extURL == "" {
// Default ingest URL: local when ERROR_LOCAL, otherwise external
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { extURL = "http://127.0.0.1:8083/api/v1/errors" } else { extURL = "https://errors.tdvorak.dev/api/v1/errors" }
}
if extURL == "" { return }
b, err := json.Marshal(p)
if err != nil { return }
post := func(u string) bool {
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b))
if err != nil { return false }
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Ingest-Token", token)
}
client := &http.Client{ Timeout: 4 * time.Second }
resp, err := client.Do(req)
if err != nil { return false }
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return resp.StatusCode >= 200 && resp.StatusCode < 300
}
if post(extURL) { return }
if u, err := url.Parse(extURL); err == nil {
h := u.Hostname()
if h == "127.0.0.1" || h == "localhost" {
u.Host = strings.Replace(u.Host, h, "host.docker.internal", 1)
_ = post(u.String())
}
}
}(p)
c.JSON(http.StatusCreated, gin.H{"id": rec.ID})
}
type listResponse struct {
Items []models.ErrorEvent `json:"items"`
Total int64 `json:"total"`
}
func (ec *ErrorController) AdminList(c *gin.Context) {
q := ec.DB.Model(&models.ErrorEvent{})
if v := strings.TrimSpace(c.Query("origin")); v != "" { q = q.Where("origin = ?", v) }
if v := strings.TrimSpace(c.Query("severity")); v != "" { q = q.Where("severity = ?", v) }
if v := strings.TrimSpace(c.Query("method")); v != "" { q = q.Where("method = ?", v) }
if v := c.Query("status"); v != "" { q = q.Where("status = ?", v) }
if v := strings.TrimSpace(c.Query("search")); v != "" {
like := "%" + v + "%"
q = q.Where("message ILIKE ? OR stack ILIKE ? OR url ILIKE ?", like, like, like)
}
if v := c.Query("from"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil { q = q.Where("occurred_at >= ?", t) }
}
if v := c.Query("to"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil { q = q.Where("occurred_at <= ?", t) }
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if page < 1 { page = 1 }
if limit < 1 || limit > 200 { limit = 20 }
var total int64
_ = q.Count(&total).Error
var items []models.ErrorEvent
_ = q.Order("occurred_at DESC").Limit(limit).Offset((page-1)*limit).Find(&items).Error
c.JSON(http.StatusOK, listResponse{Items: items, Total: total})
}
func (ec *ErrorController) AdminGet(c *gin.Context) {
var rec models.ErrorEvent
if err := ec.DB.First(&rec, c.Param("id")).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load"})
return
}
c.JSON(http.StatusOK, rec)
}
// AdminListExternal proxies list to external error-review admin API
func (ec *ErrorController) AdminListExternal(c *gin.Context) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Domain managed via environment only (with default); token from env
base := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
if base == "" {
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid admin URL"}); return }
u.Path = strings.TrimRight(u.Path, "/") + "/errors"
u.RawQuery = c.Request.URL.RawQuery
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"}); return }
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Admin-Token", token)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{ Timeout: 8 * time.Second }
resp, err := client.Do(req)
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
defer resp.Body.Close()
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
}
// AdminGetExternal proxies detail to external error-review admin API
func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
var s models.Settings
_ = ec.DB.First(&s).Error
// Domain managed via environment only (with default); token from env
base := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
if base == "" {
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://error.tdvorak.dev/api/v1/admin" }
}
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
u, err := url.Parse(base)
if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid admin URL"}); return }
id := strings.TrimSpace(c.Param("id"))
u.Path = strings.TrimRight(u.Path, "/") + "/errors/" + id
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "request build failed"}); return }
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Admin-Token", token)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{ Timeout: 8 * time.Second }
resp, err := client.Do(req)
if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}); return }
defer resp.Body.Close()
c.Status(resp.StatusCode)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
io.Copy(c.Writer, resp.Body)
}
+48 -22
View File
@@ -25,6 +25,12 @@ func (ctrl *EventController) GetEventByID(c *gin.Context) {
}
// If not public, allow only owner (when identified upstream)
if !ev.IsPublic {
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
c.JSON(http.StatusOK, ev)
return
}
}
if userID, exists := c.Get("userID"); !exists || ev.CreatedByID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Not allowed"})
return
@@ -112,19 +118,29 @@ func (ctrl *EventController) CreateEvent(c *gin.Context) {
}
func (ctrl *EventController) GetEvents(c *gin.Context) {
var events []models.Event
query := ctrl.DB.Preload("Attachments")
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
var events []models.Event
query := ctrl.DB.Preload("Attachments")
// Admin sees all events
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
return
}
}
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
}
@@ -132,17 +148,27 @@ func (ctrl *EventController) GetEvents(c *gin.Context) {
func (ctrl *EventController) GetUpcomingEvents(c *gin.Context) {
var events []models.Event
query := ctrl.DB.Preload("Attachments").Where("start_time >= ?", time.Now()).Order("start_time ASC").Limit(5)
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
// Admin sees all upcoming events
if roleVal, hasRole := c.Get("userRole"); hasRole {
if role, _ := roleVal.(string); role == "admin" {
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
return
}
}
if userID, exists := c.Get("userID"); !exists {
query = query.Where("is_public = ?", true)
} else {
query = query.Where("created_by_id = ? OR is_public = ?", userID, true)
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
if err := query.Find(&events).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch events"})
return
}
c.JSON(http.StatusOK, events)
}
+41
View File
@@ -452,6 +452,47 @@ func (fc *FACRController) SearchClubs(c *gin.Context) {
}
}
if len(results) > 1 {
unique := make([]SearchResult, 0, len(results))
seenByID := map[string]int{}
seenByName := map[string]int{}
normKey := func(s string) string {
x := strings.ToLower(strings.TrimSpace(s))
x = strings.ReplaceAll(x, ".", "")
x = strings.ReplaceAll(x, " ", "")
return x
}
for _, r := range results {
id := strings.TrimSpace(r.ClubID)
if id != "" {
if idx, ok := seenByID[id]; ok {
// Keep the one with a logo
if strings.TrimSpace(unique[idx].LogoURL) == "" && strings.TrimSpace(r.LogoURL) != "" {
unique[idx] = r
}
continue
}
seenByID[id] = len(unique)
unique = append(unique, r)
continue
}
key := normKey(r.Name)
if key == "" {
unique = append(unique, r)
continue
}
if idx, ok := seenByName[key]; ok {
if strings.TrimSpace(unique[idx].LogoURL) == "" && strings.TrimSpace(r.LogoURL) != "" {
unique[idx] = r
}
continue
}
seenByName[key] = len(unique)
unique = append(unique, r)
}
results = unique
}
// respond and close the function
c.JSON(http.StatusOK, gin.H{
"query": q,
+1 -1
View File
@@ -33,7 +33,7 @@ func (fc *FilesController) GetStorageUsage(c *gin.Context) {
_ = fc.DB.First(&settings).Error
quotaMB := settings.StorageQuotaMB
if quotaMB <= 0 {
quotaMB = 1024
quotaMB = 5120
}
warnPct := settings.StorageWarnThreshold
if warnPct <= 0 {
+115 -107
View File
@@ -449,29 +449,21 @@ func (nc *NavigationController) ReorderSocialLinks(c *gin.Context) {
// @Summary Seed default navigation
// @Description Creates default navigation items if the database is empty
// @Tags navigation
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]interface{}
// @Router /api/v1/admin/navigation/seed [post]
func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
// Check if navigation items already exist
var count int64
nc.DB.Model(&models.NavigationItem{}).Count(&count)
if count > 0 {
c.JSON(http.StatusOK, gin.H{
"message": "Navigation items already exist",
"count": count,
"seeded": false,
})
return
}
// Default frontend navigation items
frontendItems := []models.NavigationItem{
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
// Check existing counts for frontend and admin separately
var frontendCount int64
var adminCount int64
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
// Default frontend navigation items
frontendItems := []models.NavigationItem{
{Label: "Domů", Type: models.NavTypePage, PageType: "home", DisplayOrder: 0, Visible: true, RequiresAdmin: false},
{Label: "O klubu", Type: models.NavTypePage, PageType: "about", DisplayOrder: 1, Visible: true, RequiresAdmin: false},
{Label: "Kalendář", Type: models.NavTypePage, PageType: "calendar", DisplayOrder: 2, Visible: true, RequiresAdmin: false},
{Label: "Zápasy", Type: models.NavTypePage, PageType: "matches", DisplayOrder: 3, Visible: true, RequiresAdmin: false},
{Label: "Aktivity", Type: models.NavTypePage, PageType: "activities", DisplayOrder: 4, Visible: true, RequiresAdmin: false},
{Label: "Hráči", Type: models.NavTypePage, PageType: "players", DisplayOrder: 5, Visible: true, RequiresAdmin: false},
@@ -483,111 +475,127 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
{Label: "Kontakt", Type: models.NavTypePage, PageType: "contact", DisplayOrder: 11, Visible: true, RequiresAdmin: false},
}
// Create items in a transaction with admin categories and children
err := nc.DB.Transaction(func(tx *gorm.DB) error {
for _, item := range frontendItems {
if err := tx.Create(&item).Error; err != nil {
return err
}
}
// Create items in a transaction with admin categories and children (seed missing parts only)
seededFrontend := false
seededAdmin := false
err := nc.DB.Transaction(func(tx *gorm.DB) error {
if frontendCount == 0 {
for _, item := range frontendItems {
if err := tx.Create(&item).Error; err != nil {
return err
}
}
seededFrontend = true
}
catOrder := 0
createCategory := func(label string) (*models.NavigationItem, error) {
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
catOrder++
if err := tx.Create(cat).Error; err != nil {
return nil, err
}
return cat, nil
}
if adminCount == 0 {
catOrder := 0
createCategory := func(label string) (*models.NavigationItem, error) {
cat := &models.NavigationItem{Label: label, Type: models.NavTypeDropdown, DisplayOrder: catOrder, Visible: true, RequiresAdmin: true}
catOrder++
if err := tx.Create(cat).Error; err != nil {
return nil, err
}
return cat, nil
}
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
child.ParentID = &pid
return tx.Create(child).Error
}
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
child.ParentID = &pid
return tx.Create(child).Error
}
zakladni, err := createCategory("Základní")
if err != nil { return err }
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil { return err }
zakladni, err := createCategory("Základní")
if err != nil { return err }
if err := createChild(zakladni, "Nástěnka", "dashboard", 0); err != nil { return err }
if err := createChild(zakladni, "Analytika", "analytics", 1); err != nil { return err }
sport, err := createCategory("Sport")
if err != nil { return err }
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
if err := createChild(sport, "Zápasy", "matches", 1); err != nil { return err }
if err := createChild(sport, "Aktivity", "activities", 2); err != nil { return err }
if err := createChild(sport, "Hráči", "players", 3); err != nil { return err }
sport, err := createCategory("Sport")
if err != nil { return err }
if err := createChild(sport, "Týmy", "teams", 0); err != nil { return err }
if err := createChild(sport, "Zápasy", "matches", 1); err != nil { return err }
if err := createChild(sport, "Hráči", "players", 2); err != nil { return err }
if err := createChild(sport, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
if err := createChild(sport, "Tabule (Scoreboard)", "scoreboard", 4); err != nil { return err }
if err := createChild(sport, "Scoreboard Remote", "scoreboard_remote", 5); err != nil { return err }
obsah, err := createCategory("Obsah")
if err != nil { return err }
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
if err := createChild(obsah, "Kategorie", "categories", 1); err != nil { return err }
if err := createChild(obsah, "O klubu", "about", 2); err != nil { return err }
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
obsah, err := createCategory("Obsah")
if err != nil { return err }
if err := createChild(obsah, "Články", "articles", 0); err != nil { return err }
if err := createChild(obsah, "Aktivity", "activities", 1); err != nil { return err }
if err := createChild(obsah, "Kategorie", "categories", 2); err != nil { return err }
if err := createChild(obsah, "Komentáře", "comments", 3); err != nil { return err }
media, err := createCategory("Média")
if err != nil { return err }
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil { return err }
if err := createChild(media, "Média", "media", 2); err != nil { return err }
if err := createChild(media, "Soubory", "files", 3); err != nil { return err }
media, err := createCategory("Média")
if err != nil { return err }
if err := createChild(media, "Videa", "videos", 0); err != nil { return err }
if err := createChild(media, "Galerie (Zonerama)", "gallery", 1); err != nil { return err }
if err := createChild(media, "Soubory", "files", 2); err != nil { return err }
kom, err := createCategory("Komunikace")
if err != nil { return err }
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
if err := createChild(kom, "Kontakty", "contacts", 1); err != nil { return err }
if err := createChild(kom, "Zpravodaj", "newsletter", 2); err != nil { return err }
kom, err := createCategory("Komunikace")
if err != nil { return err }
if err := createChild(kom, "Zprávy", "messages", 0); err != nil { return err }
if err := createChild(kom, "Zpravodaj", "newsletter", 1); err != nil { return err }
if err := createChild(kom, "Kontakty", "contacts", 2); err != nil { return err }
marketing, err := createCategory("Marketing")
if err != nil { return err }
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
if err := createChild(marketing, "Bannery", "banners", 1); err != nil { return err }
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil { return err }
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 3); err != nil { return err }
if err := createChild(marketing, "Ankety", "polls", 4); err != nil { return err }
if err := createChild(marketing, "Soutěže", "sweepstakes", 5); err != nil { return err }
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 6); err != nil { return err }
marketing, err := createCategory("Marketing")
if err != nil { return err }
if err := createChild(marketing, "Sponzoři", "sponsors", 0); err != nil { return err }
if err := createChild(marketing, "Bannery", "banners", 1); err != nil { return err }
if err := createChild(marketing, "Oblečení", "clothing", 2); err != nil { return err }
if err := createChild(marketing, "Ankety", "polls", 3); err != nil { return err }
if err := createChild(marketing, "Soutěže", "sweepstakes", 4); err != nil { return err }
if err := createChild(marketing, "Odměny & Úspěchy", "engagement", 5); err != nil { return err }
if err := createChild(marketing, "Zkrácené odkazy", "shortlinks", 6); err != nil { return err }
nastroje, err := createCategory("Nástroje")
if err != nil { return err }
if err := createChild(nastroje, "Tabule (Scoreboard)", "scoreboard", 0); err != nil { return err }
if err := createChild(nastroje, "Scoreboard Remote", "scoreboard_remote", 1); err != nil { return err }
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 2); err != nil { return err }
nastroje, err := createCategory("Nástroje")
if err != nil { return err }
if err := createChild(nastroje, "Prefetch & Cache", "prefetch", 0); err != nil { return err }
nastaveni, err := createCategory("Nastavení")
if err != nil { return err }
if err := createChild(nastaveni, "Navigace", "navigation", 0); err != nil { return err }
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil { return err }
if err := createChild(nastaveni, "Nastavení", "settings", 2); err != nil { return err }
if err := createChild(nastaveni, "Alias soutěží", "competition_aliases", 3); err != nil { return err }
nastaveni, err := createCategory("Nastavení")
if err != nil { return err }
if err := createChild(nastaveni, "Nastavení", "settings", 0); err != nil { return err }
if err := createChild(nastaveni, "Uživatelé", "users", 1); err != nil { return err }
if err := createChild(nastaveni, "Navigace", "navigation", 2); err != nil { return err }
napoveda, err := createCategory("Nápověda")
if err != nil { return err }
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
napoveda, err := createCategory("Nápověda")
if err != nil { return err }
if err := createChild(napoveda, "Dokumentace", "docs", 0); err != nil { return err }
return nil
})
seededAdmin = true
}
return nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed navigation items"})
return
}
// Since creation is split, compute counts again
var total int64
nc.DB.Model(&models.NavigationItem{}).Count(&total)
var frontendCount int64
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
var adminCount int64
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
// Since creation is split, compute counts again
var total int64
nc.DB.Model(&models.NavigationItem{}).Count(&total)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", false).Count(&frontendCount)
nc.DB.Model(&models.NavigationItem{}).Where("requires_admin = ?", true).Count(&adminCount)
c.JSON(http.StatusOK, gin.H{
"message": "Default navigation items created successfully",
"count": total,
"frontend_count": frontendCount,
"admin_count": adminCount,
"seeded": true,
})
message := "Navigation items already exist"
if seededFrontend && seededAdmin {
message = "Default frontend and admin navigation created successfully"
} else if seededFrontend {
message = "Default frontend navigation created successfully"
} else if seededAdmin {
message = "Default admin navigation created successfully"
}
c.JSON(http.StatusOK, gin.H{
"message": message,
"count": total,
"frontend_count": frontendCount,
"admin_count": adminCount,
"seeded": seededFrontend || seededAdmin,
"seeded_frontend": seededFrontend,
"seeded_admin": seededAdmin,
})
}
@@ -16,6 +16,7 @@ import (
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"github.com/gin-gonic/gin"
)
@@ -140,6 +141,7 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
_ = os.MkdirAll(sponsorDir, 0o755)
saved := 0
created := make([]string, 0, 8)
if ctx.Request.MultipartForm != nil {
files := ctx.Request.MultipartForm.File["files"]
if len(files) == 0 {
@@ -164,18 +166,20 @@ func (c *ScoreboardController) UploadSponsors(ctx *gin.Context) {
if _, err := io.Copy(&buf, src); err == nil {
if err := sanitizeAndWriteLogo(buf.Bytes(), outPath); err == nil {
saved++
created = append(created, "/uploads/sponsors/"+outName)
} else {
// Fallback: write original bytes with original extension
rawName := ensureUniqueFilename(sponsorDir, name)
rawPath := filepath.Join(sponsorDir, rawName)
_ = os.WriteFile(rawPath, buf.Bytes(), 0o644)
saved++
created = append(created, "/uploads/sponsors/"+rawName)
}
}
_ = src.Close()
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": saved})
ctx.JSON(http.StatusOK, gin.H{"saved": saved, "files": created})
}
// DeleteSponsor deletes a sponsor logo by filename (?name=)
@@ -229,3 +233,64 @@ func (c *ScoreboardController) UploadQR(ctx *gin.Context) {
}
ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
// PrefillSponsorsFromPage copies logo images from existing Sponsors into uploads/sponsors for overlay use.
// Optional JSON body: { "ids": [1,2,3] } to limit to specific sponsors.
func (c *ScoreboardController) PrefillSponsorsFromPage(ctx *gin.Context) {
var body struct{ IDs []uint `json:"ids"` }
_ = ctx.ShouldBindJSON(&body)
var list []models.Sponsor
q := c.DB.Model(&models.Sponsor{})
if len(body.IDs) > 0 {
q = q.Where("id IN ?", body.IDs)
} else {
q = q.Where("is_active = ?", true)
}
if err := q.Find(&list).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
sponsorDir := filepath.Join(uploadsBaseDir(), "sponsors")
_ = os.MkdirAll(sponsorDir, 0o755)
created := make([]string, 0, len(list))
for _, s := range list {
logo := strings.TrimSpace(s.LogoURL)
if logo == "" { continue }
var data []byte
if strings.HasPrefix(logo, "/uploads/") {
p := filepath.Join(config.AppConfig.UploadDir, strings.TrimPrefix(logo, "/uploads/"))
if b, err := os.ReadFile(p); err == nil { data = b } else { continue }
} else if strings.HasPrefix(strings.ToLower(logo), "http://") || strings.HasPrefix(strings.ToLower(logo), "https://") {
resp, err := http.Get(logo)
if err != nil { continue }
func() {
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return }
b, _ := io.ReadAll(resp.Body)
if len(b) > 0 { data = b }
}()
if len(data) == 0 { continue }
} else {
continue
}
base := sanitizeFilename(s.Name)
if base == "" {
seg := logo
if i := strings.LastIndex(seg, "/"); i >= 0 { seg = seg[i+1:] }
if j := strings.LastIndex(seg, "."); j >= 0 { seg = seg[:j] }
base = sanitizeFilename(seg)
if base == "" { base = fmt.Sprintf("sponsor-%d", time.Now().UnixNano()) }
}
outName := ensureUniqueFilename(sponsorDir, base+".png")
outPath := filepath.Join(sponsorDir, outName)
if err := sanitizeAndWriteLogo(data, outPath); err != nil {
// fallback to raw write
rawName := ensureUniqueFilename(sponsorDir, base+".png")
_ = os.WriteFile(filepath.Join(sponsorDir, rawName), data, 0o644)
created = append(created, "/uploads/sponsors/"+rawName)
} else {
created = append(created, "/uploads/sponsors/"+outName)
}
}
ctx.JSON(http.StatusOK, gin.H{"saved": len(created), "files": created})
}
+18 -2
View File
@@ -345,8 +345,21 @@ func (c *ScoreboardController) StartTimer(ctx *gin.Context) {
if s.ElapsedSeconds == 0 && s.Timer != "" {
s.ElapsedSeconds = parseTimerToSeconds(s.Timer)
}
s.TimerStartUnix = time.Now().Unix() - int64(s.ElapsedSeconds)
s.Running = true
// Respect caps similarly to computeTimer
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.Half >= 2 { cap = s.HalfLength * 120 }
if s.ElapsedSeconds >= cap {
// Already at or beyond cap; keep paused at cap
s.ElapsedSeconds = cap
s.Timer = formatSeconds(s.ElapsedSeconds)
s.Running = false
s.TimerStartUnix = 0
} else {
// Start from current elapsed
s.TimerStartUnix = time.Now().Unix() - int64(s.ElapsedSeconds)
s.Running = true
}
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
@@ -368,8 +381,11 @@ func (c *ScoreboardController) PauseTimer(ctx *gin.Context) {
// Cap and set display string
cap := s.HalfLength * 60
if cap <= 0 { cap = 45 * 60 }
if s.Half >= 2 { cap = s.HalfLength * 120 }
if s.ElapsedSeconds > cap { s.ElapsedSeconds = cap }
s.Timer = formatSeconds(s.ElapsedSeconds)
// Clear start marker when paused
s.TimerStartUnix = 0
if err := c.DB.Save(s).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"}); return
}
+194 -172
View File
@@ -20,31 +20,35 @@ type SweepstakesController struct {
Email email.EmailService
}
func NewSweepstakesController(db *gorm.DB, es email.EmailService) *SweepstakesController {
return &SweepstakesController{DB: db, Email: es}
}
// Public: visualization data for a specific sweepstake (within visibility window)
// GET /api/v1/sweepstakes/:id/visual
func (sc *SweepstakesController) PublicVisualData(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
now := time.Now()
if s.VisibilityUntil == nil || now.After(*s.VisibilityUntil) || now.Before(s.EndAt) {
c.JSON(http.StatusNotFound, gin.H{"error":"Not available"}); return
}
var winners []struct{ UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"` }
_ = sc.DB.Table("sweepstake_winners").Select("user_id, prize_name").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error
type entryRow struct {
UserID uint `json:"user_id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
var entries []entryRow
q := sc.DB.Table("sweepstake_entries AS e").
Select("e.user_id, TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS display_name, COALESCE(up.animated_avatar_url, up.avatar_url, '') AS avatar_url").
Joins("JOIN users u ON u.id = e.user_id").
Joins("LEFT JOIN user_profiles up ON up.user_id = u.id").
Where("e.sweepstake_id = ?", id)
_ = q.Scan(&entries).Error
c.JSON(http.StatusOK, gin.H{ "sweepstake": s, "entries": entries, "winners": winners })
id := strings.TrimSpace(c.Param("id"))
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
now := time.Now()
if s.VisibilityUntil == nil || now.After(*s.VisibilityUntil) || now.Before(s.EndAt) {
c.JSON(http.StatusNotFound, gin.H{"error":"Not available"}); return
}
var winners []struct{ UserID uint `json:"user_id"`; PrizeName string `json:"prize_name"` }
_ = sc.DB.Table("sweepstake_winners").Select("user_id, prize_name").Where("sweepstake_id = ?", id).Order("id ASC").Scan(&winners).Error
type entryRow struct {
UserID uint `json:"user_id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
}
var entries []entryRow
q := sc.DB.Table("sweepstake_entries AS e").
Select("e.user_id, TRIM(CONCAT(COALESCE(u.first_name,''),' ',COALESCE(u.last_name,''))) AS display_name, COALESCE(up.animated_avatar_url, up.avatar_url, '') AS avatar_url").
Joins("JOIN users u ON u.id = e.user_id").
Joins("LEFT JOIN user_profiles up ON up.user_id = u.id").
Where("e.sweepstake_id = ?", id)
_ = q.Scan(&entries).Error
c.JSON(http.StatusOK, gin.H{ "sweepstake": s, "entries": entries, "winners": winners })
}
// Admin: set or change prize for a specific winner
@@ -234,169 +238,187 @@ func (sc *SweepstakesController) AdminVisualData(c *gin.Context) {
})
}
func NewSweepstakesController(db *gorm.DB, es email.EmailService) *SweepstakesController {
return &SweepstakesController{DB: db, Email: es}
}
// Public: get current visible sweepstake (upcoming/active/finalized within visibility window)
func (sc *SweepstakesController) GetCurrent(c *gin.Context) {
now := time.Now()
var s models.Sweepstake
q := sc.DB.Where("start_at <= ? AND (visibility_until IS NULL OR visibility_until >= ?)", now, now).
Order("start_at DESC")
if err := q.First(&s).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"sweepstake": nil})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load sweepstake"})
return
}
// Compute state
state := "upcoming"
if now.After(s.StartAt) && now.Before(s.EndAt) {
state = "active"
} else if now.After(s.EndAt) {
state = "finalized"
}
// Load prizes (public info)
var prizes []models.SweepstakePrize
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Order("display_order ASC, id ASC").Find(&prizes).Error
// Winners if selected
var winners []models.SweepstakeWinner
if s.WinnersSelectedAt != nil {
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Find(&winners).Error
}
// Current user status
hasEntered := false
visualPlayedAt := (*time.Time)(nil)
if uid, ok := c.Get("userID"); ok && uid != nil {
var e models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, uid.(uint)).First(&e).Error; err == nil {
hasEntered = true
visualPlayedAt = e.VisualPlayedAt
}
}
c.JSON(http.StatusOK, gin.H{
"sweepstake": s,
"prizes": prizes,
"winners": winners,
"state": state,
"has_entered": hasEntered,
"visual_played_at": visualPlayedAt,
})
}
// Protected: enter/join sweepstake (idempotent)
func (sc *SweepstakesController) Enter(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
uid, _ := c.Get("userID")
userID := uid.(uint)
// Load sweepstake
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil {
if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return
}
now := time.Now()
if !(now.After(s.StartAt) && now.Before(s.EndAt)) {
c.JSON(http.StatusBadRequest, gin.H{"error":"Soutěž není aktivní"}); return
}
// Idempotent create
entry := models.SweepstakeEntry{ SweepstakeID: s.ID, UserID: userID, Status: "valid" }
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, userID).FirstOrCreate(&entry).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: mark visualization as played (only once)
func (sc *SweepstakesController) MarkVisualPlayed(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
uid, _ := c.Get("userID")
userID := uid.(uint)
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }
now := time.Now()
if !(now.After(s.EndAt) && (s.VisibilityUntil == nil || now.Before(*s.VisibilityUntil))) {
c.JSON(http.StatusBadRequest, gin.H{"error":"Vizualizace není dostupná"}); return
}
_ = sc.DB.Model(&models.SweepstakeEntry{}).
Where("sweepstake_id = ? AND user_id = ? AND visual_played_at IS NULL", s.ID, userID).
Updates(map[string]interface{}{"visual_played_at": time.Now(), "view_count": gorm.Expr("view_count + 1")}).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: my wins list
func (sc *SweepstakesController) MyWinnings(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var wins []models.SweepstakeWinner
if err := sc.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&wins).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return
}
c.JSON(http.StatusOK, gin.H{"items": wins})
}
// Admin: list sweepstakes
// Admin: list sweepstakes with optional status filter
func (sc *SweepstakesController) AdminList(c *gin.Context) {
var items []models.Sweepstake
q := sc.DB.Model(&models.Sweepstake{})
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"}); return }
c.JSON(http.StatusOK, gin.H{"items": items})
status := strings.TrimSpace(c.Query("status"))
var items []models.Sweepstake
q := sc.DB.Model(&models.Sweepstake{}).Order("start_at DESC, id DESC")
if status != "" { q = q.Where("status = ?", status) }
if err := q.Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Admin: create sweepstake
func (sc *SweepstakesController) AdminCreate(c *gin.Context) {
var body struct{
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
RulesURL string `json:"rules_url"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
PickerStyle string `json:"picker_style"`
TotalPrizes int `json:"total_prizes"`
PrizeSummary string `json:"prize_summary"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Title) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
}
item := models.Sweepstake{
Title: strings.TrimSpace(body.Title),
Description: strings.TrimSpace(body.Description),
ImageURL: strings.TrimSpace(body.ImageURL),
RulesURL: strings.TrimSpace(body.RulesURL),
StartAt: body.StartAt, EndAt: body.EndAt,
PickerStyle: ifEmpty(body.PickerStyle, "wheel"),
TotalPrizes: ifZero(body.TotalPrizes, 1),
PrizeSummary: strings.TrimSpace(body.PrizeSummary),
Status: "scheduled",
}
if time.Now().After(item.StartAt) { item.Status = "active" }
if err := sc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create"}); return }
c.JSON(http.StatusOK, item)
var body struct{
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"image_url"`
RulesURL string `json:"rules_url"`
StartAt time.Time `json:"start_at"`
EndAt time.Time `json:"end_at"`
PickerStyle string `json:"picker_style"`
TotalPrizes int `json:"total_prizes"`
PrizeSummary string `json:"prize_summary"`
EntryCostPoints int `json:"entry_cost_points"`
EntryFeeCZK float64 `json:"entry_fee_czk"`
MaxEntriesPerUser int `json:"max_entries_per_user"`
}
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Title) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return
}
item := models.Sweepstake{
Title: strings.TrimSpace(body.Title),
Description: strings.TrimSpace(body.Description),
ImageURL: strings.TrimSpace(body.ImageURL),
RulesURL: strings.TrimSpace(body.RulesURL),
StartAt: body.StartAt, EndAt: body.EndAt,
PickerStyle: ifEmpty(body.PickerStyle, "wheel"),
TotalPrizes: func(v int) int { if v < 1 { return 1 }; if v > 100 { return 100 }; return v }(ifZero(body.TotalPrizes, 1)),
PrizeSummary: strings.TrimSpace(body.PrizeSummary),
EntryCostPoints: func(v int) int { if v < 0 { return 0 }; return v }(body.EntryCostPoints),
EntryFeeCZK: func(v float64) float64 { if v < 0 { return 0 }; return v }(body.EntryFeeCZK),
MaxEntriesPerUser: func(v int) int { if v <= 0 { return 1 }; return v }(body.MaxEntriesPerUser),
Status: "scheduled",
}
if time.Now().After(item.StartAt) { item.Status = "active" }
if err := sc.DB.Create(&item).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to create"}); return }
c.JSON(http.StatusOK, item)
}
// Admin: update sweepstake
func (sc *SweepstakesController) AdminUpdate(c *gin.Context) {
id := c.Param("id")
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
allowed := map[string]bool{"title":true,"description":true,"image_url":true,"rules_url":true,"start_at":true,"end_at":true,"picker_style":true,"total_prizes":true,"prize_summary":true,"status":true}
upd := map[string]interface{}{}
for k,v := range body { if allowed[k] { upd[k] = v } }
if len(upd)==0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
if err := sc.DB.Model(&models.Sweepstake{}).Where("id = ?", id).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
id := c.Param("id")
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error":"Invalid payload"}); return }
allowed := map[string]bool{"title":true,"description":true,"image_url":true,"rules_url":true,"start_at":true,"end_at":true,"picker_style":true,"total_prizes":true,"prize_summary":true,"status":true,"entry_cost_points":true,"entry_fee_czk":true,"max_entries_per_user":true}
upd := map[string]interface{}{}
for k,v := range body { if allowed[k] { upd[k] = v } }
if len(upd)==0 { c.JSON(http.StatusBadRequest, gin.H{"error":"No changes"}); return }
// Clamp total_prizes if provided
if v, ok := upd["total_prizes"]; ok {
// Coerce to integer first
vv := 1
switch t := v.(type) {
case int:
vv = t
case int64:
vv = int(t)
case float64:
vv = int(t)
case string:
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { vv = n }
default:
// leave default 1
}
if vv < 1 { vv = 1 }
if vv > 100 { vv = 100 }
upd["total_prizes"] = vv
}
if err := sc.DB.Model(&models.Sweepstake{}).Where("id = ?", id).Updates(upd).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Admin: delete sweepstake
func (sc *SweepstakesController) AdminDelete(c *gin.Context) {
id := c.Param("id")
if err := sc.DB.Delete(&models.Sweepstake{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
id := c.Param("id")
if err := sc.DB.Delete(&models.Sweepstake{}, "id = ?", id).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: enter sweepstake (deduct points if needed, enforce max entries)
func (sc *SweepstakesController) Enter(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
uid, _ := c.Get("userID")
userID := uid.(uint)
var s models.Sweepstake
if err := sc.DB.First(&s, id).Error; err != nil { if err == gorm.ErrRecordNotFound { c.JSON(http.StatusNotFound, gin.H{"error":"Not found"}); return }; c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load"}); return }
now := time.Now()
if !(now.After(s.StartAt) && now.Before(s.EndAt)) { c.JSON(http.StatusBadRequest, gin.H{"error":"Soutěž není aktivní"}); return }
maxPerUser := s.MaxEntriesPerUser
if maxPerUser <= 0 { maxPerUser = 1 }
var existingCount int64
if err := sc.DB.Model(&models.SweepstakeEntry{}).Where("sweepstake_id = ? AND user_id = ? AND status = ?", s.ID, userID, "valid").Count(&existingCount).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to check entries"}); return }
if existingCount >= int64(maxPerUser) { c.JSON(http.StatusBadRequest, gin.H{"error":"Dosáhli jste limitu účastí v této soutěži"}); return }
costPoints := s.EntryCostPoints
if costPoints < 0 { costPoints = 0 }
if costPoints > 0 {
svc := services.NewEngagementService(sc.DB)
up, err := svc.EnsureProfile(userID)
if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze načíst profil"}); return }
if up.Points < int64(costPoints) { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Nemáte dostatek bodů (potřeba: %d)", costPoints)}); return }
if _, err := svc.AwardPointsAndXP(userID, -int64(costPoints), 0, "sweepstake_entry", map[string]interface{}{"sweepstake_id": s.ID}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze odečíst body"}); return }
e := models.SweepstakeEntry{ SweepstakeID: s.ID, UserID: userID, Status: "valid" }
if err := sc.DB.Create(&e).Error; err != nil { _, _ = svc.AwardPointsAndXP(userID, int64(costPoints), 0, "sweepstake_entry_refund", map[string]interface{}{"sweepstake_id": s.ID}); c.JSON(http.StatusInternalServerError, gin.H{"error":"Nelze vytvořit účast"}); return }
c.JSON(http.StatusOK, gin.H{"ok": true})
return
}
entry := models.SweepstakeEntry{ SweepstakeID: s.ID, UserID: userID, Status: "valid" }
if existingCount == 0 {
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, userID).FirstOrCreate(&entry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return }
} else {
if err := sc.DB.Create(&entry).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to join"}); return }
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: mark visual played time for current user's entry
func (sc *SweepstakesController) MarkVisualPlayed(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
uid, _ := c.Get("userID")
userID := uid.(uint)
now := time.Now()
var e models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", id, userID).Order("id ASC").First(&e).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error":"Entry not found"}); return }
_ = sc.DB.Model(&models.SweepstakeEntry{}).Where("id = ?", e.ID).Update("visual_played_at", &now).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// Protected: list my winnings
func (sc *SweepstakesController) MyWinnings(c *gin.Context) {
uid, _ := c.Get("userID")
userID := uid.(uint)
var items []models.SweepstakeWinner
if err := sc.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&items).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed"}); return }
c.JSON(http.StatusOK, gin.H{"items": items})
}
// Public: get current visible sweepstake (upcoming/active/finalized within visibility window)
func (sc *SweepstakesController) GetCurrent(c *gin.Context) {
now := time.Now()
var s models.Sweepstake
q := sc.DB.Where("start_at <= ? AND (visibility_until IS NULL OR visibility_until >= ?)", now, now).Order("start_at DESC")
if err := q.First(&s).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, gin.H{"sweepstake": nil})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load sweepstake"})
return
}
state := "upcoming"
if now.After(s.StartAt) && now.Before(s.EndAt) { state = "active" } else if now.After(s.EndAt) { state = "finalized" }
var prizes []models.SweepstakePrize
_ = sc.DB.Where("sweepstake_id = ?", s.ID).Order("display_order ASC, id ASC").Find(&prizes).Error
var winners []models.SweepstakeWinner
if s.WinnersSelectedAt != nil { _ = sc.DB.Where("sweepstake_id = ?", s.ID).Find(&winners).Error }
hasEntered := false
visualPlayedAt := (*time.Time)(nil)
if uid, ok := c.Get("userID"); ok && uid != nil {
var e models.SweepstakeEntry
if err := sc.DB.Where("sweepstake_id = ? AND user_id = ?", s.ID, uid.(uint)).First(&e).Error; err == nil {
hasEntered = true
visualPlayedAt = e.VisualPlayedAt
}
}
c.JSON(http.StatusOK, gin.H{
"sweepstake": s,
"prizes": prizes,
"winners": winners,
"state": state,
"has_entered": hasEntered,
"visual_played_at": visualPlayedAt,
})
}
// Admin: list entries
+49
View File
@@ -75,6 +75,55 @@ func JWTAuth(db *gorm.DB) gin.HandlerFunc {
}
}
// JWTOptional attempts to authenticate the request if a token or auth cookie is present.
func JWTOptional(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
if strings.ToLower(c.GetHeader("X-Dev-Admin")) == "true" {
c.Set("userRole", "admin")
c.Set("user", &models.User{Role: "admin"})
c.Next()
return
}
}
var tokenString string
if authHeader := c.GetHeader("Authorization"); authHeader != "" {
parts := strings.Split(authHeader, " ")
if len(parts) == 2 && parts[0] == "Bearer" {
tokenString = parts[1]
}
}
if tokenString == "" {
if cookie, err := c.Request.Cookie("auth_token"); err == nil {
tokenString = cookie.Value
}
}
if tokenString == "" {
c.Next()
return
}
claims, err := utils.ParseJWT(tokenString)
if err != nil {
c.Next()
return
}
var user models.User
if err := db.First(&user, claims.UserID).Error; err != nil {
c.Next()
return
}
c.Set("user", &user)
c.Set("claims", claims)
c.Set("userID", user.ID)
c.Set("userRole", user.Role)
c.Next()
}
}
// DevBypass checks for special dev header and grants admin role when not in production
func DevBypass() gin.HandlerFunc {
return func(c *gin.Context) {
+9
View File
@@ -7,6 +7,7 @@ import (
"sync"
"time"
"fotbal-club/internal/config"
"github.com/gin-gonic/gin"
)
@@ -64,6 +65,14 @@ func CSRFProtection() gin.HandlerFunc {
return
}
// Dev-only: skip CSRF when using X-Admin-Token (remote admin tools)
if config.AppConfig != nil && config.AppConfig.AppEnv != "production" {
if token := c.GetHeader("X-Admin-Token"); token != "" && token == config.AppConfig.AdminAccessToken {
c.Next()
return
}
}
// Get token from header or form
token := c.GetHeader("X-CSRF-Token")
if token == "" {
+2 -2
View File
@@ -15,10 +15,10 @@ func DBContext() gin.HandlerFunc {
// 15 seconds is generous for most queries while preventing indefinite hangs
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
// Store the context so controllers can use it with db.WithContext(ctx)
c.Set("dbCtx", ctx)
c.Next()
}
}
@@ -0,0 +1,44 @@
package middleware
import (
"strings"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
"github.com/gin-gonic/gin"
)
func ErrorStatusReporter(reporter *services.ErrorReporter) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
if reporter == nil {
return
}
status := c.Writer.Status()
if status >= 500 {
msg := ""
if len(c.Errors) > 0 {
var parts []string
for _, e := range c.Errors {
if e != nil && e.Err != nil {
parts = append(parts, e.Err.Error())
} else if e != nil {
parts = append(parts, e.Error())
}
}
msg = strings.Join(parts, "; ")
}
reporter.Report(c.Request.Context(), &services.ErrorEvent{
Origin: "backend",
Language: "go",
Severity: "error",
Message: msg,
URL: c.Request.URL.Path,
Method: c.Request.Method,
Status: status,
RequestID: GetRequestID(c),
})
logger.Error("Reported 5xx status=%d path=%s", status, c.Request.URL.Path)
}
}
}
+38
View File
@@ -6,6 +6,7 @@ import (
"runtime/debug"
"fotbal-club/pkg/logger"
"fotbal-club/internal/services"
"github.com/gin-gonic/gin"
)
@@ -41,3 +42,40 @@ func CustomRecovery() gin.HandlerFunc {
c.Next()
}
}
func CustomRecoveryWithReporter(reporter *services.ErrorReporter) gin.HandlerFunc {
if reporter == nil {
return CustomRecovery()
}
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
stack := string(debug.Stack())
requestID := GetRequestID(c)
logger.Error("Panic recovered",
"request_id", requestID,
"error", fmt.Sprintf("%v", err),
"stack", stack,
"path", c.Request.URL.Path,
"method", c.Request.Method,
)
reporter.Report(c.Request.Context(), &services.ErrorEvent{
Origin: "backend",
Language: "go",
Severity: "fatal",
Message: fmt.Sprintf("%v", err),
Stack: stack,
URL: c.Request.URL.Path,
Method: c.Request.Method,
RequestID: requestID,
})
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
"request_id": requestID,
})
c.Abort()
}
}()
c.Next()
}
}
+12 -1
View File
@@ -41,7 +41,18 @@ func ValidateContentType() gin.HandlerFunc {
path := c.Request.URL.Path
// Allow multipart for uploads and image processing crop upload
if strings.Contains(path, "/upload") || strings.Contains(path, "/image-processing/crop-upload") {
if strings.Contains(path, "/upload") || strings.Contains(path, "/image-processing/crop-upload") || strings.Contains(path, "/admin/scoreboard/qr") || strings.Contains(path, "/admin/scoreboard/load") {
c.Next()
return
}
// Allow scoreboard timer control endpoints without requiring JSON body
// These actions do not read request body and are triggered via simple POSTs from remote UI
if strings.Contains(path, "/admin/scoreboard/timer/") {
c.Next()
return
}
if strings.Contains(path, "/admin/scoreboard/swap-sides") || strings.Contains(path, "/admin/scoreboard/second-half") {
c.Next()
return
}
+34
View File
@@ -0,0 +1,34 @@
package models
import (
"time"
"gorm.io/datatypes"
)
type ErrorEvent struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Origin string `json:"origin"`
Language string `json:"language"`
Severity string `json:"severity"`
Message string `json:"message"`
Stack string `json:"stack"`
Component string `json:"component"`
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column"`
URL string `json:"url"`
Method string `json:"method"`
Status int `json:"status"`
RequestID string `json:"request_id"`
UserID *uint `json:"user_id"`
SessionToken string `json:"session_token"`
Tags datatypes.JSON `json:"tags" gorm:"type:jsonb"`
Context datatypes.JSON `json:"context" gorm:"type:jsonb"`
Env string `json:"env"`
Version string `json:"version"`
Hostname string `json:"hostname"`
OccurredAt time.Time `json:"occurred_at"`
}
+130 -63
View File
@@ -2,6 +2,7 @@ package models
import (
"encoding/json"
"strings"
"time"
"gorm.io/gorm"
@@ -19,38 +20,84 @@ type User struct {
LastLogin *time.Time `json:"last_login,omitempty"`
}
// LoadVideosOverrides hydrates the in-memory VideosOverrides slice from the persisted JSON column.
func (s *Settings) LoadVideosOverrides() {
if s.VideosOverridesJSON == "" {
s.VideosOverrides = nil
return
}
var items []VideoTitleOverride
if err := json.Unmarshal([]byte(s.VideosOverridesJSON), &items); err != nil {
s.VideosOverrides = nil
return
}
s.VideosOverrides = items
}
// SetVideosOverrides stores provided overrides and updates serialized JSON column.
func (s *Settings) SetVideosOverrides(items []VideoTitleOverride) error {
s.VideosOverrides = items
if len(items) == 0 {
s.VideosOverridesJSON = ""
return nil
}
b, err := json.Marshal(items)
if err != nil {
return err
}
s.VideosOverridesJSON = string(b)
return nil
}
func (s *Settings) LoadVideosTitleOverrides() {
if s.VideosOverrides == nil && s.VideosOverridesJSON != "" {
s.LoadVideosOverrides()
}
m := map[string]string{}
if len(s.VideosOverrides) > 0 {
for _, it := range s.VideosOverrides {
vid := strings.TrimSpace(it.VideoID)
t := strings.TrimSpace(it.Title)
if vid != "" && t != "" {
m[vid] = t
}
}
}
s.VideosTitleOverrides = m
}
// Article represents a blog article
type Article struct {
gorm.Model
Title string `gorm:"not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
AuthorID *uint `gorm:"index" json:"author_id,omitempty"`
Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
ImageURL string `json:"image_url"`
Published bool `gorm:"default:false" json:"published"`
PublishedAt *time.Time `json:"published_at,omitempty"`
Slug string `gorm:"uniqueIndex" json:"slug"`
Excerpt string `gorm:"type:text" json:"excerpt"`
Featured bool `gorm:"default:false;index" json:"featured"`
// Fields for SEO and social previews
SEOTitle string `json:"seo_title"`
SEODescription string `gorm:"type:text" json:"seo_description"`
// OG image for social sharing (optional)
OGImageURL string `json:"og_image_url"`
// Optional: link to external content or embedded media
ExternalLink string `json:"external_link"`
ViewCount int `gorm:"default:0;index" json:"view_count"`
ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes
UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session)
// Store the category name directly to simplify queries (denormalized)
CategoryName string `json:"category_name"`
Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...]
// Gallery association (optional)
GalleryAlbumID string `json:"gallery_album_id"`
GalleryAlbumURL string `json:"gallery_album_url"`
// Stored as JSON string or comma-separated list; frontend normalizes
gorm.Model
Title string `gorm:"not null" json:"title"`
Content string `gorm:"type:text;not null" json:"content"`
AuthorID *uint `gorm:"index" json:"author_id,omitempty"`
Author *User `gorm:"foreignKey:AuthorID" json:"author,omitempty"`
CategoryID *uint `gorm:"index" json:"category_id,omitempty"`
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
ImageURL string `json:"image_url"`
Published bool `gorm:"default:false" json:"published"`
PublishedAt *time.Time `json:"published_at,omitempty"`
Slug string `gorm:"uniqueIndex" json:"slug"`
Excerpt string `gorm:"type:text" json:"excerpt"`
Featured bool `gorm:"default:false;index" json:"featured"`
// Fields for SEO and social previews
SEOTitle string `json:"seo_title"`
SEODescription string `gorm:"type:text" json:"seo_description"`
// OG image for social sharing (optional)
OGImageURL string `json:"og_image_url"`
// Optional: link to external content or embedded media
ExternalLink string `json:"external_link"`
ViewCount int `gorm:"default:0;index" json:"view_count"`
ReadTime int `gorm:"default:0" json:"read_time"` // estimated reading time in minutes
UniqueViews int `gorm:"default:0" json:"unique_views"` // Unique visitors (tracked by IP/session)
// Store the category name directly to simplify queries (denormalized)
CategoryName string `json:"category_name"`
Attachments string `gorm:"type:text" json:"attachments"` // JSON array: ["url1", "url2", ...]
// Gallery association (optional)
GalleryAlbumID string `json:"gallery_album_id"`
GalleryAlbumURL string `json:"gallery_album_url"`
// Stored as JSON string or comma-separated list; frontend normalizes
GalleryPhotoIDs string `gorm:"type:text" json:"gallery_photo_ids"`
// YouTube video association (optional)
YouTubeVideoID string `json:"youtube_video_id"`
@@ -80,43 +127,43 @@ func (ArticleTeamLink) TableName() string { return "article_team_links" }
// ArticleMatchLink represents a link from an article to a match identified by an external FACR match ID
type ArticleMatchLink struct {
gorm.Model
ArticleID uint `gorm:"not null;index" json:"article_id"`
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
ExternalMatchID string `gorm:"not null;index" json:"external_match_id"`
Title string `json:"title"`
gorm.Model
ArticleID uint `gorm:"not null;index" json:"article_id"`
Article Article `gorm:"foreignKey:ArticleID" json:"-"`
ExternalMatchID string `gorm:"not null;index" json:"external_match_id"`
Title string `json:"title"`
}
func (ArticleMatchLink) TableName() string { return "article_match_links" }
// Team represents a football team
type Team struct {
gorm.Model
Name string `gorm:"not null"`
ShortName string
Description string
LogoURL string `json:"logo_url"`
IsActive bool `gorm:"default:true"`
gorm.Model
Name string `gorm:"not null"`
ShortName string
Description string
LogoURL string `json:"logo_url"`
IsActive bool `gorm:"default:true"`
}
// Player represents a football player
type Player struct {
gorm.Model
FirstName string `gorm:"not null" json:"first_name"`
LastName string `gorm:"not null" json:"last_name"`
DateOfBirth time.Time `json:"date_of_birth"`
Position string `json:"position"`
JerseyNumber int `json:"jersey_number"`
TeamID uint `json:"team_id"`
Team Team `gorm:"foreignKey:TeamID" json:"team"`
Nationality string `json:"nationality"`
Height int `json:"height"` // in cm
Weight int `json:"weight"` // in kg
ImageURL string `json:"image_url"`
Gender string `json:"gender"`
IsActive bool `gorm:"default:true" json:"is_active"`
Email string `json:"email"`
Phone string `json:"phone"`
gorm.Model
FirstName string `gorm:"not null" json:"first_name"`
LastName string `gorm:"not null" json:"last_name"`
DateOfBirth time.Time `json:"date_of_birth"`
Position string `json:"position"`
JerseyNumber int `json:"jersey_number"`
TeamID uint `json:"team_id"`
Team Team `gorm:"foreignKey:TeamID" json:"team"`
Nationality string `json:"nationality"`
Height int `json:"height"` // in cm
Weight int `json:"weight"` // in kg
ImageURL string `json:"image_url"`
Gender string `json:"gender"`
IsActive bool `gorm:"default:true" json:"is_active"`
Email string `json:"email"`
Phone string `json:"phone"`
}
// Sponsor represents a sponsor
@@ -135,6 +182,19 @@ type Sponsor struct {
Height int `json:"height"`
}
// VideoTitleOverride represents a per-video title override (for auto YouTube source)
type VideoTitleOverride struct {
VideoID string `json:"video_id"`
Title string `json:"title"`
}
// CustomNavLink represents a simple custom navigation link stored in settings.custom_nav
type CustomNavLink struct {
Label string `json:"label"`
URL string `json:"url"`
External bool `json:"external"`
}
type Settings struct {
gorm.Model
// Frontpage layout and style variants (e.g., "classic", "grid"; "light", "dark")
@@ -218,6 +278,12 @@ type Settings struct {
VideosJSON string `gorm:"type:text" json:"-"`
VideosItemsJSON string `gorm:"type:text" json:"-"`
// Title overrides for auto-fetched videos (stored as JSON array of {video_id,title})
VideosOverridesJSON string `gorm:"type:text" json:"-"`
VideosOverrides []VideoTitleOverride `gorm:"-" json:"videos_overrides,omitempty"`
// Derived helper for API responses (map form used by frontend/admin): video_id -> title
VideosTitleOverrides map[string]string `gorm:"-" json:"videos_title_overrides,omitempty"`
// Merch module configuration
MerchModuleEnabled bool `json:"merch_module_enabled"`
MerchStyle string `json:"merch_style"` // grid | slider (future)
@@ -267,17 +333,18 @@ type Settings struct {
StorageQuotaMB int `json:"storage_quota_mb"`
StorageWarnThreshold int `json:"storage_warn_threshold"`
StorageCriticalThreshold int `json:"storage_critical_threshold"`
// External error-review integration
ErrorReviewIngestURL string `json:"error_review_ingest_url"`
ErrorReviewIngestToken string `json:"error_review_ingest_token"`
ErrorReviewAdminURL string `json:"error_review_admin_url"`
ErrorReviewAdminToken string `json:"error_review_admin_token"`
ErrorReviewUIURL string `json:"error_review_ui_url"`
}
// TableName specifies table name for Settings model
func (Settings) TableName() string { return "settings" }
// CustomNavLink represents an item in the main navigation managed via settings
type CustomNavLink struct {
Label string `json:"label"`
URL string `json:"url"`
External bool `json:"external"`
}
// LoadCustomNav hydrates the in-memory CustomNav slice from the persisted JSON string.
func (s *Settings) LoadCustomNav() {
-1
View File
@@ -84,7 +84,6 @@ func (n *NavigationItem) GetURL() string {
"scoreboard": "/admin/scoreboard",
"scoreboard_remote": "/admin/scoreboard/remote",
"clothing": "/admin/obleceni",
"media": "/admin/media",
"sponsors": "/admin/sponzori",
"banners": "/admin/bannery",
"messages": "/admin/zpravy",
+2
View File
@@ -16,6 +16,8 @@ type Sweepstake struct {
PickerStyle string `json:"picker_style" gorm:"size:16;not null;default:'wheel'"` // wheel|cycler
TotalPrizes int `json:"total_prizes" gorm:"not null;default:1"`
PrizeSummary string `json:"prize_summary" gorm:"type:text"`
EntryCostPoints int `json:"entry_cost_points" gorm:"not null;default:0"`
EntryFeeCZK float64 `json:"entry_fee_czk" gorm:"not null;default:0"`
WinnersSelectedAt *time.Time `json:"winners_selected_at"`
VisibilityUntil *time.Time `json:"visibility_until"`
DrawSeed string `json:"draw_seed" gorm:"size:64"`
+28 -9
View File
@@ -58,6 +58,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
youtubeController := controllers.NewYouTubeController(db)
umamiController := controllers.NewUmamiController()
imageProcessingController := &controllers.ImageProcessingController{}
errorController := controllers.NewErrorController(db)
// API v1 group
{
@@ -95,6 +96,8 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// SMTP validation (public during setup; does not send email, only connects)
api.POST("/setup/validate-smtp", baseController.ValidateSMTP)
api.POST("/errors", middleware.RateLimit(120, time.Minute), errorController.Ingest)
// Auth routes
auth := api.Group("/auth")
{
@@ -122,14 +125,15 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Event routes (public)
eventController := &controllers.EventController{DB: db}
events := api.Group("/events")
events.Use(middleware.JWTOptional(db))
{
events.GET("", eventController.GetEvents)
events.GET("/upcoming", eventController.GetUpcomingEvents)
events.GET("/:id", eventController.GetEventByID)
}
// Comments (public list)
api.GET("/comments", commentController.GetComments)
// Comments (public list; attach optional auth to personalize reactions)
api.GET("/comments", middleware.JWTOptional(db), commentController.GetComments)
// Engagement (public + protected)
api.GET("/engagement/rewards", engagementController.GetRewards)
@@ -271,6 +275,13 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
admin := protected.Group("/admin")
admin.Use(middleware.RoleAuth("admin"))
{
// Errors
admin.GET("/errors", errorController.AdminList)
admin.GET("/errors/:id", errorController.AdminGet)
// External Error Review proxies
admin.GET("/errors/external", errorController.AdminListExternal)
admin.GET("/errors/external/:id", errorController.AdminGetExternal)
// Comments
commentsAdmin := admin.Group("/comments")
{
@@ -321,6 +332,7 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
// Scoreboard sponsors & QR (admin-only)
admin.GET("/scoreboard/sponsors", scoreboardController.ListSponsors)
admin.POST("/scoreboard/sponsors/upload", scoreboardController.UploadSponsors)
admin.POST("/scoreboard/sponsors/prefill", scoreboardController.PrefillSponsorsFromPage)
admin.DELETE("/scoreboard/sponsors", scoreboardController.DeleteSponsor)
admin.GET("/scoreboard/qr", scoreboardController.GetQR)
admin.POST("/scoreboard/qr", scoreboardController.UploadQR)
@@ -578,18 +590,25 @@ func SetupRoutes(api *gin.RouterGroup, db *gorm.DB) {
api.GET("/scoreboard", scoreboardController.GetPublic)
api.GET("/scoreboard/colors/derive", scoreboardController.DeriveColors)
api.GET("/scoreboard/sponsors", scoreboardController.ListSponsors)
api.GET("/scoreboard/qr", scoreboardController.GetQR)
api.GET("/settings", baseController.GetPublicSettings)
api.GET("/competition-aliases", baseController.GetPublicCompetitionAliases)
api.GET("/public/team-logo-overrides", baseController.GetPublicTeamLogoOverrides)
api.GET("/articles/featured", baseController.GetFeaturedArticles)
api.GET("/articles", baseController.GetArticles)
api.GET("/articles/:id", baseController.GetArticle)
api.GET("/articles/slug/:slug", baseController.GetArticleBySlug)
api.POST("/articles/:id/read", baseController.IncrementArticleRead)
api.POST("/articles/:id/track-view", baseController.TrackArticleView)
api.GET("/articles/:id/match-link", baseController.GetArticleMatchLink)
// Articles (public; use optional auth so admin/editor can see drafts in list when requesting published=false)
articlesPub := api.Group("/articles")
articlesPub.Use(middleware.JWTOptional(db))
{
articlesPub.GET("/featured", baseController.GetFeaturedArticles)
articlesPub.GET("", baseController.GetArticles)
articlesPub.GET("/slug/:slug", baseController.GetArticleBySlug)
articlesPub.GET("/:id", baseController.GetArticle)
articlesPub.POST("/:id/read", baseController.IncrementArticleRead)
articlesPub.POST("/:id/track-view", baseController.TrackArticleView)
articlesPub.GET("/:id/match-link", baseController.GetArticleMatchLink)
}
api.GET("/categories", baseController.GetCategories)
api.GET("/youtube/videos", youtubeController.GetYouTubeVideos)
api.GET("/about", aboutController.GetPublicAboutPage)
+104
View File
@@ -0,0 +1,104 @@
package services
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"time"
"strings"
"fotbal-club/internal/config"
"fotbal-club/pkg/httpclient"
)
type ErrorEvent struct {
Origin string `json:"origin"`
Language string `json:"language"`
Severity string `json:"severity,omitempty"`
Message string `json:"message"`
Stack string `json:"stack,omitempty"`
Component string `json:"component,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
URL string `json:"url,omitempty"`
Method string `json:"method,omitempty"`
Status int `json:"status,omitempty"`
RequestID string `json:"request_id,omitempty"`
UserID *uint `json:"user_id,omitempty"`
Session string `json:"session_token,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
Context map[string]interface{} `json:"context,omitempty"`
Env string `json:"env,omitempty"`
Version string `json:"version,omitempty"`
Hostname string `json:"hostname,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
}
type ErrorReporter struct {
client *http.Client
endpoint string
token string
hostname string
appEnv string
}
func NewErrorReporter(cfg *config.Config) *ErrorReporter {
if cfg == nil {
return nil
}
endpoint := cfg.ErrorIngestURL
if strings.TrimSpace(endpoint) == "" {
if cfg.AppEnv == "production" {
endpoint = "https://errors.tdvorak.dev/api/v1/errors"
} else {
base := strings.TrimRight(cfg.PublicAPIBaseURL, "/")
if base != "" {
endpoint = base + "/errors"
}
}
}
if strings.TrimSpace(endpoint) == "" {
return nil
}
host, _ := os.Hostname()
return &ErrorReporter{
client: httpclient.FastClient(),
endpoint: endpoint,
token: cfg.ErrorIngestToken,
hostname: host,
appEnv: cfg.AppEnv,
}
}
func (r *ErrorReporter) Report(ctx context.Context, ev *ErrorEvent) {
if r == nil || ev == nil || r.endpoint == "" {
return
}
if ev.Env == "" {
ev.Env = r.appEnv
}
if ev.Hostname == "" {
ev.Hostname = r.hostname
}
if ev.OccurredAt.IsZero() {
ev.OccurredAt = time.Now()
}
b, err := json.Marshal(ev)
if err != nil {
return
}
ctx2, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx2, http.MethodPost, r.endpoint, bytes.NewReader(b))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
if r.token != "" {
req.Header.Set("Authorization", "Bearer "+r.token)
}
_, _ = r.client.Do(req)
}
@@ -0,0 +1,175 @@
package services
import (
"bytes"
"encoding/json"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/models"
"gorm.io/gorm"
)
type erMonitor struct {
ID uint `json:"id,omitempty"`
Name string `json:"name"`
URL string `json:"url"`
Enabled bool `json:"enabled"`
CheckIntervalSec int `json:"check_interval_sec"`
TimeoutMs int `json:"timeout_ms"`
ExpectStatusMin int `json:"expect_status_min"`
ExpectStatusMax int `json:"expect_status_max"`
}
type erListResp struct {
Items []erMonitor `json:"items"`
}
func StartErrorReviewAutoRegister(db *gorm.DB) {
go func() {
defer func() { _ = recover() }()
time.Sleep(2 * time.Second)
for i := 0; i < 3; i++ {
if tryAutoRegister(db) {
return
}
time.Sleep(time.Duration(2+i) * time.Second)
}
}()
}
func tryAutoRegister(db *gorm.DB) bool {
if db == nil || config.AppConfig == nil {
return false
}
var s models.Settings
_ = db.First(&s).Error
// Domain is managed via environment only; fallback based on ERROR_LOCAL
adminURL := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_URL"))
if adminURL == "" {
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b {
errorLocal = true
}
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b {
errorLocal = true
}
}
if errorLocal {
adminURL = "http://127.0.0.1:8083/api/v1/admin"
} else {
adminURL = "https://error.tdvorak.dev/api/v1/admin"
}
}
// Prefer env token only
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
if token == "" {
return false
}
// Only register after setup is complete (club name present)
if strings.TrimSpace(s.ClubName) == "" {
return false
}
if strings.HasSuffix(adminURL, "/") {
adminURL = strings.TrimRight(adminURL, "/")
}
base := strings.TrimRight(strings.TrimSpace(s.APIBaseURL), "/")
if base == "" {
base = strings.TrimRight(config.AppConfig.PublicAPIBaseURL, "/")
}
if base == "" {
// Fallback for local dev
errorLocal := false
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("ERROR_LOCAL"))); err == nil && b { errorLocal = true }
if !errorLocal {
if b, err := strconv.ParseBool(strings.TrimSpace(os.Getenv("error_local"))); err == nil && b { errorLocal = true }
}
if errorLocal {
base = "http://127.0.0.1:8080/api/v1"
} else {
return false
}
}
// Ensure API suffix present
if !strings.Contains(base, "/api/") { base = strings.TrimRight(base, "/") + "/api/v1" }
monURL := base + "/health"
disp := "Fotbal Club"
if strings.TrimSpace(s.ClubName) != "" { disp = strings.TrimSpace(s.ClubName) }
host := ""
if u, err := url.Parse(monURL); err == nil { host = u.Hostname() }
name := disp
if host != "" { name = name + " (" + host + ")" }
name = name + " API Health"
mon := erMonitor{
Name: name,
URL: monURL,
Enabled: true,
CheckIntervalSec: 60,
TimeoutMs: 4000,
ExpectStatusMin: 200,
ExpectStatusMax: 399,
}
client := &http.Client{Timeout: 5 * time.Second}
req, _ := http.NewRequest(http.MethodGet, adminURL+"/monitors", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Admin-Token", token)
res, err := client.Do(req)
if err != nil {
return false
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return false
}
var list erListResp
if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
return false
}
var existing *erMonitor
for i := range list.Items {
if strings.TrimSpace(list.Items[i].URL) == monURL {
existing = &list.Items[i]
break
}
}
b, _ := json.Marshal(mon)
if existing == nil {
req2, _ := http.NewRequest(http.MethodPost, adminURL+"/monitors", bytes.NewReader(b))
req2.Header.Set("Authorization", "Bearer "+token)
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("X-Admin-Token", token)
res2, err2 := client.Do(req2)
if err2 != nil {
return false
}
defer res2.Body.Close()
if res2.StatusCode == http.StatusCreated || res2.StatusCode == http.StatusOK {
return true
}
return false
}
req3, _ := http.NewRequest(http.MethodPut, adminURL+"/monitors/"+itoa(existing.ID), bytes.NewReader(b))
req3.Header.Set("Authorization", "Bearer "+token)
req3.Header.Set("Content-Type", "application/json")
req3.Header.Set("X-Admin-Token", token)
res3, err3 := client.Do(req3)
if err3 != nil {
return false
}
defer res3.Body.Close()
if res3.StatusCode == http.StatusOK {
return true
}
return false
}
func itoa(u uint) string {
return strconv.FormatUint(uint64(u), 10)
}
+31 -2
View File
@@ -17,8 +17,7 @@ func EvaluateSpamScore(s string) (float64, []string) {
rules = append(rules, "too_short")
}
// Excessive repeated characters like 'aaaaaa' or '!!!!'
repeatRe := regexp.MustCompile(`([a-zA-Z!?.])\1{4,}`)
if repeatRe.MatchString(content) {
if hasExcessiveRepetition(content, 5) {
rules = append(rules, "repeated_chars")
}
// Low vowel ratio suggests gibberish in Czech/English latin text
@@ -53,3 +52,33 @@ func EvaluateSpamScore(s string) (float64, []string) {
if score > 1.0 { score = 1.0 }
return score, rules
}
// hasExcessiveRepetition checks if s contains a run of the same character of length >= minRun
// Limited to ASCII letters and the punctuation characters ! ? . to mirror the previous intent.
func hasExcessiveRepetition(s string, minRun int) bool {
if minRun < 2 { minRun = 2 }
run := 1
var prev rune
first := true
for _, r := range s {
if first {
prev = r
first = false
continue
}
if r == prev && (isAsciiLetter(r) || r == '!' || r == '?' || r == '.') {
run++
if run >= minRun {
return true
}
} else {
prev = r
run = 1
}
}
return false
}
func isAsciiLetter(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
}
+2
View File
@@ -87,6 +87,8 @@ func (s *SweepstakesService) FinalizeSweepstake(sw *models.Sweepstake, seed stri
if nWinners == 0 {
if cur.TotalPrizes > 0 { nWinners = cur.TotalPrizes }
}
// Cap winners to a safe maximum
if nWinners > 100 { nWinners = 100 }
if nWinners > len(entries) { nWinners = len(entries) }
// Build seed