• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / feed-master / 21549514637

31 Jan 2026 07:13PM UTC coverage: 73.69% (-0.9%) from 74.577%
21549514637

push

github

umputun
ci: update Go version to 1.25 in build workflow

go.mod requires Go 1.25 but CI was configured for 1.22, causing build failures.

1420 of 1927 relevant lines covered (73.69%)

10.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

64.73
/app/api/server.go
1
// Package api provides rest-like server
2
package api
3

4
import (
5
        "context"
6
        "crypto/subtle"
7
        "encoding/xml"
8
        "fmt"
9
        "html/template"
10
        "net/http"
11
        "net/url"
12
        "os"
13
        "path/filepath"
14
        "strings"
15
        "sync"
16
        "time"
17

18
        "github.com/go-pkgz/lcw/v2"
19
        log "github.com/go-pkgz/lgr"
20
        "github.com/go-pkgz/rest"
21
        "github.com/go-pkgz/rest/logger"
22
        "github.com/go-pkgz/routegroup"
23

24
        "github.com/umputun/feed-master/app/config"
25
        "github.com/umputun/feed-master/app/feed"
26
        "github.com/umputun/feed-master/app/youtube"
27
        ytfeed "github.com/umputun/feed-master/app/youtube/feed"
28
)
29

30
//go:generate moq -out mocks/yt_service.go -pkg mocks -skip-ensure -fmt goimports . YoutubeSvc
31
//go:generate moq -out mocks/store.go -pkg mocks -skip-ensure -fmt goimports . Store
32
//go:generate moq -out mocks/youtube_store.go -pkg mocks -skip-ensure -fmt goimports . YoutubeStore
33

34
// Server provides HTTP API
35
type Server struct {
36
        Version       string
37
        Conf          config.Conf
38
        Store         Store
39
        YoutubeStore  YoutubeStore
40
        YoutubeSvc    YoutubeSvc
41
        TemplLocation string
42
        AdminPasswd   string
43

44
        httpServer *http.Server
45
        cache      lcw.LoadingCache[[]byte]
46
        templates  *template.Template
47
}
48

49
// YoutubeSvc provides access to youtube's audio rss
50
type YoutubeSvc interface {
51
        RSSFeed(cinfo youtube.FeedInfo) (string, error)
52
        StoreRSS(chanID, rss string) error
53
        RemoveEntry(entry ytfeed.Entry) error
54
}
55

56
// Store provides access to feed data
57
type Store interface {
58
        Load(fmFeed string, maxItems int, skipJunk bool) ([]feed.Item, error)
59
}
60

61
// YoutubeStore provides access to YouTube channel data
62
type YoutubeStore interface {
63
        Load(channelID string, maxItems int) ([]ytfeed.Entry, error)
64
}
65

66
// Run starts http server for API with all routes
67
func (s *Server) Run(ctx context.Context, port int) {
1✔
68
        log.Printf("[INFO] starting server on port %d", port)
1✔
69
        var err error
1✔
70
        o := lcw.NewOpts[[]byte]()
1✔
71
        if s.cache, err = lcw.NewExpirableCache(o.TTL(time.Minute*3), o.MaxCacheSize(10*1024*1024)); err != nil {
1✔
72
                log.Printf("[PANIC] failed to make loading cache, %v", err)
×
73
                return
×
74
        }
×
75

76
        serverLock := sync.Mutex{}
1✔
77
        go func() {
2✔
78
                <-ctx.Done()
1✔
79
                serverLock.Lock()
1✔
80
                defer serverLock.Unlock()
1✔
81
                if s.httpServer != nil {
2✔
82
                        if clsErr := s.httpServer.Close(); clsErr != nil {
1✔
83
                                log.Printf("[ERROR] failed to close proxy http server, %v", clsErr)
×
84
                        }
×
85
                }
86
        }()
87

88
        if s.TemplLocation == "" {
1✔
89
                s.TemplLocation = "webapp/templates/*"
×
90
        }
×
91
        log.Printf("[DEBUG] loading templates from %s", s.TemplLocation)
1✔
92
        s.loadTemplates()
1✔
93

1✔
94
        serverLock.Lock()
1✔
95
        s.httpServer = &http.Server{
1✔
96
                Addr:              fmt.Sprintf(":%d", port),
1✔
97
                Handler:           s.router(),
1✔
98
                ReadHeaderTimeout: 5 * time.Second,
1✔
99
                WriteTimeout:      s.Conf.System.HTTPResponseTimeout,
1✔
100
                IdleTimeout:       30 * time.Second,
1✔
101
        }
1✔
102
        serverLock.Unlock()
1✔
103
        err = s.httpServer.ListenAndServe()
1✔
104
        log.Printf("[WARN] http server terminated, %s", err)
1✔
105
}
106

