diff --git a/src/server/auth/middleware.go b/src/server/auth/middleware.go index 4aca787..e6c7a0f 100644 --- a/src/server/auth/middleware.go +++ b/src/server/auth/middleware.go @@ -9,10 +9,10 @@ import ( ) type Middleware struct { - Username string - Password string - BasePath string - Public string + Username string + Password string + BasePath string + SkipAuthPaths []string } func unsafeMethod(method string) bool { @@ -20,9 +20,11 @@ func unsafeMethod(method string) bool { } func (m *Middleware) Handler(c *router.Context) { - if strings.HasPrefix(c.Req.URL.Path, m.BasePath+m.Public) { - c.Next() - return + for _, path := range m.SkipAuthPaths { + if strings.HasPrefix(c.Req.URL.Path, m.BasePath+path) { + c.Next() + return + } } if IsAuthenticated(c.Req, m.Username, m.Password) { c.Next() diff --git a/src/server/fever.go b/src/server/fever.go new file mode 100644 index 0000000..9fe788c --- /dev/null +++ b/src/server/fever.go @@ -0,0 +1,340 @@ +package server + +import ( + "crypto/md5" + "encoding/base64" + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/nkanaev/yarr/src/server/router" + "github.com/nkanaev/yarr/src/storage" +) + +type FeverGroup struct { + ID int64 `json:"id"` + Title string `json:"title"` +} + +type FeverFeedsGroup struct { + GroupID int64 `json:"group_id"` + FeedIDs string `json:"feed_ids"` +} + +type FeverFeed struct { + ID int64 `json:"id"` + FaviconID int64 `json:"favicon_id"` + Title string `json:"title"` + Url string `json:"url"` + SiteUrl string `json:"site_url"` + IsSpark int `json:"is_spark"` + LastUpdated int64 `json:"last_updated_on_time"` +} + +type FeverItem struct { + ID int64 `json:"id"` + FeedID int64 `json:"feed_id"` + Title string `json:"title"` + Author string `json:"author"` + HTML string `json:"html"` + Url string `json:"url"` + IsSaved int `json:"is_saved"` + IsRead int `json:"is_read"` + CreatedAt int64 `json:"created_on_time"` +} + +type FeverFavicon struct { + ID int64 `json:"id"` + Data string `json:"data"` +} + +func writeFeverJSON(c *router.Context, data map[string]interface{}) { + data["api_version"] = 1 + data["auth"] = 1 + data["last_refreshed_on_time"] = time.Now().Unix() + c.JSON(http.StatusOK, data) +} + +func (s *Server) feverAuth(c *router.Context) bool { + if s.Username != "" && s.Password != "" { + apiKey := c.Req.FormValue("api_key") + md5HashValue := md5.Sum([]byte(fmt.Sprintf("%s:%s", s.Username, s.Password))) + if apiKey != fmt.Sprintf("%x", md5HashValue[:]) { + return false + } + } + return true +} + +func formHasValue(values url.Values, value string) bool { + if _, ok := values[value]; ok { + return true + } + return false +} + +func (s *Server) handleFever(c *router.Context) { + c.Req.ParseForm() + if !s.feverAuth(c) { + c.JSON(http.StatusOK, map[string]interface{}{ + "api_version": 1, + "auth": 0, + "last_refreshed_on_time": 0, + }) + return + } + + switch { + case formHasValue(c.Req.Form, "groups"): + s.feverGroupsHandler(c) + case formHasValue(c.Req.Form, "feeds"): + s.feverFeedsHandler(c) + case formHasValue(c.Req.Form, "unread_item_ids"): + s.feverUnreadItemIDsHandler(c) + case formHasValue(c.Req.Form, "saved_item_ids"): + s.feverSavedItemIDsHandler(c) + case formHasValue(c.Req.Form, "favicons"): + s.feverFaviconsHandler(c) + case formHasValue(c.Req.Form, "items"): + s.feverItemsHandler(c) + case formHasValue(c.Req.Form, "links"): + s.feverLinksHandler(c) + case formHasValue(c.Req.Form, "mark"): + s.feverMarkHandler(c) + default: + c.JSON(http.StatusOK, map[string]interface{}{ + "api_version": 1, + "auth": 1, + "last_refreshed_on_time": 0, + }) + } +} + +func joinInts(values []int64) string { + var result strings.Builder + for i, val := range values { + fmt.Fprintf(&result, "%d", val) + if i != len(values)-1 { + result.WriteString(",") + } + } + return result.String() +} + +func feedGroups(db *storage.Storage) []*FeverFeedsGroup { + feeds := db.ListFeeds() + + groupFeeds := make(map[int64][]int64) + for _, feed := range feeds { + // TODO: what about top-level feeds? + if feed.FolderId == nil { + continue + } + groupFeeds[*feed.FolderId] = append(groupFeeds[*feed.FolderId], feed.Id) + } + result := make([]*FeverFeedsGroup, 0) + for groupId, feedIds := range groupFeeds { + result = append(result, &FeverFeedsGroup{ + GroupID: groupId, + FeedIDs: joinInts(feedIds), + }) + } + return result +} + +func (s *Server) feverGroupsHandler(c *router.Context) { + folders := s.db.ListFolders() + groups := make([]*FeverGroup, len(folders)) + for i, folder := range folders { + groups[i] = &FeverGroup{ID: folder.Id, Title: folder.Title} + } + writeFeverJSON(c, map[string]interface{}{ + "groups": groups, + "feeds_groups": feedGroups(s.db), + }) +} + +func (s *Server) feverFeedsHandler(c *router.Context) { + feeds := s.db.ListFeeds() + httpStates := s.db.ListHTTPStates() + + feverFeeds := make([]*FeverFeed, len(feeds)) + for i, feed := range feeds { + var lastUpdated int64 + if state, ok := httpStates[feed.Id]; ok { + lastUpdated = state.LastRefreshed.Unix() + } + feverFeeds[i] = &FeverFeed{ + ID: feed.Id, + FaviconID: feed.Id, + Title: feed.Title, + Url: feed.FeedLink, + SiteUrl: feed.Link, + IsSpark: 0, + LastUpdated: lastUpdated, + } + } + writeFeverJSON(c, map[string]interface{}{ + "feeds": feverFeeds, + "feeds_groups": feedGroups(s.db), + }) +} + +func (s *Server) feverUnreadItemIDsHandler(c *router.Context) { + status := storage.UNREAD + itemIds := make([]int64, 0, 1000) + batch := 10000 + + items := s.db.ListItems(storage.ItemFilter{ + Status: &status, + }, batch, true) + for _, item := range items { + itemIds = append(itemIds, item.Id) + } + writeFeverJSON(c, map[string]interface{}{ + "unread_item_ids": joinInts(itemIds), + }) +} + +func (s *Server) feverSavedItemIDsHandler(c *router.Context) { + status := storage.STARRED + itemIds := make([]int64, 0, 1000) + batch := 10000 + + items := s.db.ListItems(storage.ItemFilter{ + Status: &status, + }, batch, true) + for _, item := range items { + itemIds = append(itemIds, item.Id) + } + writeFeverJSON(c, map[string]interface{}{ + "saved_item_ids": joinInts(itemIds), + }) +} + +func (s *Server) feverFaviconsHandler(c *router.Context) { + feeds := s.db.ListFeeds() + favicons := make([]*FeverFavicon, len(feeds)) + for i, feed := range feeds { + data := "data:image/gif;base64,R0lGODlhAQABAAAAACw=" + if feed.HasIcon { + icon := s.db.GetFeed(feed.Id).Icon + data = fmt.Sprintf( + "data:%s;base64,%s", + http.DetectContentType(*icon), + base64.StdEncoding.EncodeToString(*icon), + ) + } + favicons[i] = &FeverFavicon{ID: feed.Id, Data: data} + } + + writeFeverJSON(c, map[string]interface{}{ + "favicons": favicons, + }) +} + +func (s *Server) feverItemsHandler(c *router.Context) { + filter := storage.ItemFilter{} + query := c.Req.URL.Query() + + switch { + case query.Get("with_ids") != "": + ids := make([]int64, 0) + for _, idstr := range strings.Split(query.Get("with_ids"), ",") { + if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil { + ids = append(ids, idnum) + } + } + filter.IDs = &ids + case query.Get("since_id") != "": + idstr := query.Get("since_id") + if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil { + filter.SinceID = &idnum + } + case query.Get("max_id") != "": + idstr := query.Get("max_id") + if idnum, err := strconv.ParseInt(idstr, 10, 64); err == nil { + filter.MaxID = &idnum + } + } + + items := s.db.ListItems(filter, 50, true) + + feverItems := make([]FeverItem, len(items)) + for i, item := range items { + date := item.Date + time := date.Unix() + + isSaved := 0 + if item.Status == storage.STARRED { + isSaved = 1 + } + isRead := 0 + if item.Status == storage.READ { + isRead = 1 + } + feverItems[i] = FeverItem{ + ID: item.Id, + FeedID: item.FeedId, + Title: item.Title, + Author: "", + HTML: item.Content, + Url: item.Link, + IsSaved: isSaved, + IsRead: isRead, + CreatedAt: time, + } + } + + writeFeverJSON(c, map[string]interface{}{ + "items": feverItems, + }) +} + +func (s *Server) feverLinksHandler(c *router.Context) { + writeFeverJSON(c, map[string]interface{}{ + "links": make([]interface{}, 0), + }) +} + +func (s *Server) feverMarkHandler(c *router.Context) { + id, err := strconv.ParseInt(c.Req.Form.Get("id"), 10, 64) + if err != nil { + log.Print("invalid id:", err) + return + } + + switch c.Req.Form.Get("mark") { + case "item": + var status storage.ItemStatus + switch c.Req.Form.Get("as") { + case "read": + status = storage.READ + case "unread": + status = storage.UNREAD + case "saved": + status = storage.STARRED + case "unsaved": + status = storage.READ + default: + c.Out.WriteHeader(http.StatusBadRequest) + return + } + s.db.UpdateItemStatus(id, status) + case "feed": + x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64) + before := time.Unix(x, 0) + s.db.MarkItemsRead(storage.MarkFilter{FeedID: &id, Before: &before}) + case "group": + x, _ := strconv.ParseInt(c.Req.Form.Get("before"), 10, 64) + before := time.Unix(x, 0) + s.db.MarkItemsRead(storage.MarkFilter{FolderID: &id, Before: &before}) + default: + c.Out.WriteHeader(http.StatusBadRequest) + return + } +} diff --git a/src/server/routes.go b/src/server/routes.go index 7c9c35b..d4f737f 100644 --- a/src/server/routes.go +++ b/src/server/routes.go @@ -31,10 +31,10 @@ func (s *Server) handler() http.Handler { if s.Username != "" && s.Password != "" { a := &auth.Middleware{ - BasePath: s.BasePath, - Username: s.Username, - Password: s.Password, - Public: "/static", + BasePath: s.BasePath, + Username: s.Username, + Password: s.Password, + SkipAuthPaths: []string{"/static", "/fever"}, } r.Use(a.Handler) } @@ -57,6 +57,7 @@ func (s *Server) handler() http.Handler { r.For("/opml/export", s.handleOPMLExport) r.For("/page", s.handlePageCrawl) r.For("/logout", s.handleLogout) + r.For("/fever/", s.handleFever) return r } diff --git a/src/storage/item.go b/src/storage/item.go index aff7c0f..22f8b42 100644 --- a/src/storage/item.go +++ b/src/storage/item.go @@ -62,11 +62,16 @@ type ItemFilter struct { Status *ItemStatus Search *string After *int64 + IDs *[]int64 + SinceID *int64 + MaxID *int64 } type MarkFilter struct { FolderID *int64 FeedID *int64 + + Before *time.Time } func (s *Storage) CreateItems(items []Item) bool { @@ -140,6 +145,24 @@ func listQueryPredicate(filter ItemFilter, newestFirst bool) (string, []interfac cond = append(cond, fmt.Sprintf("(i.date, i.id) %s (select date, id from items where id = ?)", compare)) args = append(args, *filter.After) } + if filter.IDs != nil && len(*filter.IDs) > 0 { + qmarks := make([]string, len(*filter.IDs)) + idargs := make([]interface{}, len(*filter.IDs)) + for i, id := range *filter.IDs { + qmarks[i] = "?" + idargs[i] = id + } + cond = append(cond, "i.id in ("+strings.Join(qmarks, ",")+")") + args = append(args, idargs...) + } + if filter.SinceID != nil { + cond = append(cond, "i.id > ?") + args = append(args, filter.SinceID) + } + if filter.MaxID != nil { + cond = append(cond, "i.id < ?") + args = append(args, filter.MaxID) + } predicate := "1" if len(cond) > 0 { @@ -157,11 +180,14 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []It if !newestFirst { order = "date asc, id asc" } + if filter.IDs != nil || filter.SinceID != nil || filter.MaxID != nil { + order = "i.id asc" + } query := fmt.Sprintf(` select i.id, i.guid, i.feed_id, - i.title, i.link, i.date, + i.title, i.link, i.content, i.date, i.status, i.image, i.podcast_url from items i where %s @@ -177,7 +203,7 @@ func (s *Storage) ListItems(filter ItemFilter, limit int, newestFirst bool) []It var x Item err = rows.Scan( &x.Id, &x.GUID, &x.FeedId, - &x.Title, &x.Link, &x.Date, + &x.Title, &x.Link, &x.Content, &x.Date, &x.Status, &x.ImageURL, &x.AudioURL, ) if err != nil {