Files
MyClub/internal/controllers/shortlink_controller.go
T
Tomas Dvorak 823fabee02 de day #74
2025-10-28 22:38:27 +01:00

247 lines
7.4 KiB
Go

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})
}