1
0
Fork 0
miniflux/fever/handler.go

515 lines
13 KiB
Go
Raw Normal View History

// Copyright 2018 Frédéric Guillot. All rights reserved.
2017-12-03 20:44:27 -05:00
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
2018-08-25 00:51:50 -04:00
package fever // import "miniflux.app/fever"
2017-12-03 20:44:27 -05:00
import (
"net/http"
2017-12-03 20:44:27 -05:00
"strconv"
"strings"
"time"
2018-08-25 00:51:50 -04:00
"miniflux.app/http/request"
"miniflux.app/http/response/json"
"miniflux.app/integration"
"miniflux.app/logger"
"miniflux.app/model"
"miniflux.app/storage"
2017-12-03 20:44:27 -05:00
"github.com/gorilla/mux"
)
2017-12-03 20:44:27 -05:00
// Serve handles Fever API calls.
func Serve(router *mux.Router, store *storage.Storage) {
handler := &handler{store}
2017-12-03 20:44:27 -05:00
sr := router.PathPrefix("/fever").Subrouter()
sr.Use(newMiddleware(store).serve)
sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
2017-12-03 20:44:27 -05:00
}
type handler struct {
2017-12-03 20:44:27 -05:00
store *storage.Storage
}
func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
2017-12-03 20:44:27 -05:00
switch {
case request.HasQueryParam(r, "groups"):
h.handleGroups(w, r)
case request.HasQueryParam(r, "feeds"):
h.handleFeeds(w, r)
case request.HasQueryParam(r, "favicons"):
h.handleFavicons(w, r)
case request.HasQueryParam(r, "unread_item_ids"):
h.handleUnreadItems(w, r)
case request.HasQueryParam(r, "saved_item_ids"):
h.handleSavedItems(w, r)
case request.HasQueryParam(r, "items"):
h.handleItems(w, r)
case r.FormValue("mark") == "item":
h.handleWriteItems(w, r)
case r.FormValue("mark") == "feed":
h.handleWriteFeeds(w, r)
case r.FormValue("mark") == "group":
h.handleWriteGroups(w, r)
2017-12-03 20:44:27 -05:00
default:
json.OK(w, r, newBaseResponse())
2017-12-03 20:44:27 -05:00
}
}
/*
A request with the groups argument will return two additional members:
groups contains an array of group objects
feeds_groups contains an array of feeds_group objects
A group object has the following members:
id (positive integer)
title (utf-8 string)
The feeds_group object is documented under Feeds/Groups Relationships.
The Kindling super group is not included in this response and is composed of all feeds with
an is_spark equal to 0.
The Sparks super group is not included in this response and is composed of all feeds with an
is_spark equal to 1.
*/
func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
2017-12-15 21:55:57 -05:00
logger.Debug("[Fever] Fetching groups for userID=%d", userID)
2017-12-03 20:44:27 -05:00
categories, err := h.store.Categories(userID)
2017-12-03 20:44:27 -05:00
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
feeds, err := h.store.Feeds(userID)
2017-12-03 20:44:27 -05:00
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
var result groupsResponse
for _, category := range categories {
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
}
result.FeedsGroups = h.buildFeedGroups(feeds)
2017-12-03 20:44:27 -05:00
result.SetCommonValues()
json.OK(w, r, result)
2017-12-03 20:44:27 -05:00
}
/*
A request with the feeds argument will return two additional members:
feeds contains an array of group objects
feeds_groups contains an array of feeds_group objects
A feed object has the following members:
id (positive integer)
favicon_id (positive integer)
title (utf-8 string)
url (utf-8 string)
site_url (utf-8 string)
is_spark (boolean integer)
last_updated_on_time (Unix timestamp/integer)
The feeds_group object is documented under Feeds/Groups Relationships.
The All Items super feed is not included in this response and is composed of all items from all feeds
that belong to a given group. For the Kindling super group and all user created groups the items
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.
*/
func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
2017-12-15 21:55:57 -05:00
logger.Debug("[Fever] Fetching feeds for userID=%d", userID)
2017-12-03 20:44:27 -05:00
feeds, err := h.store.Feeds(userID)
2017-12-03 20:44:27 -05:00
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
var result feedsResponse
2017-12-22 14:33:01 -05:00
result.Feeds = make([]feed, 0)
2017-12-03 20:44:27 -05:00
for _, f := range feeds {
2017-12-29 18:32:04 -05:00
subscripion := feed{
2017-12-03 20:44:27 -05:00
ID: f.ID,
Title: f.Title,
URL: f.FeedURL,
SiteURL: f.SiteURL,
IsSpark: 0,
LastUpdated: f.CheckedAt.Unix(),
2017-12-29 18:32:04 -05:00
}
if f.Icon != nil {
subscripion.FaviconID = f.Icon.IconID
}
result.Feeds = append(result.Feeds, subscripion)
2017-12-03 20:44:27 -05:00
}
result.FeedsGroups = h.buildFeedGroups(feeds)
2017-12-03 20:44:27 -05:00
result.SetCommonValues()
json.OK(w, r, result)
2017-12-03 20:44:27 -05:00
}
/*
A request with the favicons argument will return one additional member:
favicons contains an array of favicon objects
A favicon object has the following members:
id (positive integer)
data (base64 encoded image data; prefixed by image type)
An example data value:
image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
The data member of a favicon object can be used with the data: protocol to embed an image in CSS or HTML.
A PHP/HTML example:
echo '<img src="data:'.$favicon['data'].'">';
*/
func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
2017-12-15 21:55:57 -05:00
logger.Debug("[Fever] Fetching favicons for userID=%d", userID)
2017-12-03 20:44:27 -05:00
icons, err := h.store.Icons(userID)
2017-12-03 20:44:27 -05:00
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
var result faviconsResponse
for _, i := range icons {
result.Favicons = append(result.Favicons, favicon{
ID: i.ID,
Data: i.DataURL(),
})
}
result.SetCommonValues()
json.OK(w, r, result)
2017-12-03 20:44:27 -05:00
}
/*
A request with the items argument will return two additional members:
items contains an array of item objects
total_items contains the total number of items stored in the database (added in API version 2)
An item object has the following members:
id (positive integer)
feed_id (positive integer)
title (utf-8 string)
author (utf-8 string)
html (utf-8 string)
url (utf-8 string)
is_saved (boolean integer)
is_read (boolean integer)
created_on_time (Unix timestamp/integer)
Most servers wont have enough memory allocated to PHP to dump all items at once.
Three optional arguments control determine the items included in the response.
Use the since_id argument with the highest id of locally cached items to request 50 additional items.
Repeat until the items array in the response is empty.
Use the max_id argument with the lowest id of locally cached items (or 0 initially) to request 50 previous items.
Repeat until the items array in the response is empty. (added in API version 2)
Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.
(added in API version 2)
*/
func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
2017-12-03 20:44:27 -05:00
var result itemsResponse
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
2017-12-15 21:55:57 -05:00
logger.Debug("[Fever] Fetching items for userID=%d", userID)
2017-12-03 20:44:27 -05:00
builder := h.store.NewEntryQueryBuilder(userID)
2017-12-03 20:44:27 -05:00
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithLimit(50)
builder.WithOrder("id")
builder.WithDirection(model.DefaultSortingDirection)
sinceID := request.QueryIntParam(r, "since_id", 0)
2017-12-03 20:44:27 -05:00
if sinceID > 0 {
builder.AfterEntryID(int64(sinceID))
2017-12-03 20:44:27 -05:00
}
maxID := request.QueryIntParam(r, "max_id", 0)
2017-12-03 20:44:27 -05:00
if maxID > 0 {
builder.WithOffset(maxID)
}
csvItemIDs := request.QueryStringParam(r, "with_ids", "")
2017-12-03 20:44:27 -05:00
if csvItemIDs != "" {
var itemIDs []int64
for _, strItemID := range strings.Split(csvItemIDs, ",") {
strItemID = strings.TrimSpace(strItemID)
itemID, _ := strconv.Atoi(strItemID)
itemIDs = append(itemIDs, int64(itemID))
}
builder.WithEntryIDs(itemIDs)
}
entries, err := builder.GetEntries()
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
builder = h.store.NewEntryQueryBuilder(userID)
2017-12-03 20:44:27 -05:00
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
result.Items = make([]item, 0)
2017-12-03 20:44:27 -05:00
for _, entry := range entries {
isRead := 0
if entry.Status == model.EntryStatusRead {
isRead = 1
}
2017-12-22 14:33:01 -05:00
isSaved := 0
if entry.Starred {
isSaved = 1
}
2017-12-03 20:44:27 -05:00
result.Items = append(result.Items, item{
ID: entry.ID,
FeedID: entry.FeedID,
Title: entry.Title,
Author: entry.Author,
HTML: entry.Content,
URL: entry.URL,
2017-12-22 14:33:01 -05:00
IsSaved: isSaved,
2017-12-03 20:44:27 -05:00
IsRead: isRead,
CreatedAt: entry.Date.Unix(),
})
}
result.SetCommonValues()
json.OK(w, r, result)
2017-12-03 20:44:27 -05:00
}
/*
The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
with the remote Fever installation.
A request with the unread_item_ids argument will return one additional member:
unread_item_ids (string/comma-separated list of positive integers)
*/
func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
2017-12-15 21:55:57 -05:00
logger.Debug("[Fever] Fetching unread items for userID=%d", userID)
2017-12-03 20:44:27 -05:00
builder := h.store.NewEntryQueryBuilder(userID)
2017-12-03 20:44:27 -05:00
builder.WithStatus(model.EntryStatusUnread)
entries, err := builder.GetEntries()
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
var itemIDs []string
for _, entry := range entries {
itemIDs = append(itemIDs, strconv.FormatInt(entry.ID, 10))
}
var result unreadResponse
result.ItemIDs = strings.Join(itemIDs, ",")
result.SetCommonValues()
json.OK(w, r, result)
2017-12-03 20:44:27 -05:00
}
/*
The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
with the remote Fever installation.
A request with the saved_item_ids argument will return one additional member:
saved_item_ids (string/comma-separated list of positive integers)
*/
func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
2017-12-15 21:55:57 -05:00
logger.Debug("[Fever] Fetching saved items for userID=%d", userID)
2017-12-03 20:44:27 -05:00
builder := h.store.NewEntryQueryBuilder(userID)
2017-12-22 14:33:01 -05:00
builder.WithStarred()
entryIDs, err := builder.GetEntryIDs()
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-22 14:33:01 -05:00
return
}
var itemsIDs []string
for _, entryID := range entryIDs {
itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
}
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
2017-12-03 20:44:27 -05:00
result.SetCommonValues()
json.OK(w, r, result)
2017-12-03 20:44:27 -05:00
}
/*
mark=item
as=? where ? is replaced with read, saved or unsaved
id=? where ? is replaced with the id of the item to modify
*/
func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
2017-12-15 21:55:57 -05:00
logger.Debug("[Fever] Receiving mark=item call for userID=%d", userID)
2017-12-03 20:44:27 -05:00
entryID := request.FormInt64Value(r, "id")
2017-12-03 20:44:27 -05:00
if entryID <= 0 {
return
}
builder := h.store.NewEntryQueryBuilder(userID)
2017-12-03 20:44:27 -05:00
builder.WithEntryID(entryID)
builder.WithoutStatus(model.EntryStatusRemoved)
entry, err := builder.GetEntry()
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
if entry == nil {
return
}
switch r.FormValue("as") {
2017-12-03 20:44:27 -05:00
case "read":
logger.Debug("[Fever] Mark entry #%d as read", entryID)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
2017-12-03 20:44:27 -05:00
case "unread":
logger.Debug("[Fever] Mark entry #%d as unread", entryID)
h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
case "saved", "unsaved":
logger.Debug("[Fever] Mark entry #%d as saved/unsaved", entryID)
if err := h.store.ToggleBookmark(userID, entryID); err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-22 14:33:01 -05:00
return
}
settings, err := h.store.Integration(userID)
2017-12-03 20:44:27 -05:00
if err != nil {
2018-10-07 21:42:43 -04:00
json.ServerError(w, r, err)
2017-12-03 20:44:27 -05:00
return
}
go func() {
integration.SendEntry(entry, settings)
2017-12-03 20:44:27 -05:00
}()
}
json.OK(w, r, newBaseResponse())
2017-12-03 20:44:27 -05:00
}
/*
mark=feed
2017-12-03 20:44:27 -05:00
as=read
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
*/
func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
feedID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
2017-12-03 20:44:27 -05:00
logger.Debug("[Fever] mark=feed, userID=%d, feedID=%d, before=%v", userID, feedID, before)
2017-12-03 20:44:27 -05:00
if feedID <= 0 {
2017-12-03 20:44:27 -05:00
return
}
go func() {
if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
logger.Error("[Fever] MarkFeedAsRead failed: %v", err)
}
}()
2017-12-03 20:44:27 -05:00
json.OK(w, r, newBaseResponse())
2017-12-03 20:44:27 -05:00
}
/*
mark=group
2017-12-03 20:44:27 -05:00
as=read
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
*/
func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
2018-09-03 17:26:40 -04:00
userID := request.UserID(r)
groupID := request.FormInt64Value(r, "id")
before := time.Unix(request.FormInt64Value(r, "before"), 0)
2017-12-03 20:44:27 -05:00
logger.Debug("[Fever] mark=group, userID=%d, groupID=%d, before=%v", userID, groupID, before)
2017-12-03 20:44:27 -05:00
if groupID < 0 {
2017-12-03 20:44:27 -05:00
return
}
go func() {
var err error
if groupID == 0 {
err = h.store.MarkAllAsRead(userID)
} else {
err = h.store.MarkCategoryAsRead(userID, groupID, before)
}
if err != nil {
logger.Error("[Fever] MarkCategoryAsRead failed: %v", err)
}
}()
2017-12-03 20:44:27 -05:00
json.OK(w, r, newBaseResponse())
2017-12-03 20:44:27 -05:00
}
/*
A feeds_group object has the following members:
group_id (positive integer)
feed_ids (string/comma-separated list of positive integers)
*/
func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
2017-12-03 20:44:27 -05:00
feedsGroupedByCategory := make(map[int64][]string)
for _, feed := range feeds {
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
}
2017-12-22 14:33:01 -05:00
result := make([]feedsGroups, 0)
2017-12-03 20:44:27 -05:00
for categoryID, feedIDs := range feedsGroupedByCategory {
result = append(result, feedsGroups{
GroupID: categoryID,
FeedIDs: strings.Join(feedIDs, ","),
})
}
return result
}