mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 18:52:56 +00:00
247 lines
7.4 KiB
Go
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})
|
|
}
|