107
// loadTemplates loads templates with custom functions
108
func (s *Server) loadTemplates() {
8✔
109
        funcMap := template.FuncMap{
8✔
110
                "currentYear": func() int {
14✔
111
                        return time.Now().Year()
6✔
112
                },
6✔
113
        }
114
        s.templates = template.Must(template.New("").Funcs(funcMap).ParseGlob(s.TemplLocation))
8✔
115
}
116

117
func (s *Server) router() http.Handler {
10✔
118
        router := routegroup.New(http.NewServeMux())
10✔
119
        router.Use(rest.RealIP, rest.Recoverer(log.Default()))
10✔
120
        router.Use(rest.Throttle(1000), timeout(60*time.Second))
10✔
121
        router.Use(rest.AppInfo("feed-master", "umputun", s.Version), rest.Ping)
10✔
122
        router.Use(rest.Throttle(5)) // rate limiter, replaces tollbooth
10✔
123

10✔
124
        router.Group().Route(func(rimg *routegroup.Bundle) {
20✔
125
                l := logger.New(logger.Log(log.Default()), logger.Prefix("[DEBUG]"), logger.IPfn(logger.AnonymizeIP))
10✔
126
                rimg.Use(l.Handler)
10✔
127
                rimg.HandleFunc("GET /images/{name}", s.getImageCtrl)
10✔
128
                rimg.HandleFunc("GET /image/{name...}", s.getImageCtrl) // handles both /image/foo and /image/foo.png
10✔
129
        })
10✔
130

131
        router.Group().Route(func(rrss *routegroup.Bundle) {
20✔
132
                l := logger.New(logger.Log(log.Default()), logger.Prefix("[INFO]"), logger.IPfn(logger.AnonymizeIP))
10✔
133
                rrss.Use(l.Handler)
10✔
134
                rrss.HandleFunc("GET /rss/{name}", s.getFeedCtrl)
10✔
135
                rrss.HandleFunc("GET /list", s.getListCtrl)
10✔
136
                rrss.HandleFunc("GET /feed/{name}", s.getFeedPageCtrl)
10✔
137
                rrss.HandleFunc("GET /feed/{name}/sources", s.getSourcesPageCtrl)
10✔
138
                rrss.HandleFunc("GET /feed/{name}/source/{source}", s.getFeedSourceCtrl)
10✔
139
                rrss.HandleFunc("GET /feeds", s.getFeedsPageCtrl)
10✔
140
        })
10✔
141

142
        router.HandleFunc("GET /config", func(w http.ResponseWriter, _ *http.Request) { rest.RenderJSON(w, s.Conf) })
11✔
143

144
        router.Mount("/yt").Route(func(r *routegroup.Bundle) {
20✔
145
                auth := rest.BasicAuth(func(user, passwd string) bool {
14✔
146
                        return (subtle.ConstantTimeCompare([]byte(s.AdminPasswd), []byte(passwd)) +
4✔
147
                                subtle.ConstantTimeCompare([]byte("admin"), []byte(user))) == 2
4✔
148
                })
4✔
149

150
                l := logger.New(logger.Log(log.Default()), logger.Prefix("[INFO]"), logger.IPfn(logger.AnonymizeIP))
10✔
151
                r.Use(l.Handler)
10✔
152
                r.HandleFunc("GET /rss/{channel}", s.getYoutubeFeedCtrl)
10✔
153
                r.HandleFunc("GET /channels", s.getYoutubeChannelsPageCtrl)
10✔
154
                r.With(auth).HandleFunc("POST /rss/generate", s.regenerateRSSCtrl)
10✔
155
                r.With(auth).HandleFunc("DELETE /entry/{channel}/{video}", s.removeEntryCtrl)
10✔
156
        })
157

158
        if s.Conf.YouTube.BaseURL != "" {
10✔
159
                baseYtURL, parseErr := url.Parse(s.Conf.YouTube.BaseURL)
×
160
                if parseErr != nil {
×
161
                        log.Printf("[ERROR] failed to parse base url %s, %v", s.Conf.YouTube.BaseURL, parseErr)
×
162
                }
×
163

164
                if mkdirErr := os.MkdirAll(s.Conf.YouTube.FilesLocation, 0o750); mkdirErr != nil {
×
165
                        log.Printf("[ERROR] failed to create directory %s, %v", s.Conf.YouTube.FilesLocation, mkdirErr)
×
166
                }
×
167

168
                ytfs, fsErr := rest.NewFileServer(baseYtURL.Path, s.Conf.YouTube.FilesLocation)
×
169
                if fsErr == nil {
×
170
                        router.Handle(baseYtURL.Path+"/{file...}", ytfs)
×
171
                } else {
×
172
                        log.Printf("[WARN] can't start static file server for yt, %v", fsErr)
×
173
                }
×
174
        }
175

176
        fs, err := rest.NewFileServer("/static", filepath.Join("webapp", "static"))
10✔
177
        if err == nil {
10✔
178
                router.Handle("/static/{file...}", fs)
×
179
        } else {
10✔
180
                log.Printf("[WARN] can't start static file server, %v", err)
10✔
181
        }
10✔
182
        return router
10✔
183
}
184

