Skip to content
Snippets Groups Projects
Commit b153fa8b authored by Frédéric Guillot's avatar Frédéric Guillot
Browse files

Add Wallabag integration

parent ce75748c
No related branches found
No related tags found
No related merge requests found
Showing with 347 additions and 36 deletions
......@@ -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}
......
......@@ -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
......
......@@ -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)
}
}
}
......@@ -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
......
// 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
}
// 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",
}
......@@ -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"
}
......@@ -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
}
......@@ -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)
}
}()
}
......@@ -71,6 +71,28 @@
<input type="password" name="instapaper_password" id="form-instapaper-password" value="{{ .form.InstapaperPassword }}">
</div>
<h3>Wallabag</h3>
<div class="form-section">
<label>
<input type="checkbox" name="wallabag_enabled" value="1" {{ if .form.WallabagEnabled }}checked{{ end }}> {{ t "Save articles to Wallabag" }}
</label>
<label for="form-wallabag-url">{{ t "Wallabag API Endpoint" }}</label>
<input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" placeholder="http://v2.wallabag.org/">
<label for="form-wallabag-client-id">{{ t "Wallabag Client ID" }}</label>
<input type="text" name="wallabag_client_id" id="form-wallabag-client-id" value="{{ .form.WallabagClientID }}">
<label for="form-wallabag-client-secret">{{ t "Wallabag Client Secret" }}</label>
<input type="password" name="wallabag_client_secret" id="form-wallabag-client-secret" value="{{ .form.WallabagClientSecret }}">
<label for="form-wallabag-username">{{ t "Wallabag Username" }}</label>
<input type="text" name="wallabag_username" id="form-wallabag-username" value="{{ .form.WallabagUsername }}">
<label for="form-wallabag-password">{{ t "Wallabag Password" }}</label>
<input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}">
</div>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button>
</div>
......
// 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{
<input type="password" name="instapaper_password" id="form-instapaper-password" value="{{ .form.InstapaperPassword }}">
</div>
<h3>Wallabag</h3>
<div class="form-section">
<label>
<input type="checkbox" name="wallabag_enabled" value="1" {{ if .form.WallabagEnabled }}checked{{ end }}> {{ t "Save articles to Wallabag" }}
</label>
<label for="form-wallabag-url">{{ t "Wallabag API Endpoint" }}</label>
<input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" placeholder="http://v2.wallabag.org/">
<label for="form-wallabag-client-id">{{ t "Wallabag Client ID" }}</label>
<input type="text" name="wallabag_client_id" id="form-wallabag-client-id" value="{{ .form.WallabagClientID }}">
<label for="form-wallabag-client-secret">{{ t "Wallabag Client Secret" }}</label>
<input type="password" name="wallabag_client_secret" id="form-wallabag-client-secret" value="{{ .form.WallabagClientSecret }}">
<label for="form-wallabag-username">{{ t "Wallabag Username" }}</label>
<input type="text" name="wallabag_username" id="form-wallabag-username" value="{{ .form.WallabagUsername }}">
<label for="form-wallabag-password">{{ t "Wallabag Password" }}</label>
<input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}">
</div>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "Loading..." }}">{{ t "Update" }}</button>
</div>
......@@ -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",
......
......@@ -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,
},
}))
}
......
......@@ -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"),
}
}
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
// 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",
......
......@@ -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,
)
......
......@@ -12,7 +12,7 @@ import (
"github.com/miniflux/miniflux/sql"
)
const schemaVersion = 10
const schemaVersion = 11
// Migrate run database migrations.
func (s *Storage) Migrate() {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment