This commit is contained in:
Tomas Dvorak
2025-11-02 01:04:02 +01:00
parent ac886502e0
commit b9cea0cd77
153 changed files with 43713 additions and 1700 deletions
+94 -4
View File
@@ -18,12 +18,79 @@ import (
"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}
}
@@ -57,6 +124,18 @@ func hashIPShort(ip string) string {
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 getScheme(c *gin.Context) string {
if p := c.GetHeader("X-Forwarded-Proto"); p != "" {
return p
@@ -204,14 +283,25 @@ func (s *ShortLinkController) CreateShortLink(c *gin.Context) {
Active: active,
ExpiresAt: body.ExpiresAt,
}
if err := s.DB.Create(&link).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot save"})
// 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, link.Code)
c.JSON(http.StatusOK, gin.H{"id": link.ID, "code": link.Code, "short_url": shortURL, "link": link})
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) {