dev day #100 - WE ARE FUCKING DONE, hotfixes incoming but we did it in 100 days, lets fucking go guys, anyone reading this...i love you

This commit is contained in:
Tomas Dvorak
2025-11-22 21:30:10 +01:00
parent f5b6f83974
commit aa036b6550
47 changed files with 3607 additions and 2177 deletions
+25 -13
View File
@@ -567,6 +567,9 @@ func (bc *BaseController) GetMatchesHistory(c *gin.Context) {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
}
if ov.ScoreOverride != nil {
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
}
@@ -689,6 +692,9 @@ func (bc *BaseController) GetMatches(c *gin.Context) {
m["date_time"] = ov.DateTimeOverride.Format(time.RFC3339)
m["date"] = ov.DateTimeOverride.Format("2006-01-02 15:04")
}
if ov.ScoreOverride != nil {
m["score"] = strings.TrimSpace(*ov.ScoreOverride)
}
if ov.HomeLogoURL != nil {
m["home_logo_url"] = *ov.HomeLogoURL
}
@@ -901,7 +907,8 @@ func (bc *BaseController) GetZoneramaAlbum(c *gin.Context) {
photoLimit := strings.TrimSpace(c.DefaultQuery("photo_limit", "24"))
rendered := strings.TrimSpace(c.DefaultQuery("rendered", "true"))
// Build external URL
api := "https://zonerama.tdvorak.dev/zonerama-album?link=" + url.QueryEscape(link)
base := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
api := base + "/zonerama-album?link=" + url.QueryEscape(link)
if photoLimit != "" {
api += "&photo_limit=" + url.QueryEscape(photoLimit)
}
@@ -2471,30 +2478,18 @@ func (bc *BaseController) PutMatchOverride(c *gin.Context) {
c.JSON(http.StatusOK, item)
}
// PatchMatchOverride partially updates fields of an override by external_match_id
func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
extID := c.Param("external_match_id")
if extID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Chyba external_match_id"})
return
}
var item models.MatchOverride
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Override nenalezen"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
return
}
var body map[string]interface{}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Prevent changing the key
delete(body, "external_match_id")
// Normalize date_time_override to *time.Time if provided as string
if v, ok := body["date_time_override"]; ok {
switch vv := v.(type) {
case string:
@@ -2513,6 +2508,23 @@ func (bc *BaseController) PatchMatchOverride(c *gin.Context) {
}
}
}
var item models.MatchOverride
if err := bc.DB.Where("external_match_id = ?", extID).First(&item).Error; err != nil {
if err == gorm.ErrRecordNotFound {
attrs := map[string]interface{}{"external_match_id": extID}
for k, v := range body {
attrs[k] = v
}
if err := bc.DB.Where("external_match_id = ?", extID).Assign(attrs).FirstOrCreate(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze vytvořit záznam"})
return
}
c.JSON(http.StatusOK, item)
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Chyba databáze"})
return
}
if err := bc.DB.Model(&item).Updates(body).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Nelze uložit změny"})
return
+48 -9
View File
@@ -10,7 +10,6 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fotbal-club/internal/models"
"fotbal-club/internal/services"
@@ -166,14 +165,37 @@ func (cc *CommentController) React(c *gin.Context) {
}
uidv, _ := c.Get("userID")
userID := uidv.(uint)
var userID uint
switch v := uidv.(type) {
case uint:
userID = v
case int:
if v > 0 { userID = uint(v) }
case int64:
if v > 0 { userID = uint(v) }
case float64:
if v > 0 { userID = uint(v) }
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
}
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Atomic upsert: enforce single reaction per (comment_id, user_id)
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt}
if err := cc.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "comment_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{"type": rt, "updated_at": time.Now()}),
}).Create(&r).Error; err != nil {
// Robust upsert without relying on a DB unique constraint: delete then insert in a transaction
if err := cc.DB.Transaction(func(tx *gorm.DB) error {
// Remove any previous reaction by this user on this comment
if err := tx.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error; err != nil {
return err
}
// Insert the new reaction
r := models.CommentReaction{CommentID: cm.ID, UserID: userID, Type: rt}
if err := tx.Create(&r).Error; err != nil {
return err
}
return nil
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to react"})
return
}
@@ -194,7 +216,24 @@ func (cc *CommentController) Unreact(c *gin.Context) {
// Ensure reactions table exists (best-effort)
_ = cc.DB.AutoMigrate(&models.CommentReaction{})
uidv, _ := c.Get("userID")
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, uidv.(uint)).Delete(&models.CommentReaction{}).Error
var userID uint
switch v := uidv.(type) {
case uint:
userID = v
case int:
if v > 0 { userID = uint(v) }
case int64:
if v > 0 { userID = uint(v) }
case float64:
if v > 0 { userID = uint(v) }
case string:
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { userID = uint(n) }
}
if userID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
_ = cc.DB.Where("comment_id = ? AND user_id = ?", cm.ID, userID).Delete(&models.CommentReaction{}).Error
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+125 -14
View File
@@ -180,6 +180,28 @@ func (cc *ContactController) recalcNewsletterAutomationEnabled() {
s.NewsletterWeeklyHour = 9
changed = true
}
// Auto-activate match reminders with sane defaults if not configured
if !s.EnableMatchReminders {
s.EnableMatchReminders = true
changed = true
}
if s.NewsletterReminderLeadHours <= 0 {
s.NewsletterReminderLeadHours = 48 // 48h before kickoff
changed = true
}
// Auto-activate match results notifications and default quiet hours if missing
if !s.EnableResults {
s.EnableResults = true
changed = true
}
// Only set quiet hours if both are unset (0,0) to avoid overriding admin-configured values
if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 {
s.NewsletterQuietStart = 22 // 22:00
s.NewsletterQuietEnd = 8 // 08:00
changed = true
}
}
if s.ID == 0 {
_ = cc.DB.Create(&s).Error
@@ -511,12 +533,6 @@ func (cc *ContactController) SubscribeToNewsletter(c *gin.Context) {
}
}
}
// Additionally, send a minimal confirmation using newsletter template with manage link (best-effort)
_ = cc.emailService.SendNewsletter(&email.NewsletterData{
Subject: "Vítejte v odběru",
Content: fmt.Sprintf("<p>Děkujeme za přihlášení. Spravujte své preference <a href=\"%s\">zde</a>.</p>", manageURL),
Recipients: []string{emailStr},
})
// Recalculate automation after (re)subscription
cc.recalcNewsletterAutomationEnabled()
c.JSON(http.StatusOK, gin.H{"message": "Subscribed"})
@@ -700,21 +716,89 @@ func (cc *ContactController) SubmitContactForm(c *gin.Context) {
return
}
go func(nm, em, subj, msgBody, ipAddr, agent string) {
go func(m models.ContactMessage) {
// 1) Notify primary contact(s) (club contact email / env fallbacks)
_ = cc.emailService.SendContactForm(&email.ContactFormData{
Name: nm,
Email: em,
Subject: subj,
Message: msgBody,
IPAddress: ipAddr,
UserAgent: agent,
Name: m.Name,
Email: m.Email,
Subject: m.Subject,
Message: m.Message,
IPAddress: m.IPAddress,
UserAgent: m.UserAgent,
})
}(name, emailStr, subject, message, ip, ua)
// 2) Auto-forward to configured list when enabled
var set models.Settings
if err := cc.DB.First(&set).Error; err == nil && set.ContactForwardEnabled && strings.TrimSpace(set.ContactForwardList) != "" {
// Build recipient list from ContactForwardList (comma/semicolon/space separated)
parts := strings.FieldsFunc(set.ContactForwardList, func(r rune) bool { return r == ',' || r == ';' || r == ' ' || r == '\n' || r == '\t' })
uniq := make(map[string]struct{})
dest := make([]string, 0, len(parts))
// Exclude addresses that already received the primary notification (contact/admin emails)
exclude := map[string]struct{}{}
if v := strings.ToLower(strings.TrimSpace(set.ContactEmail)); v != "" {
exclude[v] = struct{}{}
}
if config.AppConfig != nil {
if v := strings.ToLower(strings.TrimSpace(config.AppConfig.ContactEmail)); v != "" {
exclude[v] = struct{}{}
}
if v := strings.ToLower(strings.TrimSpace(config.AppConfig.AdminEmail)); v != "" {
exclude[v] = struct{}{}
}
}
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
lv := strings.ToLower(v)
if _, ok := uniq[lv]; ok {
continue
}
if _, skip := exclude[lv]; skip {
continue
}
uniq[lv] = struct{}{}
dest = append(dest, v)
}
if len(dest) > 0 {
fwd := &email.EmailData{
Subject: fmt.Sprintf("Přeposláno: Kontaktní formulář - %s", strings.TrimSpace(m.Subject)),
To: dest,
Template: "contact_form",
Data: struct {
Name string
Email string
Subject string
Message string
Time string
IP string
Agent string
}{
Name: m.Name,
Email: m.Email,
Subject: m.Subject,
Message: m.Message,
Time: m.CreatedAt.Format(time.RFC1123Z),
IP: m.IPAddress,
Agent: m.UserAgent,
},
}
if err := cc.emailService.SendEmail(fwd); err != nil {
logger.Error("Auto-forward of contact message %d failed: %v", m.ID, err)
} else {
logger.Info("Auto-forwarded contact message %d to %v", m.ID, dest)
}
}
}
}(msg)
c.JSON(http.StatusOK, gin.H{"message": "Message received", "id": msg.ID})
}
func (cc *ContactController) AdminSmtpTest(c *gin.Context) {
// ... rest of the code remains the same ...
if c.GetString("userRole") != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
@@ -904,6 +988,33 @@ func (cc *ContactController) UpdateNewsletterAutomation(c *gin.Context) {
s = models.Settings{}
}
s.NewsletterEnabled = input.Enabled
// If enabling, ensure defaults for weekly/matches/results are set like auto-recalc does
if input.Enabled {
if !s.EnableWeekly {
s.EnableWeekly = true
}
if strings.TrimSpace(s.NewsletterWeeklyDay) == "" {
s.NewsletterWeeklyDay = "sun"
}
if s.NewsletterWeeklyHour < 0 || s.NewsletterWeeklyHour > 23 {
s.NewsletterWeeklyHour = 9
}
if !s.EnableMatchReminders {
s.EnableMatchReminders = true
}
if s.NewsletterReminderLeadHours <= 0 {
s.NewsletterReminderLeadHours = 48
}
if !s.EnableResults {
s.EnableResults = true
}
if s.NewsletterQuietStart == 0 && s.NewsletterQuietEnd == 0 {
s.NewsletterQuietStart = 22
s.NewsletterQuietEnd = 8
}
}
if s.ID == 0 {
if err := cc.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist setting"})
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -5,11 +5,13 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"fotbal-club/internal/config"
"fotbal-club/internal/services"
"fotbal-club/pkg/logger"
@@ -149,9 +151,10 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
body.PhotoLimit = 50 // Default to 50 photos per album
}
// Call external API
apiURL := fmt.Sprintf("https://zonerama.tdvorak.dev/zonerama-album?link=%s&photo_limit=%d",
body.Link, body.PhotoLimit)
// Call external API (configurable base)
apiBase := strings.TrimSuffix(config.AppConfig.ZoneramaAPIBase, "/")
apiURL := fmt.Sprintf("%s/zonerama-album?link=%s&photo_limit=%d",
apiBase, url.QueryEscape(body.Link), body.PhotoLimit)
logger.Info("Fetching album from Zonerama API: %s", apiURL)
@@ -242,13 +245,13 @@ func (gc *GalleryController) FetchAlbum(c *gin.Context) {
}
logger.Info("Album %s saved successfully with %d photos", albumData.ID, len(albumData.Photos))
// Regenerate flat gallery files for frontend consumption
if err := services.RegenerateFlatGalleryFiles(); err != nil {
logger.Error("Failed to regenerate flat gallery files: %v", err)
// Don't fail the request, just log the error
}
c.JSON(http.StatusOK, gin.H{
"message": "Album fetched and saved successfully",
"album": albumData,
@@ -300,13 +303,13 @@ func (gc *GalleryController) DeleteAlbum(c *gin.Context) {
}
logger.Info("Deleted album: %s", albumID)
// Regenerate flat gallery files for frontend consumption
if err := services.RegenerateFlatGalleryFiles(); err != nil {
logger.Error("Failed to regenerate flat gallery files: %v", err)
// Don't fail the request, just log the error
}
c.JSON(http.StatusOK, gin.H{"message": "Album deleted successfully"})
}
@@ -316,27 +319,27 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) {
var settings struct {
GalleryURL string `json:"gallery_url"`
}
if err := gc.DB.Table("settings").Select("gallery_url").First(&settings).Error; err != nil {
logger.Error("Failed to load settings: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load gallery settings"})
return
}
zoneramaURL := strings.TrimSpace(settings.GalleryURL)
if zoneramaURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Zonerama URL is not configured in settings"})
return
}
// Validate it's a Zonerama URL
if !strings.Contains(strings.ToLower(zoneramaURL), "zonerama.com") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Configured gallery URL is not a Zonerama URL"})
return
}
logger.Info("Triggering Zonerama refresh from: %s", zoneramaURL)
// Call the refresh service in a goroutine to avoid blocking
go func() {
if err := services.RefreshZoneramaNow(zoneramaURL); err != nil {
@@ -349,7 +352,7 @@ func (gc *GalleryController) RefreshFromZonerama(c *gin.Context) {
}
}
}()
c.JSON(http.StatusOK, gin.H{
"message": "Zonerama refresh started",
"url": zoneramaURL,
+77 -1
View File
@@ -262,6 +262,11 @@ func (nc *NavigationController) UpdateNavigationItem(c *gin.Context) {
updates["requires_admin"] = b
}
}
if v, ok := raw["allow_editor"]; ok {
if b, ok2 := v.(bool); ok2 {
updates["allow_editor"] = b
}
}
if len(updates) == 0 {
// Nothing to update
@@ -372,6 +377,72 @@ func (nc *NavigationController) GetSocialLinks(c *gin.Context) {
c.JSON(http.StatusOK, links)
}
// GetEditorAllowedAdminNav returns admin navigation items that are explicitly allowed for editors
// Top-level items are included only when:
// - type != dropdown and allow_editor = true (and visible = true), or
// - type == dropdown and it has at least one child with allow_editor = true (and visible = true)
//
// Children are filtered to allow_editor = true and visible = true
func (nc *NavigationController) GetEditorAllowedAdminNav(c *gin.Context) {
var top []models.NavigationItem
// Load all top-level admin items (categories and direct items)
if err := nc.DB.Where("parent_id IS NULL AND requires_admin = ? AND visible = ?", true, true).
Order("display_order ASC").
Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("requires_admin = ? AND visible = ? AND allow_editor = ?", true, true, true).Order("display_order ASC")
}).
Find(&top).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch editor navigation"})
return
}
// Filter according to allow_editor rules
out := make([]models.NavigationItem, 0, len(top))
// Only allow a curated set of admin pages that have editor-capable APIs
allowed := map[string]bool{
"articles": true,
"activities": true,
"shortlinks": true,
}
for i := range top {
it := top[i]
include := false
if it.Type == models.NavTypeDropdown {
// Filter children by page_type allow-list (children already have allow_editor=true from preload)
if len(it.Children) > 0 {
children := make([]models.NavigationItem, 0, len(it.Children))
for _, ch := range it.Children {
if allowed[ch.PageType] {
// ensure URL is set
if ch.URL == "" {
ch.URL = ch.GetURL()
}
children = append(children, ch)
}
}
it.Children = children
if len(it.Children) > 0 {
include = true
}
}
} else {
// direct admin page: include only when marked allow_editor
if it.AllowEditor && allowed[it.PageType] {
include = true
}
}
if include {
// Ensure URLs are computed
if it.URL == "" {
it.URL = it.GetURL()
}
out = append(out, it)
}
}
c.JSON(http.StatusOK, out)
}
// GetAllSocialLinks returns all social links including hidden ones (admin only)
// @Summary Get all social links (admin)
// @Description Returns all social links for admin management
@@ -593,7 +664,12 @@ func (nc *NavigationController) SeedDefaultNavigation(c *gin.Context) {
createChild := func(parent *models.NavigationItem, label, pageType string, order int) error {
pid := parent.ID
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true}
allowEditor := false
switch pageType {
case "articles", "activities", "shortlinks":
allowEditor = true
}
child := &models.NavigationItem{Label: label, Type: models.NavTypeInternal, PageType: pageType, DisplayOrder: order, Visible: true, RequiresAdmin: true, AllowEditor: allowEditor}
child.ParentID = &pid
return tx.Create(child).Error
}
+130 -96
View File
@@ -29,66 +29,68 @@ type ShortLinkController struct {
// 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
}
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})
// 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 {
@@ -125,7 +127,9 @@ func hashIPShort(ip string) string {
}
func codeFromHash(s string, n int) string {
if n <= 0 { n = 7 }
if n <= 0 {
n = 7
}
alphabet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
sum := sha256.Sum256([]byte(s))
out := make([]byte, n)
@@ -137,20 +141,24 @@ func codeFromHash(s string, n int) string {
}
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)
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 {
@@ -174,11 +182,20 @@ func parseTarget(raw string) (string, error) {
raw = string(dec)
}
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", errors.New("invalid url")
// Try as-is first
if u, err := url.Parse(raw); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" {
return u.String(), nil
}
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) {
@@ -274,23 +291,25 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
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 == "" {
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 }
if body.Active != nil {
active = *body.Active
}
link := models.ShortLink{
Code: code,
TargetURL: target,
@@ -329,22 +348,37 @@ func (s *ShortLinkController) ListShortLinks(c *gin.Context) {
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 }
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"` }
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 }
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 }
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).
File diff suppressed because it is too large Load Diff