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" "gorm.io/gorm/clause" ) type ShortLinkController struct { DB *gorm.DB } // PublicCreateShortLink creates (or upserts) a short link for a given target URL. // Restrictions: only allows shortening links pointing to this site (request host) // or to the configured FrontendBaseURL. Intended for visitor share/copy flows. func (s *ShortLinkController) PublicCreateShortLink(c *gin.Context) { var body struct { TargetURL string `json:"target_url"` Title string `json:"title"` } 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 } tu, _ := url.Parse(target) if tu == nil || tu.Host == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_url"}) return } // Allow only same-site or configured frontend host reqHost := c.Request.Host stripPort := func(h string) string { if i := strings.IndexByte(h, ':'); i >= 0 { return h[:i] } return h } allowed := stripPort(tu.Host) == stripPort(reqHost) if !allowed && config.AppConfig != nil && strings.TrimSpace(config.AppConfig.FrontendBaseURL) != "" { if fu, err := url.Parse(config.AppConfig.FrontendBaseURL); err == nil && fu.Host != "" { if stripPort(fu.Host) == stripPort(tu.Host) { allowed = true } } } if !allowed { c.JSON(http.StatusForbidden, gin.H{"error": "target host not allowed"}) return } // Deterministic code from URL so repeated calls return same shortlink code := "p-" + codeFromHash(target, 7) link := models.ShortLink{ Code: code, TargetURL: target, Title: strings.TrimSpace(body.Title), Active: true, } if err := s.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "code"}}, DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "active", "updated_at"}), }).Create(&link).Error; err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()}) return } var saved models.ShortLink if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil { saved = link } scheme := getScheme(c) host := c.Request.Host shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code) c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved}) } 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 codeFromHash(s string, n int) string { if n <= 0 { n = 7 } alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" sum := sha256.Sum256([]byte(s)) out := make([]byte, n) for i := 0; i < n; i++ { b := sum[i%len(sum)] out[i] = alphabet[int(b)%len(alphabet)] } return string(out) } func sanitizeCode(in string) string { s := strings.TrimSpace(in) if s == "" { return "" } // filter allowed runes rb := make([]rune, 0, len(s)) for _, ch := range s { if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' { rb = append(rb, ch) } } if len(rb) == 0 { return "" } if len(rb) > 16 { rb = rb[:16] } return string(rb) } 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) } } // Try as-is first if u, err := url.Parse(raw); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" { return u.String(), nil } // If scheme is missing, try https:// fallback, then http:// if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { if u, err := url.Parse("https://" + raw); err == nil && u.Host != "" { return u.String(), nil } if u, err := url.Parse("http://" + raw); err == nil && u.Host != "" { return u.String(), nil } } return "", errors.New("invalid url") } 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 := sanitizeCode(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, } // Upsert on code to avoid duplicate errors and keep link stable across regenerations if err := s.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "code"}}, DoUpdates: clause.AssignmentColumns([]string{"target_url", "title", "source_type", "source_id", "active", "expires_at", "updated_at"}), }).Create(&link).Error; err != nil { // Return database error message for easier debugging (non-sensitive) c.JSON(http.StatusBadRequest, gin.H{"error": "cannot save shortlink", "details": err.Error()}) return } // Ensure we return the saved record (ID will be empty on update path) var saved models.ShortLink if err := s.DB.Where("code = ?", code).First(&saved).Error; err != nil { // Fallback to in-memory link if fetch fails saved = link } scheme := getScheme(c) host := c.Request.Host shortURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, code) c.JSON(http.StatusOK, gin.H{"id": saved.ID, "code": code, "short_url": shortURL, "link": saved}) } 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}) }