From b153fa8b3cd2e48bbe13326695f11d2013427ebc Mon Sep 17 00:00:00 2001 From: Frédéric Guillot Date: Mon, 18 Dec 2017 20:52:46 -0800 Subject: Add Wallabag integration --- http/client.go | 86 +++++++++++++++++++----- integration/instapaper/instapaper.go | 2 +- integration/integration.go | 30 +++++++-- integration/pinboard/pinboard.go | 2 +- integration/wallabag/wallabag.go | 116 +++++++++++++++++++++++++++++++++ locale/translations.go | 12 +++- locale/translations/fr_FR.json | 8 ++- model/integration.go | 6 ++ scheduler/scheduler.go | 2 +- server/template/html/integrations.html | 22 +++++++ server/template/views.go | 26 +++++++- server/ui/controller/integrations.go | 6 ++ server/ui/form/integration.go | 18 +++++ sql/schema_version_11.sql | 6 ++ sql/sql.go | 9 ++- storage/integration.go | 30 ++++++++- storage/migration.go | 2 +- 17 files changed, 347 insertions(+), 36 deletions(-) create mode 100644 integration/wallabag/wallabag.go create mode 100644 sql/schema_version_11.sql diff --git a/http/client.go b/http/client.go index 9524dd6..304a9cc 100644 --- a/http/client.go +++ b/http/client.go @@ -5,10 +5,14 @@ package http import ( + "bytes" "crypto/tls" + "encoding/json" "fmt" + "io" "net/http" "net/url" + "strings" "time" "github.com/miniflux/miniflux/helper" @@ -21,20 +25,59 @@ const requestTimeout = 300 // Client is a HTTP Client :) type Client struct { - url string - etagHeader string - lastModifiedHeader string - username string - password string - Insecure bool + url string + etagHeader string + lastModifiedHeader string + authorizationHeader string + username string + password string + Insecure bool } // Get execute a GET HTTP request. func (c *Client) Get() (*Response, error) { defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[HttpClient:Get] url=%s", c.url)) + request, err := c.buildRequest(http.MethodGet, nil) + if err != nil { + return nil, err + } + + return c.executeRequest(request) +} + +// PostForm execute a POST HTTP request with form values. +func (c *Client) PostForm(values url.Values) (*Response, error) { + request, err := c.buildRequest(http.MethodPost, strings.NewReader(values.Encode())) + if err != nil { + return nil, err + } + + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + return c.executeRequest(request) +} + +// PostJSON execute a POST HTTP request with JSON payload. +func (c *Client) PostJSON(data interface{}) (*Response, error) { + b, err := json.Marshal(data) + if err != nil { + return nil, err + } + + request, err := c.buildRequest(http.MethodPost, bytes.NewReader(b)) + if err != nil { + return nil, err + } + + request.Header.Add("Content-Type", "application/json") + return c.executeRequest(request) +} + +func (c *Client) executeRequest(request *http.Request) (*Response, error) { + defer helper.ExecutionTime(time.Now(), fmt.Sprintf("[HttpClient] url=%s", c.url)) + client := c.buildClient() - resp, err := client.Do(c.buildRequest()) + resp, err := client.Do(request) if err != nil { return nil, err } @@ -48,7 +91,8 @@ func (c *Client) Get() (*Response, error) { ContentType: resp.Header.Get("Content-Type"), } - logger.Debug("[HttpClient:Get] OriginalURL=%s, StatusCode=%d, ETag=%s, LastModified=%s, EffectiveURL=%s", + logger.Debug("[HttpClient:%s] OriginalURL=%s, StatusCode=%d, ETag=%s, LastModified=%s, EffectiveURL=%s", + request.Method, c.url, response.StatusCode, response.ETag, @@ -59,19 +103,18 @@ func (c *Client) Get() (*Response, error) { return response, err } -func (c *Client) buildRequest() *http.Request { - link, _ := url.Parse(c.url) - request := &http.Request{ - URL: link, - Method: http.MethodGet, - Header: c.buildHeaders(), +func (c *Client) buildRequest(method string, body io.Reader) (*http.Request, error) { + request, err := http.NewRequest(method, c.url, body) + if err != nil { + return nil, err } if c.username != "" && c.password != "" { request.SetBasicAuth(c.username, c.password) } - return request + request.Header = c.buildHeaders() + return request, nil } func (c *Client) buildClient() http.Client { @@ -88,7 +131,7 @@ func (c *Client) buildClient() http.Client { func (c *Client) buildHeaders() http.Header { headers := make(http.Header) headers.Add("User-Agent", userAgent) - headers.Add("Accept", "text/html,application/xhtml+xml,application/xml,application/json") + headers.Add("Accept", "text/html,application/xhtml+xml,application/xml,application/json,image/*") if c.etagHeader != "" { headers.Add("If-None-Match", c.etagHeader) @@ -98,6 +141,10 @@ func (c *Client) buildHeaders() http.Header { headers.Add("If-Modified-Since", c.lastModifiedHeader) } + if c.authorizationHeader != "" { + headers.Add("Authorization", c.authorizationHeader) + } + return headers } @@ -106,11 +153,16 @@ func NewClient(url string) *Client { return &Client{url: url, Insecure: false} } -// NewClientWithCredentials returns a new HTTP client that require authentication. +// NewClientWithCredentials returns a new HTTP client that requires authentication. func NewClientWithCredentials(url, username, password string) *Client { return &Client{url: url, Insecure: false, username: username, password: password} } +// NewClientWithAuthorization returns a new client with a custom authorization header. +func NewClientWithAuthorization(url, authorization string) *Client { + return &Client{url: url, Insecure: false, authorizationHeader: authorization} +} + // NewClientWithCacheHeaders returns a new HTTP client that send cache headers. func NewClientWithCacheHeaders(url, etagHeader, lastModifiedHeader string) *Client { return &Client{url: url, etagHeader: etagHeader, lastModifiedHeader: lastModifiedHeader, Insecure: false} diff --git a/integration/instapaper/instapaper.go b/integration/instapaper/instapaper.go index 51c5e05..33a2535 100644 --- a/integration/instapaper/instapaper.go +++ b/integration/instapaper/instapaper.go @@ -27,7 +27,7 @@ func (c *Client) AddURL(link, title string) error { client := http.NewClientWithCredentials(apiURL, c.username, c.password) response, err := client.Get() if response.HasServerFailure() { - return fmt.Errorf("unable to send bookmark to instapaper, status=%d", response.StatusCode) + return fmt.Errorf("instapaper: unable to send url, status=%d", response.StatusCode) } return err diff --git a/integration/integration.go b/integration/integration.go index 18975e9..1468a2b 100644 --- a/integration/integration.go +++ b/integration/integration.go @@ -7,6 +7,7 @@ package integration import ( "github.com/miniflux/miniflux/integration/instapaper" "github.com/miniflux/miniflux/integration/pinboard" + "github.com/miniflux/miniflux/integration/wallabag" "github.com/miniflux/miniflux/logger" "github.com/miniflux/miniflux/model" ) @@ -15,17 +16,36 @@ import ( func SendEntry(entry *model.Entry, integration *model.Integration) { if integration.PinboardEnabled { client := pinboard.NewClient(integration.PinboardToken) - err := client.AddBookmark(entry.URL, entry.Title, integration.PinboardTags, integration.PinboardMarkAsUnread) + err := client.AddBookmark( + entry.URL, + entry.Title, + integration.PinboardTags, + integration.PinboardMarkAsUnread, + ) + if err != nil { - logger.Error("[Pinboard] %v", err) + logger.Error("[Integration] %v", err) } } if integration.InstapaperEnabled { client := instapaper.NewClient(integration.InstapaperUsername, integration.InstapaperPassword) - err := client.AddURL(entry.URL, entry.Title) - if err != nil { - logger.Error("[Instapaper] %v", err) + if err := client.AddURL(entry.URL, entry.Title); err != nil { + logger.Error("[Integration] %v", err) + } + } + + if integration.WallabagEnabled { + client := wallabag.NewClient( + integration.WallabagURL, + integration.WallabagClientID, + integration.WallabagClientSecret, + integration.WallabagUsername, + integration.WallabagPassword, + ) + + if err := client.AddEntry(entry.URL, entry.Title); err != nil { + logger.Error("[Integration] %v", err) } } } diff --git a/integration/pinboard/pinboard.go b/integration/pinboard/pinboard.go index 2e1bbd7..bad65b1 100644 --- a/integration/pinboard/pinboard.go +++ b/integration/pinboard/pinboard.go @@ -33,7 +33,7 @@ func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error client := http.NewClient("https://api.pinboard.in/v1/posts/add?" + values.Encode()) response, err := client.Get() if response.HasServerFailure() { - return fmt.Errorf("unable to send bookmark to pinboard, status=%d", response.StatusCode) + return fmt.Errorf("pinboard: unable to send bookmark, status=%d", response.StatusCode) } return err diff --git a/integration/wallabag/wallabag.go b/integration/wallabag/wallabag.go new file mode 100644 index 0000000..fbb100a --- /dev/null +++ b/integration/wallabag/wallabag.go @@ -0,0 +1,116 @@ +// 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 wallabag + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + + "github.com/miniflux/miniflux/http" +) + +// Client represents a Wallabag client. +type Client struct { + baseURL string + clientID string + clientSecret string + username string + password string +} + +// AddEntry sends a link to Wallabag. +func (c *Client) AddEntry(link, title string) error { + accessToken, err := c.getAccessToken() + if err != nil { + return err + } + + return c.createEntry(accessToken, link, title) +} + +func (c *Client) createEntry(accessToken, link, title string) error { + endpoint, err := getAPIEndpoint(c.baseURL, "/api/entries.json") + if err != nil { + return fmt.Errorf("wallbag: unable to get entries endpoint: %v", err) + } + + client := http.NewClientWithAuthorization(endpoint, "Bearer "+accessToken) + response, err := client.PostJSON(map[string]string{"url": link, "title": title}) + if err != nil { + return fmt.Errorf("wallabag: unable to post entry: %v", err) + } + + if response.HasServerFailure() { + return fmt.Errorf("wallabag: request failed, status=%d", response.StatusCode) + } + + return nil +} + +func (c *Client) getAccessToken() (string, error) { + values := url.Values{} + values.Add("grant_type", "password") + values.Add("client_id", c.clientID) + values.Add("client_secret", c.clientSecret) + values.Add("username", c.username) + values.Add("password", c.password) + + endpoint, err := getAPIEndpoint(c.baseURL, "/oauth/v2/token") + if err != nil { + return "", fmt.Errorf("wallbag: unable to get token endpoint: %v", err) + } + + client := http.NewClient(endpoint) + response, err := client.PostForm(values) + if err != nil { + return "", fmt.Errorf("wallabag: unable to get access token: %v", err) + } + + if response.HasServerFailure() { + return "", fmt.Errorf("wallabag: request failed, status=%d", response.StatusCode) + } + + token, err := decodeTokenResponse(response.Body) + if err != nil { + return "", err + } + + return token.AccessToken, nil +} + +// NewClient returns a new Wallabag client. +func NewClient(baseURL, clientID, clientSecret, username, password string) *Client { + return &Client{baseURL, clientID, clientSecret, username, password} +} + +func getAPIEndpoint(baseURL, path string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("wallabag: invalid API endpoint: %v", err) + } + u.Path = path + return u.String(), nil +} + +type tokenResponse struct { + AccessToken string `json:"access_token"` + Expires int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +func decodeTokenResponse(body io.Reader) (*tokenResponse, error) { + var token tokenResponse + + decoder := json.NewDecoder(body) + if err := decoder.Decode(&token); err != nil { + return nil, fmt.Errorf("wallabag: unable to decode token response: %v", err) + } + + return &token, nil +} diff --git a/locale/translations.go b/locale/translations.go index 8f5f4ed..7a82d05 100644 --- a/locale/translations.go +++ b/locale/translations.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-16 17:48:32.323083386 -0800 PST m=+0.056720065 +// 2017-12-18 18:49:32.159555255 -0800 PST m=+0.041213049 package locale @@ -171,12 +171,18 @@ var translations = map[string]string{ "Scraper Rules": "Règles pour récupérer le contenu original", "Rewrite Rules": "Règles de réécriture", "Preferences saved!": "Préférences sauvegardées !", - "Your external account is now linked !": "Votre compte externe est maintenant associé !" + "Your external account is now linked !": "Votre compte externe est maintenant associé !", + "Save articles to Wallabag": "Sauvegarder les articles vers Wallabag", + "Wallabag API Endpoint": "URL de l'API de Wallabag", + "Wallabag Client ID": "Identifiant du client Wallabag", + "Wallabag Client Secret": "Clé secrète du client Wallabag", + "Wallabag Username": "Nom d'utilisateur de Wallabag", + "Wallabag Password": "Mot de passe de Wallabag" } `, } var translationsChecksums = map[string]string{ "en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897", - "fr_FR": "f52a6503ee61d1103adb280c242d438a89936b34d147d29c2502cec8b2cc9ff9", + "fr_FR": "3a71dbf4fcdb488acdaf43530e521a0c17a28ef637fbd60b204e468afb0dbe09", } diff --git a/locale/translations/fr_FR.json b/locale/translations/fr_FR.json index 5015f2a..1a5cbd6 100644 --- a/locale/translations/fr_FR.json +++ b/locale/translations/fr_FR.json @@ -155,5 +155,11 @@ "Scraper Rules": "Règles pour récupérer le contenu original", "Rewrite Rules": "Règles de réécriture", "Preferences saved!": "Préférences sauvegardées !", - "Your external account is now linked !": "Votre compte externe est maintenant associé !" + "Your external account is now linked !": "Votre compte externe est maintenant associé !", + "Save articles to Wallabag": "Sauvegarder les articles vers Wallabag", + "Wallabag API Endpoint": "URL de l'API de Wallabag", + "Wallabag Client ID": "Identifiant du client Wallabag", + "Wallabag Client Secret": "Clé secrète du client Wallabag", + "Wallabag Username": "Nom d'utilisateur de Wallabag", + "Wallabag Password": "Mot de passe de Wallabag" } diff --git a/model/integration.go b/model/integration.go index d8ca279..5ddaef2 100644 --- a/model/integration.go +++ b/model/integration.go @@ -18,4 +18,10 @@ type Integration struct { FeverUsername string FeverPassword string FeverToken string + WallabagEnabled bool + WallabagURL string + WallabagClientID string + WallabagClientSecret string + WallabagUsername string + WallabagPassword string } diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index cc3de6a..90011cb 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -34,7 +34,7 @@ func NewSessionScheduler(store *storage.Storage, frequency int) { for _ = range c { nbSessions := store.CleanOldSessions() nbUserSessions := store.CleanOldUserSessions() - logger.Debug("[SessionScheduler] cleaned %d sessions and %d user sessions", nbSessions, nbUserSessions) + logger.Info("[SessionScheduler] cleaned %d sessions and %d user sessions", nbSessions, nbUserSessions) } }() } diff --git a/server/template/html/integrations.html b/server/template/html/integrations.html index adc5a1b..5005d68 100644 --- a/server/template/html/integrations.html +++ b/server/template/html/integrations.html @@ -71,6 +71,28 @@ +

Wallabag

+
+ + + + + + + + + + + + + + + + +
+
diff --git a/server/template/views.go b/server/template/views.go index f455c2f..571727e 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-15 18:49:24.044316922 -0800 PST m=+0.016912794 +// 2017-12-18 18:49:32.144679579 -0800 PST m=+0.026337373 package template @@ -882,6 +882,28 @@ var templateViewsMap = map[string]string{ +

Wallabag

+
+ + + + + + + + + + + + + + + + +
+
@@ -1201,7 +1223,7 @@ var templateViewsMapChecksums = map[string]string{ "feeds": "c22af39b42ba9ca69ea0914ca789303ec2c5b484abcd4eaa49016e365381257c", "history": "9a67599a5d8d67ef958e3f07da339b749f42892667547c9e60a54477e8d32a56", "import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f", - "integrations": "30249eefa4e2da62051447537ee5c4ed3dad377656fec3080e0e96c3c697c672", + "integrations": "3c14d7de904911aad7f3ebec6d1a20b50843287f58125c526e167f429f3d455d", "login": "04f3ce79bfa5753f69e0d956c2a8999c0da549c7925634a3e8134975da0b0e0f", "sessions": "878dbe8f8ea783b44130c495814179519fa5c3aa2666ac87508f94d58dd008bf", "settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9", diff --git a/server/ui/controller/integrations.go b/server/ui/controller/integrations.go index babcfb7..99971b0 100644 --- a/server/ui/controller/integrations.go +++ b/server/ui/controller/integrations.go @@ -40,6 +40,12 @@ func (c *Controller) ShowIntegrations(ctx *core.Context, request *core.Request, FeverEnabled: integration.FeverEnabled, FeverUsername: integration.FeverUsername, FeverPassword: integration.FeverPassword, + WallabagEnabled: integration.WallabagEnabled, + WallabagURL: integration.WallabagURL, + WallabagClientID: integration.WallabagClientID, + WallabagClientSecret: integration.WallabagClientSecret, + WallabagUsername: integration.WallabagUsername, + WallabagPassword: integration.WallabagPassword, }, })) } diff --git a/server/ui/form/integration.go b/server/ui/form/integration.go index 209b842..8cc6d35 100644 --- a/server/ui/form/integration.go +++ b/server/ui/form/integration.go @@ -22,6 +22,12 @@ type IntegrationForm struct { FeverEnabled bool FeverUsername string FeverPassword string + WallabagEnabled bool + WallabagURL string + WallabagClientID string + WallabagClientSecret string + WallabagUsername string + WallabagPassword string } // Merge copy form values to the model. @@ -36,6 +42,12 @@ func (i IntegrationForm) Merge(integration *model.Integration) { integration.FeverEnabled = i.FeverEnabled integration.FeverUsername = i.FeverUsername integration.FeverPassword = i.FeverPassword + integration.WallabagEnabled = i.WallabagEnabled + integration.WallabagURL = i.WallabagURL + integration.WallabagClientID = i.WallabagClientID + integration.WallabagClientSecret = i.WallabagClientSecret + integration.WallabagUsername = i.WallabagUsername + integration.WallabagPassword = i.WallabagPassword } // NewIntegrationForm returns a new AuthForm. @@ -51,5 +63,11 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm { FeverEnabled: r.FormValue("fever_enabled") == "1", FeverUsername: r.FormValue("fever_username"), FeverPassword: r.FormValue("fever_password"), + WallabagEnabled: r.FormValue("wallabag_enabled") == "1", + WallabagURL: r.FormValue("wallabag_url"), + WallabagClientID: r.FormValue("wallabag_client_id"), + WallabagClientSecret: r.FormValue("wallabag_client_secret"), + WallabagUsername: r.FormValue("wallabag_username"), + WallabagPassword: r.FormValue("wallabag_password"), } } diff --git a/sql/schema_version_11.sql b/sql/schema_version_11.sql new file mode 100644 index 0000000..79f08d7 --- /dev/null +++ b/sql/schema_version_11.sql @@ -0,0 +1,6 @@ +alter table integrations add column wallabag_enabled bool default 'f'; +alter table integrations add column wallabag_url text default ''; +alter table integrations add column wallabag_client_id text default ''; +alter table integrations add column wallabag_client_secret text default ''; +alter table integrations add column wallabag_username text default ''; +alter table integrations add column wallabag_password text default ''; \ No newline at end of file diff --git a/sql/sql.go b/sql/sql.go index b026243..3cbfe11 100644 --- a/sql/sql.go +++ b/sql/sql.go @@ -1,5 +1,5 @@ // Code generated by go generate; DO NOT EDIT. -// 2017-12-16 17:48:32.268871258 -0800 PST m=+0.002507937 +// 2017-12-18 18:49:32.121198779 -0800 PST m=+0.002856573 package sql @@ -116,6 +116,12 @@ create table sessions ( created_at timestamp with time zone not null default now(), primary key(id) );`, + "schema_version_11": `alter table integrations add column wallabag_enabled bool default 'f'; +alter table integrations add column wallabag_url text default ''; +alter table integrations add column wallabag_client_id text default ''; +alter table integrations add column wallabag_client_secret text default ''; +alter table integrations add column wallabag_username text default ''; +alter table integrations add column wallabag_password text default '';`, "schema_version_2": `create extension if not exists hstore; alter table users add column extra hstore; create index users_extra_idx on users using gin(extra); @@ -157,6 +163,7 @@ alter table users add column entry_direction entry_sorting_direction default 'as var SqlMapChecksums = map[string]string{ "schema_version_1": "7be580fc8a93db5da54b2f6e87019803c33b0b0c28482c7af80cef873bdac4e2", "schema_version_10": "8faf15ddeff7c8cc305e66218face11ed92b97df2bdc2d0d7944d61441656795", + "schema_version_11": "dc5bbc302e01e425b49c48ddcd8e29e3ab2bb8e73a6cd1858a6ba9fbec0b5243", "schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4", "schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12", "schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9", diff --git a/storage/integration.go b/storage/integration.go index 6d89a68..779ca81 100644 --- a/storage/integration.go +++ b/storage/integration.go @@ -47,7 +47,13 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) { fever_enabled, fever_username, fever_password, - fever_token + fever_token, + wallabag_enabled, + wallabag_url, + wallabag_client_id, + wallabag_client_secret, + wallabag_username, + wallabag_password FROM integrations WHERE user_id=$1 ` @@ -65,6 +71,12 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) { &integration.FeverUsername, &integration.FeverPassword, &integration.FeverToken, + &integration.WallabagEnabled, + &integration.WallabagURL, + &integration.WallabagClientID, + &integration.WallabagClientSecret, + &integration.WallabagUsername, + &integration.WallabagPassword, ) switch { case err == sql.ErrNoRows: @@ -90,8 +102,14 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error { fever_enabled=$8, fever_username=$9, fever_password=$10, - fever_token=$11 - WHERE user_id=$12 + fever_token=$11, + wallabag_enabled=$12, + wallabag_url=$13, + wallabag_client_id=$14, + wallabag_client_secret=$15, + wallabag_username=$16, + wallabag_password=$17 + WHERE user_id=$18 ` _, err := s.db.Exec( query, @@ -106,6 +124,12 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error { integration.FeverUsername, integration.FeverPassword, integration.FeverToken, + integration.WallabagEnabled, + integration.WallabagURL, + integration.WallabagClientID, + integration.WallabagClientSecret, + integration.WallabagUsername, + integration.WallabagPassword, integration.UserID, ) diff --git a/storage/migration.go b/storage/migration.go index d29c76d..0408330 100644 --- a/storage/migration.go +++ b/storage/migration.go @@ -12,7 +12,7 @@ import ( "github.com/miniflux/miniflux/sql" ) -const schemaVersion = 10 +const schemaVersion = 11 // Migrate run database migrations. func (s *Storage) Migrate() { -- cgit v1.2.3