This commit is contained in:
Tomas Dvorak
2025-10-28 22:38:27 +01:00
parent 3d621e2187
commit 823fabee02
106 changed files with 9011 additions and 3930 deletions
+2
View File
@@ -146,6 +146,8 @@ func MigrateDB(db *gorm.DB) error {
&models.EventAttachment{},
&models.UploadedFile{},
&models.FileUsage{},
&models.ShortLink{},
&models.LinkClick{},
)
}
+65 -11
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"reflect"
"strconv"
"strings"
@@ -36,6 +37,52 @@ type EmailData struct {
FromName string
}
// rewriteLinksForTracking wraps all http/https and site-relative links in the provided HTML
// with the tracking redirect that includes the email log id (m) and token (t).
// - makeAbs builds absolute URLs against PublicAPIBaseURL
// - frontendBase is the absolute frontend base (e.g., https://club.cz)
// - publicAPIBase is the absolute API base (e.g., https://api.club.cz/api/v1)
func rewriteLinksForTracking(htmlIn string, makeAbs func(string, url.Values) string, logID int, token string, frontendBase string, publicAPIBase string) string {
if strings.TrimSpace(htmlIn) == "" {
return htmlIn
}
aTagRE := regexp.MustCompile(`(?i)<a\b[^>]*href=["'][^"']+["'][^>]*>`)
hrefRE := regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
isTrackedPrefix := strings.TrimSuffix(publicAPIBase, "/") + "/email/click"
return aTagRE.ReplaceAllStringFunc(htmlIn, func(anchor string) string {
m := hrefRE.FindStringSubmatch(anchor)
if len(m) < 2 {
return anchor
}
href := strings.TrimSpace(m[1])
lower := strings.ToLower(href)
if href == "" || href == "#" || strings.HasPrefix(lower, "mailto:") || strings.HasPrefix(lower, "tel:") || strings.HasPrefix(lower, "javascript:") {
return anchor
}
// Skip if already tracked
if strings.HasPrefix(href, isTrackedPrefix) {
return anchor
}
// Build absolute target
var target string
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
target = href
} else if strings.HasPrefix(href, "/") {
target = strings.TrimSuffix(frontendBase, "/") + href
} else {
target = strings.TrimSuffix(frontendBase, "/") + "/" + href
}
tracked := makeAbs("/email/click", url.Values{
"m": {fmt.Sprintf("%d", logID)},
"t": {token},
"u": {target},
})
newAttr := `href="` + tracked + `"`
return hrefRE.ReplaceAllString(anchor, newAttr)
})
}
type EmailService interface {
SendEmail(data *EmailData) error
SendContactForm(data *ContactFormData) error
@@ -1005,53 +1052,60 @@ func (s *emailService) SendNewsletter(data *NewsletterData) error {
Subject: data.Subject,
Content: data.Content,
Date: time.Now().Format("02. 01. 2006"),
UnsubscribeURL: makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {unsubscribePath}}),
ManageURL: makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {manageURL}}),
UnsubscribeURL: makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {unsubscribePath}}),
ManageURL: makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {manageURL}}),
ClubName: clubName,
ClubLogoURL: clubLogo,
PrimaryColor: primaryColor,
SecondaryColor: secondaryColor,
AccentColor: accentColor,
// socials assigned below
OpenPixelURL: makeAbs("/api/v1/email/open.gif", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}}),
OpenPixelURL: makeAbs("/email/open.gif", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}}),
WebsiteURL: func() string {
if siteURL == "" {
return ""
}
return makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {siteURL}})
return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {siteURL}})
}(),
ClubURL: func() string {
if clubURL == "" {
return ""
}
return makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {clubURL}})
return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {clubURL}})
}(),
ContactEmail: contactEmail,
ContactURL: func() string {
if contactURL == "" {
return ""
}
return makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {contactURL}})
return makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {contactURL}})
}(),
}
// Wrap socials if present
if v := getStringField(set, "FacebookURL"); v != "" {
personalTemplateData.FacebookURL = makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
personalTemplateData.FacebookURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
}
if v := getStringField(set, "InstagramURL"); v != "" {
personalTemplateData.InstagramURL = makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
personalTemplateData.InstagramURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
}
if v := getStringField(set, "YouTubeURL"); v != "" {
personalTemplateData.YouTubeURL = makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
personalTemplateData.YouTubeURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
}
if personalTemplateData.YouTubeURL == "" {
if v := getStringField(set, "YoutubeURL"); v != "" {
personalTemplateData.YouTubeURL = makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
personalTemplateData.YouTubeURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
}
}
if v := getStringField(set, "TwitterURL"); v != "" {
personalTemplateData.TwitterURL = makeAbs("/api/v1/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
personalTemplateData.TwitterURL = makeAbs("/email/click", url.Values{"m": {fmt.Sprintf("%d", elog.ID)}, "t": {trackTok}, "u": {v}})
}
// Rewrite links in Content to include tracking (per recipient)
frontendBase := strings.TrimSuffix(s.config.FrontendBaseURL, "/")
publicAPIBase := strings.TrimSuffix(s.config.PublicAPIBaseURL, "/")
if strings.TrimSpace(personalTemplateData.Content) != "" {
personalTemplateData.Content = rewriteLinksForTracking(personalTemplateData.Content, makeAbs, int(elog.ID), trackTok, frontendBase, publicAPIBase)
}
emailData := &EmailData{
+9 -1
View File
@@ -3,6 +3,7 @@ package utils
import (
"errors"
"os"
"strconv"
"strings"
"time"
@@ -20,7 +21,14 @@ type JWTClaims struct {
// GenerateJWT generates a new JWT token for the given user
func GenerateJWT(userID uint, email, role string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
// Respect configurable expiration via JWT_EXPIRATION_HOURS; default 24h
expHours := 24
if v := os.Getenv("JWT_EXPIRATION_HOURS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 24*365 {
expHours = n
}
}
expirationTime := time.Now().Add(time.Duration(expHours) * time.Hour)
claims := &JWTClaims{
UserID: userID,