aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorGravatar Frédéric Guillot <fred@miniflux.net>2017-12-03 17:44:27 -0800
committerGravatar Frédéric Guillot <fred@miniflux.net>2017-12-03 17:44:27 -0800
commitbc20e0884b3ca051ae77e1bb6e2de11419d36d4d (patch)
tree993ff7aad2b98d5fe71b2decde7b36c4dd99d184
parentae62e543d3a1173cd39f1910cb67c95a56a7a6a4 (diff)
Add Fever API
-rw-r--r--README.md2
-rw-r--r--locale/translations.go13
-rw-r--r--locale/translations/fr_FR.json9
-rw-r--r--model/icon.go13
-rw-r--r--model/integration.go4
-rw-r--r--server/core/json_response.go5
-rw-r--r--server/core/request.go19
-rw-r--r--server/core/response.go2
-rw-r--r--server/fever/fever.go636
-rw-r--r--server/middleware/fever.go57
-rw-r--r--server/routes.go8
-rw-r--r--server/static/bin.go2
-rw-r--r--server/static/css.go2
-rw-r--r--server/static/js.go2
-rw-r--r--server/template/common.go2
-rw-r--r--server/template/html/integrations.html17
-rw-r--r--server/template/views.go21
-rw-r--r--server/ui/controller/integrations.go11
-rw-r--r--server/ui/form/integration.go9
-rw-r--r--sql/schema_version_5.sql4
-rw-r--r--sql/sql.go8
-rw-r--r--storage/entry_query_builder.go100
-rw-r--r--storage/icon.go31
-rw-r--r--storage/integration.go44
24 files changed, 984 insertions, 37 deletions
diff --git a/README.md b/README.md
index 9926dae..6bf027d 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 5803665..7ecb5d3 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 4fb615e..4674491 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 7bf12bf..3608a0a 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 7afa9b3..d8ca279 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 3e0b0e8..ed29d6a 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 4a2acc3..540b2ac 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 4aef8af..fc15e42 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 0000000..a54562e
--- /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 '<img src="data:'.$favicon['data'].'">';
+*/
+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 0000000..d864390
--- /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 5cb1e99..a9fc7e8 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 5191604..1f3a993 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 eab3bda..919e903 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 f996746..15f83a3 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 586bf37..d7afe65 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 5d33306..adc5a1b 100644
--- a/server/template/html/integrations.html
+++ b/server/template/html/integrations.html
@@ -28,10 +28,23 @@
<div class="alert alert-error">{{ t .errorMessage }}</div>
{{ end }}
+ <h3>Fever</h3>
+ <div class="form-section">
+ <label>
+ <input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "Activate Fever API" }}
+ </label>
+
+ <label for="form-fever-username">{{ t "Fever Username" }}</label>
+ <input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}">
+
+ <label for="form-fever-password">{{ t "Fever Password" }}</label>
+ <input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
+ </div>
+
<h3>Pinboard</h3>
<div class="form-section">
<label>
- <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Enable Pinboard" }}
+ <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Save articles to Pinboard" }}
</label>
<label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label>
@@ -48,7 +61,7 @@
<h3>Instapaper</h3>
<div class="form-section">
<label>
- <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Enable Instapaper" }}
+ <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Save articles to Instapaper" }}
</label>
<label for="form-instapaper-username">{{ t "Instapaper Username" }}</label>
diff --git a/server/template/views.go b/server/template/views.go
index 514c78e..fdee059 100644
--- a/server/template/views.go
+++ b/server/template/views.go
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.019569008 -0800 PST m=+0.010742856
+// 2017-12-03 17:25:29.413238818 -0800 PST m=+0.026265743
package template
@@ -811,10 +811,23 @@ var templateViewsMap = map[string]string{
<div class="alert alert-error">{{ t .errorMessage }}</div>
{{ end }}
+ <h3>Fever</h3>
+ <div class="form-section">
+ <label>
+ <input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "Activate Fever API" }}
+ </label>
+
+ <label for="form-fever-username">{{ t "Fever Username" }}</label>
+ <input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}">
+
+ <label for="form-fever-password">{{ t "Fever Password" }}</label>
+ <input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
+ </div>
+
<h3>Pinboard</h3>
<div class="form-section">
<label>
- <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Enable Pinboard" }}
+ <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Save articles to Pinboard" }}
</label>
<label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label>
@@ -831,7 +844,7 @@ var templateViewsMap = map[string]string{
<h3>Instapaper</h3>
<div class="form-section">
<label>
- <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Enable Instapaper" }}
+ <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Save articles to Instapaper" }}
</label>
<label for="form-instapaper-username">{{ t "Instapaper Username" }}</label>
@@ -1160,7 +1173,7 @@ var templateViewsMapChecksums = map[string]string{
"feeds": "c22af39b42ba9ca69ea0914ca789303ec2c5b484abcd4eaa49016e365381257c",
"history": "9a67599a5d8d67ef958e3f07da339b749f42892667547c9e60a54477e8d32a56",
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
- "integrations": "4e51fabe73b4ee2c2268f77dbbf7987c2a176c5a5714ea29ac31986928f22b8a",
+ "integrations": "30249eefa4e2da62051447537ee5c4ed3dad377656fec3080e0e96c3c697c672",
"login": "04f3ce79bfa5753f69e0d956c2a8999c0da549c7925634a3e8134975da0b0e0f",
"sessions": "878dbe8f8ea783b44130c495814179519fa5c3aa2666ac87508f94d58dd008bf",
"settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9",
diff --git a/server/ui/controller/integrations.go b/server/ui/controller/integrations.go
index a3e545e..ceac09a 100644
--- a/server/ui/controller/integrations.go
+++ b/server/ui/controller/integrations.go
@@ -5,7 +5,9 @@
package controller
import (
+ "crypto/md5"
"errors"
+ "fmt"
"github.com/miniflux/miniflux2/integration"
"github.com/miniflux/miniflux2/model"
@@ -38,6 +40,9 @@ func (c *Controller) ShowIntegrations(ctx *core.Context, request *core.Request,
InstapaperEnabled: integration.InstapaperEnabled,
InstapaperUsername: integration.InstapaperUsername,
InstapaperPassword: integration.InstapaperPassword,
+ FeverEnabled: integration.FeverEnabled,
+ FeverUsername: integration.FeverUsername,
+ FeverPassword: integration.FeverPassword,
},
}))
}
@@ -54,6 +59,12 @@ func (c *Controller) UpdateIntegration(ctx *core.Context, request *core.Request,
integrationForm := form.NewIntegrationForm(request.Request())
integrationForm.Merge(integration)
+ if integration.FeverEnabled {
+ integration.FeverToken = fmt.Sprintf("%x", md5.Sum([]byte(integration.FeverUsername+":"+integration.FeverPassword)))
+ } else {
+ integration.FeverToken = ""
+ }
+
err = c.store.UpdateIntegration(integration)
if err != nil {
response.HTML().ServerError(err)
diff --git a/server/ui/form/integration.go b/server/ui/form/integration.go
index be54e23..bd32403 100644
--- a/server/ui/form/integration.go
+++ b/server/ui/form/integration.go
@@ -19,6 +19,9 @@ type IntegrationForm struct {
InstapaperEnabled bool
InstapaperUsername string
InstapaperPassword string
+ FeverEnabled bool
+ FeverUsername string
+ FeverPassword string
}
// Merge copy form values to the model.
@@ -30,6 +33,9 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
integration.InstapaperEnabled = i.InstapaperEnabled
integration.InstapaperUsername = i.InstapaperUsername
integration.InstapaperPassword = i.InstapaperPassword
+ integration.FeverEnabled = i.FeverEnabled
+ integration.FeverUsername = i.FeverUsername
+ integration.FeverPassword = i.FeverPassword
}
// NewIntegrationForm returns a new AuthForm.
@@ -42,5 +48,8 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
InstapaperEnabled: r.FormValue("instapaper_enabled") == "1",
InstapaperUsername: r.FormValue("instapaper_username"),
InstapaperPassword: r.FormValue("instapaper_password"),
+ FeverEnabled: r.FormValue("fever_enabled") == "1",
+ FeverUsername: r.FormValue("fever_username"),
+ FeverPassword: r.FormValue("fever_password"),
}
}
diff --git a/sql/schema_version_5.sql b/sql/schema_version_5.sql
index 326562f..dac7937 100644
--- a/sql/schema_version_5.sql
+++ b/sql/schema_version_5.sql
@@ -7,5 +7,9 @@ create table integrations (
instapaper_enabled bool default 'f',
instapaper_username text default '',
instapaper_password text default '',
+ fever_enabled bool default 'f',
+ fever_username text default '',
+ fever_password text default '',
+ fever_token text default '',
primary key(user_id)
)
diff --git a/sql/sql.go b/sql/sql.go
index e188131..d3a3897 100644
--- a/sql/sql.go
+++ b/sql/sql.go
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
-// 2017-12-02 21:11:24.01125036 -0800 PST m=+0.002424208
+// 2017-12-03 17:25:29.391052668 -0800 PST m=+0.004079593
package sql
@@ -130,6 +130,10 @@ alter table users add column entry_direction entry_sorting_direction default 'as
instapaper_enabled bool default 'f',
instapaper_username text default '',
instapaper_password text default '',
+ fever_enabled bool default 'f',
+ fever_username text default '',
+ fever_password text default '',
+ fever_token text default '',
primary key(user_id)
)
`,
@@ -140,5 +144,5 @@ var SqlMapChecksums = map[string]string{
"schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4",
"schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
"schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
- "schema_version_5": "4e7958c01f15def3f8619fc5bee6f0d99e773353aeea08188f77ef089fc9d3e7",
+ "schema_version_5": "46397e2f5f2c82116786127e9f6a403e975b14d2ca7b652a48cd1ba843e6a27c",
}
diff --git a/storage/entry_query_builder.go b/storage/entry_query_builder.go
index 6f7f4bd..143fd3b 100644
--- a/storage/entry_query_builder.go
+++ b/storage/entry_query_builder.go
@@ -9,24 +9,47 @@ import (
"strings"
"time"
+ "github.com/lib/pq"
+
"github.com/miniflux/miniflux2/helper"
"github.com/miniflux/miniflux2/model"
)
// EntryQueryBuilder builds a SQL query to fetch entries.
type EntryQueryBuilder struct {
- store *Storage
- feedID int64
- userID int64
- timezone string
- categoryID int64
- status string
- notStatus string
- order string
- direction string
- limit int
- offset int
- entryID int64
+ store *Storage
+ feedID int64
+ userID int64
+ timezone string
+ categoryID int64
+ status string
+ notStatus string
+ order string
+ direction string
+ limit int
+ offset int
+ entryID int64
+ greaterThanEntryID int64
+ entryIDs []int64
+ before *time.Time
+}
+
+// Before add condition base on the entry date.
+func (e *EntryQueryBuilder) Before(date *time.Time) *EntryQueryBuilder {
+ e.before = date
+ return e
+}
+
+// WithGreaterThanEntryID adds a condition > entryID.
+func (e *EntryQueryBuilder) WithGreaterThanEntryID(entryID int64) *EntryQueryBuilder {
+ e.greaterThanEntryID = entryID
+ return e
+}
+
+// WithEntryIDs adds a condition to fetch only the given entry IDs.
+func (e *EntryQueryBuilder) WithEntryIDs(entryIDs []int64) *EntryQueryBuilder {
+ e.entryIDs = entryIDs
+ return e
}
// WithEntryID set the entryID.
@@ -195,6 +218,44 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
return entries, nil
}
+// GetEntryIDs returns a list of entry IDs that match the condition.
+func (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) {
+ debugStr := "[EntryQueryBuilder:GetEntryIDs] userID=%d, feedID=%d, categoryID=%d, status=%s, order=%s, direction=%s, offset=%d, limit=%d"
+ defer helper.ExecutionTime(time.Now(), fmt.Sprintf(debugStr, e.userID, e.feedID, e.categoryID, e.status, e.order, e.direction, e.offset, e.limit))
+
+ query := `
+ SELECT
+ e.id
+ FROM entries e
+ LEFT JOIN feeds f ON f.id=e.feed_id
+ WHERE %s %s
+ `
+
+ args, conditions := e.buildCondition()
+ query = fmt.Sprintf(query, conditions, e.buildSorting())
+ // log.Println(query)
+
+ rows, err := e.store.db.Query(query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("unable to get entries: %v", err)
+ }
+ defer rows.Close()
+
+ var entryIDs []int64
+ for rows.Next() {
+ var entryID int64
+
+ err := rows.Scan(&entryID)
+ if err != nil {
+ return nil, fmt.Errorf("unable to fetch entry row: %v", err)
+ }
+
+ entryIDs = append(entryIDs, entryID)
+ }
+
+ return entryIDs, nil
+}
+
func (e *EntryQueryBuilder) buildCondition() ([]interface{}, string) {
args := []interface{}{e.userID}
conditions := []string{"e.user_id = $1"}
@@ -214,6 +275,16 @@ func (e *EntryQueryBuilder) buildCondition() ([]interface{}, string) {
args = append(args, e.entryID)
}
+ if e.greaterThanEntryID != 0 {
+ conditions = append(conditions, fmt.Sprintf("e.id > $%d", len(args)+1))
+ args = append(args, e.greaterThanEntryID)
+ }
+
+ if e.entryIDs != nil {
+ conditions = append(conditions, fmt.Sprintf("e.id=ANY($%d)", len(args)+1))
+ args = append(args, pq.Array(e.entryIDs))
+ }
+
if e.status != "" {
conditions = append(conditions, fmt.Sprintf("e.status=$%d", len(args)+1))
args = append(args, e.status)
@@ -224,6 +295,11 @@ func (e *EntryQueryBuilder) buildCondition() ([]interface{}, string) {
args = append(args, e.notStatus)
}
+ if e.before != nil {
+ conditions = append(conditions, fmt.Sprintf("e.published_at < $%d", len(args)+1))
+ args = append(args, e.before)
+ }
+
return args, strings.Join(conditions, " AND ")
}
diff --git a/storage/icon.go b/storage/icon.go
index e021d29..48b5f46 100644
--- a/storage/icon.go
+++ b/storage/icon.go
@@ -101,6 +101,37 @@ func (s *Storage) CreateFeedIcon(feed *model.Feed, icon *model.Icon) error {
return nil
}
+// Icons returns all icons tht belongs to a user.
+func (s *Storage) Icons(userID int64) (model.Icons, error) {
+ defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:Icons] userID=%d", userID))
+ query := `
+ SELECT
+ icons.id, icons.hash, icons.mime_type, icons.content
+ FROM icons
+ LEFT JOIN feed_icons ON feed_icons.icon_id=icons.id
+ LEFT JOIN feeds ON feeds.id=feed_icons.feed_id
+ WHERE feeds.user_id=$1
+ `
+
+ rows, err := s.db.Query(query, userID)
+ if err != nil {
+ return nil, fmt.Errorf("unable to fetch icons: %v", err)
+ }
+ defer rows.Close()
+
+ var icons model.Icons
+ for rows.Next() {
+ var icon model.Icon
+ err := rows.Scan(&icon.ID, &icon.Hash, &icon.MimeType, &icon.Content)
+ if err != nil {
+ return nil, fmt.Errorf("unable to fetch icons row: %v", err)
+ }
+ icons = append(icons, &icon)
+ }
+
+ return icons, nil
+}
+
func normalizeMimeType(mimeType string) string {
mimeType = strings.ToLower(mimeType)
switch mimeType {
diff --git a/storage/integration.go b/storage/integration.go
index 07b67a8..1c461b9 100644
--- a/storage/integration.go
+++ b/storage/integration.go
@@ -11,6 +11,28 @@ import (
"github.com/miniflux/miniflux2/model"
)
+// UserByFeverToken returns a user by using the Fever API token.
+func (s *Storage) UserByFeverToken(token string) (*model.User, error) {
+ query := `
+ SELECT
+ users.id, users.is_admin, users.timezone
+ FROM users
+ LEFT JOIN integrations ON integrations.user_id=users.id
+ WHERE integrations.fever_enabled='t' AND integrations.fever_token=$1
+ `
+
+ var user model.User
+ err := s.db.QueryRow(query, token).Scan(&user.ID, &user.IsAdmin, &user.Timezone)
+ switch {
+ case err == sql.ErrNoRows:
+ return nil, nil
+ case err != nil:
+ return nil, fmt.Errorf("unable to fetch user: %v", err)
+ }
+
+ return &user, nil
+}
+
// Integration returns user integration settings.
func (s *Storage) Integration(userID int64) (*model.Integration, error) {
query := `SELECT
@@ -21,7 +43,11 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
pinboard_mark_as_unread,
instapaper_enabled,
instapaper_username,
- instapaper_password
+ instapaper_password,
+ fever_enabled,
+ fever_username,
+ fever_password,
+ fever_token
FROM integrations
WHERE user_id=$1
`
@@ -35,6 +61,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.InstapaperEnabled,
&integration.InstapaperUsername,
&integration.InstapaperPassword,
+ &integration.FeverEnabled,
+ &integration.FeverUsername,
+ &integration.FeverPassword,
+ &integration.FeverToken,
)
switch {
case err == sql.ErrNoRows:
@@ -56,8 +86,12 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
pinboard_mark_as_unread=$4,
instapaper_enabled=$5,
instapaper_username=$6,
- instapaper_password=$7
- WHERE user_id=$8
+ instapaper_password=$7,
+ fever_enabled=$8,
+ fever_username=$9,
+ fever_password=$10,
+ fever_token=$11
+ WHERE user_id=$12
`
_, err := s.db.Exec(
query,
@@ -68,6 +102,10 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.InstapaperEnabled,
integration.InstapaperUsername,
integration.InstapaperPassword,
+ integration.FeverEnabled,
+ integration.FeverUsername,
+ integration.FeverPassword,
+ integration.FeverToken,
integration.UserID,
)