185
// GET /rss/{name} - returns rss for given feeds set
186
func (s *Server) getFeedCtrl(w http.ResponseWriter, r *http.Request) {
3✔
187
        feedName := r.PathValue("name")
3✔
188

3✔
189
        data, err := s.cache.Get("feed::"+feedName, func() ([]byte, error) {
6✔
190
                items, err := s.Store.Load(feedName, s.Conf.System.MaxTotal, true)
3✔
191
                if err != nil {
3✔
192
                        return nil, fmt.Errorf("load feed %s: %w", feedName, err)
×
193
                }
×
194

195
                for i, itm := range items {
9✔
196
                        // add ts suffix to titles
6✔
197
                        switch s.Conf.Feeds[feedName].ExtendDateTitle {
6✔
198
                        case "yyyyddmm":
×
199
                                items[i].Title = fmt.Sprintf("%s (%s)", itm.Title, itm.DT.Format("2006-02-01")) //nolint:govet // intentional yyyy-dd-mm format
×
200
                        case "yyyymmdd":
2✔
201
                                items[i].Title = fmt.Sprintf("%s (%s)", itm.Title, itm.DT.Format("2006-01-02"))
2✔
202
                        }
203
                }
204

205
                rss := feed.Rss2{
3✔
206
                        Version:        "2.0",
3✔
207
                        ItemList:       items,
3✔
208
                        Title:          s.Conf.Feeds[feedName].Title,
3✔
209
                        Description:    s.Conf.Feeds[feedName].Description,
3✔
210
                        Language:       s.Conf.Feeds[feedName].Language,
3✔
211
                        Link:           s.Conf.Feeds[feedName].Link,
3✔
212
                        PubDate:        items[0].PubDate,
3✔
213
                        LastBuildDate:  time.Now().Format(time.RFC822Z),
3✔
214
                        ItunesAuthor:   s.Conf.Feeds[feedName].Author,
3✔
215
                        ItunesExplicit: "no",
3✔
216
                        ItunesOwner: &feed.ItunesOwner{
3✔
217
                                Name:  "Feed Master",
3✔
218
                                Email: s.Conf.Feeds[feedName].OwnerEmail,
3✔
219
                        },
3✔
220
                        NsItunes: "http://www.itunes.com/dtds/podcast-1.0.dtd",
3✔
221
                        NsMedia:  "http://search.yahoo.com/mrss/",
3✔
222
                }
3✔
223

3✔
224
                // replace link to UI page
3✔
225
                if s.Conf.System.BaseURL != "" {
4✔
226
                        baseURL := strings.TrimSuffix(s.Conf.System.BaseURL, "/")
1✔
227
                        rss.Link = baseURL + "/feed/" + feedName
1✔
228
                        imagesURL := baseURL + "/images/" + feedName
1✔
229
                        rss.ItunesImage = &feed.ItunesImg{URL: imagesURL}
1✔
230
                        rss.MediaThumbnail = &feed.MediaThumbnail{URL: imagesURL}
1✔
231
                }
1✔
232

233
                b, err := xml.MarshalIndent(&rss, "", "  ")
3✔
234
                if err != nil {
3✔
235
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to marshal rss")
×
236
                        return nil, fmt.Errorf("failed to marshal rss for %s: %w", feedName, err)
×
237
                }
×
238

239
                res := `<?xml version="1.0" encoding="UTF-8"?>` + "\n" + string(b)
3✔
240

3✔
241
                // this hack to avoid having different items for marshal and unmarshal due to "itunes" namespace
3✔
242
                res = strings.ReplaceAll(res, "<duration>", "<itunes:duration>")
3✔
243
                res = strings.ReplaceAll(res, "</duration>", "</itunes:duration>")
3✔
244

3✔
245
                return []byte(res), nil
3✔
246
        })
247

248
        if err != nil {
3✔
249
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "failed to get feed")
×
250
                return
×
251
        }
