From 7f2fd1fdd85a62b187ad901d4917e561e39e37b2 Mon Sep 17 00:00:00 2001 From: Frédéric Guillot Date: Sun, 20 May 2018 15:29:14 -0700 Subject: Add Pocket authorization flow in the user interface --- daemon/routes.go | 2 + http/context/context.go | 5 ++ integration/pocket/connector.go | 103 ++++++++++++++++++++++++++++++++++++++++ integration/pocket/pocket.go | 27 ++++++----- locale/translations.go | 13 +++-- locale/translations/fr_FR.json | 9 +++- middleware/app_session.go | 1 + middleware/context_keys.go | 3 ++ model/session.go | 11 +++-- sql/sql.go | 2 +- template/common.go | 2 +- template/html/integrations.html | 30 +++++++----- template/views.go | 34 +++++++------ ui/integration_pocket.go | 87 +++++++++++++++++++++++++++++++++ ui/session/session.go | 5 ++ ui/static/bin.go | 2 +- ui/static/css.go | 2 +- ui/static/js.go | 2 +- 18 files changed, 286 insertions(+), 54 deletions(-) create mode 100644 integration/pocket/connector.go create mode 100644 ui/integration_pocket.go diff --git a/daemon/routes.go b/daemon/routes.go index 998df5c..49e8467 100644 --- a/daemon/routes.go +++ b/daemon/routes.go @@ -133,6 +133,8 @@ func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handle uiRouter.HandleFunc("/bookmarklet", uiController.Bookmarklet).Name("bookmarklet").Methods("GET") uiRouter.HandleFunc("/integrations", uiController.ShowIntegrations).Name("integrations").Methods("GET") uiRouter.HandleFunc("/integration", uiController.UpdateIntegration).Name("updateIntegration").Methods("POST") + uiRouter.HandleFunc("/integration/pocket/authorize", uiController.PocketAuthorize).Name("pocketAuthorize").Methods("GET") + uiRouter.HandleFunc("/integration/pocket/callback", uiController.PocketCallback).Name("pocketCallback").Methods("GET") uiRouter.HandleFunc("/sessions", uiController.ShowSessions).Name("sessions").Methods("GET") uiRouter.HandleFunc("/sessions/{sessionID}/remove", uiController.RemoveSession).Name("removeSession").Methods("POST") uiRouter.HandleFunc("/export", uiController.Export).Name("export").Methods("GET") diff --git a/http/context/context.go b/http/context/context.go index cf7cb09..f0fed23 100644 --- a/http/context/context.go +++ b/http/context/context.go @@ -78,6 +78,11 @@ func (c *Context) FlashErrorMessage() string { return c.getContextStringValue(middleware.FlashErrorMessageContextKey) } +// PocketRequestToken returns the Pocket Request Token if any. +func (c *Context) PocketRequestToken() string { + return c.getContextStringValue(middleware.PocketRequestTokenContextKey) +} + func (c *Context) getContextStringValue(key *middleware.ContextKey) string { if v := c.request.Context().Value(key); v != nil { return v.(string) diff --git a/integration/pocket/connector.go b/integration/pocket/connector.go new file mode 100644 index 0000000..b3ed79d --- /dev/null +++ b/integration/pocket/connector.go @@ -0,0 +1,103 @@ +// Copyright 2018 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 pocket + +import ( + "errors" + "fmt" + "io/ioutil" + "net/url" + + "github.com/miniflux/miniflux/http/client" +) + +// Connector manages the authorization flow with Pocket to get a personal access token. +type Connector struct { + consumerKey string +} + +// RequestToken fetches a new request token from Pocket API. +func (c *Connector) RequestToken(redirectURL string) (string, error) { + type req struct { + ConsumerKey string `json:"consumer_key"` + RedirectURI string `json:"redirect_uri"` + } + + clt := client.New("https://getpocket.com/v3/oauth/request") + response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, RedirectURI: redirectURL}) + if err != nil { + return "", fmt.Errorf("pocket: unable to fetch request token: %v", err) + } + + if response.HasServerFailure() { + return "", fmt.Errorf("pocket: unable to fetch request token, status=%d", response.StatusCode) + } + + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("pocket: unable to read response body: %v", err) + } + + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", fmt.Errorf("pocket: unable to parse response: %v", err) + } + + code := values.Get("code") + if code == "" { + return "", errors.New("pocket: code is empty") + } + + return code, nil +} + +// AccessToken fetches a new access token once the end-user authorized the application. +func (c *Connector) AccessToken(requestToken string) (string, error) { + type req struct { + ConsumerKey string `json:"consumer_key"` + Code string `json:"code"` + } + + clt := client.New("https://getpocket.com/v3/oauth/authorize") + response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, Code: requestToken}) + if err != nil { + return "", fmt.Errorf("pocket: unable to fetch access token: %v", err) + } + + if response.HasServerFailure() { + return "", fmt.Errorf("pocket: unable to fetch access token, status=%d", response.StatusCode) + } + + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("pocket: unable to read response body: %v", err) + } + + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", fmt.Errorf("pocket: unable to parse response: %v", err) + } + + token := values.Get("access_token") + if token == "" { + return "", errors.New("pocket: access_token is empty") + } + + return token, nil +} + +// AuthorizationURL returns the authorization URL for the end-user. +func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string { + return fmt.Sprintf( + "https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s", + requestToken, + redirectURL, + ) +} + +// NewConnector returns a new Pocket Connector. +func NewConnector(consumerKey string) *Connector { + return &Connector{consumerKey} +} diff --git a/integration/pocket/pocket.go b/integration/pocket/pocket.go index a46cd30..16a826c 100644 --- a/integration/pocket/pocket.go +++ b/integration/pocket/pocket.go @@ -1,4 +1,4 @@ -// Copyright 2017 Frédéric Guillot. All rights reserved. +// Copyright 2018 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. @@ -16,21 +16,20 @@ type Client struct { consumerKey string } -// Parameters for a Pocket add call. -type Parameters struct { - AccessToken string `json:"access_token"` - ConsumerKey string `json:"consumer_key"` - Title string `json:"title,omitempty"` - URL string `json:"url,omitempty"` -} - // AddURL sends a single link to Pocket. func (c *Client) AddURL(link, title string) error { if c.consumerKey == "" || c.accessToken == "" { return fmt.Errorf("pocket: missing credentials") } - parameters := &Parameters{ + type body struct { + AccessToken string `json:"access_token"` + ConsumerKey string `json:"consumer_key"` + Title string `json:"title,omitempty"` + URL string `json:"url"` + } + + data := &body{ AccessToken: c.accessToken, ConsumerKey: c.consumerKey, Title: title, @@ -38,12 +37,16 @@ func (c *Client) AddURL(link, title string) error { } clt := client.New("https://getpocket.com/v3/add") - response, err := clt.PostJSON(parameters) + response, err := clt.PostJSON(data) + if err != nil { + return fmt.Errorf("pocket: unable to send url: %v", err) + } + if response.HasServerFailure() { return fmt.Errorf("pocket: unable to send url, status=%d", response.StatusCode) } - return err + return nil } // NewClient returns a new Pocket client. diff --git a/locale/translations.go b/locale/translations.go index d5c4e16..2de2c4c 100755 --- a/locale/translations.go +++ b/locale/translations.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2018-05-20 11:35:19.498340382 -0700 PDT m=+0.010175046 +// 2018-05-20 15:22:33.795349513 -0700 PDT m=+0.026820026 package locale @@ -462,7 +462,14 @@ var translations = map[string]string{ "View Comments": "Voir les commentaires", "This file is empty": "Ce fichier est vide", "Your external account is now dissociated!": "Votre compte externe est maintenant dissocié !", - "You must define a password otherwise you won't be able to login again.": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite." + "You must define a password otherwise you won't be able to login again.": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.", + "Save articles to Pocket": "Sauvegarder les articles vers Pocket", + "Connect your Pocket account": "Connectez votre compte Pocket", + "Pocket Consumer Key": "« Pocket Consumer Key »", + "Pocket Access Token": "« Pocket Access Token »", + "Your Pocket account is now linked!": "Votre compte Pocket est maintenant connecté !", + "Unable to fetch access token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !", + "Unable to fetch request token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !" } `, "nl_NL": `{ @@ -1137,7 +1144,7 @@ var translations = map[string]string{ var translationsChecksums = map[string]string{ "de_DE": "791d72c96137ab03b729017bdfa27c8eed2f65912e372fcb5b2796d5099d5498", "en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897", - "fr_FR": "5a954b28ac31af6fc525cb000d86c884950dac7414b695bd38a4c0aebdfe35b5", + "fr_FR": "808f1561135bb3d0f886833f00a51402113925e9086c01f71b30bcc679d9b7c2", "nl_NL": "1a73f1dd1c4c0d2c2adc8695cdd050c2dad81c14876caed3892b44adc2491265", "pl_PL": "da709c14ff71f3b516eec66cb2758d89c5feab1472c94b2b518f425162a9f806", "zh_CN": "d80594c1b67d15e9f4673d3d62fe4949e8606a5fdfb741d8a8921f21dceb8cf2", diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index ac3c571..6d66f11 100644 --- a/locale/translations/fr_FR.json +++ b/locale/translations/fr_FR.json @@ -222,5 +222,12 @@ "View Comments": "Voir les commentaires", "This file is empty": "Ce fichier est vide", "Your external account is now dissociated!": "Votre compte externe est maintenant dissocié !", - "You must define a password otherwise you won't be able to login again.": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite." + "You must define a password otherwise you won't be able to login again.": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.", + "Save articles to Pocket": "Sauvegarder les articles vers Pocket", + "Connect your Pocket account": "Connectez votre compte Pocket", + "Pocket Consumer Key": "« Pocket Consumer Key »", + "Pocket Access Token": "« Pocket Access Token »", + "Your Pocket account is now linked!": "Votre compte Pocket est maintenant connecté !", + "Unable to fetch access token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !", + "Unable to fetch request token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !" } diff --git a/middleware/app_session.go b/middleware/app_session.go index e898ee3..806debd 100644 --- a/middleware/app_session.go +++ b/middleware/app_session.go @@ -55,6 +55,7 @@ func (m *Middleware) AppSession(next http.Handler) http.Handler { ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage) ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage) ctx = context.WithValue(ctx, UserLanguageContextKey, session.Data.Language) + ctx = context.WithValue(ctx, PocketRequestTokenContextKey, session.Data.PocketRequestToken) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/middleware/context_keys.go b/middleware/context_keys.go index cc6bd2d..026da05 100644 --- a/middleware/context_keys.go +++ b/middleware/context_keys.go @@ -46,4 +46,7 @@ var ( // FlashErrorMessageContextKey is the context key used to store a flash error message. FlashErrorMessageContextKey = &ContextKey{"FlashErrorMessage"} + + // PocketRequestTokenContextKey is the context key for Pocket Request Token. + PocketRequestTokenContextKey = &ContextKey{"PocketRequestToken"} ) diff --git a/model/session.go b/model/session.go index 25f0388..763f709 100644 --- a/model/session.go +++ b/model/session.go @@ -13,11 +13,12 @@ import ( // SessionData represents the data attached to the session. type SessionData struct { - CSRF string `json:"csrf"` - OAuth2State string `json:"oauth2_state"` - FlashMessage string `json:"flash_message"` - FlashErrorMessage string `json:"flash_error_message"` - Language string `json:"language"` + CSRF string `json:"csrf"` + OAuth2State string `json:"oauth2_state"` + FlashMessage string `json:"flash_message"` + FlashErrorMessage string `json:"flash_error_message"` + Language string `json:"language"` + PocketRequestToken string `json:"pocket_request_token"` } func (s SessionData) String() string { diff --git a/sql/sql.go b/sql/sql.go index 4dcf761..0a83d7a 100644 --- a/sql/sql.go +++ b/sql/sql.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2018-05-20 11:35:19.489434225 -0700 PDT m=+0.001268896 +// 2018-05-20 15:22:33.771570698 -0700 PDT m=+0.003041211 package sql diff --git a/template/common.go b/template/common.go index 8519d0c..ecb1bd2 100644 --- a/template/common.go +++ b/template/common.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2018-05-20 11:35:19.497832269 -0700 PDT m=+0.009666945 +// 2018-05-20 15:22:33.793709932 -0700 PDT m=+0.025180445 package template diff --git a/template/html/integrations.html b/template/html/integrations.html index b4a70a5..4316656 100644 --- a/template/html/integrations.html +++ b/template/html/integrations.html @@ -73,6 +73,23 @@ +

