mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #79
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A compact list of Czech and English bad words with family-friendly replacements.
|
||||
// Note: This is a lightweight, non-exhaustive list intended for community sites.
|
||||
var badWordMap = map[string]string{
|
||||
// Czech
|
||||
"kráva": "osobo",
|
||||
"debil": "nezdvořák",
|
||||
"idiot": "nešika",
|
||||
"blbec": "popleta",
|
||||
"pitomec": "nezbeda",
|
||||
"trouba": "popleta",
|
||||
"sprostý": "nevhodný",
|
||||
"sráč": "strašpytel",
|
||||
"čůrák": "šibal",
|
||||
"kokot": "popleta",
|
||||
"kretén": "nešika",
|
||||
"hovno": "ťuťo",
|
||||
"nasrat": "naštvat",
|
||||
"nasr**": "naštv**",
|
||||
"prdel": "zadek",
|
||||
"píča": "potížistka",
|
||||
"piča": "potížistka",
|
||||
"zmrd": "nezbeda",
|
||||
"sračka": "nepěknost",
|
||||
"sračky": "nepěknosti",
|
||||
"posrat": "pokazit",
|
||||
"posranej": "zkalený",
|
||||
"šukat": "láskovat",
|
||||
"mrdat": "lumpačit",
|
||||
"mrdka": "neplecha",
|
||||
"kurva": "mrška",
|
||||
"zasran": "nepříjemn",
|
||||
"do prdele": "sakryš",
|
||||
"čubka": "neposedná",
|
||||
"svině": "nezdárná",
|
||||
|
||||
// English
|
||||
"shit": "shoot",
|
||||
"fuck": "flip",
|
||||
"fucking": "flipping",
|
||||
"asshole": "meanie",
|
||||
"bitch": "rascal",
|
||||
"bastard": "rascal",
|
||||
"dick": "goof",
|
||||
"dickhead": "goof",
|
||||
"cock": "goof",
|
||||
"pussy": "rascal",
|
||||
"cunt": "rascal",
|
||||
"crap": "crud",
|
||||
"damn": "darn",
|
||||
}
|
||||
|
||||
// Compiled replacement patterns and sensitive patterns
|
||||
type compiledReplacement struct {
|
||||
re *regexp.Regexp
|
||||
replacement string
|
||||
}
|
||||
|
||||
var compiledRepls []compiledReplacement
|
||||
var sensitiveRegexps []*regexp.Regexp
|
||||
|
||||
func init() {
|
||||
// Build compiled replacements from explicit words/phrases
|
||||
for w, rep := range badWordMap {
|
||||
var pat string
|
||||
if strings.Contains(w, " ") {
|
||||
// phrase: allow flexible spacing
|
||||
pat = "(?i)\\b" + strings.ReplaceAll(regexp.QuoteMeta(w), " ", "\\s+") + "\\b"
|
||||
} else {
|
||||
pat = "(?i)\\b" + regexp.QuoteMeta(w) + "[a-zá-ž0-9]*\\b"
|
||||
}
|
||||
compiledRepls = append(compiledRepls, compiledReplacement{ re: regexp.MustCompile(pat), replacement: rep })
|
||||
}
|
||||
|
||||
// Add Czech stems with diacritic + leet tolerant patterns
|
||||
czStems := []struct{ stem, rep string }{
|
||||
{"kurv", "mrška"}, {"píc", "potížistka"}, {"pic", "potížistka"}, {"mrd", "lumpačit"}, {"šuk", "láskovat"}, {"srač", "nepěknost"}, {"hovn", "ťuťo"}, {"zmrd", "nezbeda"}, {"čubk", "neposedná"}, {"svin", "nezdárná"}, {"kokot", "popleta"}, {"čur", "šibal"}, {"cur", "šibal"},
|
||||
{"debil", "nezdvořák"}, {"idiot", "nešika"}, {"kretén", "nešika"}, {"blbec", "popleta"}, {"prdel", "zadek"},
|
||||
}
|
||||
for _, it := range czStems {
|
||||
pat := "(?i)\\b" + diacriticLeetPattern(it.stem) + "[a-zá-ž0-9]*\\b"
|
||||
compiledRepls = append(compiledRepls, compiledReplacement{ re: regexp.MustCompile(pat), replacement: it.rep })
|
||||
}
|
||||
|
||||
// English stems (simple suffix handling)
|
||||
en := []struct{ rawPattern, rep string }{
|
||||
{`(?i)\bshit(ty|head|s|ting)?\b`, "shoot"},
|
||||
{`(?i)\bfuck(ing|er|ers|ed|s)?\b`, "flip"},
|
||||
{`(?i)\bass(hole|hat|es)?\b`, "meanie"},
|
||||
{`(?i)\bbitch(es|y)?\b`, "rascal"},
|
||||
{`(?i)\bbastard(s)?\b`, "rascal"},
|
||||
{`(?i)\bdick(head|s)?\b`, "goof"},
|
||||
{`(?i)\bcock(s|ing)?\b`, "goof"},
|
||||
{`(?i)\bpussy\b`, "rascal"},
|
||||
{`(?i)\bcunt(s)?\b`, "rascal"},
|
||||
{`(?i)\bcrap(py|s)?\b`, "crud"},
|
||||
{`(?i)\bdamn(ed|s|ing)?\b`, "darn"},
|
||||
}
|
||||
for _, e := range en {
|
||||
compiledRepls = append(compiledRepls, compiledReplacement{ re: regexp.MustCompile(e.rawPattern), replacement: e.rep })
|
||||
}
|
||||
|
||||
// Sensitive stems (trigger moderation)
|
||||
sensStems := []string{"kurv", "píc", "pic", "mrd", "šuk", "čur", "cur", "kokot", "cunt", "fuck"}
|
||||
for _, s := range sensStems {
|
||||
// Czech stems get diacritic+leet tolerant pattern; English raw
|
||||
var re *regexp.Regexp
|
||||
if isASCII(s) {
|
||||
re = regexp.MustCompile("(?i)\\b" + regexp.QuoteMeta(s) + "[a-z0-9]*\\b")
|
||||
} else {
|
||||
re = regexp.MustCompile("(?i)\\b" + diacriticLeetPattern(s) + "[a-zá-ž0-9]*\\b")
|
||||
}
|
||||
sensitiveRegexps = append(sensitiveRegexps, re)
|
||||
}
|
||||
}
|
||||
|
||||
// FilterBadWords replaces bad words with friendlier counterparts while preserving approximate case.
|
||||
func FilterBadWords(s string) (string, bool) {
|
||||
if strings.TrimSpace(s) == "" { return s, false }
|
||||
out := s
|
||||
replaced := false
|
||||
for _, cr := range compiledRepls {
|
||||
out2 := cr.re.ReplaceAllStringFunc(out, func(m string) string {
|
||||
replaced = true
|
||||
// preserve basic case style
|
||||
if isTitle(m) { return title(cr.replacement) }
|
||||
if isUpper(m) { return strings.ToUpper(cr.replacement) }
|
||||
return cr.replacement
|
||||
})
|
||||
out = out2
|
||||
}
|
||||
return out, replaced
|
||||
}
|
||||
|
||||
// ContainsSensitiveWords returns true and the matched words if content contains strong/explicit terms.
|
||||
func ContainsSensitiveWords(s string) (bool, []string) {
|
||||
if strings.TrimSpace(s) == "" { return false, nil }
|
||||
found := []string{}
|
||||
for _, re := range sensitiveRegexps {
|
||||
if loc := re.FindStringIndex(s); loc != nil {
|
||||
found = append(found, s[loc[0]:loc[1]])
|
||||
}
|
||||
}
|
||||
if len(found) == 0 { return false, nil }
|
||||
return true, found
|
||||
}
|
||||
|
||||
func isUpper(s string) bool { return s == strings.ToUpper(s) }
|
||||
func isTitle(s string) bool { return len(s) > 0 && strings.ToUpper(s[:1]) == s[:1] && strings.ToLower(s[1:]) == s[1:] }
|
||||
func title(s string) string { if len(s)==0 {return s}; return strings.ToUpper(s[:1]) + s[1:] }
|
||||
|
||||
// Helpers for Czech diacritics + simple leetspeak
|
||||
func diacriticLeetPattern(stem string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range stem {
|
||||
b.WriteString(expandRune(r))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func expandRune(r rune) string {
|
||||
switch r {
|
||||
case 'a', 'A': return "[aá@4]"
|
||||
case 'e', 'E': return "[eéě3]"
|
||||
case 'i', 'I', 'l', 'L': return "[iíl1!]"
|
||||
case 'o', 'O': return "[oó0]"
|
||||
case 'u', 'U': return "[uúů]"
|
||||
case 'y', 'Y': return "[yý]"
|
||||
case 'c', 'C': return "[cč]"
|
||||
case 's', 'S': return "[sš5]"
|
||||
case 'z', 'Z': return "[zž2]"
|
||||
case 'r', 'R': return "[rř]"
|
||||
case 't', 'T': return "[tť7]"
|
||||
case 'n', 'N': return "[nň]"
|
||||
case 'd', 'D': return "[dď]"
|
||||
case 'p', 'P': return "[p]"
|
||||
case 'k', 'K': return "[k]"
|
||||
case 'm', 'M': return "[m]"
|
||||
case 'v', 'V': return "[v]"
|
||||
case 'h', 'H': return "[h]"
|
||||
case 'g', 'G': return "[g]"
|
||||
default:
|
||||
// escape everything else
|
||||
return regexp.QuoteMeta(string(r))
|
||||
}
|
||||
}
|
||||
|
||||
func isASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ { if s[i] >= 128 { return false } }
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
)
|
||||
|
||||
// EngagementService encapsulates points, XP, achievements, and rewards
|
||||
|
||||
type EngagementService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// AwardPointsCapped applies simple anti-abuse caps per reason.
|
||||
// - poll_vote: max 1 award per day
|
||||
// - comment_create: max 10 awards per day
|
||||
// - newsletter_subscribe: once per lifetime
|
||||
func (s *EngagementService) AwardPointsCapped(userID uint, delta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) {
|
||||
if userID == 0 || delta == 0 { return nil, nil }
|
||||
if !s.canAwardMore(userID, strings.TrimSpace(reason)) {
|
||||
return s.EnsureProfile(userID) // return current profile without adding
|
||||
}
|
||||
return s.AwardPoints(userID, delta, reason, meta)
|
||||
}
|
||||
|
||||
func (s *EngagementService) canAwardMore(userID uint, reason string) bool {
|
||||
now := time.Now()
|
||||
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
switch reason {
|
||||
case "poll_vote":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "poll_vote", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 1
|
||||
case "comment_create":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ? AND created_at >= ?", userID, "comment_create", startOfDay).
|
||||
Count(&cnt).Error
|
||||
return cnt < 10
|
||||
case "newsletter_subscribe":
|
||||
var cnt int64
|
||||
_ = s.DB.Model(&models.PointsTransaction{}).
|
||||
Where("user_id = ? AND reason = ?", userID, "newsletter_subscribe").
|
||||
Count(&cnt).Error
|
||||
return cnt == 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func NewEngagementService(db *gorm.DB) *EngagementService { return &EngagementService{DB: db} }
|
||||
|
||||
// EnsureProfile creates a profile if missing
|
||||
func (s *EngagementService) EnsureProfile(userID uint) (*models.UserProfile, error) {
|
||||
var up models.UserProfile
|
||||
if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
up = models.UserProfile{UserID: userID, Points: 0, Level: 1, XP: 0}
|
||||
if err := s.DB.Create(&up).Error; err != nil { return nil, err }
|
||||
return &up, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &up, nil
|
||||
}
|
||||
|
||||
// AwardPoints adds points/xp and logs transaction; returns updated profile
|
||||
func (s *EngagementService) AwardPoints(userID uint, delta int64, reason string, meta map[string]interface{}) (*models.UserProfile, error) {
|
||||
if userID == 0 || delta == 0 { return nil, nil }
|
||||
if _, err := s.EnsureProfile(userID); err != nil { return nil, err }
|
||||
pt := models.PointsTransaction{ UserID: userID, Delta: delta, XPDelta: delta, Reason: strings.TrimSpace(reason) }
|
||||
if meta != nil { pt.Meta = meta }
|
||||
if err := s.DB.Create(&pt).Error; err != nil { return nil, err }
|
||||
// Update profile atomically
|
||||
if err := s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"points": gorm.Expr("points + ?", delta),
|
||||
"xp": gorm.Expr("xp + ?", delta),
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil { return nil, err }
|
||||
// Recompute level
|
||||
var up models.UserProfile
|
||||
if err := s.DB.Where("user_id = ?", userID).First(&up).Error; err != nil { return nil, err }
|
||||
lvl := ComputeLevel(up.XP)
|
||||
if lvl != up.Level {
|
||||
_ = s.DB.Model(&models.UserProfile{}).Where("user_id = ?", userID).Update("level", lvl).Error
|
||||
up.Level = lvl
|
||||
}
|
||||
return &up, nil
|
||||
}
|
||||
|
||||
// ComputeLevel returns level for given XP (simple quadratic growth)
|
||||
func ComputeLevel(xp int64) int {
|
||||
// Level 1 at 0 xp, each level requires +100 * level xp increment approximately
|
||||
lvl := 1
|
||||
threshold := int64(100)
|
||||
remaining := xp
|
||||
for remaining >= threshold {
|
||||
remaining -= threshold
|
||||
lvl++
|
||||
threshold += int64(100)
|
||||
if lvl > 200 { break }
|
||||
}
|
||||
if lvl < 1 { lvl = 1 }
|
||||
return lvl
|
||||
}
|
||||
|
||||
// CheckAndAwardAchievements evaluates basic achievements and awards when reached
|
||||
func (s *EngagementService) CheckAndAwardAchievements(userID uint) error {
|
||||
if userID == 0 { return nil }
|
||||
// Preload completed achievements for user
|
||||
var done []models.UserAchievement
|
||||
_ = s.DB.Where("user_id = ?", userID).Find(&done).Error
|
||||
doneSet := map[uint]bool{}
|
||||
for _, ua := range done { doneSet[ua.AchievementID] = true }
|
||||
|
||||
// Ensure default achievements exist
|
||||
defaults := []models.Achievement{
|
||||
{Code: "first_comment", Title: "První komentář", Description: "Napsal/a jste první komentář.", Points: 10, XP: 10, Active: true},
|
||||
{Code: "first_vote", Title: "První hlasování", Description: "Poprvé jste hlasoval/a v anketě.", Points: 8, XP: 8, Active: true},
|
||||
{Code: "newsletter_sub", Title: "Odběr novinek", Description: "Přihlášení k odběru newsletteru.", Points: 12, XP: 12, Active: true},
|
||||
{Code: "comments_10", Title: "Komentátor", Description: "10 komentářů!", Points: 20, XP: 20, Active: true},
|
||||
{Code: "votes_10", Title: "Hlasující", Description: "10 hlasování!", Points: 20, XP: 20, Active: true},
|
||||
}
|
||||
for _, a := range defaults {
|
||||
var existing models.Achievement
|
||||
if err := s.DB.Where("code = ?", a.Code).First(&existing).Error; err != nil {
|
||||
_ = s.DB.Create(&a).Error
|
||||
}
|
||||
}
|
||||
|
||||
// Compute counts
|
||||
var commentCount int64
|
||||
_ = s.DB.Model(&models.Comment{}).Where("user_id = ?", userID).Count(&commentCount).Error
|
||||
var voteCount int64
|
||||
_ = s.DB.Model(&models.PollVote{}).Where("user_id = ?", userID).Count(&voteCount).Error
|
||||
var hasNewsletter bool
|
||||
if err := s.DB.Model(&models.NewsletterSubscription{}).Select("1").Where("LOWER(email) = (SELECT LOWER(email) FROM users WHERE id = ?) AND is_active = ?", userID, true).Limit(1).Scan(&hasNewsletter).Error; err != nil {
|
||||
// ignore
|
||||
}
|
||||
|
||||
awardByCode := func(code string) {
|
||||
var a models.Achievement
|
||||
if err := s.DB.Where("code = ? AND active = ?", code, true).First(&a).Error; err == nil {
|
||||
var existing models.UserAchievement
|
||||
if err := s.DB.Where("user_id = ? AND achievement_id = ?", userID, a.ID).First(&existing).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
_ = s.DB.Create(&models.UserAchievement{UserID: userID, AchievementID: a.ID}).Error
|
||||
_, _ = s.AwardPoints(userID, a.Points, "achievement:"+code, map[string]interface{}{"achievement_id": a.ID})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if commentCount >= 1 { awardByCode("first_comment") }
|
||||
if voteCount >= 1 { awardByCode("first_vote") }
|
||||
if hasNewsletter { awardByCode("newsletter_sub") }
|
||||
if commentCount >= 10 { awardByCode("comments_10") }
|
||||
if voteCount >= 10 { awardByCode("votes_10") }
|
||||
return nil
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func BuildNewsletterDigest(cacheDir string, prefs NewsletterPrefs) (subject stri
|
||||
}
|
||||
|
||||
// Upcoming events
|
||||
if want["events"] || want["matches"] {
|
||||
if want["events"] {
|
||||
items := pickUpcomingEvents(ev, 6)
|
||||
if len(items) > 0 {
|
||||
sections = append(sections, renderEventsSection(items))
|
||||
@@ -140,6 +140,30 @@ func pickUpcomingEvents(v any, n int) []Event {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback URL to internal activity detail when not provided
|
||||
if strings.TrimSpace(e.Url) == "" {
|
||||
// Try to read numeric id from generic JSON number (float64)
|
||||
if idv, ok := m["id"]; ok {
|
||||
switch t := idv.(type) {
|
||||
case float64:
|
||||
if t > 0 {
|
||||
e.Url = "/aktivita/" + fmt.Sprintf("%d", int64(t))
|
||||
}
|
||||
case int:
|
||||
if t > 0 {
|
||||
e.Url = "/aktivita/" + fmt.Sprintf("%d", t)
|
||||
}
|
||||
case int64:
|
||||
if t > 0 {
|
||||
e.Url = "/aktivita/" + fmt.Sprintf("%d", t)
|
||||
}
|
||||
case string:
|
||||
if strings.TrimSpace(t) != "" {
|
||||
e.Url = "/aktivita/" + strings.TrimSpace(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Simple heuristics to evaluate spammy text. Returns score 0..1 and triggered rules.
|
||||
func EvaluateSpamScore(s string) (float64, []string) {
|
||||
var rules []string
|
||||
content := strings.TrimSpace(s)
|
||||
if content == "" {
|
||||
return 1.0, []string{"empty"}
|
||||
}
|
||||
// Too short
|
||||
if len([]rune(content)) < 6 {
|
||||
rules = append(rules, "too_short")
|
||||
}
|
||||
// Excessive repeated characters like 'aaaaaa' or '!!!!'
|
||||
repeatRe := regexp.MustCompile(`([a-zA-Z!?.])\1{4,}`)
|
||||
if repeatRe.MatchString(content) {
|
||||
rules = append(rules, "repeated_chars")
|
||||
}
|
||||
// Low vowel ratio suggests gibberish in Czech/English latin text
|
||||
letters := regexp.MustCompile(`[A-Za-zÁáÉéĚěÍíÓóÚúŮůÝýŽžŠšČčŘřŤťŇňĎď]`).FindAllString(content, -1)
|
||||
if len(letters) >= 8 {
|
||||
vowels := regexp.MustCompile(`[AaEeIiOoUuYyÁáÉéĚěÍíÓóÚúŮůÝý]`).FindAllString(content, -1)
|
||||
ratio := float64(len(vowels)) / float64(len(letters))
|
||||
if ratio < 0.18 { // very low vowel ratio
|
||||
rules = append(rules, "low_vowel_ratio")
|
||||
}
|
||||
}
|
||||
// Too many links
|
||||
linkCount := len(regexp.MustCompile(`https?://`).FindAllStringIndex(content, -1))
|
||||
if linkCount >= 3 {
|
||||
rules = append(rules, "too_many_links")
|
||||
}
|
||||
// All-caps shouting
|
||||
if content == strings.ToUpper(content) && len(content) >= 8 {
|
||||
rules = append(rules, "all_caps")
|
||||
}
|
||||
// Compute score by rules weight
|
||||
weights := map[string]float64{
|
||||
"empty": 1.0,
|
||||
"too_short": 0.4,
|
||||
"repeated_chars": 0.3,
|
||||
"low_vowel_ratio": 0.3,
|
||||
"too_many_links": 0.5,
|
||||
"all_caps": 0.2,
|
||||
}
|
||||
score := 0.0
|
||||
for _, r := range rules { score += weights[r] }
|
||||
if score > 1.0 { score = 1.0 }
|
||||
return score, rules
|
||||
}
|
||||
Reference in New Issue
Block a user