From e210acb81048f11e1cd1c2a6641542e84c460e3e Mon Sep 17 00:00:00 2001 From: Mohammad Mahdi Date: Fri, 13 Mar 2026 22:11:01 +0330 Subject: [PATCH] feat: add BaseURL config, RSS image enclosure, and separate rate limiters - Load and validate BASE_URL, store in Config - RSSGenerator now receives Config; adds for photo messages using BaseURL - RateLimiter tracks feed and image limiters; provide FeedRateLimit and ImageRateLimit middleware - Update routes to use the new middlewares and inject Config into RSSGenerator --- internal/config/config.go | 14 +++++++-- internal/rss/generator.go | 55 +++++++++++++++++++++++++++------- internal/server/http.go | 4 +-- internal/server/ratelimiter.go | 46 ++++++++++++++++++++-------- 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 3a7cf24..c24385a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { ProxyURL string AppHost string AppPort int + BaseURL string } func Load(logger *zap.Logger) *Config { @@ -74,13 +75,22 @@ func Load(logger *zap.Logger) *Config { proxyURL := os.Getenv("TG_PROXY_URL") + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + logger.Fatal("BASE_URL cant be empty") + } + if baseURL[len(baseURL)-1] == '/' { + baseURL = baseURL[:len(baseURL)-1] + } + return &Config{ - TgAppId: func() int { i, _ := strconv.Atoi(appId); return i }(), - TgAppHash: strings.TrimSpace(appHash), + TgAppId: func() int { i, _ := strconv.Atoi(appId); return i }(), + TgAppHash: strings.TrimSpace(appHash), SessionPath: sessionFile, ProxyURL: strings.TrimSpace(proxyURL), AppHost: appHost, AppPort: appPort, + BaseURL: baseURL, } } diff --git a/internal/rss/generator.go b/internal/rss/generator.go index da17a45..8954a6a 100644 --- a/internal/rss/generator.go +++ b/internal/rss/generator.go @@ -2,9 +2,11 @@ package rss import ( "encoding/xml" + "errors" "fmt" "net/url" "strconv" + "tgss/internal/config" "time" "github.com/gotd/td/tg" @@ -12,11 +14,18 @@ import ( ) type RSSItem struct { - Title string `xml:"title"` - Link string `xml:"link"` - PubDate string `xml:"pubDate,omitempty"` - Description string `xml:"description"` - Guid string `xml:"guid,omitempty"` + Title string `xml:"title"` + Link string `xml:"link"` + Enclosure *RSSEnclosure `xml:"enclosure,omitempty"` + PubDate string `xml:"pubDate,omitempty"` + Description string `xml:"description"` + Guid string `xml:"guid,omitempty"` +} + +type RSSEnclosure struct { + URL string `xml:"url,attr"` + Length string `xml:"lenghth,omitempty,attr"` + Type string `xml:"type,attr"` } type RSSChannel struct { @@ -40,10 +49,11 @@ type RSSFeed struct { type RSSGenerator struct { logger *zap.Logger + config *config.Config } -func NewRSSGenerator(logger *zap.Logger) *RSSGenerator { - return &RSSGenerator{logger: logger} +func NewRSSGenerator(logger *zap.Logger, config *config.Config) *RSSGenerator { + return &RSSGenerator{logger: logger, config: config} } func (r *RSSGenerator) GenerateFeed(items []tg.MessageClass, channelId string) *RSSFeed { @@ -85,6 +95,7 @@ func (r *RSSGenerator) GenerateFeed(items []tg.MessageClass, channelId string) * rssChannel.Items = append(rssChannel.Items, *item) } + // Logging is done by AI if errorCount > 0 { r.logger.Warn("feed generation completed with errors", zap.String("channel", channelId), @@ -115,13 +126,37 @@ func (r *RSSGenerator) messageToItem(msg tg.MessageClass, channelId string) (*RS description = "No content" } - return &RSSItem{ + rssItem := &RSSItem{ Title: "Post by @" + channelId + " on Telegram", Link: messageURL.String(), PubDate: time.Unix(int64(message.Date), 0).Format("Mon, 02 Jan 2006 15:04 MST"), Description: description, Guid: messageURL.String(), - }, nil + } + + if err = r.messageHasPhoto(message); err == nil { + if enclosureURL, err := url.ParseRequestURI(r.config.BaseURL + "/image/" + channelId + "/" + strconv.Itoa(msg.GetID())); err == nil { + rssItem.Enclosure = &RSSEnclosure{ + URL: enclosureURL.String(), + Length: "0", + Type: "image/jpeg", + } + } + } + + return rssItem, nil } -// TODO: add a hasPhoto helper to add the rss tag accordingly \ No newline at end of file +func (r *RSSGenerator) messageHasPhoto(message tg.MessageClass) error { + media, ok := message.(*tg.Message).Media.(*tg.MessageMediaPhoto) + if !ok { + return errors.New("the message does not contain a photo media object") + } + + _, ok = media.Photo.(*tg.Photo) + if !ok { + return errors.New("the message media has no photo payload") + } + + return nil +} diff --git a/internal/server/http.go b/internal/server/http.go index b52320a..739ee94 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -49,8 +49,8 @@ func RegisterRoutes(params RouterParams) { }) params.GinEngine.GET("/feed/:id/json", params.ChannelHandler.GetMessagesJson) - params.GinEngine.GET("/feed/:id", params.RateLimiter.RateLimit(), params.ChannelHandler.GetMessagesRSS) - params.GinEngine.GET("/image/:channelId/:messageId", params.ImageHandler.GetImage) + params.GinEngine.GET("/feed/:id", params.RateLimiter.FeedRateLimit(), params.ChannelHandler.GetMessagesRSS) + params.GinEngine.GET("/image/:channelId/:messageId", params.RateLimiter.ImageRateLimit(), params.ImageHandler.GetImage) // TODO: improve accurecy in rss channel fields // TODO: add ui with templates under /setup with fetch diff --git a/internal/server/ratelimiter.go b/internal/server/ratelimiter.go index 70c78df..5ab08b0 100644 --- a/internal/server/ratelimiter.go +++ b/internal/server/ratelimiter.go @@ -13,8 +13,9 @@ import ( // TODO: another limiter rate for images type ClientLimiter struct { - limiter *rate.Limiter - lastSeen time.Time + feedLimiter *rate.Limiter + imageLimiter *rate.Limiter + lastSeen time.Time } type RateLimiter struct { @@ -27,19 +28,30 @@ func NewRateLimiter(logger *zap.Logger) *RateLimiter { return &RateLimiter{logger: logger, mu: sync.Mutex{}, clients: map[string]*ClientLimiter{}} } -func (r *RateLimiter) getLimiter(ip string) *rate.Limiter { +func (r *RateLimiter) getLimiter(ip string, kind string) *rate.Limiter { r.mu.Lock() defer r.mu.Unlock() if c, ok := r.clients[ip]; ok { c.lastSeen = time.Now() - return c.limiter + if kind == "image" { + return c.imageLimiter + } + return c.feedLimiter } - limiter := rate.NewLimiter(rate.Every(time.Second*3), 5) // TODO: get from config - r.clients[ip] = &ClientLimiter{limiter: limiter, lastSeen: time.Now()} + feedLim := rate.NewLimiter(rate.Every(3*time.Second), 5) + imageLim := rate.NewLimiter(rate.Every(5*time.Second), 4) - return limiter + r.clients[ip] = &ClientLimiter{ + feedLimiter: feedLim, + imageLimiter: imageLim, + lastSeen: time.Now(), + } + if kind == "image" { + return imageLim + } + return feedLim } func (r *RateLimiter) CleanupRateLimiter() { @@ -91,16 +103,26 @@ func RegisterRateLimiterCleanup(lc fx.Lifecycle, logger *zap.Logger, r *RateLimi }) } -func (r *RateLimiter) RateLimit() gin.HandlerFunc { +func (r *RateLimiter) FeedRateLimit() gin.HandlerFunc { return func(c *gin.Context) { ip := c.ClientIP() - - limiter := r.getLimiter(ip) - - if !limiter.Allow() { + lim := r.getLimiter(ip, "feed") + if !lim.Allow() { c.AbortWithStatusJSON(429, gin.H{"error": "Slow down pls"}) return } c.Next() } } + +func (r *RateLimiter) ImageRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + lim := r.getLimiter(ip, "image") + if !lim.Allow() { + c.AbortWithStatusJSON(429, gin.H{"error": "Slow down pls, these arent your mothers nudes"}) + return + } + c.Next() + } +}