fever api support

Fever API spec: https://github.com/DigitalDJ/tinytinyrss-fever-plugin/blob/master/fever-api.md
This commit is contained in:
icefed 2022-11-29 21:06:07 +08:00 committed by nkanaev
parent 7ecbbff18a
commit 66f2a973a3
4 changed files with 382 additions and 13 deletions

View File

@ -12,7 +12,7 @@ type Middleware struct {
Username string
Password string
BasePath string
Public string
SkipAuthPaths []string
}
func unsafeMethod(method string) bool {
@ -20,10 +20,12 @@ func unsafeMethod(method string) bool {
}
func (m *Middleware) Handler(c *router.Context) {
if strings.HasPrefix(c.Req.URL.Path, m.BasePath+m.Public) {
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()
return

340
src/server/fever.go Normal file
View File

@ -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
}
}

View File

@ -34,7 +34,7 @@ func (s *Server) handler() http.Handler {
BasePath: s.BasePath,
Username: s.Username,
Password: s.Password,
Public: "/static",
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
}

View File

@ -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 {