×
252

253
        w.Header().Set("Content-Type", "application/xml; charset=UTF-8")
3✔
254
        _, _ = fmt.Fprintf(w, "%s", data)
3✔
255
}
256

257
// GET /image/{name}
258
func (s *Server) getImageCtrl(w http.ResponseWriter, r *http.Request) {
×
259
        fm := r.PathValue("name")
×
260
        fm = strings.TrimSuffix(fm, ".png")
×
261
        feedConf, found := s.Conf.Feeds[fm]
×
262
        if !found {
×
263
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest,
×
264
                        fmt.Errorf("image %s not found", fm), "failed to load image")
×
265
                return
×
266
        }
×
267

268
        b, err := os.ReadFile(feedConf.Image)
×
269
        if err != nil {
×
270
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest,
×
271
                        fmt.Errorf("can't read %s", r.PathValue("name")), "failed to read image")
×
272
                return
×
273
        }
×
274
        w.Header().Set("Content-Type", "image/png")
×
275
        if _, err := w.Write(b); err != nil {
×
276
                log.Printf("[WARN] failed to send image, %s", err)
×
277
        }
×
278
}
279

280
// GET /list - returns list of feeds
281
func (s *Server) getListCtrl(w http.ResponseWriter, _ *http.Request) {
×
282
        feeds := s.feeds()
×
283
        rest.RenderJSON(w, feeds)
×
284
}
×
285

286
// GET /yt/rss/{channel} - returns rss for given youtube channel
287
func (s *Server) getYoutubeFeedCtrl(w http.ResponseWriter, r *http.Request) {
×
288
        channel := r.PathValue("channel")
×
289

×
290
        fi := youtube.FeedInfo{ID: channel}
×
291
        for _, f := range s.Conf.YouTube.Channels {
×
292
                if f.ID == channel {
×
293
                        fi = f
×
294
                        break
×
295
                }
296
        }
297

298
        res, err := s.YoutubeSvc.RSSFeed(fi)
×
299
        if err != nil {
×
300
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to read yt list")
×
301
                return
×
302
        }
×
303

304
        w.Header().Set("Content-Type", "application/xml; charset=UTF-8")
×
305
        res = `<?xml version="1.0" encoding="UTF-8"?>` + "\n" + res
×
306
        _, _ = fmt.Fprintf(w, "%s", res)
×
307
}
308

309
// POST /yt/rss/generate - generates rss for all (each) youtube channels
310
func (s *Server) regenerateRSSCtrl(w http.ResponseWriter, r *http.Request) {
1✔
311
        for _, f := range s.Conf.YouTube.Channels {
3✔
312
                res, err := s.YoutubeSvc.RSSFeed(youtube.FeedInfo{ID: f.ID})
2✔
313
                if err != nil {
2✔
314
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to read yt rss for "+f.ID)
×
315
                        return
×
316
                }
×
317
                if err := s.YoutubeSvc.StoreRSS(f.ID, res); err != nil {
2✔
318
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to store yt rss for "+f.ID)
×
319
                        return
×
320
                }
×
321
        }
322
        rest.RenderJSON(w, rest.JSON{"status": "ok", "feeds": len(s.Conf.YouTube.Channels)})
1✔
323
}
324

325
// DELETE /yt/entry/{channel}/{video} - deletes entry from youtube channel and videID
326
func (s *Server) removeEntryCtrl(w http.ResponseWriter, r *http.Request) {
1✔
327
        err := s.YoutubeSvc.RemoveEntry(ytfeed.Entry{ChannelID: r.PathValue("channel"), VideoID: r.PathValue("video")})
1✔
328
        if err != nil {
1✔
329
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to remove entry")
×
330
                return
×
331
        }
×
332
        rest.RenderJSON(w, rest.JSON{"status": "ok", "removed": r.PathValue("video")})
1✔
333
}
334

335
func (s *Server) feeds() []string {
1✔
336
        feeds := make([]string, 0, len(s.Conf.Feeds))
1✔
337
        for k := range s.Conf.Feeds {
3✔
338
                feeds = append(feeds, k)
2✔
339
        }
2✔
340
        return feeds
1✔
341
}
342

343
// timeout wraps http.TimeoutHandler as middleware
344
func timeout(dt time.Duration) func(http.Handler) http.Handler {
10✔
345
        return func(h http.Handler) http.Handler {
22✔
346
                return http.TimeoutHandler(h, dt, "timeout")
12✔
347
        }
12✔
348
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc