From bc20e0884b3ca051ae77e1bb6e2de11419d36d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guillot?= Date: Sun, 3 Dec 2017 17:44:27 -0800 Subject: [PATCH] Add Fever API --- README.md | 2 +- locale/translations.go | 13 +- locale/translations/fr_FR.json | 9 +- model/icon.go | 13 + model/integration.go | 4 + server/core/json_response.go | 5 + server/core/request.go | 19 + server/core/response.go | 2 +- server/fever/fever.go | 636 +++++++++++++++++++++++++ server/middleware/fever.go | 57 +++ server/routes.go | 8 + server/static/bin.go | 2 +- server/static/css.go | 2 +- server/static/js.go | 2 +- server/template/common.go | 2 +- server/template/html/integrations.html | 17 +- server/template/views.go | 21 +- server/ui/controller/integrations.go | 11 + server/ui/form/integration.go | 9 + sql/schema_version_5.sql | 4 + sql/sql.go | 8 +- storage/entry_query_builder.go | 100 +++- storage/icon.go | 31 ++ storage/integration.go | 44 +- 24 files changed, 984 insertions(+), 37 deletions(-) create mode 100644 server/fever/fever.go create mode 100644 server/middleware/fever.go 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 @@
{{ t .errorMessage }}
{{ end }} +

Fever

+
+ + + + + + + +
+

Pinboard

@@ -48,7 +61,7 @@

Instapaper

diff --git a/server/template/views.go b/server/template/views.go index 514c78eb..fdee0590 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{
{{ t .errorMessage }}
{{ end }} +

Fever

+
+ + + + + + + +
+

Pinboard

@@ -831,7 +844,7 @@ var templateViewsMap = map[string]string{

Instapaper

@@ -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 a3e545e0..ceac09ab 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 be54e23a..bd324039 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 326562f9..dac79374 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 e1881310..d3a3897e 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 6f7f4bd8..143fd3ba 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 e021d291..48b5f461 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 07b67a89..1c461b9f 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, )