mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #90 🥳
This commit is contained in:
@@ -72,7 +72,65 @@ func generateTeamNameAliases(name string) []string {
|
||||
es := abbreviateAmpersand(e)
|
||||
if es != "" && es != base && es != t && es != e { add(es) }
|
||||
|
||||
variants := []string{t, s, e, es}
|
||||
// Generate PN-abbreviated variants like "... n. X." / "... p. X." from full forms (nad/pod)
|
||||
makePNAbbrevs := func(s string) []string {
|
||||
if strings.TrimSpace(s) == "" { return nil }
|
||||
// Build variants for "nad <Word>" / "pod <Word>" ->
|
||||
// n. W., n.W., n. W, n.W (and p. analogs)
|
||||
mk := func(in string, re *regexp.Regexp, repPrefix string, withFinalDot bool, withSpace bool) string {
|
||||
return re.ReplaceAllStringFunc(in, func(m string) string {
|
||||
sub := re.FindStringSubmatch(m)
|
||||
if len(sub) < 2 { return m }
|
||||
letter := firstRuneUpper(sub[1])
|
||||
if letter == "" { return m }
|
||||
if withFinalDot {
|
||||
if withSpace { return repPrefix + " " + letter + "." }
|
||||
return repPrefix + letter + "."
|
||||
}
|
||||
if withSpace { return repPrefix + " " + letter }
|
||||
return repPrefix + letter
|
||||
})
|
||||
}
|
||||
// spaced + with dot
|
||||
a := mk(s, rePNNadWord, "n.", true, true)
|
||||
a = mk(a, rePNPodWord, "p.", true, true)
|
||||
// no space + with dot
|
||||
b := mk(s, rePNNadWord, "n.", true, false)
|
||||
b = mk(b, rePNPodWord, "p.", true, false)
|
||||
// spaced + without final dot
|
||||
c := mk(s, rePNNadWord, "n.", false, true)
|
||||
c = mk(c, rePNPodWord, "p.", false, true)
|
||||
// no space + without final dot
|
||||
d := mk(s, rePNNadWord, "n.", false, false)
|
||||
d = mk(d, rePNPodWord, "p.", false, false)
|
||||
// collect distinct, non-empty, changed variants
|
||||
seen := map[string]struct{}{}
|
||||
out := []string{}
|
||||
addv := func(x string) {
|
||||
x = strings.TrimSpace(x)
|
||||
if x == "" || x == s { return }
|
||||
if _, ok := seen[x]; ok { return }
|
||||
seen[x] = struct{}{}
|
||||
out = append(out, x)
|
||||
}
|
||||
addv(a); addv(b); addv(c); addv(d)
|
||||
return out
|
||||
}
|
||||
for _, v := range []string{t, e} {
|
||||
for _, p := range makePNAbbrevs(v) { add(p) }
|
||||
}
|
||||
|
||||
// Also generate and add versions with common club prefixes stripped (SK, FK, MFK, TJ, 1.BFK, ...)
|
||||
st := stripOrgPrefixes(t)
|
||||
se := stripOrgPrefixes(e)
|
||||
if st != "" && st != t { add(st) }
|
||||
if se != "" && se != e { add(se) }
|
||||
// PN abbreviations for stripped versions as well
|
||||
for _, v := range []string{st, se} {
|
||||
for _, p := range makePNAbbrevs(v) { add(p) }
|
||||
}
|
||||
|
||||
variants := []string{t, s, e, es, st, se}
|
||||
for _, v := range variants {
|
||||
if strings.TrimSpace(v) == "" { continue }
|
||||
nd := strings.ReplaceAll(v, ".", "")
|
||||
@@ -84,7 +142,7 @@ func generateTeamNameAliases(name string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$`)
|
||||
var reLegalSuffix = regexp.MustCompile(`(?i)[\s,]*(z\.?\s*s\.?|o\.?\s*s\.?)[\s]*$`)
|
||||
|
||||
func trimLegalSuffixes(s string) string {
|
||||
return strings.TrimSpace(reLegalSuffix.ReplaceAllString(s, ""))
|
||||
@@ -96,6 +154,25 @@ var (
|
||||
reMultiSpace = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
var (
|
||||
rePNNadWord = regexp.MustCompile(`(?i)\bnad\s+([\p{L}-]+)`)
|
||||
rePNPodWord = regexp.MustCompile(`(?i)\bpod\s+([\p{L}-]+)`)
|
||||
)
|
||||
|
||||
// Remove leading organization tokens like "1.BFK", "FK", "SK", "TJ", "MFK", "SFC", ...
|
||||
var reLeadingOrg = regexp.MustCompile(`(?i)^(?:\d+\.)?\s*(?:sfc|afc|fc|fk|mfk|tj|sk|afk|bfk|hfk)\.?\s+`)
|
||||
|
||||
func stripOrgPrefixes(s string) string {
|
||||
x := strings.TrimSpace(s)
|
||||
if x == "" { return x }
|
||||
for {
|
||||
nx := reLeadingOrg.ReplaceAllString(x, "")
|
||||
nx = strings.TrimSpace(nx)
|
||||
if nx == x || nx == "" { return nx }
|
||||
x = nx
|
||||
}
|
||||
}
|
||||
|
||||
func expandPNAbbrev(s string) string {
|
||||
if s == "" { return s }
|
||||
x := reAbbrevP.ReplaceAllString(s, "pod ")
|
||||
@@ -667,14 +744,15 @@ func (bc *BaseController) GetStandings(c *gin.Context) {
|
||||
if err := bc.DB.Find(&tlovs).Error; err == nil {
|
||||
tloByID := map[string]models.TeamLogoOverride{}
|
||||
for _, it := range tlovs {
|
||||
tloByID[it.ExternalTeamID] = it
|
||||
if it.ExternalTeamID == "" { continue }
|
||||
tloByID[strings.ToLower(it.ExternalTeamID)] = it
|
||||
}
|
||||
for i := range rows {
|
||||
id, _ := rows[i]["team_id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if tlo, ok := tloByID[id]; ok {
|
||||
if tlo, ok := tloByID[strings.ToLower(id)]; ok {
|
||||
if strings.TrimSpace(tlo.TeamName) != "" {
|
||||
rows[i]["team"] = tlo.TeamName
|
||||
}
|
||||
@@ -2059,101 +2137,166 @@ func computeEstimatedReadMinutes(html string) int {
|
||||
return minutes
|
||||
}
|
||||
|
||||
// GetAdminMatches returns cached matches merged with DB overrides (admin only)
|
||||
// GetAdminMatches returns cached FACR matches merged with DB overrides (admin only)
|
||||
func (bc *BaseController) GetAdminMatches(c *gin.Context) {
|
||||
// Read cached events
|
||||
p := filepath.Join("cache", "prefetch", "events_upcoming.json")
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNoContent, gin.H{"message": "No cached matches"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
var matches []map[string]interface{}
|
||||
if err := json.NewDecoder(f).Decode(&matches); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist cache"})
|
||||
return
|
||||
}
|
||||
// Read cached FACR club info (contains competitions with matches)
|
||||
p := filepath.Join("cache", "prefetch", "facr_club_info.json")
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNoContent, gin.H{"message": "No cached FACR matches"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Load overrides
|
||||
var movs []models.MatchOverride
|
||||
if err := bc.DB.Find(&movs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
|
||||
return
|
||||
}
|
||||
movByID := map[string]models.MatchOverride{}
|
||||
for _, m := range movs {
|
||||
movByID[m.ExternalMatchID] = m
|
||||
}
|
||||
var facr struct {
|
||||
Competitions []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Matches []struct {
|
||||
MatchID string `json:"match_id"`
|
||||
DateTime string `json:"date_time"`
|
||||
Date string `json:"date"`
|
||||
Time string `json:"time"`
|
||||
Home string `json:"home"`
|
||||
HomeTeam string `json:"home_team"`
|
||||
HomeID string `json:"home_id"`
|
||||
HomeTeamID string `json:"home_team_id"`
|
||||
HomeTeamFACRID string `json:"home_team_facr_id"`
|
||||
Away string `json:"away"`
|
||||
AwayTeam string `json:"away_team"`
|
||||
AwayID string `json:"away_id"`
|
||||
AwayTeamID string `json:"away_team_id"`
|
||||
AwayTeamFACRID string `json:"away_team_facr_id"`
|
||||
Score string `json:"score"`
|
||||
Venue string `json:"venue"`
|
||||
HomeLogoURL string `json:"home_logo_url"`
|
||||
AwayLogoURL string `json:"away_logo_url"`
|
||||
} `json:"matches"`
|
||||
} `json:"competitions"`
|
||||
}
|
||||
if err := json.NewDecoder(f).Decode(&facr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze nacist FACR cache"})
|
||||
return
|
||||
}
|
||||
|
||||
var tlovs []models.TeamLogoOverride
|
||||
if err := bc.DB.Find(&tlovs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
|
||||
return
|
||||
}
|
||||
tloByTeam := map[string]models.TeamLogoOverride{}
|
||||
for _, t := range tlovs {
|
||||
tloByTeam[t.ExternalTeamID] = t
|
||||
}
|
||||
// Helper to pick first non-empty string
|
||||
firstNonEmpty := func(ss ...string) string {
|
||||
for _, s := range ss {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Apply overrides in-place
|
||||
for _, m := range matches {
|
||||
// External match ID
|
||||
var matchID string
|
||||
if v, ok := m["match_id"].(string); ok {
|
||||
matchID = v
|
||||
} else if v2, ok2 := m["id"].(string); ok2 {
|
||||
matchID = v2
|
||||
}
|
||||
// Flatten and normalize to a simple slice of maps
|
||||
items := make([]map[string]any, 0, 256)
|
||||
for _, comp := range facr.Competitions {
|
||||
for _, m := range comp.Matches {
|
||||
id := strings.TrimSpace(m.MatchID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
home := firstNonEmpty(m.Home, m.HomeTeam)
|
||||
away := firstNonEmpty(m.Away, m.AwayTeam)
|
||||
homeID := firstNonEmpty(m.HomeID, m.HomeTeamID, m.HomeTeamFACRID)
|
||||
awayID := firstNonEmpty(m.AwayID, m.AwayTeamID, m.AwayTeamFACRID)
|
||||
item := map[string]any{
|
||||
"id": id,
|
||||
"match_id": id,
|
||||
"date_time": strings.TrimSpace(m.DateTime),
|
||||
"date": strings.TrimSpace(m.Date),
|
||||
"time": strings.TrimSpace(m.Time),
|
||||
"competitionName": strings.TrimSpace(comp.Name),
|
||||
"competition_id": strings.TrimSpace(comp.ID),
|
||||
"home": home,
|
||||
"home_team": home,
|
||||
"home_id": homeID,
|
||||
"away": away,
|
||||
"away_team": away,
|
||||
"away_id": awayID,
|
||||
"score": strings.TrimSpace(m.Score),
|
||||
"venue": strings.TrimSpace(m.Venue),
|
||||
"home_logo_url": strings.TrimSpace(m.HomeLogoURL),
|
||||
"away_logo_url": strings.TrimSpace(m.AwayLogoURL),
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
if ov, ok := movByID[matchID]; ok {
|
||||
if ov.HomeNameOverride != nil {
|
||||
m["home"] = *ov.HomeNameOverride
|
||||
m["home_team"] = *ov.HomeNameOverride
|
||||
}
|
||||
if ov.AwayNameOverride != nil {
|
||||
m["away"] = *ov.AwayNameOverride
|
||||
m["away_team"] = *ov.AwayNameOverride
|
||||
}
|
||||
if ov.VenueOverride != nil {
|
||||
m["venue"] = *ov.VenueOverride
|
||||
}
|
||||
if ov.DateTimeOverride != nil {
|
||||
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
|
||||
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
|
||||
}
|
||||
if ov.HomeLogoURL != nil {
|
||||
m["home_logo_url"] = *ov.HomeLogoURL
|
||||
}
|
||||
if ov.AwayLogoURL != nil {
|
||||
m["away_logo_url"] = *ov.AwayLogoURL
|
||||
}
|
||||
}
|
||||
// Load overrides and apply
|
||||
var movs []models.MatchOverride
|
||||
if err := bc.DB.Find(&movs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (match overrides)"})
|
||||
return
|
||||
}
|
||||
movByID := map[string]models.MatchOverride{}
|
||||
for _, m := range movs {
|
||||
movByID[m.ExternalMatchID] = m
|
||||
}
|
||||
|
||||
// Team-logo overrides by team id
|
||||
if homeID, ok := m["home_id"].(string); ok {
|
||||
if tlo, ok := tloByTeam[homeID]; ok {
|
||||
if tlo.LogoURL != "" {
|
||||
m["home_logo_url"] = tlo.LogoURL
|
||||
}
|
||||
if tlo.TeamName != "" {
|
||||
m["home"] = tlo.TeamName
|
||||
}
|
||||
}
|
||||
}
|
||||
if awayID, ok := m["away_id"].(string); ok {
|
||||
if tlo, ok := tloByTeam[awayID]; ok {
|
||||
if tlo.LogoURL != "" {
|
||||
m["away_logo_url"] = tlo.LogoURL
|
||||
}
|
||||
if tlo.TeamName != "" {
|
||||
m["away"] = tlo.TeamName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var tlovs []models.TeamLogoOverride
|
||||
if err := bc.DB.Find(&tlovs).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze (team logo overrides)"})
|
||||
return
|
||||
}
|
||||
tloByTeam := map[string]models.TeamLogoOverride{}
|
||||
for _, t := range tlovs {
|
||||
tloByTeam[t.ExternalTeamID] = t
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, matches)
|
||||
for _, m := range items {
|
||||
matchID, _ := m["match_id"].(string)
|
||||
if ov, ok := movByID[matchID]; ok {
|
||||
if ov.HomeNameOverride != nil {
|
||||
m["home"] = *ov.HomeNameOverride
|
||||
m["home_team"] = *ov.HomeNameOverride
|
||||
}
|
||||
if ov.AwayNameOverride != nil {
|
||||
m["away"] = *ov.AwayNameOverride
|
||||
m["away_team"] = *ov.AwayNameOverride
|
||||
}
|
||||
if ov.VenueOverride != nil {
|
||||
m["venue"] = *ov.VenueOverride
|
||||
}
|
||||
if ov.DateTimeOverride != nil {
|
||||
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
|
||||
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
|
||||
m["time"] = ov.DateTimeOverride.Format("15:04")
|
||||
}
|
||||
if ov.HomeLogoURL != nil {
|
||||
m["home_logo_url"] = *ov.HomeLogoURL
|
||||
}
|
||||
if ov.AwayLogoURL != nil {
|
||||
m["away_logo_url"] = *ov.AwayLogoURL
|
||||
}
|
||||
}
|
||||
if homeID, _ := m["home_id"].(string); homeID != "" {
|
||||
if tlo, ok := tloByTeam[homeID]; ok {
|
||||
if strings.TrimSpace(tlo.LogoURL) != "" {
|
||||
m["home_logo_url"] = tlo.LogoURL
|
||||
}
|
||||
if strings.TrimSpace(tlo.TeamName) != "" {
|
||||
m["home"] = tlo.TeamName
|
||||
m["home_team"] = tlo.TeamName
|
||||
}
|
||||
}
|
||||
}
|
||||
if awayID, _ := m["away_id"].(string); awayID != "" {
|
||||
if tlo, ok := tloByTeam[awayID]; ok {
|
||||
if strings.TrimSpace(tlo.LogoURL) != "" {
|
||||
m["away_logo_url"] = tlo.LogoURL
|
||||
}
|
||||
if strings.TrimSpace(tlo.TeamName) != "" {
|
||||
m["away"] = tlo.TeamName
|
||||
m["away_team"] = tlo.TeamName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, items)
|
||||
}
|
||||
|
||||
// --- Admin: Match & Team Logo Overrides ---
|
||||
@@ -2411,6 +2554,8 @@ func (bc *BaseController) PatchTeamLogoOverride(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
|
||||
return
|
||||
}
|
||||
// Best-effort: update public snapshot cache so frontend fallback sees latest aliases
|
||||
go bc.writeTeamLogoOverridesCache()
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
@@ -4841,115 +4986,134 @@ func (bc *BaseController) DeleteCategory(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"chyba": "Nelze smazat kategorii"})
|
||||
return
|
||||
}
|
||||
// Successful deletion
|
||||
c.JSON(http.StatusOK, gin.H{"zprava": "Kategorie byla smazána"})
|
||||
}
|
||||
|
||||
// UploadImage handles generic file uploads (images, documents, archives)
|
||||
func (bc *BaseController) UploadImage(c *gin.Context) {
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
// Enforce maximum upload size (bytes)
|
||||
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(f.Filename)
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
allowed := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true, ".pdf": true}
|
||||
if !allowed[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
|
||||
return
|
||||
}
|
||||
// Light content sniffing to ensure the uploaded payload matches the declared extension
|
||||
// and basic SVG sanitization (reject obvious script/event patterns).
|
||||
if src, err := f.Open(); err == nil {
|
||||
buf := make([]byte, 2048)
|
||||
n, _ := src.Read(buf)
|
||||
_ = src.Close()
|
||||
detected := http.DetectContentType(buf[:n])
|
||||
validCT := false
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
validCT = strings.Contains(detected, "pdf") || detected == "application/octet-stream"
|
||||
case ".svg":
|
||||
validCT = strings.Contains(strings.ToLower(detected), "image/svg+xml") || strings.Contains(strings.ToLower(detected), "xml") || strings.HasPrefix(strings.ToLower(detected), "text/")
|
||||
default:
|
||||
validCT = strings.HasPrefix(detected, "image/")
|
||||
}
|
||||
if !validCT {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Additional SVG content check against common script vectors
|
||||
if ext == ".svg" {
|
||||
if src2, err := f.Open(); err == nil {
|
||||
defer src2.Close()
|
||||
check := make([]byte, 65536)
|
||||
n, _ := io.ReadFull(src2, check)
|
||||
lower := strings.ToLower(string(check[:n]))
|
||||
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
dir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "./uploads"
|
||||
}
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
randHex := hex.EncodeToString(b)
|
||||
outName := fmt.Sprintf("upload_%d_%s%s", time.Now().Unix(), randHex, ext)
|
||||
outPath := filepath.Join(dir, outName)
|
||||
if err := c.SaveUploadedFile(f, outPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
|
||||
return
|
||||
}
|
||||
urlPath := "/uploads/" + outName
|
||||
ft := services.NewFileTracker(bc.DB)
|
||||
mimeType := f.Header.Get("Content-Type")
|
||||
_ = ft.TrackFileUpload(outPath, urlPath, outName, mimeType, f.Size, nil)
|
||||
f, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
// Enforce maximum upload size (bytes)
|
||||
if max := config.AppConfig.MaxUploadSize; max > 0 && f.Size > max {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(f.Filename)
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
// Allow images, PDFs, Office docs, text, archives, and common media
|
||||
allowed := map[string]bool{
|
||||
// Images
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true, ".svg": true,
|
||||
// Documents
|
||||
".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true, ".txt": true, ".csv": true,
|
||||
// Archives
|
||||
".zip": true, ".rar": true, ".7z": true, ".tar": true, ".gz": true,
|
||||
// Media
|
||||
".mp4": true, ".avi": true, ".mov": true, ".mp3": true, ".wav": true,
|
||||
}
|
||||
if !allowed[ext] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported file type"})
|
||||
return
|
||||
}
|
||||
// Light content sniffing to ensure payload matches extension and sanitize SVGs
|
||||
if src, err := f.Open(); err == nil {
|
||||
defer src.Close()
|
||||
buf := make([]byte, 2048)
|
||||
n, _ := io.ReadFull(src, buf)
|
||||
if n < 0 { n = 0 }
|
||||
dl := strings.ToLower(http.DetectContentType(buf[:n]))
|
||||
|
||||
// Build absolute URL from request (supports proxies)
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
||||
// Take the first value if comma-separated
|
||||
parts := strings.Split(xf, ",")
|
||||
if len(parts) > 0 {
|
||||
h := strings.TrimSpace(parts[0])
|
||||
if h != "" {
|
||||
host = h
|
||||
}
|
||||
}
|
||||
}
|
||||
// Append forwarded port when host has no explicit port and it's non-default
|
||||
if !strings.Contains(host, ":") {
|
||||
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
|
||||
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
|
||||
host = host + ":" + xfp
|
||||
}
|
||||
}
|
||||
}
|
||||
absolute := scheme + "://" + host + urlPath
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
// Always return a backend-relative path for storage
|
||||
"url": urlPath,
|
||||
// Convenience absolute URL for immediate usage in UIs
|
||||
"absolute_url": absolute,
|
||||
// Basic metadata (best-effort)
|
||||
"name": outName,
|
||||
"type": mimeType,
|
||||
"size": f.Size,
|
||||
})
|
||||
validCT := false
|
||||
switch ext {
|
||||
case ".pdf":
|
||||
validCT = strings.Contains(dl, "pdf") || dl == "application/octet-stream"
|
||||
case ".svg":
|
||||
validCT = strings.Contains(dl, "image/svg+xml") || strings.Contains(dl, "xml") || strings.HasPrefix(dl, "text/")
|
||||
lower := strings.ToLower(string(buf[:n]))
|
||||
if strings.Contains(lower, "<script") || strings.Contains(lower, "onload=") || strings.Contains(lower, "javascript:") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsafe svg content"})
|
||||
return
|
||||
}
|
||||
case ".docx", ".xlsx", ".pptx":
|
||||
validCT = strings.Contains(dl, "officedocument") || strings.Contains(dl, "application/zip") || dl == "application/octet-stream"
|
||||
case ".doc", ".xls", ".ppt":
|
||||
validCT = strings.Contains(dl, "msword") || strings.Contains(dl, "vnd.ms-") || dl == "application/octet-stream"
|
||||
case ".zip":
|
||||
validCT = strings.Contains(dl, "zip") || dl == "application/octet-stream"
|
||||
case ".rar":
|
||||
validCT = strings.Contains(dl, "rar") || dl == "application/octet-stream"
|
||||
case ".7z":
|
||||
validCT = strings.Contains(dl, "7z") || dl == "application/octet-stream"
|
||||
case ".tar":
|
||||
validCT = strings.Contains(dl, "tar") || dl == "application/octet-stream"
|
||||
case ".gz":
|
||||
validCT = strings.Contains(dl, "gzip") || dl == "application/octet-stream"
|
||||
case ".txt", ".csv":
|
||||
validCT = strings.HasPrefix(dl, "text/") || dl == "application/octet-stream"
|
||||
case ".mp4", ".avi", ".mov":
|
||||
validCT = strings.HasPrefix(dl, "video/") || dl == "application/octet-stream"
|
||||
case ".mp3", ".wav":
|
||||
validCT = strings.HasPrefix(dl, "audio/") || dl == "application/octet-stream"
|
||||
default:
|
||||
validCT = strings.HasPrefix(dl, "image/")
|
||||
}
|
||||
if !validCT {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid content type for file"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dir := config.AppConfig.UploadDir
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
dir = "./uploads"
|
||||
}
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
randHex := hex.EncodeToString(b)
|
||||
outName := fmt.Sprintf("upload_%d_%s%s", time.Now().Unix(), randHex, ext)
|
||||
outPath := filepath.Join(dir, outName)
|
||||
if err := c.SaveUploadedFile(f, outPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save"})
|
||||
return
|
||||
}
|
||||
urlPath := "/uploads/" + outName
|
||||
ft := services.NewFileTracker(bc.DB)
|
||||
mimeType := f.Header.Get("Content-Type")
|
||||
_ = ft.TrackFileUpload(outPath, urlPath, outName, mimeType, f.Size, nil)
|
||||
|
||||
// Build absolute URL from request (supports proxies)
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || strings.EqualFold(c.Request.Header.Get("X-Forwarded-Proto"), "https") || strings.Contains(strings.ToLower(c.Request.Header.Get("CF-Visitor")), "https") {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if xf := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Host")); xf != "" {
|
||||
parts := strings.Split(xf, ",")
|
||||
if len(parts) > 0 {
|
||||
h := strings.TrimSpace(parts[0])
|
||||
if h != "" { host = h }
|
||||
}
|
||||
}
|
||||
if !strings.Contains(host, ":") {
|
||||
if xfp := strings.TrimSpace(c.Request.Header.Get("X-Forwarded-Port")); xfp != "" {
|
||||
if (scheme == "http" && xfp != "80") || (scheme == "https" && xfp != "443") {
|
||||
host = host + ":" + xfp
|
||||
}
|
||||
}
|
||||
}
|
||||
absolute := scheme + "://" + host + urlPath
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"url": urlPath,
|
||||
"absolute_url": absolute,
|
||||
"name": outName,
|
||||
"type": mimeType,
|
||||
"size": f.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// Global newsletter automation instance (set from main)
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
@@ -24,7 +27,54 @@ func (cc *CommentController) AdminListBans(c *gin.Context) {
|
||||
if err := cc.DB.Where("until IS NULL OR until > ?", now).Order("created_at DESC").Find(&bans).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error":"Failed to load bans"}); return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": bans})
|
||||
// Load users
|
||||
uids := make([]uint, 0, len(bans))
|
||||
seen := map[uint]bool{}
|
||||
for _, b := range bans { if !seen[b.UserID] { uids = append(uids, b.UserID); seen[b.UserID] = true } }
|
||||
type userRow struct { ID uint; FirstName string; LastName string; Email string; Role string }
|
||||
users := map[uint]userRow{}
|
||||
if len(uids) > 0 {
|
||||
var rows []userRow
|
||||
_ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error
|
||||
for _, r := range rows { users[r.ID] = r }
|
||||
}
|
||||
usernameByID := map[uint]string{}
|
||||
if len(uids) > 0 {
|
||||
type prof struct{ UserID uint; Username string }
|
||||
var profs []prof
|
||||
_ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error
|
||||
for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } }
|
||||
}
|
||||
type banOut struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
Until *time.Time `json:"until"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedByID uint `json:"created_by_id"`
|
||||
User struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Username string `json:"username,omitempty"`
|
||||
} `json:"user"`
|
||||
}
|
||||
out := make([]banOut, 0, len(bans))
|
||||
for _, b := range bans {
|
||||
o := banOut{ ID: b.ID, UserID: b.UserID, Reason: b.Reason, Until: b.Until, CreatedAt: b.CreatedAt, CreatedByID: b.CreatedByID }
|
||||
if u, ok := users[b.UserID]; ok {
|
||||
o.User.ID = u.ID
|
||||
o.User.FirstName = u.FirstName
|
||||
o.User.LastName = u.LastName
|
||||
o.User.Email = u.Email
|
||||
o.User.Role = u.Role
|
||||
}
|
||||
if v, ok := usernameByID[b.UserID]; ok { o.User.Username = v }
|
||||
out = append(out, o)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// Admin: lift a ban early by setting until = now
|
||||
@@ -74,11 +124,12 @@ func (cc *CommentController) React(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
uid, _ := c.Get("userID")
|
||||
// delete previous reaction for this user
|
||||
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uid).Delete(&models.CommentReaction{}).Error
|
||||
// create new
|
||||
// Upsert reaction to ensure exactly one reaction per (comment_id,user_id)
|
||||
r := models.CommentReaction{ CommentID: cm.ID, UserID: uid.(uint), Type: strings.TrimSpace(body.Type) }
|
||||
if err := cc.DB.Create(&r).Error; err != nil {
|
||||
if err := cc.DB.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{"type": r.Type, "updated_at": time.Now()}),
|
||||
}).Create(&r).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
|
||||
return
|
||||
}
|
||||
@@ -140,8 +191,71 @@ func (cc *CommentController) AdminList(c *gin.Context) {
|
||||
Scan(&rows).Error
|
||||
for _, r := range rows { adminLiked[r.CommentID] = true }
|
||||
}
|
||||
// Prepare target labels (titles) for admin visibility: articles and events
|
||||
articleIDs := make([]uint, 0)
|
||||
eventIDs := make([]uint, 0)
|
||||
for _, it := range items {
|
||||
switch it.TargetType {
|
||||
case "article":
|
||||
if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil {
|
||||
articleIDs = append(articleIDs, uint(v))
|
||||
}
|
||||
case "event":
|
||||
if v, err := strconv.ParseUint(strings.TrimSpace(it.TargetID), 10, 64); err == nil {
|
||||
eventIDs = append(eventIDs, uint(v))
|
||||
}
|
||||
}
|
||||
}
|
||||
articleTitleByID := map[uint]string{}
|
||||
if len(articleIDs) > 0 {
|
||||
type row struct{ ID uint; Title string }
|
||||
var rows []row
|
||||
_ = cc.DB.Table("articles").Select("id, title").Where("id IN ?", articleIDs).Scan(&rows).Error
|
||||
for _, r := range rows { articleTitleByID[r.ID] = r.Title }
|
||||
}
|
||||
eventTitleByID := map[uint]string{}
|
||||
if len(eventIDs) > 0 {
|
||||
type row struct{ ID uint; Title string }
|
||||
var rows []row
|
||||
_ = cc.DB.Table("events").Select("id, title").Where("id IN ?", eventIDs).Scan(&rows).Error
|
||||
for _, r := range rows { eventTitleByID[r.ID] = r.Title }
|
||||
}
|
||||
out := make([]commentOutput, 0, len(items))
|
||||
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) }
|
||||
for _, r := range items {
|
||||
co := toOutput(r)
|
||||
if v, ok := repCounts[r.ID]; ok { co.Reports = v }
|
||||
if adminLiked[r.ID] { co.AdminLiked = true }
|
||||
// Compose human label for target
|
||||
switch r.TargetType {
|
||||
case "article":
|
||||
if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil {
|
||||
if t, ok := articleTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" {
|
||||
co.TargetLabel = fmt.Sprintf("Článek: %s (#%s)", t, r.TargetID)
|
||||
} else {
|
||||
co.TargetLabel = fmt.Sprintf("Článek #%s", r.TargetID)
|
||||
}
|
||||
} else {
|
||||
co.TargetLabel = "Článek"
|
||||
}
|
||||
case "event":
|
||||
if v, err := strconv.ParseUint(strings.TrimSpace(r.TargetID), 10, 64); err == nil {
|
||||
if t, ok := eventTitleByID[uint(v)]; ok && strings.TrimSpace(t) != "" {
|
||||
co.TargetLabel = fmt.Sprintf("Aktivita: %s (#%s)", t, r.TargetID)
|
||||
} else {
|
||||
co.TargetLabel = fmt.Sprintf("Aktivita #%s", r.TargetID)
|
||||
}
|
||||
} else {
|
||||
co.TargetLabel = "Aktivita"
|
||||
}
|
||||
case "gallery_album":
|
||||
co.TargetLabel = fmt.Sprintf("Galerie album #%s", r.TargetID)
|
||||
case "youtube_video":
|
||||
co.TargetLabel = fmt.Sprintf("YouTube video %s", r.TargetID)
|
||||
default:
|
||||
co.TargetLabel = fmt.Sprintf("%s #%s", r.TargetType, r.TargetID)
|
||||
}
|
||||
out = append(out, co)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out, "total": total, "page": page, "page_size": size})
|
||||
}
|
||||
|
||||
@@ -181,9 +295,60 @@ func (cc *CommentController) CreateUnbanRequest(c *gin.Context) {
|
||||
|
||||
// Admin: list unban requests
|
||||
func (cc *CommentController) AdminListUnban(c *gin.Context) {
|
||||
// Only pending requests
|
||||
var items []models.UnbanRequest
|
||||
_ = cc.DB.Order("created_at DESC").Find(&items).Error
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
_ = cc.DB.Where("status = ?", "pending").Order("created_at DESC").Find(&items).Error
|
||||
// Load users and usernames
|
||||
uids := make([]uint, 0, len(items))
|
||||
seen := map[uint]bool{}
|
||||
for _, it := range items { if !seen[it.UserID] { uids = append(uids, it.UserID); seen[it.UserID] = true } }
|
||||
type userRow struct { ID uint; FirstName string; LastName string; Email string; Role string }
|
||||
users := map[uint]userRow{}
|
||||
if len(uids) > 0 {
|
||||
var rows []userRow
|
||||
_ = cc.DB.Table("users").Select("id, first_name, last_name, email, role").Where("id IN ?", uids).Scan(&rows).Error
|
||||
for _, r := range rows { users[r.ID] = r }
|
||||
}
|
||||
usernameByID := map[uint]string{}
|
||||
if len(uids) > 0 {
|
||||
type prof struct{ UserID uint; Username string }
|
||||
var profs []prof
|
||||
_ = cc.DB.Table("user_profiles").Select("user_id, username").Where("user_id IN ?", uids).Scan(&profs).Error
|
||||
for _, p := range profs { if strings.TrimSpace(p.Username) != "" { usernameByID[p.UserID] = p.Username } }
|
||||
}
|
||||
type unbanOut struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ResolvedByID *uint `json:"resolved_by_id,omitempty"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
User struct {
|
||||
ID uint `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Username string `json:"username,omitempty"`
|
||||
} `json:"user"`
|
||||
}
|
||||
out := make([]unbanOut, 0, len(items))
|
||||
for _, it := range items {
|
||||
var u userRow
|
||||
if r, ok := users[it.UserID]; ok { u = r }
|
||||
o := unbanOut{
|
||||
ID: it.ID, UserID: it.UserID, Message: it.Message, Status: it.Status, CreatedAt: it.CreatedAt, ResolvedByID: it.ResolvedByID, ResolvedAt: it.ResolvedAt,
|
||||
}
|
||||
o.User.ID = u.ID
|
||||
o.User.FirstName = u.FirstName
|
||||
o.User.LastName = u.LastName
|
||||
o.User.Email = u.Email
|
||||
o.User.Role = u.Role
|
||||
if v, ok := usernameByID[it.UserID]; ok { o.User.Username = v }
|
||||
out = append(out, o)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// Admin: resolve unban request
|
||||
@@ -218,6 +383,7 @@ type commentOutput struct {
|
||||
ID uint `json:"id"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
TargetLabel string `json:"target_label,omitempty"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -33,16 +33,93 @@ func (cc *ContactController) GetContactMessages(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
// Pagination
|
||||
page, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("page", "1")))
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
|
||||
if page <= 0 { page = 1 }
|
||||
if limit <= 0 || limit > 200 { limit = 50 }
|
||||
items, total, err := models.GetContactMessages(cc.DB, page, limit)
|
||||
if err != nil {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
// Filters
|
||||
search := strings.TrimSpace(c.DefaultQuery("search", ""))
|
||||
isReadParam := strings.TrimSpace(c.DefaultQuery("isRead", ""))
|
||||
var isRead *bool
|
||||
if isReadParam != "" {
|
||||
v := strings.ToLower(isReadParam)
|
||||
if v == "true" || v == "1" {
|
||||
t := true
|
||||
isRead = &t
|
||||
} else if v == "false" || v == "0" {
|
||||
f := false
|
||||
isRead = &f
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting (map UI fields to DB columns)
|
||||
sortBy := strings.TrimSpace(c.DefaultQuery("sortBy", "createdAt"))
|
||||
sortOrder := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sortOrder", "desc")))
|
||||
if sortOrder != "asc" {
|
||||
sortOrder = "desc"
|
||||
}
|
||||
sortField := "created_at"
|
||||
switch sortBy {
|
||||
case "name":
|
||||
sortField = "name"
|
||||
case "email":
|
||||
sortField = "email"
|
||||
case "subject":
|
||||
sortField = "subject"
|
||||
case "createdAt", "created_at":
|
||||
sortField = "created_at"
|
||||
}
|
||||
|
||||
// Build query
|
||||
q := cc.DB.Model(&models.ContactMessage{})
|
||||
if search != "" {
|
||||
s := "%" + strings.ToLower(search) + "%"
|
||||
q = q.Where("LOWER(name) LIKE ? OR LOWER(email) LIKE ? OR LOWER(subject) LIKE ? OR LOWER(message) LIKE ?", s, s, s, s)
|
||||
}
|
||||
if isRead != nil {
|
||||
q = q.Where("is_read = ?", *isRead)
|
||||
}
|
||||
|
||||
// Count total
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "page_size": limit})
|
||||
|
||||
// Fetch page
|
||||
var items []models.ContactMessage
|
||||
offset := (page - 1) * limit
|
||||
if err := q.Order(sortField+" "+sortOrder).Offset(offset).Limit(limit).Find(&items).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch messages"})
|
||||
return
|
||||
}
|
||||
|
||||
// Compute pages (ceil)
|
||||
pages := 1
|
||||
if limit > 0 {
|
||||
pages = (int(total) + limit - 1) / limit
|
||||
if pages == 0 {
|
||||
pages = 1
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": items,
|
||||
"pagination": gin.H{
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"pages": pages,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// MarkMessageAsRead marks a message as read (admin)
|
||||
@@ -75,19 +152,36 @@ func (cc *ContactController) recalcNewsletterAutomationEnabled() {
|
||||
_ = cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active).Error
|
||||
enabled := active > 0
|
||||
|
||||
// Persist flag
|
||||
// Persist flag and, when enabling for the first time, ensure weekly digest is active with sane defaults
|
||||
var s models.Settings
|
||||
_ = cc.DB.First(&s).Error
|
||||
if s.ID == 0 {
|
||||
s = models.Settings{}
|
||||
}
|
||||
changed := false
|
||||
if s.NewsletterEnabled != enabled {
|
||||
s.NewsletterEnabled = enabled
|
||||
if s.ID == 0 {
|
||||
_ = cc.DB.Create(&s).Error
|
||||
} else {
|
||||
_ = cc.DB.Save(&s).Error
|
||||
changed = true
|
||||
}
|
||||
if enabled {
|
||||
// Auto-activate weekly digest and preset schedule if not configured
|
||||
if !s.EnableWeekly {
|
||||
s.EnableWeekly = true
|
||||
changed = true
|
||||
}
|
||||
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
|
||||
s.NewsletterWeeklyDay = "sun"
|
||||
changed = true
|
||||
}
|
||||
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
|
||||
s.NewsletterWeeklyHour = 9
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if s.ID == 0 {
|
||||
_ = cc.DB.Create(&s).Error
|
||||
} else if changed {
|
||||
_ = cc.DB.Save(&s).Error
|
||||
}
|
||||
|
||||
// Update runtime
|
||||
@@ -736,6 +830,8 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
||||
var total, active int64
|
||||
cc.DB.Model(&models.NewsletterSubscription{}).Count(&total)
|
||||
cc.DB.Model(&models.NewsletterSubscription{}).Where("is_active = ?", true).Count(&active)
|
||||
var s models.Settings
|
||||
_ = cc.DB.First(&s).Error
|
||||
var subs []models.NewsletterSubscription
|
||||
_ = cc.DB.Where("is_active = ?", true).Limit(20).Find(&subs).Error
|
||||
sample := make([]string, 0, len(subs))
|
||||
@@ -751,6 +847,31 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
next := time.Now().Add(interval)
|
||||
// Compute next scheduled weekly time (exact), using settings (default Sun 09:00)
|
||||
weeklyDay := strings.ToLower(strings.TrimSpace(s.NewsletterWeeklyDay))
|
||||
if weeklyDay == "" { weeklyDay = "sun" }
|
||||
weeklyHour := s.NewsletterWeeklyHour
|
||||
if weeklyHour < 0 || weeklyHour > 23 { weeklyHour = 9 }
|
||||
// find next occurrence
|
||||
now := time.Now()
|
||||
target := time.Date(now.Year(), now.Month(), now.Day(), weeklyHour, 0, 0, 0, now.Location())
|
||||
toWD := func(d string) time.Weekday {
|
||||
switch d {
|
||||
case "mon": return time.Monday
|
||||
case "tue": return time.Tuesday
|
||||
case "wed": return time.Wednesday
|
||||
case "thu": return time.Thursday
|
||||
case "fri": return time.Friday
|
||||
case "sat": return time.Saturday
|
||||
default: return time.Sunday
|
||||
}
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
if target.Weekday() == toWD(weeklyDay) && target.After(now) {
|
||||
break
|
||||
}
|
||||
target = target.Add(24 * time.Hour)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total_subscribers": total,
|
||||
"active_subscribers": active,
|
||||
@@ -758,6 +879,16 @@ func (cc *ContactController) GetNewsletterStatus(c *gin.Context) {
|
||||
"interval_minutes": int(interval.Minutes()),
|
||||
"next_approximate": next,
|
||||
"newsletter_enabled": config.AppConfig != nil && config.AppConfig.NewsletterEnabled,
|
||||
// Scheduling detail
|
||||
"weekly_enabled": s.EnableWeekly,
|
||||
"weekly_day": weeklyDay,
|
||||
"weekly_hour": weeklyHour,
|
||||
"weekly_next_scheduled": target,
|
||||
"matches_enabled": s.EnableMatchReminders,
|
||||
"reminder_lead_hours": s.NewsletterReminderLeadHours,
|
||||
"results_enabled": s.EnableResults,
|
||||
"quiet_start": s.NewsletterQuietStart,
|
||||
"quiet_end": s.NewsletterQuietEnd,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,29 @@ type EngagementController struct {
|
||||
Email email.EmailService
|
||||
}
|
||||
|
||||
// parseMetaTime tries to parse time from metadata value which can be string (RFC3339 or YYYY-MM-DD) or numeric unix seconds.
|
||||
func parseMetaTime(v interface{}) time.Time {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" { return time.Time{} }
|
||||
if ts, err := time.Parse(time.RFC3339, s); err == nil { return ts }
|
||||
if ts, err := time.Parse("2006-01-02T15:04", s); err == nil { return ts }
|
||||
if ts, err := time.Parse("2006-01-02", s); err == nil { return ts }
|
||||
case float64:
|
||||
// JSON numbers decode to float64
|
||||
if t <= 0 { return time.Time{} }
|
||||
return time.Unix(int64(t), 0)
|
||||
case int64:
|
||||
if t <= 0 { return time.Time{} }
|
||||
return time.Unix(t, 0)
|
||||
case int:
|
||||
if t <= 0 { return time.Time{} }
|
||||
return time.Unix(int64(t), 0)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func NewEngagementController(db *gorm.DB, es email.EmailService) *EngagementController {
|
||||
return &EngagementController{DB: db, Email: es}
|
||||
}
|
||||
@@ -224,7 +247,31 @@ func (ec *EngagementController) GetRewards(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load rewards"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, items)
|
||||
// Filter by optional validity window in metadata (valid_from, valid_to). Also accept legacy expires_at as valid_to.
|
||||
now := time.Now()
|
||||
filtered := make([]models.RewardItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
// Mandatory unlock reward is always available
|
||||
if strings.EqualFold(strings.TrimSpace(it.Type), "avatar_upload_unlock") {
|
||||
filtered = append(filtered, it)
|
||||
continue
|
||||
}
|
||||
var startPtr, endPtr *time.Time
|
||||
if it.Metadata != nil {
|
||||
if v, ok := it.Metadata["valid_from"]; ok {
|
||||
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
|
||||
}
|
||||
if v, ok := it.Metadata["valid_to"]; ok {
|
||||
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
|
||||
} else if v2, ok2 := it.Metadata["expires_at"]; ok2 { // alias
|
||||
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
|
||||
}
|
||||
}
|
||||
if startPtr != nil && now.Before(*startPtr) { continue }
|
||||
if endPtr != nil && now.After(*endPtr) { continue }
|
||||
filtered = append(filtered, it)
|
||||
}
|
||||
c.JSON(http.StatusOK, filtered)
|
||||
}
|
||||
|
||||
// POST /api/v1/engagement/redeem (auth)
|
||||
@@ -252,6 +299,27 @@ func (ec *EngagementController) Redeem(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not active"})
|
||||
return
|
||||
}
|
||||
// Check validity window (metadata.valid_from/valid_to or expires_at)
|
||||
if item.Metadata != nil {
|
||||
var startPtr, endPtr *time.Time
|
||||
if v, ok := item.Metadata["valid_from"]; ok {
|
||||
if ts := parseMetaTime(v); !ts.IsZero() { startPtr = &ts }
|
||||
}
|
||||
if v, ok := item.Metadata["valid_to"]; ok {
|
||||
if ts := parseMetaTime(v); !ts.IsZero() { endPtr = &ts }
|
||||
} else if v2, ok2 := item.Metadata["expires_at"]; ok2 {
|
||||
if ts := parseMetaTime(v2); !ts.IsZero() { endPtr = &ts }
|
||||
}
|
||||
now := time.Now()
|
||||
if startPtr != nil && now.Before(*startPtr) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward is not currently available"})
|
||||
return
|
||||
}
|
||||
if endPtr != nil && now.After(*endPtr) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Reward validity has ended"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if item.Stock == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Out of stock"})
|
||||
return
|
||||
|
||||
@@ -228,7 +228,7 @@ func (ec *ErrorController) AdminListExternal(c *gin.Context) {
|
||||
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" }
|
||||
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
|
||||
}
|
||||
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
|
||||
u, err := url.Parse(base)
|
||||
@@ -263,7 +263,7 @@ func (ec *ErrorController) AdminGetExternal(c *gin.Context) {
|
||||
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" }
|
||||
if errorLocal { base = "http://127.0.0.1:8083/api/v1/admin" } else { base = "https://errors.tdvorak.dev/api/v1/admin" }
|
||||
}
|
||||
token := strings.TrimSpace(os.Getenv("ERROR_REVIEW_ADMIN_TOKEN"))
|
||||
u, err := url.Parse(base)
|
||||
|
||||
@@ -620,7 +620,7 @@ func (fc *FilesController) RefreshFileTracking(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "File tracking refreshed successfully",
|
||||
"message": "Sledování souborů bylo úspěšně aktualizováno",
|
||||
"stats": stats,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,35 +162,106 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var updates models.NavigationItem
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
// Bind into a generic map to know which fields are present (partial update)
|
||||
var raw map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update fields
|
||||
item.Label = updates.Label
|
||||
item.URL = updates.URL
|
||||
item.Icon = updates.Icon
|
||||
item.Type = updates.Type
|
||||
item.PageType = updates.PageType
|
||||
item.PageID = updates.PageID
|
||||
item.Visible = updates.Visible
|
||||
item.DisplayOrder = updates.DisplayOrder
|
||||
item.ParentID = updates.ParentID
|
||||
item.Target = updates.Target
|
||||
item.CSSClass = updates.CSSClass
|
||||
item.RequiresAuth = updates.RequiresAuth
|
||||
item.RequiresAdmin = updates.RequiresAdmin
|
||||
|
||||
if err := nc.DB.Save(&item).Error; err != nil {
|
||||
|
||||
// Allow-list of updatable fields and basic type normalization
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if v, ok := raw["label"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["label"] = s }
|
||||
}
|
||||
if v, ok := raw["url"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["url"] = s }
|
||||
}
|
||||
if v, ok := raw["icon"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["icon"] = s }
|
||||
}
|
||||
if v, ok := raw["type"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["type"] = s }
|
||||
}
|
||||
if v, ok := raw["page_type"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["page_type"] = s }
|
||||
}
|
||||
if v, ok := raw["page_id"]; ok {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
updates["page_id"] = int(t)
|
||||
case int:
|
||||
updates["page_id"] = t
|
||||
case int32:
|
||||
updates["page_id"] = int(t)
|
||||
case int64:
|
||||
updates["page_id"] = int(t)
|
||||
case nil:
|
||||
updates["page_id"] = nil
|
||||
}
|
||||
}
|
||||
if v, ok := raw["visible"]; ok {
|
||||
if b, ok2 := v.(bool); ok2 { updates["visible"] = b }
|
||||
}
|
||||
if v, ok := raw["display_order"]; ok {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
updates["display_order"] = int(t)
|
||||
case int:
|
||||
updates["display_order"] = t
|
||||
case int32:
|
||||
updates["display_order"] = int(t)
|
||||
case int64:
|
||||
updates["display_order"] = int(t)
|
||||
}
|
||||
}
|
||||
if v, ok := raw["parent_id"]; ok {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
updates["parent_id"] = int(t)
|
||||
case int:
|
||||
updates["parent_id"] = t
|
||||
case int32:
|
||||
updates["parent_id"] = int(t)
|
||||
case int64:
|
||||
updates["parent_id"] = int(t)
|
||||
case nil:
|
||||
updates["parent_id"] = nil
|
||||
}
|
||||
}
|
||||
if v, ok := raw["target"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["target"] = s }
|
||||
}
|
||||
if v, ok := raw["css_class"]; ok {
|
||||
if s, ok2 := v.(string); ok2 { updates["css_class"] = s }
|
||||
}
|
||||
if v, ok := raw["requires_auth"]; ok {
|
||||
if b, ok2 := v.(bool); ok2 { updates["requires_auth"] = b }
|
||||
}
|
||||
if v, ok := raw["requires_admin"]; ok {
|
||||
if b, ok2 := v.(bool); ok2 { updates["requires_admin"] = b }
|
||||
}
|
||||
|
||||
if len(updates) == 0 {
|
||||
// Nothing to update
|
||||
c.JSON(http.StatusOK, item)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.DB.Model(&item).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Failed to update navigation item",
|
||||
"details": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Reload to return consistent, fresh data
|
||||
if err := nc.DB.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Updated", "id": id})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
@@ -524,8 +595,8 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
|
||||
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 }
|
||||
// Kategorie admin page removed (categories derived from competition aliases)
|
||||
if err := createChild(obsah, "Komentáře", "comments", 2); err != nil { return err }
|
||||
|
||||
media, err := createCategory("Média")
|
||||
if err != nil { return err }
|
||||
|
||||
Reference in New Issue
Block a user