mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
388 lines
12 KiB
Go
388 lines
12 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"
|
|
"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})
|
|
}
|