Pocket

+
+ + + + + + + + + {{ if not .form.PocketAccessToken }} +

{{ t "Connect your Pocket account" }}

+ {{ end }} +
+

Wallabag

-

Pocket

-
- - - - - - - -
-
diff --git a/template/views.go b/template/views.go index 57031bb..a74fb75 100644 --- a/template/views.go +++ b/template/views.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2018-05-20 11:35:19.495047296 -0700 PDT m=+0.006881961 +// 2018-05-20 15:22:33.785042033 -0700 PDT m=+0.016512546 package template @@ -886,6 +886,23 @@ var templateViewsMap = map[string]string{ +

Pocket

+
+ + + + + + + + + {{ if not .form.PocketAccessToken }} +

{{ t "Connect your Pocket account" }}

+ {{ end }} +
+

Wallabag

-

Pocket

-
- - - - - - - -
-
@@ -1259,7 +1263,7 @@ var templateViewsMapChecksums = map[string]string{ "feeds": "2a5abe37968ea34a0576dbef52341645cb1fc9562e351382fbf721491da6f4fa", "history_entries": "451f0b202f47c9db5344d3e73862f5b7afbd4323fbdba21b6087866c40f045d3", "import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f", - "integrations": "919b73a7dec91c2973db840eecf709de1e2e004f5a592d7e377ef1cb0926adce", + "integrations": "9b31a245d7d10e28a5682ec993b173972b3d69a86e731e8b09cf13a9ecbf6a09", "login": "7d83c3067c02f1f6aafdd8816c7f97a4eb5a5a4bdaaaa4cc1e2fbb9c17ea65e8", "sessions": "3fa79031dd883847eba92fbafe5f535fa3a4e1614bb610f20588b6f8fc8b3624", "settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9", diff --git a/ui/integration_pocket.go b/ui/integration_pocket.go new file mode 100644 index 0000000..4f65d61 --- /dev/null +++ b/ui/integration_pocket.go @@ -0,0 +1,87 @@ +// Copyright 2018 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 ui + +import ( + "net/http" + + "github.com/miniflux/miniflux/http/context" + "github.com/miniflux/miniflux/http/response" + "github.com/miniflux/miniflux/http/response/html" + "github.com/miniflux/miniflux/http/route" + "github.com/miniflux/miniflux/integration/pocket" + "github.com/miniflux/miniflux/logger" + "github.com/miniflux/miniflux/ui/session" +) + +// PocketAuthorize redirects the end-user to Pocket website to authorize the application. +func (c *Controller) PocketAuthorize(w http.ResponseWriter, r *http.Request) { + ctx := context.New(r) + + user, err := c.store.UserByID(ctx.UserID()) + if err != nil { + html.ServerError(w, err) + return + } + + integration, err := c.store.Integration(user.ID) + if err != nil { + html.ServerError(w, err) + return + } + + sess := session.New(c.store, ctx) + connector := pocket.NewConnector(integration.PocketConsumerKey) + redirectURL := c.cfg.BaseURL() + route.Path(c.router, "pocketCallback") + requestToken, err := connector.RequestToken(redirectURL) + if err != nil { + logger.Error("[Pocket:Authorize] %v", err) + sess.NewFlashErrorMessage(c.translator.GetLanguage(ctx.UserLanguage()).Get("Unable to fetch request token from Pocket!")) + response.Redirect(w, r, route.Path(c.router, "integrations")) + return + } + + sess.SetPocketRequestToken(requestToken) + response.Redirect(w, r, connector.AuthorizationURL(requestToken, redirectURL)) +} + +// PocketCallback saves the personal access token after the authorization step. +func (c *Controller) PocketCallback(w http.ResponseWriter, r *http.Request) { + ctx := context.New(r) + sess := session.New(c.store, ctx) + + user, err := c.store.UserByID(ctx.UserID()) + if err != nil { + html.ServerError(w, err) + return + } + + integration, err := c.store.Integration(user.ID) + if err != nil { + html.ServerError(w, err) + return + } + + connector := pocket.NewConnector(integration.PocketConsumerKey) + accessToken, err := connector.AccessToken(ctx.PocketRequestToken()) + if err != nil { + logger.Error("[Pocket:Callback] %v", err) + sess.NewFlashErrorMessage(c.translator.GetLanguage(ctx.UserLanguage()).Get("Unable to fetch access token from Pocket!")) + response.Redirect(w, r, route.Path(c.router, "integrations")) + return + } + + sess.SetPocketRequestToken("") + integration.PocketAccessToken = accessToken + + err = c.store.UpdateIntegration(integration) + if err != nil { + html.ServerError(w, err) + return + } + + sess.NewFlashMessage(c.translator.GetLanguage(ctx.UserLanguage()).Get("Your Pocket account is now linked!")) + response.Redirect(w, r, route.Path(c.router, "integrations")) +} diff --git a/ui/session/session.go b/ui/session/session.go index fc25406..3d630a8 100644 --- a/ui/session/session.go +++ b/ui/session/session.go @@ -56,6 +56,11 @@ func (s *Session) SetLanguage(language string) { s.store.UpdateSessionField(s.ctx.SessionID(), "language", language) } +// SetPocketRequestToken updates Pocket Request Token. +func (s *Session) SetPocketRequestToken(requestToken string) { + s.store.UpdateSessionField(s.ctx.SessionID(), "pocket_request_token", requestToken) +} + // New returns a new session handler. func New(store *storage.Storage, ctx *context.Context) *Session { return &Session{store, ctx} diff --git a/ui/static/bin.go b/ui/static/bin.go index a5c0ed0..6c74cd9 100644 --- a/ui/static/bin.go +++ b/ui/static/bin.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2018-05-20 11:35:19.492969127 -0700 PDT m=+0.004803782 +// 2018-05-20 15:22:33.779971968 -0700 PDT m=+0.011442481 package static diff --git a/ui/static/css.go b/ui/static/css.go index 8e28baf..6755dcf 100644 --- a/ui/static/css.go +++ b/ui/static/css.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2018-05-20 11:35:19.493979584 -0700 PDT m=+0.005814260 +// 2018-05-20 15:22:33.782567368 -0700 PDT m=+0.014037881 package static diff --git a/ui/static/js.go b/ui/static/js.go index b507236..d4558c2 100644 --- a/ui/static/js.go +++ b/ui/static/js.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2018-05-20 11:35:19.494515654 -0700 PDT m=+0.006350329 +// 2018-05-20 15:22:33.784038886 -0700 PDT m=+0.015509399 package static -- cgit v1.2.3