diff --git a/README.md b/README.md
index 9926dae4..6bf027d2 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ TODO
- [X] Flush history
- [X] OAuth2
- [X] Touch events
-- [ ] Fever API?
+- [X] Fever API
Credits
-------
diff --git a/locale/translations.go b/locale/translations.go
index 58036653..7ecb5d3c 100644
--- a/locale/translations.go
+++ b/locale/translations.go
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.028184492 -0800 PST m=+0.019358340
+// 2017-12-03 17:25:29.428779083 -0800 PST m=+0.041806008
package locale
@@ -160,15 +160,18 @@ var translations = map[string]string{
"Mark bookmark as unread": "Marquer le lien comme non lu",
"Pinboard Tags": "Libellés de Pinboard",
"Pinboard API Token": "Jeton de sécurité de l'API de Pinboard",
- "Enable Pinboard": "Activer Pinboard",
- "Enable Instapaper": "Activer Instapaper",
+ "Save articles to Pinboard": "Sauvegarder les articles vers Pinboard",
+ "Save articles to Instapaper": "Sauvegarder les articles vers Instapaper",
"Instapaper Username": "Nom d'utilisateur Instapaper",
- "Instapaper Password": "Mot de passe Instapaper"
+ "Instapaper Password": "Mot de passe Instapaper",
+ "Activate Fever API": "Activer l'API de Fever",
+ "Fever Username": "Nom d'utilisateur pour l'API de Fever",
+ "Fever Password": "Mot de passe pour l'API de Fever"
}
`,
}
var translationsChecksums = map[string]string{
"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
- "fr_FR": "17a85afeb45665dc1a74cfb1fde83e0ed4ba335a8da56a328cf20ee4baec7567",
+ "fr_FR": "a2f9b16737041413669e754eddf07ec7817e70dd42dc99a951a162d166663f1c",
}
diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json
index 4fb615ed..4674491c 100644
--- a/locale/translations/fr_FR.json
+++ b/locale/translations/fr_FR.json
@@ -144,8 +144,11 @@
"Mark bookmark as unread": "Marquer le lien comme non lu",
"Pinboard Tags": "Libellés de Pinboard",
"Pinboard API Token": "Jeton de sécurité de l'API de Pinboard",
- "Enable Pinboard": "Activer Pinboard",
- "Enable Instapaper": "Activer Instapaper",
+ "Save articles to Pinboard": "Sauvegarder les articles vers Pinboard",
+ "Save articles to Instapaper": "Sauvegarder les articles vers Instapaper",
"Instapaper Username": "Nom d'utilisateur Instapaper",
- "Instapaper Password": "Mot de passe Instapaper"
+ "Instapaper Password": "Mot de passe Instapaper",
+ "Activate Fever API": "Activer l'API de Fever",
+ "Fever Username": "Nom d'utilisateur pour l'API de Fever",
+ "Fever Password": "Mot de passe pour l'API de Fever"
}
diff --git a/model/icon.go b/model/icon.go
index 7bf12bfe..3608a0a0 100644
--- a/model/icon.go
+++ b/model/icon.go
@@ -4,6 +4,11 @@
package model
+import (
+ "encoding/base64"
+ "fmt"
+)
+
// Icon represents a website icon (favicon)
type Icon struct {
ID int64 `json:"id"`
@@ -12,6 +17,14 @@ type Icon struct {
Content []byte `json:"content"`
}
+// DataURL returns the data URL of the icon.
+func (i *Icon) DataURL() string {
+ return fmt.Sprintf("%s;base64,%s", i.MimeType, base64.StdEncoding.EncodeToString(i.Content))
+}
+
+// Icons represents a list of icon.
+type Icons []*Icon
+
// FeedIcon is a jonction table between feeds and icons
type FeedIcon struct {
FeedID int64 `json:"feed_id"`
diff --git a/model/integration.go b/model/integration.go
index 7afa9b31..d8ca2798 100644
--- a/model/integration.go
+++ b/model/integration.go
@@ -14,4 +14,8 @@ type Integration struct {
InstapaperEnabled bool
InstapaperUsername string
InstapaperPassword string
+ FeverEnabled bool
+ FeverUsername string
+ FeverPassword string
+ FeverToken string
}
diff --git a/server/core/json_response.go b/server/core/json_response.go
index 3e0b0e8f..ed29d6ab 100644
--- a/server/core/json_response.go
+++ b/server/core/json_response.go
@@ -103,3 +103,8 @@ func (j *JSONResponse) toJSON(v interface{}) []byte {
return b
}
+
+// NewJSONResponse returns a new JSONResponse.
+func NewJSONResponse(w http.ResponseWriter, r *http.Request) *JSONResponse {
+ return &JSONResponse{request: r, writer: w}
+}
diff --git a/server/core/request.go b/server/core/request.go
index 4a2acc3b..540b2ac1 100644
--- a/server/core/request.go
+++ b/server/core/request.go
@@ -51,6 +51,18 @@ func (r *Request) Cookie(name string) string {
return cookie.Value
}
+// FormValue returns a form value as integer.
+func (r *Request) FormValue(param string) string {
+ return r.request.FormValue(param)
+}
+
+// FormIntegerValue returns a form value as integer.
+func (r *Request) FormIntegerValue(param string) int64 {
+ value := r.request.FormValue(param)
+ integer, _ := strconv.Atoi(value)
+ return int64(integer)
+}
+
// IntegerParam returns an URL parameter as integer.
func (r *Request) IntegerParam(param string) (int64, error) {
vars := mux.Vars(r.request)
@@ -105,6 +117,13 @@ func (r *Request) QueryIntegerParam(param string, defaultValue int) int {
return val
}
+// HasQueryParam checks if the query string contains the given parameter.
+func (r *Request) HasQueryParam(param string) bool {
+ values := r.request.URL.Query()
+ _, ok := values[param]
+ return ok
+}
+
// NewRequest returns a new Request struct.
func NewRequest(w http.ResponseWriter, r *http.Request) *Request {
return &Request{writer: w, request: r}
diff --git a/server/core/response.go b/server/core/response.go
index 4aef8af0..fc15e420 100644
--- a/server/core/response.go
+++ b/server/core/response.go
@@ -26,7 +26,7 @@ func (r *Response) SetCookie(cookie *http.Cookie) {
// JSON returns a JSONResponse.
func (r *Response) JSON() *JSONResponse {
r.commonHeaders()
- return &JSONResponse{writer: r.writer, request: r.request}
+ return NewJSONResponse(r.writer, r.request)
}
// HTML returns a HTMLResponse.
diff --git a/server/fever/fever.go b/server/fever/fever.go
new file mode 100644
index 00000000..a54562ef
--- /dev/null
+++ b/server/fever/fever.go
@@ -0,0 +1,636 @@
+// Copyright 2017 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 (
+ "log"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/miniflux/miniflux2/integration"
+ "github.com/miniflux/miniflux2/model"
+ "github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/storage"
+)
+
+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
+}
+
+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 linksResponse struct {
+ baseResponse
+ Links []string `json:"links"`
+}
+
+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 {
+ store *storage.Storage
+}
+
+// Handler handles Fever API calls
+func (c *Controller) Handler(ctx *core.Context, request *core.Request, response *core.Response) {
+ switch {
+ case request.HasQueryParam("groups"):
+ c.handleGroups(ctx, request, response)
+ case request.HasQueryParam("feeds"):
+ c.handleFeeds(ctx, request, response)
+ case request.HasQueryParam("favicons"):
+ c.handleFavicons(ctx, request, response)
+ case request.HasQueryParam("unread_item_ids"):
+ c.handleUnreadItems(ctx, request, response)
+ case request.HasQueryParam("saved_item_ids"):
+ c.handleSavedItems(ctx, request, response)
+ case request.HasQueryParam("items"):
+ c.handleItems(ctx, request, response)
+ case request.HasQueryParam("links"):
+ c.handleLinks(ctx, request, response)
+ case request.FormValue("mark") == "item":
+ c.handleWriteItems(ctx, request, response)
+ case request.FormValue("mark") == "feed":
+ c.handleWriteFeeds(ctx, request, response)
+ case request.FormValue("mark") == "group":
+ c.handleWriteGroups(ctx, request, response)
+ default:
+ response.JSON().Standard(newBaseResponse())
+ }
+}
+
+/*
+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 (c *Controller) handleGroups(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Fetching groups for userID=%d\n", userID)
+
+ categories, err := c.store.Categories(userID)
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ feeds, err := c.store.Feeds(userID)
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ var result groupsResponse
+ for _, category := range categories {
+ result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
+ }
+
+ result.FeedsGroups = c.buildFeedGroups(feeds)
+ result.SetCommonValues()
+ response.JSON().Standard(result)
+}
+
+/*
+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 (c *Controller) handleFeeds(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Fetching feeds for userID=%d\n", userID)
+
+ feeds, err := c.store.Feeds(userID)
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ var result feedsResponse
+ for _, f := range feeds {
+ result.Feeds = append(result.Feeds, feed{
+ ID: f.ID,
+ FaviconID: f.Icon.IconID,
+ Title: f.Title,
+ URL: f.FeedURL,
+ SiteURL: f.SiteURL,
+ IsSpark: 0,
+ LastUpdated: f.CheckedAt.Unix(),
+ })
+ }
+
+ result.FeedsGroups = c.buildFeedGroups(feeds)
+ result.SetCommonValues()
+ response.JSON().Standard(result)
+}
+
+/*
+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 '';
+*/
+func (c *Controller) handleFavicons(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Fetching favicons for userID=%d\n", userID)
+
+ icons, err := c.store.Icons(userID)
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ var result faviconsResponse
+ for _, i := range icons {
+ result.Favicons = append(result.Favicons, favicon{
+ ID: i.ID,
+ Data: i.DataURL(),
+ })
+ }
+
+ result.SetCommonValues()
+ response.JSON().Standard(result)
+}
+
+/*
+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 won’t 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 (c *Controller) handleItems(ctx *core.Context, request *core.Request, response *core.Response) {
+ var result itemsResponse
+
+ userID := ctx.UserID()
+ timezone := ctx.UserTimezone()
+ log.Printf("[Fever] Fetching items for userID=%d\n", userID)
+
+ builder := c.store.GetEntryQueryBuilder(userID, timezone)
+ builder.WithoutStatus(model.EntryStatusRemoved)
+ builder.WithLimit(50)
+ builder.WithOrder("id")
+ builder.WithDirection(model.DefaultSortingDirection)
+
+ sinceID := request.QueryIntegerParam("since_id", 0)
+ if sinceID > 0 {
+ builder.WithGreaterThanEntryID(int64(sinceID))
+ }
+
+ maxID := request.QueryIntegerParam("max_id", 0)
+ if maxID > 0 {
+ builder.WithOffset(maxID)
+ }
+
+ csvItemIDs := request.QueryStringParam("with_ids", "")
+ 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 {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ builder = c.store.GetEntryQueryBuilder(userID, timezone)
+ builder.WithoutStatus(model.EntryStatusRemoved)
+ result.Total, err = builder.CountEntries()
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ for _, entry := range entries {
+ isRead := 0
+ if entry.Status == model.EntryStatusRead {
+ isRead = 1
+ }
+
+ result.Items = append(result.Items, item{
+ ID: entry.ID,
+ FeedID: entry.FeedID,
+ Title: entry.Title,
+ Author: entry.Author,
+ HTML: entry.Content,
+ URL: entry.URL,
+ IsSaved: 0,
+ IsRead: isRead,
+ CreatedAt: entry.Date.Unix(),
+ })
+ }
+
+ result.SetCommonValues()
+ response.JSON().Standard(result)
+}
+
+/*
+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 (c *Controller) handleUnreadItems(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Fetching unread items for userID=%d\n", userID)
+
+ builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+ builder.WithStatus(model.EntryStatusUnread)
+ entries, err := builder.GetEntries()
+ if err != nil {
+ response.JSON().ServerError(err)
+ 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()
+ response.JSON().Standard(result)
+}
+
+/*
+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 (c *Controller) handleSavedItems(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Fetching saved items for userID=%d\n", userID)
+
+ var result savedResponse
+ result.SetCommonValues()
+ response.JSON().Standard(result)
+}
+
+/*
+A request with the links argument will return one additional member:
+
+ links contains an array of link objects
+
+A link object has the following members:
+
+ id (positive integer)
+ feed_id (positive integer) only use when is_item equals 1
+ item_id (positive integer) only use when is_item equals 1
+ temperature (positive float)
+ is_item (boolean integer)
+ is_local (boolean integer) used to determine if the source feed and favicon should be displayed
+ is_saved (boolean integer) only use when is_item equals 1
+ title (utf-8 string)
+ url (utf-8 string)
+ item_ids (string/comma-separated list of positive integers)
+*/
+func (c *Controller) handleLinks(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Fetching links for userID=%d\n", userID)
+
+ var result linksResponse
+ result.SetCommonValues()
+ response.JSON().Standard(result)
+}
+
+/*
+ mark=item
+ as=? where ? is replaced with read, saved or unsaved
+ id=? where ? is replaced with the id of the item to modify
+*/
+func (c *Controller) handleWriteItems(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Receiving mark=item call for userID=%d\n", userID)
+
+ entryID := request.FormIntegerValue("id")
+ if entryID <= 0 {
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+ builder.WithEntryID(entryID)
+ builder.WithoutStatus(model.EntryStatusRemoved)
+
+ entry, err := builder.GetEntry()
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ if entry == nil {
+ return
+ }
+
+ switch request.FormValue("as") {
+ case "read":
+ c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
+ case "unread":
+ c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
+ case "saved":
+ settings, err := c.store.Integration(userID)
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ go func() {
+ integration.SendEntry(entry, settings)
+ }()
+ }
+
+ response.JSON().Standard(newBaseResponse())
+}
+
+/*
+ mark=? where ? is replaced with feed or group
+ 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 client’s most recent items API request
+*/
+func (c *Controller) handleWriteFeeds(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Receiving mark=feed call for userID=%d\n", userID)
+
+ feedID := request.FormIntegerValue("id")
+ if feedID <= 0 {
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+ builder.WithStatus(model.EntryStatusUnread)
+ builder.WithFeedID(feedID)
+
+ before := request.FormIntegerValue("before")
+ if before > 0 {
+ t := time.Unix(before, 0)
+ builder.Before(&t)
+ }
+
+ entryIDs, err := builder.GetEntryIDs()
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ response.JSON().Standard(newBaseResponse())
+}
+
+/*
+ mark=? where ? is replaced with feed or group
+ 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 client’s most recent items API request
+*/
+func (c *Controller) handleWriteGroups(ctx *core.Context, request *core.Request, response *core.Response) {
+ userID := ctx.UserID()
+ log.Printf("[Fever] Receiving mark=group call for userID=%d\n", userID)
+
+ groupID := request.FormIntegerValue("id")
+ if groupID < 0 {
+ return
+ }
+
+ builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
+ builder.WithStatus(model.EntryStatusUnread)
+ builder.WithCategoryID(groupID)
+
+ before := request.FormIntegerValue("before")
+ if before > 0 {
+ t := time.Unix(before, 0)
+ builder.Before(&t)
+ }
+
+ entryIDs, err := builder.GetEntryIDs()
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
+ if err != nil {
+ response.JSON().ServerError(err)
+ return
+ }
+
+ response.JSON().Standard(newBaseResponse())
+}
+
+/*
+A feeds_group object has the following members:
+
+ group_id (positive integer)
+ feed_ids (string/comma-separated list of positive integers)
+
+*/
+func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups {
+ feedsGroupedByCategory := make(map[int64][]string)
+ for _, feed := range feeds {
+ feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
+ }
+
+ var result []feedsGroups
+ for categoryID, feedIDs := range feedsGroupedByCategory {
+ result = append(result, feedsGroups{
+ GroupID: categoryID,
+ FeedIDs: strings.Join(feedIDs, ","),
+ })
+ }
+
+ return result
+}
+
+// NewController returns a new Fever API.
+func NewController(store *storage.Storage) *Controller {
+ return &Controller{store: store}
+}
diff --git a/server/middleware/fever.go b/server/middleware/fever.go
new file mode 100644
index 00000000..d8643900
--- /dev/null
+++ b/server/middleware/fever.go
@@ -0,0 +1,57 @@
+// Copyright 2017 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 middleware
+
+import (
+ "context"
+ "log"
+ "net/http"
+
+ "github.com/miniflux/miniflux2/storage"
+)
+
+// FeverMiddleware is the middleware that handles Fever API.
+type FeverMiddleware struct {
+ store *storage.Storage
+}
+
+// Handler executes the middleware.
+func (f *FeverMiddleware) Handler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ log.Println("[Middleware:Fever]")
+
+ apiKey := r.FormValue("api_key")
+ user, err := f.store.UserByFeverToken(apiKey)
+ if err != nil {
+ log.Println(err)
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"api_version": 3, "auth": 0}`))
+ return
+ }
+
+ if user == nil {
+ log.Println("[Middleware:Fever] Fever authentication failure")
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"api_version": 3, "auth": 0}`))
+ return
+ }
+
+ log.Printf("[Middleware:Fever] User #%d is authenticated\n", user.ID)
+ f.store.SetLastLogin(user.ID)
+
+ ctx := r.Context()
+ ctx = context.WithValue(ctx, UserIDContextKey, user.ID)
+ ctx = context.WithValue(ctx, UserTimezoneContextKey, user.Timezone)
+ ctx = context.WithValue(ctx, IsAdminUserContextKey, user.IsAdmin)
+ ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true)
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// NewFeverMiddleware returns a new FeverMiddleware.
+func NewFeverMiddleware(s *storage.Storage) *FeverMiddleware {
+ return &FeverMiddleware{store: s}
+}
diff --git a/server/routes.go b/server/routes.go
index 5cb1e998..a9fc7e82 100644
--- a/server/routes.go
+++ b/server/routes.go
@@ -15,6 +15,7 @@ import (
"github.com/miniflux/miniflux2/reader/opml"
api_controller "github.com/miniflux/miniflux2/server/api/controller"
"github.com/miniflux/miniflux2/server/core"
+ "github.com/miniflux/miniflux2/server/fever"
"github.com/miniflux/miniflux2/server/middleware"
"github.com/miniflux/miniflux2/server/template"
ui_controller "github.com/miniflux/miniflux2/server/ui/controller"
@@ -29,17 +30,24 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han
templateEngine := template.NewEngine(cfg, router, translator)
apiController := api_controller.NewController(store, feedHandler)
+ feverController := fever.NewController(store)
uiController := ui_controller.NewController(cfg, store, pool, feedHandler, opml.NewHandler(store))
apiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
middleware.NewBasicAuthMiddleware(store).Handler,
))
+ feverHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
+ middleware.NewFeverMiddleware(store).Handler,
+ ))
+
uiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
middleware.NewSessionMiddleware(store, router).Handler,
middleware.NewTokenMiddleware(store).Handler,
))
+ router.Handle("/fever/", feverHandler.Use(feverController.Handler))
+
router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
router.Handle("/v1/users", apiHandler.Use(apiController.GetUsers)).Methods("GET")
router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.GetUser)).Methods("GET")
diff --git a/server/static/bin.go b/server/static/bin.go
index 51916042..1f3a9936 100644
--- a/server/static/bin.go
+++ b/server/static/bin.go
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.016429412 -0800 PST m=+0.007603260
+// 2017-12-03 17:25:29.40151375 -0800 PST m=+0.014540675
package static
diff --git a/server/static/css.go b/server/static/css.go
index eab3bda4..919e9033 100644
--- a/server/static/css.go
+++ b/server/static/css.go
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.017204599 -0800 PST m=+0.008378447
+// 2017-12-03 17:25:29.40458076 -0800 PST m=+0.017607685
package static
diff --git a/server/static/js.go b/server/static/js.go
index f9967467..15f83a37 100644
--- a/server/static/js.go
+++ b/server/static/js.go
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.018743922 -0800 PST m=+0.009917770
+// 2017-12-03 17:25:29.409871548 -0800 PST m=+0.022898473
package static
diff --git a/server/template/common.go b/server/template/common.go
index 586bf37d..d7afe653 100644
--- a/server/template/common.go
+++ b/server/template/common.go
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.027142168 -0800 PST m=+0.018316016
+// 2017-12-03 17:25:29.427766854 -0800 PST m=+0.040793779
package template
diff --git a/server/template/html/integrations.html b/server/template/html/integrations.html
index 5d333069..adc5a1bd 100644
--- a/server/template/html/integrations.html
+++ b/server/template/html/integrations.html
@@ -28,10 +28,23 @@