diff --git a/http/client.go b/http/client.go index 9524dd67..304a9cc5 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 51c5e05e..33a25353 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 18975e97..1468a2b5 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 2e1bbd74..bad65b1e 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 00000000..fbb100a8 --- /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 8f5f4eda..7a82d051 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 5015f2ae..1a5cbd6b 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 d8ca2798..5ddaef27 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 cc3de6aa..90011cb9 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 adc5a1bd..5005d688 100644 --- a/server/template/html/integrations.html +++ b/server/template/html/integrations.html @@ -71,6 +71,28 @@ +