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)
|
||||
|
||||
Reference in New Issue
Block a user