dev day #90 🥳

This commit is contained in:
Tomas Dvorak
2025-11-12 20:31:37 +01:00
parent 8762bde4bf
commit f3db65d350
103 changed files with 4053 additions and 2189 deletions
+360 -196
View File
@@ -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)