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 @@