1
0
Fork 0

Move Fever middleware and routes to fever package

This commit is contained in:
Frédéric Guillot 2018-11-11 09:52:12 -08:00
parent 25c12053a6
commit a9f98adb07
4 changed files with 188 additions and 168 deletions

View file

@ -24,7 +24,6 @@ func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handle
router := mux.NewRouter() router := mux.NewRouter()
templateEngine := template.NewEngine(cfg, router) templateEngine := template.NewEngine(cfg, router)
apiController := api.NewController(store, feedHandler) apiController := api.NewController(store, feedHandler)
feverController := fever.NewController(cfg, store)
uiController := ui.NewController(cfg, store, pool, feedHandler, templateEngine, router) uiController := ui.NewController(cfg, store, pool, feedHandler, templateEngine, router)
middleware := middleware.New(cfg, store, router) middleware := middleware.New(cfg, store, router)
@ -45,9 +44,7 @@ func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handle
w.Write([]byte("User-agent: *\nDisallow: /")) w.Write([]byte("User-agent: *\nDisallow: /"))
}) })
feverRouter := router.PathPrefix("/fever").Subrouter() fever.Serve(router, cfg, store)
feverRouter.Use(middleware.FeverAuth)
feverRouter.HandleFunc("/", feverController.Handler).Name("feverEndpoint")
apiRouter := router.PathPrefix("/v1").Subrouter() apiRouter := router.PathPrefix("/v1").Subrouter()
apiRouter.Use(middleware.BasicAuth) apiRouter.Use(middleware.BasicAuth)

View file

