package controllers import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "net/http" "net/url" "strings" "time" "fotbal-club/internal/config" "fotbal-club/internal/models" "fotbal-club/internal/services" "github.com/gin-gonic/gin" "gorm.io/gorm" ) type ShortLinkController struct { DB *gorm.DB } func NewShortLinkController(db *gorm.DB) *ShortLinkController { return &ShortLinkController{DB: db} } func randCode(n int) (string, error) { alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" b := make([]byte, n) if _, err := rand.Read(b); err != nil { return "", err } for i := range b { b[i] = alphabet[int(b[i])%len(alphabet)] } return string(b), nil } func clientIP(c *gin.Context) string { if xff := c.GetHeader("X-Forwarded-For"); xff != "" { parts := strings.Split(xff, ",") return strings.TrimSpace(parts[0]) } if xr := c.GetHeader("X-Real-IP"); xr != "" { return xr } return c.ClientIP() } func hashIPShort(ip string) string { salted := ip + "_fotbal_club_2025" h := sha256.Sum256([]byte(salted)) return hex.EncodeToString(h[:]) } func getScheme(c *gin.Context) string { if p := c.GetHeader("X-Forwarded-Proto"); p != "" { return p } if c.Request.TLS != nil { return "https" } return "http" } func parseTarget(raw string) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", errors.New("empty url") } // allow base64-encoded form as well if !(strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://")) { if dec, err := base64.URLEncoding.DecodeString(raw); err == nil { raw = string(dec) } } u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return "", errors.New("invalid url") } return u.String(), nil } func (s *ShortLinkController) RedirectShort(c *gin.Context) { code := strings.TrimSpace(c.Param("code")) if code == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing code"}) return } var link models.ShortLink err := s.DB.Where("code = ? AND active = ?", code, true).First(&link).Error if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } if link.ExpiresAt != nil && time.Now().After(*link.ExpiresAt) { c.JSON(http.StatusNotFound, gin.H{"error": "expired"}) return } _ = s.DB.Model(&link).UpdateColumn("click_count", gorm.Expr("click_count + 1")).Error // Record click u, _ := url.Parse(link.TargetURL) click := models.LinkClick{ ShortLinkID: &link.ID, TargetURL: link.TargetURL, IPHash: hashIPShort(clientIP(c)), UserAgent: c.GetHeader("User-Agent"), Referrer: c.GetHeader("Referer"), UTMSource: u.Query().Get("utm_source"), UTMMedium: u.Query().Get("utm_medium"), UTMCampaign: u.Query().Get("utm_campaign"), UTMContent: u.Query().Get("utm_content"), UTMTerm: u.Query().Get("utm_term"), } _ = s.DB.Create(&click).Error // Umami event (best-effort) cfg := config.AppConfig if cfg != nil && cfg.UmamiURL != "" && cfg.UmamiWebsiteID != "" { svc := services.NewUmamiService() _ = svc.SendEvent(cfg.UmamiWebsiteID, "ShortLink Click", "/s/"+code, link.Title, map[string]any{"code": code, "target": link.TargetURL}, "web") } c.Redirect(http.StatusFound, link.TargetURL) } func (s *ShortLinkController) RedirectAndTrack(c *gin.Context) { raw := strings.TrimSpace(c.Query("u")) target, err := parseTarget(raw) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid url"}) return } u, _ := url.Parse(target) click := models.LinkClick{ TargetURL: target, IPHash: hashIPShort(clientIP(c)), UserAgent: c.GetHeader("User-Agent"), Referrer: c.GetHeader("Referer"), UTMSource: u.Query().Get("utm_source"), UTMMedium: u.Query().Get("utm_medium"), UTMCampaign: u.Query().Get("utm_campaign"), UTMContent: u.Query().Get("utm_content"), UTMTerm: u.Query().Get("utm_term"), } _ = s.DB.Create(&click).Error cfg := config.AppConfig if cfg != nil && cfg.UmamiURL != "" && cfg.UmamiWebsiteID != "" { svc := services.NewUmamiService() _ = svc.SendEvent(cfg.UmamiWebsiteID, "Link Redirect", "/r", "Link Redirect", map[string]any{"target": target}, "web") } c.Redirect(http.StatusFound, target) } func (s *ShortLinkController) CreateShortLink(c *gin.Context) { var body struct { TargetURL string `json:"target_url"` Title string `json:"title"` SourceType string `json:"source_type"` SourceID *uint `json:"source_id"` ExpiresAt *time.Time `json:"expires_at"` Code string `json:"code"` Active *bool `json:"active"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } target, err := parseTarget(body.TargetURL) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) return } code := strings.TrimSpace(body.Code) if code == "" { for i := 0; i < 5; i++ { cnd, _ := randCode(7) var cnt int64 s.DB.Model(&models.ShortLink{}).Where("code = ?", cnd).Count(&cnt) if cnt == 0 { code = cnd break } } } if code == "" { c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot generate code"}) return } active := true if body.Active != nil { active = *body.Active } link := models.ShortLink{ Code: code, TargetURL: target, Title: strings.TrimSpace(body.Title), SourceType: strings.TrimSpace(body.SourceType), SourceID: body.SourceID, Active: active, ExpiresAt: body.ExpiresAt, } if err := s.DB.Create(&link).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"}) return } scheme := getScheme(c) host := c.Request.Host shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, link.Code) c.JSON(http.StatusOK, gin.H{"id": link.ID, "code": link.Code, "short_url": shortURL, "link": link}) } func (s *ShortLinkController) ListShortLinks(c *gin.Context) { var items []models.ShortLink _ = s.DB.Order("created_at DESC").Limit(200).Find(&items).Error c.JSON(http.StatusOK, gin.H{"items": items}) } func (s *ShortLinkController) GetShortLinkStats(c *gin.Context) { id := strings.TrimSpace(c.Param("id")) if id == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "missing id"}); return } var link models.ShortLink if err := s.DB.First(&link, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}); return } start := time.Now().AddDate(0,0,-30) type Row struct{ Date string `json:"date"`; Count int64 `json:"count"` } var rows []Row s.DB.Model(&models.LinkClick{}). Select("DATE(created_at) as date, COUNT(*) as count"). Where("short_link_id = ? AND created_at >= ?", link.ID, start). Group("DATE(created_at)").Order("date ASC").Scan(&rows) var refRows []struct{ Referrer string; Count int64 } s.DB.Model(&models.LinkClick{}). Select("referrer, COUNT(*) as count"). Where("short_link_id = ? AND created_at >= ?", link.ID, start). Group("referrer").Order("count DESC").Limit(20).Scan(&refRows) var utmRows []struct{ Source, Medium, Campaign string; Count int64 } s.DB.Model(&models.LinkClick{}). Select("utm_source as source, utm_medium as medium, utm_campaign as campaign, COUNT(*) as count"). Where("short_link_id = ? AND created_at >= ?", link.ID, start). Group("utm_source, utm_medium, utm_campaign").Order("count DESC").Limit(50).Scan(&utmRows) c.JSON(http.StatusOK, gin.H{"timeseries": rows, "referrers": refRows, "utms": utmRows}) }