@ -1,4 +1,4 @@
// Copyright 2017 Frédéric Guillot. All rights reserved. // Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0 // Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
@ -17,142 +17,44 @@ import (
"miniflux.app/logger" "miniflux.app/logger"
"miniflux.app/model" "miniflux.app/model"
"miniflux.app/storage" "miniflux.app/storage"
"github.com/gorilla/mux"
) )
type baseResponse struct { // Serve handles Fever API calls.
Version int `json:"api_version"` func Serve(router *mux.Router, cfg *config.Config, store *storage.Storage) {
Authenticated int `json:"auth"` handler := &handler{cfg, store}
LastRefresh int64 `json:"last_refreshed_on_time"`
sr := router.PathPrefix("/fever").Subrouter()
sr.Use(newMiddleware(store).serve)
sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
} }
func (b *baseResponse) SetCommonValues() { type handler struct {
b.Version = 3
b.Authenticated = 1
b.LastRefresh = time.Now().Unix()
}
/*
The default response is a JSON object containing two members:
api_version contains the version of the API responding (positive integer)
auth whether the request was successfully authenticated (boolean integer)
The API can also return XML by passing xml as the optional value of the api argument like so:
http://yourdomain.com/fever/?api=xml
The top level XML element is named response.
The response to each successfully authenticated request will have auth set to 1 and include
at least one additional member:
last_refreshed_on_time contains the time of the most recently refreshed (not updated)
feed (Unix timestamp/integer)
*/
func newBaseResponse() baseResponse {
r := baseResponse{}
r.SetCommonValues()
return r
}
type groupsResponse struct {
baseResponse
Groups []group `json:"groups"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type feedsResponse struct {
baseResponse
Feeds []feed `json:"feeds"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type faviconsResponse struct {
baseResponse
Favicons []favicon `json:"favicons"`
}
type itemsResponse struct {
baseResponse
Items []item `json:"items"`
Total int `json:"total_items"`
}
type unreadResponse struct {
baseResponse
ItemIDs string `json:"unread_item_ids"`
}
type savedResponse struct {
baseResponse
ItemIDs string `json:"saved_item_ids"`
}
type group struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type feedsGroups struct {
GroupID int64 `json:"group_id"`
FeedIDs string `json:"feed_ids"`
}
type feed 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 item 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 favicon struct {
ID int64 `json:"id"`
Data string `json:"data"`
}
// Controller implements the Fever API.
type Controller struct {
cfg *config.Config cfg *config.Config
store *storage.Storage store *storage.Storage
} }
// Handler handles Fever API calls func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
func (c *Controller) Handler(w http.ResponseWriter, r *http.Request) {
switch { switch {
case request.HasQueryParam(r, "groups"): case request.HasQueryParam(r, "groups"):
c.handleGroups(w, r) h.handleGroups(w, r)
case request.HasQueryParam(r, "feeds"): case request.HasQueryParam(r, "feeds"):
c.handleFeeds(w, r) h.handleFeeds(w, r)
case request.HasQueryParam(r, "favicons"): case request.HasQueryParam(r, "favicons"):
c.handleFavicons(w, r) h.handleFavicons(w, r)
case request.HasQueryParam(r, "unread_item_ids"): case request.HasQueryParam(r, "unread_item_ids"):
c.handleUnreadItems(w, r) h.handleUnreadItems(w, r)
case request.HasQueryParam(r, "saved_item_ids"): case request.HasQueryParam(r, "saved_item_ids"):
c.handleSavedItems(w, r) h.handleSavedItems(w, r)
case request.HasQueryParam(r, "items"): case request.HasQueryParam(r, "items"):
c.handleItems(w, r) h.handleItems(w, r)
case r.FormValue("mark") == "item": case r.FormValue("mark") == "item":
c.handleWriteItems(w, r) h.handleWriteItems(w, r)
case r.FormValue("mark") == "feed": case r.FormValue("mark") == "feed":
c.handleWriteFeeds(w, r) h.handleWriteFeeds(w, r)
case r.FormValue("mark") == "group": case r.FormValue("mark") == "group":
c.handleWriteGroups(w, r) h.handleWriteGroups(w, r)
default: default:
json.OK(w, r, newBaseResponse()) json.OK(w, r, newBaseResponse())
} }
@ -178,17 +80,17 @@ The “Sparks” super group is not included in this response and is composed of
is_spark equal to 1. is_spark equal to 1.
*/ */
func (c *Controller) handleGroups(w http.ResponseWriter, r *http.Request) { func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching groups for userID=%d", userID) logger.Debug("[Fever] Fetching groups for userID=%d", userID)
categories, err := c.store.Categories(userID) categories, err := h.store.Categories(userID)
if err != nil { if err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
feeds, err := c.store.Feeds(userID) feeds, err := h.store.Feeds(userID)
if err != nil { if err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
@ -199,7 +101,7 @@ func (c *Controller) handleGroups(w http.ResponseWriter, r *http.Request) {
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title}) result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
} }
result.FeedsGroups = c.buildFeedGroups(feeds) result.FeedsGroups = h.buildFeedGroups(feeds)
result.SetCommonValues() result.SetCommonValues()
json.OK(w, r, result) json.OK(w, r, result)
} }
@ -228,11 +130,11 @@ should be limited to feeds with an is_spark equal to 0.
For the Sparks super group the items should be limited to feeds with an is_spark equal to 1. For the Sparks super group the items should be limited to feeds with an is_spark equal to 1.
*/ */
func (c *Controller) handleFeeds(w http.ResponseWriter, r *http.Request) { func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching feeds for userID=%d", userID) logger.Debug("[Fever] Fetching feeds for userID=%d", userID)
feeds, err := c.store.Feeds(userID) feeds, err := h.store.Feeds(userID)
if err != nil { if err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
@ -257,7 +159,7 @@ func (c *Controller) handleFeeds(w http.ResponseWriter, r *http.Request) {
result.Feeds = append(result.Feeds, subscripion) result.Feeds = append(result.Feeds, subscripion)
} }
result.FeedsGroups = c.buildFeedGroups(feeds) result.FeedsGroups = h.buildFeedGroups(feeds)
result.SetCommonValues() result.SetCommonValues()
json.OK(w, r, result) json.OK(w, r, result)
} }
@ -281,11 +183,11 @@ A PHP/HTML example:
echo '<img src="data:'.$favicon['data'].'">'; echo '<img src="data:'.$favicon['data'].'">';
*/ */
func (c *Controller) handleFavicons(w http.ResponseWriter, r *http.Request) { func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching favicons for userID=%d", userID) logger.Debug("[Fever] Fetching favicons for userID=%d", userID)
icons, err := c.store.Icons(userID) icons, err := h.store.Icons(userID)
if err != nil { if err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
@ -334,13 +236,13 @@ Three optional arguments control determine the items included in the response.
(added in API version 2) (added in API version 2)
*/ */
func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) { func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
var result itemsResponse var result itemsResponse
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching items for userID=%d", userID) logger.Debug("[Fever] Fetching items for userID=%d", userID)
builder := c.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(50) builder.WithLimit(50)
builder.WithOrder("id") builder.WithOrder("id")
@ -375,7 +277,7 @@ func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) {
return return
} }
builder = c.store.NewEntryQueryBuilder(userID) builder = h.store.NewEntryQueryBuilder(userID)
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries() result.Total, err = builder.CountEntries()
if err != nil { if err != nil {
@ -419,11 +321,11 @@ with the remote Fever installation.
A request with the unread_item_ids argument will return one additional member: A request with the unread_item_ids argument will return one additional member:
unread_item_ids (string/comma-separated list of positive integers) unread_item_ids (string/comma-separated list of positive integers)
*/ */
func (c *Controller) handleUnreadItems(w http.ResponseWriter, r *http.Request) { func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching unread items for userID=%d", userID) logger.Debug("[Fever] Fetching unread items for userID=%d", userID)
builder := c.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread) builder.WithStatus(model.EntryStatusUnread)
entries, err := builder.GetEntries() entries, err := builder.GetEntries()
if err != nil { if err != nil {
@ -450,11 +352,11 @@ with the remote Fever installation.
saved_item_ids (string/comma-separated list of positive integers) saved_item_ids (string/comma-separated list of positive integers)
*/ */
func (c *Controller) handleSavedItems(w http.ResponseWriter, r *http.Request) { func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Fetching saved items for userID=%d", userID) logger.Debug("[Fever] Fetching saved items for userID=%d", userID)
builder := c.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithStarred() builder.WithStarred()
entryIDs, err := builder.GetEntryIDs() entryIDs, err := builder.GetEntryIDs()
@ -478,7 +380,7 @@ func (c *Controller) handleSavedItems(w http.ResponseWriter, r *http.Request) {
as=? where ? is replaced with read, saved or unsaved as=? where ? is replaced with read, saved or unsaved
id=? where ? is replaced with the id of the item to modify id=? where ? is replaced with the id of the item to modify
*/ */
func (c *Controller) handleWriteItems(w http.ResponseWriter, r *http.Request) { func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
logger.Debug("[Fever] Receiving mark=item call for userID=%d", userID) logger.Debug("[Fever] Receiving mark=item call for userID=%d", userID)
@ -487,7 +389,7 @@ func (c *Controller) handleWriteItems(w http.ResponseWriter, r *http.Request) {
return return
} }
builder := c.store.NewEntryQueryBuilder(userID) builder := h.store.NewEntryQueryBuilder(userID)
builder.WithEntryID(entryID) builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved) builder.WithoutStatus(model.EntryStatusRemoved)
@ -504,25 +406,25 @@ func (c *Controller) handleWriteItems(w http.ResponseWriter, r *http.Request) {
switch r.FormValue("as") { switch r.FormValue("as") {
case "read": case "read":
logger.Debug("[Fever] Mark entry #%d as read", entryID) logger.Debug("[Fever] Mark entry #%d as read", entryID)
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead) h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
case "unread": case "unread":
logger.Debug("[Fever] Mark entry #%d as unread", entryID) logger.Debug("[Fever] Mark entry #%d as unread", entryID)
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread) h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
case "saved", "unsaved": case "saved", "unsaved":
logger.Debug("[Fever] Mark entry #%d as saved/unsaved", entryID) logger.Debug("[Fever] Mark entry #%d as saved/unsaved", entryID)
if err := c.store.ToggleBookmark(userID, entryID); err != nil { if err := h.store.ToggleBookmark(userID, entryID); err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
settings, err := c.store.Integration(userID) settings, err := h.store.Integration(userID)
if err != nil { if err != nil {
json.ServerError(w, r, err) json.ServerError(w, r, err)
return return
} }
go func() { go func() {
integration.SendEntry(c.cfg, entry, settings) integration.SendEntry(h.cfg, entry, settings)
}() }()
} }
@ -535,7 +437,7 @@ func (c *Controller) handleWriteItems(w http.ResponseWriter, r *http.Request) {
id=? where ? is replaced with the id of the feed or group to modify id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request
*/ */
func (c *Controller) handleWriteFeeds(w http.ResponseWriter, r *http.Request) { func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
feedID := request.FormInt64Value(r, "id") feedID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0) before := time.Unix(request.FormInt64Value(r, "before"), 0)
@ -547,7 +449,7 @@ func (c *Controller) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
} }
go func() { go func() {
if err := c.store.MarkFeedAsRead(userID, feedID, before); err != nil { if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
logger.Error("[Fever] MarkFeedAsRead failed: %v", err) logger.Error("[Fever] MarkFeedAsRead failed: %v", err)
} }
}() }()
@ -561,7 +463,7 @@ func (c *Controller) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
id=? where ? is replaced with the id of the feed or group to modify id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request
*/ */
func (c *Controller) handleWriteGroups(w http.ResponseWriter, r *http.Request) { func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r) userID := request.UserID(r)
groupID := request.FormInt64Value(r, "id") groupID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0) before := time.Unix(request.FormInt64Value(r, "before"), 0)
@ -576,9 +478,9 @@ func (c *Controller) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
var err error var err error
if groupID == 0 { if groupID == 0 {
err = c.store.MarkAllAsRead(userID) err = h.store.MarkAllAsRead(userID)
} else { } else {
err = c.store.MarkCategoryAsRead(userID, groupID, before) err = h.store.MarkCategoryAsRead(userID, groupID, before)
} }
if err != nil { if err != nil {
@ -596,7 +498,7 @@ A feeds_group object has the following members:
feed_ids (string/comma-separated list of positive integers) feed_ids (string/comma-separated list of positive integers)
*/ */
func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups { func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
feedsGroupedByCategory := make(map[int64][]string) feedsGroupedByCategory := make(map[int64][]string)
for _, feed := range feeds { for _, feed := range feeds {
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10)) feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
@ -612,8 +514,3 @@ func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups {
return result return result
} }
// NewController returns a new Fever API.
func NewController(cfg *config.Config, store *storage.Storage) *Controller {
return &Controller{cfg, store}
}

View file

@ -2,7 +2,7 @@
// Use of this source code is governed by the Apache 2.0 // Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package middleware // import "miniflux.app/middleware" package fever // import "miniflux.app/fever"
import ( import (
"context" "context"
@ -11,34 +11,40 @@ import (
"miniflux.app/http/request" "miniflux.app/http/request"
"miniflux.app/http/response/json" "miniflux.app/http/response/json"
"miniflux.app/logger" "miniflux.app/logger"
"miniflux.app/storage"
) )
var feverAuthFailureResponse = map[string]int{"api_version": 3, "auth": 0} type middleware struct {
store *storage.Storage
}
// FeverAuth handles Fever API authentication. func newMiddleware(s *storage.Storage) *middleware {
func (m *Middleware) FeverAuth(next http.Handler) http.Handler { return &middleware{s}
}
func (m *middleware) serve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.FormValue("api_key") apiKey := r.FormValue("api_key")
if apiKey == "" { if apiKey == "" {
logger.Info("[Middleware:Fever] No API key provided") logger.Info("[Fever] No API key provided")
json.OK(w, r, feverAuthFailureResponse) json.OK(w, r, newAuthFailureResponse())
return return
} }
user, err := m.store.UserByFeverToken(apiKey) user, err := m.store.UserByFeverToken(apiKey)
if err != nil { if err != nil {
logger.Error("[Middleware:Fever] %v", err) logger.Error("[Fever] %v", err)
json.OK(w, r, feverAuthFailureResponse) json.OK(w, r, newAuthFailureResponse())
return return
} }
if user == nil { if user == nil {
logger.Info("[Middleware:Fever] No user found with this API key") logger.Info("[Fever] No user found with this API key")
json.OK(w, r, feverAuthFailureResponse) json.OK(w, r, newAuthFailureResponse())
return return
} }
logger.Info("[Middleware:Fever] User #%d is authenticated", user.ID) logger.Info("[Fever] User #%d is authenticated", user.ID)
m.store.SetLastLogin(user.ID) m.store.SetLastLogin(user.ID)
ctx := r.Context() ctx := r.Context()

120
fever/response.go Normal file
View file

@ -0,0 +1,120 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package fever // import "miniflux.app/fever"
import (
"time"
)
type baseResponse struct {
Version int `json:"api_version"`
Authenticated int `json:"auth"`
LastRefresh int64 `json:"last_refreshed_on_time"`
}
func (b *baseResponse) SetCommonValues() {
b.Version = 3
b.Authenticated = 1
b.LastRefresh = time.Now().Unix()
}
/*
The default response is a JSON object containing two members:
api_version contains the version of the API responding (positive integer)
auth whether the request was successfully authenticated (boolean integer)
The API can also return XML by passing xml as the optional value of the api argument like so:
http://yourdomain.com/fever/?api=xml
The top level XML element is named response.
The response to each successfully authenticated request will have auth set to 1 and include
at least one additional member:
last_refreshed_on_time contains the time of the most recently refreshed (not updated)
feed (Unix timestamp/integer)
*/
func newBaseResponse() baseResponse {
r := baseResponse{}
r.SetCommonValues()
return r
}
func newAuthFailureResponse() baseResponse {
return baseResponse{Version: 3, Authenticated: 0}
}
type groupsResponse struct {
baseResponse
Groups []group `json:"groups"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type feedsResponse struct {
baseResponse
Feeds []feed `json:"feeds"`
FeedsGroups []feedsGroups `json:"feeds_groups"`
}
type faviconsResponse struct {
baseResponse
Favicons []favicon `json:"favicons"`
}
type itemsResponse struct {
baseResponse
Items []item `json:"items"`
Total int `json:"total_items"`
}
type unreadResponse struct {
baseResponse
ItemIDs string `json:"unread_item_ids"`
}
type savedResponse struct {
baseResponse
ItemIDs string `json:"saved_item_ids"`
}
type group struct {
ID int64 `json:"id"`
Title string `json:"title"`
}
type feedsGroups struct {
GroupID int64 `json:"group_id"`
FeedIDs string `json:"feed_ids"`
}
type feed 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 item 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 favicon struct {
ID int64 `json:"id"`
Data string `json:"data"`
}