Browse Source

Add generic webhook integration

Frédéric Guillot 2 years ago
parent
commit
48f6885f44
39 changed files with 465 additions and 262 deletions
  1. 7 0
      internal/crypto/crypto.go
  2. 9 0
      internal/database/migrations.go
  3. 40 29
      internal/integration/integration.go
  4. 64 0
      internal/integration/webhook/webhook.go
  5. 3 0
      internal/locale/translations/de_DE.json
  6. 3 0
      internal/locale/translations/el_EL.json
  7. 3 0
      internal/locale/translations/en_US.json
  8. 3 0
      internal/locale/translations/es_ES.json
  9. 3 0
      internal/locale/translations/fi_FI.json
  10. 3 0
      internal/locale/translations/fr_FR.json
  11. 3 0
      internal/locale/translations/hi_IN.json
  12. 3 0
      internal/locale/translations/id_ID.json
  13. 3 0
      internal/locale/translations/it_IT.json
  14. 3 0
      internal/locale/translations/ja_JP.json
  15. 3 0
      internal/locale/translations/nl_NL.json
  16. 3 0
      internal/locale/translations/pl_PL.json
  17. 3 0
      internal/locale/translations/pt_BR.json
  18. 3 0
      internal/locale/translations/ru_RU.json
  19. 3 0
      internal/locale/translations/tr_TR.json
  20. 3 0
      internal/locale/translations/uk_UA.json
  21. 3 0
      internal/locale/translations/zh_CN.json
  22. 3 0
      internal/locale/translations/zh_TW.json
  23. 7 0
      internal/model/entry.go
  24. 3 0
      internal/model/integration.go
  25. 1 1
      internal/reader/atom/atom_03.go
  26. 2 2
      internal/reader/atom/atom_10.go
  27. 11 1
      internal/reader/handler/handler.go
  28. 5 2
      internal/reader/json/json.go
  29. 0 27
      internal/reader/processor/processor.go
  30. 1 1
      internal/reader/rdf/rdf.go
  31. 2 2
      internal/reader/rss/rss.go
  32. 12 13
      internal/storage/enclosure.go
  33. 19 11
      internal/storage/entry.go
  34. 3 3
      internal/storage/feed.go
  35. 15 3
      internal/storage/integration.go
  36. 188 167
      internal/template/templates/views/integrations.html
  37. 7 0
      internal/ui/form/integration.go
  38. 3 0
      internal/ui/integration_show.go
  39. 12 0
      internal/ui/integration_update.go

+ 7 - 0
internal/crypto/crypto.go

@@ -4,6 +4,7 @@
 package crypto // import "miniflux.app/v2/internal/crypto"
 
 import (
+	"crypto/hmac"
 	"crypto/rand"
 	"crypto/sha256"
 	"encoding/base64"
@@ -48,3 +49,9 @@ func HashPassword(password string) (string, error) {
 	bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
 	return string(bytes), err
 }
+
+func GenerateSHA256Hmac(secret string, data []byte) string {
+	h := hmac.New(sha256.New, []byte(secret))
+	h.Write(data)
+	return hex.EncodeToString(h.Sum(nil))
+}

+ 9 - 0
internal/database/migrations.go

@@ -758,4 +758,13 @@ var migrations = []func(tx *sql.Tx) error{
 		`)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		sql := `
+			ALTER TABLE integrations ADD COLUMN webhook_enabled bool default 'f';
+			ALTER TABLE integrations ADD COLUMN webhook_url text default '';
+			ALTER TABLE integrations ADD COLUMN webhook_secret text default '';
+		`
+		_, err = tx.Exec(sql)
+		return err
+	},
 }

+ 40 - 29
internal/integration/integration.go

@@ -19,6 +19,7 @@ import (
 	"miniflux.app/v2/internal/integration/shiori"
 	"miniflux.app/v2/internal/integration/telegrambot"
 	"miniflux.app/v2/internal/integration/wallabag"
+	"miniflux.app/v2/internal/integration/webhook"
 	"miniflux.app/v2/internal/logger"
 	"miniflux.app/v2/internal/model"
 )
@@ -168,45 +169,55 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
 	}
 }
 
-// PushEntries pushes an entry array to third-party providers during feed refreshes.
-func PushEntries(entries model.Entries, integration *model.Integration) {
-	if integration.MatrixBotEnabled {
-		logger.Debug("[Integration] Sending %d entries for User #%d to Matrix", len(entries), integration.UserID)
+// PushEntries pushes a list of entries to activated third-party providers during feed refreshes.
+func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *model.Integration) {
+	if userIntegrations.MatrixBotEnabled {
+		logger.Debug("[Integration] Sending %d entries for User #%d to Matrix", len(entries), userIntegrations.UserID)
 
-		err := matrixbot.PushEntries(entries, integration.MatrixBotURL, integration.MatrixBotUser, integration.MatrixBotPassword, integration.MatrixBotChatID)
+		err := matrixbot.PushEntries(entries, userIntegrations.MatrixBotURL, userIntegrations.MatrixBotUser, userIntegrations.MatrixBotPassword, userIntegrations.MatrixBotChatID)
 		if err != nil {
 			logger.Error("[Integration] push entries to matrix bot failed: %v", err)
 		}
 	}
-}
 
-// PushEntry pushes an entry to third-party providers during feed refreshes.
-func PushEntry(entry *model.Entry, feed *model.Feed, integration *model.Integration) {
-	if integration.TelegramBotEnabled {
-		logger.Debug("[Integration] Sending Entry %q for User #%d to Telegram", entry.URL, integration.UserID)
+	if userIntegrations.WebhookEnabled {
+		logger.Debug("[Integration] Sending %d entries for User #%d to Webhook URL: %s", len(entries), userIntegrations.UserID, userIntegrations.WebhookURL)
 
-		err := telegrambot.PushEntry(entry, integration.TelegramBotToken, integration.TelegramBotChatID)
-		if err != nil {
-			logger.Error("[Integration] push entry to telegram bot failed: %v", err)
+		webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret)
+		if err := webhookClient.SendWebhook(entries); err != nil {
+			logger.Error("[Integration] sending entries to webhook failed: %v", err)
 		}
 	}
-	if integration.AppriseEnabled {
-		logger.Debug("[Integration] Sending Entry %q for User #%d to apprise", entry.URL, integration.UserID)
-
-		var appriseServiceURLs string
-		if len(feed.AppriseServiceURLs) > 0 {
-			appriseServiceURLs = feed.AppriseServiceURLs
-		} else {
-			appriseServiceURLs = integration.AppriseServicesURL
-		}
-
-		client := apprise.NewClient(
-			appriseServiceURLs,
-			integration.AppriseURL,
-		)
 
-		if err := client.SendNotification(entry); err != nil {
-			logger.Error("[Integration] push entry to apprise failed: %v", err)
+	// Integrations that only support sending individual entries
+	if userIntegrations.TelegramBotEnabled || userIntegrations.AppriseEnabled {
+		for _, entry := range entries {
+			if userIntegrations.TelegramBotEnabled {
+				logger.Debug("[Integration] Sending Entry %q for User #%d to Telegram", entry.URL, userIntegrations.UserID)
+
+				err := telegrambot.PushEntry(entry, userIntegrations.TelegramBotToken, userIntegrations.TelegramBotChatID)
+				if err != nil {
+					logger.Error("[Integration] push entry to telegram bot failed: %v", err)
+				}
+			}
+
+			if userIntegrations.AppriseEnabled {
+				logger.Debug("[Integration] Sending Entry %q for User #%d to apprise", entry.URL, userIntegrations.UserID)
+
+				appriseServiceURLs := userIntegrations.AppriseURL
+				if feed.AppriseServiceURLs != "" {
+					appriseServiceURLs = feed.AppriseServiceURLs
+				}
+
+				client := apprise.NewClient(
+					userIntegrations.AppriseServicesURL,
+					appriseServiceURLs,
+				)
+
+				if err := client.SendNotification(entry); err != nil {
+					logger.Error("[Integration] push entry to apprise failed: %v", err)
+				}
+			}
 		}
 	}
 }

+ 64 - 0
internal/integration/webhook/webhook.go

@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package webhook // import "miniflux.app/v2/internal/integration/webhook"
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+
+	"miniflux.app/v2/internal/crypto"
+	"miniflux.app/v2/internal/model"
+	"miniflux.app/v2/internal/version"
+)
+
+const defaultClientTimeout = 10 * time.Second
+
+type Client struct {
+	webhookURL    string
+	webhookSecret string
+}
+
+func NewClient(webhookURL, webhookSecret string) *Client {
+	return &Client{webhookURL, webhookSecret}
+}
+
+func (c *Client) SendWebhook(entries model.Entries) error {
+	if c.webhookURL == "" {
+		return fmt.Errorf(`webhook: missing webhook URL`)
+	}
+
+	if len(entries) == 0 {
+		return nil
+	}
+
+	requestBody, err := json.Marshal(entries)
+	if err != nil {
+		return fmt.Errorf("webhook: unable to encode request body: %v", err)
+	}
+
+	request, err := http.NewRequest(http.MethodPost, c.webhookURL, bytes.NewReader(requestBody))
+	if err != nil {
+		return fmt.Errorf("webhook: unable to create request: %v", err)
+	}
+
+	request.Header.Set("Content-Type", "application/json")
+	request.Header.Set("User-Agent", "Miniflux/"+version.Version)
+	request.Header.Set("X-Miniflux-Signature", crypto.GenerateSHA256Hmac(c.webhookSecret, requestBody))
+
+	httpClient := &http.Client{Timeout: defaultClientTimeout}
+	response, err := httpClient.Do(request)
+	if err != nil {
+		return fmt.Errorf("webhook: unable to send request: %v", err)
+	}
+	defer response.Body.Close()
+
+	if response.StatusCode >= 400 {
+		return fmt.Errorf("webhook: incorrect response status code: url=%s status=%d", c.webhookURL, response.StatusCode)
+	}
+
+	return nil
+}

+ 3 - 0
internal/locale/translations/de_DE.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "API-Schlüsselbezeichnung",
     "form.submit.loading": "Lade...",
     "form.submit.saving": "Speichern...",

+ 3 - 0
internal/locale/translations/el_EL.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "Ετικέτα κλειδιού API",
     "form.submit.loading": "Φόρτωση...",
     "form.submit.saving": "Αποθήκευση...",

+ 3 - 0
internal/locale/translations/en_US.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "API Key Label",
     "form.submit.loading": "Loading…",
     "form.submit.saving": "Saving…",

+ 3 - 0
internal/locale/translations/es_ES.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "Etiqueta de clave API",
     "form.submit.loading": "Cargando...",
     "form.submit.saving": "Guardando...",

+ 3 - 0
internal/locale/translations/fi_FI.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "API Key Label",
     "form.submit.loading": "Ladataan...",
     "form.submit.saving": "Tallennetaan...",

+ 3 - 0
internal/locale/translations/fr_FR.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Sauvegarder les articles vers Shaarli",
     "form.integration.shaarli_endpoint": "URL de l'API de Shaarli",
     "form.integration.shaarli_api_secret": "Clé d'API de Shaarli API",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "Libellé de la clé d'API",
     "form.submit.loading": "Chargement...",
     "form.submit.saving": "Sauvegarde en cours...",

+ 3 - 0
internal/locale/translations/hi_IN.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "एपीआई कुंजी लेबल",
     "form.submit.loading": "लोड हो रहा है...",
     "form.submit.saving": "सहेजा जा रहा है...",

+ 3 - 0
internal/locale/translations/id_ID.json

@@ -386,6 +386,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "Label Kunci API",
     "form.submit.loading": "Memuat...",
     "form.submit.saving": "Menyimpan...",

+ 3 - 0
internal/locale/translations/it_IT.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "Etichetta chiave API",
     "form.submit.loading": "Caricamento in corso...",
     "form.submit.saving": "Salvataggio in corso...",

+ 3 - 0
internal/locale/translations/ja_JP.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "API キーラベル",
     "form.submit.loading": "読み込み中…",
     "form.submit.saving": "保存中…",

+ 3 - 0
internal/locale/translations/nl_NL.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "API-sleutellabel",
     "form.submit.loading": "Laden...",
     "form.submit.saving": "Opslaag...",

+ 3 - 0
internal/locale/translations/pl_PL.json

@@ -391,6 +391,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "Etykieta klucza API",
     "form.submit.loading": "Ładowanie...",
     "form.submit.saving": "Zapisywanie...",

+ 3 - 0
internal/locale/translations/pt_BR.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "Etiqueta da chave de API",
     "form.submit.loading": "Carregando...",
     "form.submit.saving": "Salvando...",

+ 3 - 0
internal/locale/translations/ru_RU.json

@@ -391,6 +391,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "Описание API-ключа",
     "form.submit.loading": "Загрузка…",
     "form.submit.saving": "Сохранение…",

+ 3 - 0
internal/locale/translations/tr_TR.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "API Anahtar Etiketi",
     "form.submit.loading": "Yükleniyor...",
     "form.submit.saving": "Kaydediliyor...",

+ 3 - 0
internal/locale/translations/uk_UA.json

@@ -392,6 +392,9 @@
   "form.integration.shaarli_activate": "Save articles to Shaarli",
   "form.integration.shaarli_endpoint": "Shaarli URL",
   "form.integration.shaarli_api_secret": "Shaarli API Secret",
+  "form.integration.webhook_activate": "Enable Webhook",
+  "form.integration.webhook_url": "Webhook URL",
+  "form.integration.webhook_secret": "Webhook Secret",
   "form.api_key.label.description": "Назва ключа API",
   "form.submit.loading": "Завантаження...",
   "form.submit.saving": "Зберігаю...",

+ 3 - 0
internal/locale/translations/zh_CN.json

@@ -387,6 +387,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "API密钥标签",
     "form.submit.loading": "载入中…",
     "form.submit.saving": "保存中…",

+ 3 - 0
internal/locale/translations/zh_TW.json

@@ -389,6 +389,9 @@
     "form.integration.shaarli_activate": "Save articles to Shaarli",
     "form.integration.shaarli_endpoint": "Shaarli URL",
     "form.integration.shaarli_api_secret": "Shaarli API Secret",
+    "form.integration.webhook_activate": "Enable Webhook",
+    "form.integration.webhook_url": "Webhook URL",
+    "form.integration.webhook_secret": "Webhook Secret",
     "form.api_key.label.description": "API金鑰標籤",
     "form.submit.loading": "載入中…",
     "form.submit.saving": "儲存中…",

+ 7 - 0
internal/model/entry.go

@@ -39,6 +39,13 @@ type Entry struct {
 	Tags        []string      `json:"tags"`
 }
 
+func NewEntry() *Entry {
+	return &Entry{
+		Enclosures: make(EnclosureList, 0),
+		Tags:       make([]string, 0),
+	}
+}
+
 // Entries represents a list of entries.
 type Entries []*Entry
 

+ 3 - 0
internal/model/integration.go

@@ -64,4 +64,7 @@ type Integration struct {
 	ShaarliEnabled       bool
 	ShaarliURL           string
 	ShaarliAPISecret     string
+	WebhookEnabled       bool
+	WebhookURL           string
+	WebhookSecret        string
 }

+ 1 - 1
internal/reader/atom/atom_03.go

@@ -86,7 +86,7 @@ type atom03Entry struct {
 }
 
 func (a *atom03Entry) Transform() *model.Entry {
-	entry := new(model.Entry)
+	entry := model.NewEntry()
 	entry.URL = a.Links.originalLink()
 	entry.Date = a.entryDate()
 	entry.Author = a.Author.String()

+ 2 - 2
internal/reader/atom/atom_10.go

@@ -95,7 +95,7 @@ type atom10Entry struct {
 }
 
 func (a *atom10Entry) Transform() *model.Entry {
-	entry := new(model.Entry)
+	entry := model.NewEntry()
 	entry.URL = a.Links.originalLink()
 	entry.Date = a.entryDate()
 	entry.Author = a.Authors.String()
@@ -219,7 +219,7 @@ func (a *atom10Entry) entryEnclosures() model.EnclosureList {
 }
 
 func (r *atom10Entry) entryCategories() []string {
-	var categoryList []string
+	categoryList := make([]string, 0)
 
 	for _, atomCategory := range r.Categories {
 		if strings.TrimSpace(atomCategory.Label) != "" {

+ 11 - 1
internal/reader/handler/handler.go

@@ -10,6 +10,7 @@ import (
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/errors"
 	"miniflux.app/v2/internal/http/client"
+	"miniflux.app/v2/internal/integration"
 	"miniflux.app/v2/internal/locale"
 	"miniflux.app/v2/internal/logger"
 	"miniflux.app/v2/internal/model"
@@ -177,15 +178,24 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
 
 		// We don't update existing entries when the crawler is enabled (we crawl only inexisting entries). Unless it is forced to refresh
 		updateExistingEntries := forceRefresh || !originalFeed.Crawler
-		if storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries); storeErr != nil {
+		newEntries, storeErr := store.RefreshFeedEntries(originalFeed.UserID, originalFeed.ID, originalFeed.Entries, updateExistingEntries)
+		if storeErr != nil {
 			originalFeed.WithError(storeErr.Error())
 			store.UpdateFeedError(originalFeed)
 			return storeErr
 		}
 
+		userIntegrations, intErr := store.Integration(userID)
+		if intErr != nil {
+			logger.Error("[RefreshFeed] Fetching integrations for user %d failed: %v; the refresh process will go on, but no integrations will run this time.", userID, intErr)
+		} else if userIntegrations != nil && len(newEntries) > 0 {
+			go integration.PushEntries(originalFeed, newEntries, userIntegrations)
+		}
+
 		// We update caching headers only if the feed has been modified,
 		// because some websites don't return the same headers when replying with a 304.
 		originalFeed.WithClientResponse(response)
+
 		checkFeedIcon(
 			store,
 			originalFeed.ID,

+ 5 - 2
internal/reader/json/json.go

@@ -181,7 +181,7 @@ func (j *jsonItem) GetEnclosures() model.EnclosureList {
 }
 
 func (j *jsonItem) Transform() *model.Entry {
-	entry := new(model.Entry)
+	entry := model.NewEntry()
 	entry.URL = j.URL
 	entry.Date = j.GetDate()
 	entry.Author = j.GetAuthor()
@@ -189,7 +189,10 @@ func (j *jsonItem) Transform() *model.Entry {
 	entry.Content = j.GetContent()
 	entry.Title = strings.TrimSpace(j.GetTitle())
 	entry.Enclosures = j.GetEnclosures()
-	entry.Tags = j.Tags
+	if len(j.Tags) > 0 {
+		entry.Tags = j.Tags
+	}
+
 	return entry
 }
 

+ 0 - 27
internal/reader/processor/processor.go

@@ -13,8 +13,6 @@ import (
 	"time"
 	"unicode/utf8"
 
-	"miniflux.app/v2/internal/integration"
-
 	"miniflux.app/v2/internal/config"
 	"miniflux.app/v2/internal/http/client"
 	"miniflux.app/v2/internal/logger"
@@ -41,9 +39,6 @@ var (
 func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.User, forceRefresh bool) {
 	var filteredEntries model.Entries
 
-	// array used for bulk push
-	entriesToPush := model.Entries{}
-
 	// Process older entries first
 	for i := len(feed.Entries) - 1; i >= 0; i-- {
 		entry := feed.Entries[i]
@@ -90,32 +85,10 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
 		// The sanitizer should always run at the end of the process to make sure unsafe HTML is filtered.
 		entry.Content = sanitizer.Sanitize(url, entry.Content)
 
-		if entryIsNew {
-			intg, err := store.Integration(feed.UserID)
-			if err != nil {
-				logger.Error("[Processor] Get integrations for user %d failed: %v; the refresh process will go on, but no integrations will run this time.", feed.UserID, err)
-			} else if intg != nil {
-				localEntry := entry
-				go func() {
-					integration.PushEntry(localEntry, feed, intg)
-				}()
-				entriesToPush = append(entriesToPush, localEntry)
-			}
-		}
-
 		updateEntryReadingTime(store, feed, entry, entryIsNew, user)
 		filteredEntries = append(filteredEntries, entry)
 	}
 
-	intg, err := store.Integration(feed.UserID)
-	if err != nil {
-		logger.Error("[Processor] Get integrations for user %d failed: %v; the refresh process will go on, but no integrations will run this time.", feed.UserID, err)
-	} else if intg != nil && len(entriesToPush) > 0 {
-		go func() {
-			integration.PushEntries(entriesToPush, intg)
-		}()
-	}
-
 	feed.Entries = filteredEntries
 }
 

+ 1 - 1
internal/reader/rdf/rdf.go

@@ -65,7 +65,7 @@ type rdfItem struct {
 }
 
 func (r *rdfItem) Transform() *model.Entry {
-	entry := new(model.Entry)
+	entry := model.NewEntry()
 	entry.Title = r.entryTitle()
 	entry.Author = r.entryAuthor()
 	entry.URL = r.entryURL()

+ 2 - 2
internal/reader/rss/rss.go

@@ -190,7 +190,7 @@ type rssItem struct {
 }
 
 func (r *rssItem) Transform() *model.Entry {
-	entry := new(model.Entry)
+	entry := model.NewEntry()
 	entry.URL = r.entryURL()
 	entry.CommentsURL = r.entryCommentsURL()
 	entry.Date = r.entryDate()
@@ -388,7 +388,7 @@ func (r *rssItem) entryEnclosures() model.EnclosureList {
 }
 
 func (r *rssItem) entryCategories() []string {
-	var categoryList []string
+	categoryList := make([]string, 0)
 
 	for _, rssCategory := range r.Categories {
 		if strings.Contains(rssCategory.Inner, "<![CDATA[") {

+ 12 - 13
internal/storage/enclosure.go

@@ -107,8 +107,10 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error
 		VALUES
 			($1, $2, $3, $4, $5, $6)
 		ON CONFLICT (user_id, entry_id, md5(url)) DO NOTHING
+		RETURNING
+			id
 	`
-	_, err := tx.Exec(
+	if err := tx.QueryRow(
 		query,
 		enclosureURL,
 		enclosure.Size,
@@ -116,24 +118,22 @@ func (s *Storage) createEnclosure(tx *sql.Tx, enclosure *model.Enclosure) error
 		enclosure.EntryID,
 		enclosure.UserID,
 		enclosure.MediaProgression,
-	)
-
-	if err != nil {
-		return fmt.Errorf(`store: unable to create enclosure: %v`, err)
+	).Scan(&enclosure.ID); err != nil && err != sql.ErrNoRows {
+		return fmt.Errorf(`store: unable to create enclosure: %w`, err)
 	}
 
 	return nil
 }
 
-func (s *Storage) updateEnclosures(tx *sql.Tx, userID, entryID int64, enclosures model.EnclosureList) error {
-	if len(enclosures) == 0 {
+func (s *Storage) updateEnclosures(tx *sql.Tx, entry *model.Entry) error {
+	if len(entry.Enclosures) == 0 {
 		return nil
 	}
 
-	sqlValues := []any{userID, entryID}
+	sqlValues := []any{entry.UserID, entry.ID}
 	sqlPlaceholders := []string{}
 
-	for _, enclosure := range enclosures {
+	for _, enclosure := range entry.Enclosures {
 		sqlPlaceholders = append(sqlPlaceholders, fmt.Sprintf(`$%d`, len(sqlValues)+1))
 		sqlValues = append(sqlValues, strings.TrimSpace(enclosure.URL))
 
@@ -143,11 +143,10 @@ func (s *Storage) updateEnclosures(tx *sql.Tx, userID, entryID int64, enclosures
 	}
 
 	query := `
-		DELETE FROM enclosures
+		DELETE FROM
+			enclosures
 		WHERE
-			user_id=$1 AND
-			entry_id=$2 AND
-			url NOT IN (%s)
+			user_id=$1 AND entry_id=$2 AND url NOT IN (%s)
 	`
 
 	query = fmt.Sprintf(query, strings.Join(sqlPlaceholders, `,`))

+ 19 - 11
internal/storage/entry.go

@@ -138,7 +138,7 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
 				$11
 			)
 		RETURNING
-			id, status
+			id, status, created_at, changed_at
 	`
 	err := tx.QueryRow(
 		query,
@@ -153,7 +153,12 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
 		entry.FeedID,
 		entry.ReadingTime,
 		pq.Array(removeDuplicates(entry.Tags)),
-	).Scan(&entry.ID, &entry.Status)
+	).Scan(
+		&entry.ID,
+		&entry.Status,
+		&entry.CreatedAt,
+		&entry.ChangedAt,
+	)
 
 	if err != nil {
 		return fmt.Errorf(`store: unable to create entry %q (feed #%d): %v`, entry.URL, entry.FeedID, err)
@@ -215,7 +220,7 @@ func (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {
 		enclosure.EntryID = entry.ID
 	}
 
-	return s.updateEnclosures(tx, entry.UserID, entry.ID, entry.Enclosures)
+	return s.updateEnclosures(tx, entry)
 }
 
 // entryExists checks if an entry already exists based on its hash when refreshing a feed.
@@ -264,7 +269,7 @@ func (s *Storage) cleanupEntries(feedID int64, entryHashes []string) error {
 }
 
 // RefreshFeedEntries updates feed entries while refreshing a feed.
-func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (err error) {
+func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries, updateExistingEntries bool) (newEntries model.Entries, err error) {
 	var entryHashes []string
 
 	for _, entry := range entries {
@@ -273,15 +278,15 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
 
 		tx, err := s.db.Begin()
 		if err != nil {
-			return fmt.Errorf(`store: unable to start transaction: %v`, err)
+			return nil, fmt.Errorf(`store: unable to start transaction: %v`, err)
 		}
 
 		entryExists, err := s.entryExists(tx, entry)
 		if err != nil {
 			if rollbackErr := tx.Rollback(); rollbackErr != nil {
-				return fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)
+				return nil, fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)
 			}
-			return err
+			return nil, err
 		}
 
 		if entryExists {
@@ -290,17 +295,20 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
 			}
 		} else {
 			err = s.createEntry(tx, entry)
+			if err == nil {
+				newEntries = append(newEntries, entry)
+			}
 		}
 
 		if err != nil {
 			if rollbackErr := tx.Rollback(); rollbackErr != nil {
-				return fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)
+				return nil, fmt.Errorf(`store: unable to rollback transaction: %v (rolled back due to: %v)`, rollbackErr, err)
 			}
-			return err
+			return nil, err
 		}
 
 		if err := tx.Commit(); err != nil {
-			return fmt.Errorf(`store: unable to commit transaction: %v`, err)
+			return nil, fmt.Errorf(`store: unable to commit transaction: %v`, err)
 		}
 
 		entryHashes = append(entryHashes, entry.Hash)
@@ -312,7 +320,7 @@ func (s *Storage) RefreshFeedEntries(userID, feedID int64, entries model.Entries
 		}
 	}()
 
-	return nil
+	return newEntries, nil
 }
 
 // ArchiveEntries changes the status of entries to "removed" after the given number of days.

+ 3 - 3
internal/storage/feed.go

@@ -345,9 +345,9 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
 			hide_globally=$24,
 			url_rewrite_rules=$25,
 			no_media_player=$26,
-			apprise_service_urls=$29
+			apprise_service_urls=$27
 		WHERE
-			id=$27 AND user_id=$28
+			id=$28 AND user_id=$29
 	`
 	_, err = s.db.Exec(query,
 		feed.FeedURL,
@@ -376,9 +376,9 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
 		feed.HideGlobally,
 		feed.UrlRewriteRules,
 		feed.NoMediaPlayer,
+		feed.AppriseServiceURLs,
 		feed.ID,
 		feed.UserID,
-		feed.AppriseServiceURLs,
 	)
 
 	if err != nil {

+ 15 - 3
internal/storage/integration.go

@@ -167,7 +167,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 			shiori_password,
 			shaarli_enabled,
 			shaarli_url,
-			shaarli_api_secret
+			shaarli_api_secret,
+			webhook_enabled,
+			webhook_url,
+			webhook_secret
 		FROM
 			integrations
 		WHERE
@@ -234,6 +237,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
 		&integration.ShaarliEnabled,
 		&integration.ShaarliURL,
 		&integration.ShaarliAPISecret,
+		&integration.WebhookEnabled,
+		&integration.WebhookURL,
+		&integration.WebhookSecret,
 	)
 	switch {
 	case err == sql.ErrNoRows:
@@ -308,9 +314,12 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 			shiori_password=$55,
 			shaarli_enabled=$56,
 			shaarli_url=$57,
-			shaarli_api_secret=$58
+			shaarli_api_secret=$58,
+			webhook_enabled=$59,
+			webhook_url=$60,
+			webhook_secret=$61
 		WHERE
-			user_id=$59
+			user_id=$62
 	`
 	_, err := s.db.Exec(
 		query,
@@ -372,6 +381,9 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
 		integration.ShaarliEnabled,
 		integration.ShaarliURL,
 		integration.ShaarliAPISecret,
+		integration.WebhookEnabled,
+		integration.WebhookURL,
+		integration.WebhookSecret,
 		integration.UserID,
 	)
 

+ 188 - 167
internal/template/templates/views/integrations.html

@@ -13,21 +13,66 @@
         <div class="alert alert-error">{{ t .errorMessage }}</div>
     {{ end }}
 
+    <details {{ if .form.AppriseEnabled }}open{{ end }}>
+        <summary>Apprise</summary>
+        <div class="form-section">
+            <label>
+                <input type="checkbox" name="apprise_enabled" value="1" {{ if .form.AppriseEnabled }}checked{{ end }}> {{ t "form.integration.apprise_activate" }}
+            </label>
+
+            <label for="form-apprise-url">{{ t "form.integration.apprise_url" }}</label>
+            <input type="url" name="apprise_url" id="form-apprise-url" value="{{ .form.AppriseURL }}" placeholder="http://apprise:8080" spellcheck="false">
+
+            <label for="form-apprise-services-url">{{ t "form.integration.apprise_services_url" }}
+                <a href="https://github.com/caronc/apprise/wiki" target="_blank">
+                    {{ icon "external-link" }}
+                </a>
+            </label>
+            <input type="text" name="apprise_services_url" id="form-apprise-services-urls" value="{{ .form.AppriseServicesURL }}" placeholder="tgram://<token>/<chat_id>/,matrix://" spellcheck="false">
+
+            <div class="buttons">
+                <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
+            </div>
+        </div>
+    </details>
+
+    <details {{ if .form.EspialEnabled }}open{{ end }}>
+        <summary>Espial</summary>
+        <div class="form-section">
+            <label>
+                <input type="checkbox" name="espial_enabled" value="1" {{ if .form.EspialEnabled }}checked{{ end }}> {{ t "form.integration.espial_activate" }}
+            </label>
+
+            <label for="form-espial-url">{{ t "form.integration.espial_endpoint" }}</label>
+            <input type="url" name="espial_url" id="form-espial-url" value="{{ .form.EspialURL }}" placeholder="https://esp.ae8.org" spellcheck="false">
+
+            <label for="form-espial-api-key">{{ t "form.integration.espial_api_key" }}</label>
+            <input type="text" name="espial_api_key" id="form-espial-api-key" value="{{ .form.EspialAPIKey }}" spellcheck="false">
+
+            <label for="form-espial-tags">{{ t "form.integration.espial_tags" }}</label>
+            <input type="text" name="espial_tags" id="form-espial-tags" value="{{ .form.EspialTags }}" spellcheck="false">
+
+            <div class="buttons">
+                <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
+            </div>
+        </div>
+    </details>
+
     <details {{ if .form.FeverEnabled }}open{{ end }}>
         <summary>Fever</summary>
         <div class="form-section">
             <label>
                 <input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "form.integration.fever_activate" }}
             </label>
-    
+
             <label for="form-fever-username">{{ t "form.integration.fever_username" }}</label>
             <input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}" autocomplete="username" spellcheck="false">
-    
+
             <label for="form-fever-password">{{ t "form.integration.fever_password" }}</label>
             <input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}" autocomplete="new-password">
-    
+
             <p>{{ t "form.integration.fever_endpoint" }} <strong>{{ rootURL }}{{ route "feverEndpoint" }}</strong></p>
-    
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
@@ -40,38 +85,15 @@
             <label>
                 <input type="checkbox" name="googlereader_enabled" value="1" {{ if .form.GoogleReaderEnabled }}checked{{ end }}> {{ t "form.integration.googlereader_activate" }}
             </label>
-    
+
             <label for="form-googlereader-username">{{ t "form.integration.googlereader_username" }}</label>
             <input type="text" name="googlereader_username" id="form-googlereader-username" value="{{ .form.GoogleReaderUsername }}" autocomplete="username" spellcheck="false">
-    
+
             <label for="form-googlereader-password">{{ t "form.integration.googlereader_password" }}</label>
             <input type="password" name="googlereader_password" id="form-googlereader-password" value="{{ .form.GoogleReaderPassword }}" autocomplete="new-password">
-    
+
             <p>{{ t "form.integration.googlereader_endpoint" }} <strong>{{ rootURL }}{{ route "login" }}</strong></p>
-    
-            <div class="buttons">
-                <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
-            </div>
-        </div>
-    </details>
 
-    <details {{ if .form.PinboardEnabled }}open{{ end }}>
-        <summary>Pinboard</summary>
-        <div class="form-section">
-            <label>
-                <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "form.integration.pinboard_activate" }}
-            </label>
-    
-            <label for="form-pinboard-token">{{ t "form.integration.pinboard_token" }}</label>
-            <input type="password" name="pinboard_token" id="form-pinboard-token" value="{{ .form.PinboardToken }}" autocomplete="new-password">
-    
-            <label for="form-pinboard-tags">{{ t "form.integration.pinboard_tags" }}</label>
-            <input type="text" name="pinboard_tags" id="form-pinboard-tags" value="{{ .form.PinboardTags }}" spellcheck="false">
-    
-            <label>
-                <input type="checkbox" name="pinboard_mark_as_unread" value="1" {{ if .form.PinboardMarkAsUnread }}checked{{ end }}> {{ t "form.integration.pinboard_bookmark" }}
-            </label>
-    
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
@@ -84,70 +106,64 @@
             <label>
                 <input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "form.integration.instapaper_activate" }}
             </label>
-    
+
             <label for="form-instapaper-username">{{ t "form.integration.instapaper_username" }}</label>
             <input type="text" name="instapaper_username" id="form-instapaper-username" value="{{ .form.InstapaperUsername }}" spellcheck="false">
-    
+
             <label for="form-instapaper-password">{{ t "form.integration.instapaper_password" }}</label>
             <input type="password" name="instapaper_password" id="form-instapaper-password" value="{{ .form.InstapaperPassword }}" autocomplete="new-password">
-    
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
         </div>
     </details>
 
-    <details {{ if .form.PocketEnabled }}open{{ end }}>
-        <summary>Pocket</summary>
+    <details {{ if .form.LinkdingEnabled }}open{{ end }}>
+        <summary>Linkding</summary>
         <div class="form-section">
             <label>
-                <input type="checkbox" name="pocket_enabled" value="1" {{ if .form.PocketEnabled }}checked{{ end }}> {{ t "form.integration.pocket_activate" }}
+                <input type="checkbox" name="linkding_enabled" value="1" {{ if .form.LinkdingEnabled }}checked{{ end }}> {{ t "form.integration.linkding_activate" }}
             </label>
-    
-            {{ if not .hasPocketConsumerKeyConfigured }}
-                <label for="form-pocket-consumer-key">{{ t "form.integration.pocket_consumer_key" }}</label>
-                <input type="text" name="pocket_consumer_key" id="form-pocket-consumer-key" value="{{ .form.PocketConsumerKey }}" spellcheck="false">
-            {{ end }}
-    
-            <label for="form-pocket-access-token">{{ t "form.integration.pocket_access_token" }}</label>
-            <input type="password" name="pocket_access_token" id="form-pocket-access-token" value="{{ .form.PocketAccessToken }}" autocomplete="new-password">
-    
-            {{ if not .form.PocketAccessToken }}
-                <p><a href="{{ route "pocketAuthorize" }}">{{ t "form.integration.pocket_connect_link" }}</a></p>
-            {{ end }}
-    
+
+            <label for="form-linkding-url">{{ t "form.integration.linkding_endpoint" }}</label>
+            <input type="url" name="linkding_url" id="form-linkding-url" value="{{ .form.LinkdingURL }}" placeholder="https://linkding.com" spellcheck="false">
+
+            <label for="form-linkding-api-key">{{ t "form.integration.linkding_api_key" }}</label>
+            <input type="text" name="linkding_api_key" id="form-linkding-api-key" value="{{ .form.LinkdingAPIKey }}" spellcheck="false">
+
+            <label for="form-linkding-tags">{{ t "form.integration.linkding_tags" }}</label>
+            <input type="text" name="linkding_tags" id="form-linkding-tags" value="{{ .form.LinkdingTags }}" spellcheck="false">
+
+            <label>
+                <input type="checkbox" name="linkding_mark_as_unread" value="1" {{ if .form.LinkdingMarkAsUnread }}checked{{ end }}> {{ t "form.integration.linkding_bookmark" }}
+            </label>
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
         </div>
     </details>
 
-    <details {{ if .form.WallabagEnabled }}open{{ end }}>
-        <summary>Wallabag</summary>
+    <details {{ if .form.MatrixBotEnabled }}open{{ end }}>
+        <summary>Matrix Bot</summary>
         <div class="form-section">
             <label>
-                <input type="checkbox" name="wallabag_enabled" value="1" {{ if .form.WallabagEnabled }}checked{{ end }}> {{ t "form.integration.wallabag_activate" }}
-            </label>
-    
-            <label>
-                <input type="checkbox" name="wallabag_only_url" value="1" {{ if .form.WallabagOnlyURL }}checked{{ end }}> {{ t "form.integration.wallabag_only_url" }}
+                <input type="checkbox" name="matrix_bot_enabled" value="1" {{ if .form.MatrixBotEnabled }}checked{{ end }}> {{ t "form.integration.matrix_bot_activate" }}
             </label>
-    
-            <label for="form-wallabag-url">{{ t "form.integration.wallabag_endpoint" }}</label>
-            <input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" placeholder="http://v2.wallabag.org/" spellcheck="false">
-    
-            <label for="form-wallabag-client-id">{{ t "form.integration.wallabag_client_id" }}</label>
-            <input type="text" name="wallabag_client_id" id="form-wallabag-client-id" value="{{ .form.WallabagClientID }}" spellcheck="false">
-    
-            <label for="form-wallabag-client-secret">{{ t "form.integration.wallabag_client_secret" }}</label>
-            <input type="password" name="wallabag_client_secret" id="form-wallabag-client-secret" value="{{ .form.WallabagClientSecret }}" autocomplete="new-password">
-    
-            <label for="form-wallabag-username">{{ t "form.integration.wallabag_username" }}</label>
-            <input type="text" name="wallabag_username" id="form-wallabag-username" value="{{ .form.WallabagUsername }}" spellcheck="false">
-    
-            <label for="form-wallabag-password">{{ t "form.integration.wallabag_password" }}</label>
-            <input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}" autocomplete="new-password">
-    
+
+            <label for="form-matrix-bot-user">{{ t "form.integration.matrix_bot_user" }}</label>
+            <input type="text" name="matrix_bot_user" id="form-matrix-bot-user" value="{{ .form.MatrixBotUser }}" spellcheck="false">
+
+            <label for="form-matrix-chat-password">{{ t "form.integration.matrix_bot_password" }}</label>
+            <input type="password" name="matrix_bot_password" id="form-matrix-password" value="{{ .form.MatrixBotPassword }}" spellcheck="false">
+
+            <label for="form-matrix-url">{{ t "form.integration.matrix_bot_url" }}</label>
+            <input type="url" name="matrix_bot_url" id="form-matrix-url" value="{{ .form.MatrixBotURL }}" spellcheck="false">
+
+            <label for="form-matrix-chat-id">{{ t "form.integration.matrix_bot_chat_id" }}</label>
+            <input type="text" name="matrix_bot_chat_id" id="form-matrix-chat-id" value="{{ .form.MatrixBotChatID }}" spellcheck="false">
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
@@ -160,13 +176,13 @@
             <label>
                 <input type="checkbox" name="notion_enabled" value="1" {{ if .form.NotionEnabled }}checked{{ end }}> {{ t "form.integration.notion_activate" }}
             </label>
-    
+
             <label for="form-notion-token">{{ t "form.integration.notion_token" }}</label>
             <input type="password" name="notion_token" id="form-notion-token" value="{{ .form.NotionToken }}" spellcheck="false">
-    
+
             <label for="form-notion-page-id">{{ t "form.integration.notion_page_id" }}</label>
             <input type="text" name="notion_page_id" id="form-notion-page-id" value="{{ .form.NotionPageID }}" spellcheck="false">
-    
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
@@ -179,35 +195,61 @@
             <label>
                 <input type="checkbox" name="nunux_keeper_enabled" value="1" {{ if .form.NunuxKeeperEnabled }}checked{{ end }}> {{ t "form.integration.nunux_keeper_activate" }}
             </label>
-    
+
             <label for="form-nunux-keeper-url">{{ t "form.integration.nunux_keeper_endpoint" }}</label>
             <input type="url" name="nunux_keeper_url" id="form-nunux-keeper-url" value="{{ .form.NunuxKeeperURL }}" placeholder="https://api.nunux.org/keeper" spellcheck="false">
-    
+
             <label for="form-nunux-keeper-api-key">{{ t "form.integration.nunux_keeper_api_key" }}</label>
             <input type="text" name="nunux_keeper_api_key" id="form-nunux-keeper-api-key" value="{{ .form.NunuxKeeperAPIKey }}" spellcheck="false">
-    
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
         </div>
     </details>
 
-    <details {{ if .form.EspialEnabled }}open{{ end }}>
-        <summary>Espial</summary>
+    <details {{ if .form.PinboardEnabled }}open{{ end }}>
+        <summary>Pinboard</summary>
         <div class="form-section">
             <label>
-                <input type="checkbox" name="espial_enabled" value="1" {{ if .form.EspialEnabled }}checked{{ end }}> {{ t "form.integration.espial_activate" }}
+                <input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "form.integration.pinboard_activate" }}
             </label>
-    
-            <label for="form-espial-url">{{ t "form.integration.espial_endpoint" }}</label>
-            <input type="url" name="espial_url" id="form-espial-url" value="{{ .form.EspialURL }}" placeholder="https://esp.ae8.org" spellcheck="false">
-    
-            <label for="form-espial-api-key">{{ t "form.integration.espial_api_key" }}</label>
-            <input type="text" name="espial_api_key" id="form-espial-api-key" value="{{ .form.EspialAPIKey }}" spellcheck="false">
-    
-            <label for="form-espial-tags">{{ t "form.integration.espial_tags" }}</label>
-            <input type="text" name="espial_tags" id="form-espial-tags" value="{{ .form.EspialTags }}" spellcheck="false">
-    
+
+            <label for="form-pinboard-token">{{ t "form.integration.pinboard_token" }}</label>
+            <input type="password" name="pinboard_token" id="form-pinboard-token" value="{{ .form.PinboardToken }}" autocomplete="new-password">
+
+            <label for="form-pinboard-tags">{{ t "form.integration.pinboard_tags" }}</label>
+            <input type="text" name="pinboard_tags" id="form-pinboard-tags" value="{{ .form.PinboardTags }}" spellcheck="false">
+
+            <label>
+                <input type="checkbox" name="pinboard_mark_as_unread" value="1" {{ if .form.PinboardMarkAsUnread }}checked{{ end }}> {{ t "form.integration.pinboard_bookmark" }}
+            </label>
+
+            <div class="buttons">
+                <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
+            </div>
+        </div>
+    </details>
+
+    <details {{ if .form.PocketEnabled }}open{{ end }}>
+        <summary>Pocket</summary>
+        <div class="form-section">
+            <label>
+                <input type="checkbox" name="pocket_enabled" value="1" {{ if .form.PocketEnabled }}checked{{ end }}> {{ t "form.integration.pocket_activate" }}
+            </label>
+
+            {{ if not .hasPocketConsumerKeyConfigured }}
+                <label for="form-pocket-consumer-key">{{ t "form.integration.pocket_consumer_key" }}</label>
+                <input type="text" name="pocket_consumer_key" id="form-pocket-consumer-key" value="{{ .form.PocketConsumerKey }}" spellcheck="false">
+            {{ end }}
+
+            <label for="form-pocket-access-token">{{ t "form.integration.pocket_access_token" }}</label>
+            <input type="password" name="pocket_access_token" id="form-pocket-access-token" value="{{ .form.PocketAccessToken }}" autocomplete="new-password">
+
+            {{ if not .form.PocketAccessToken }}
+                <p><a href="{{ route "pocketAuthorize" }}">{{ t "form.integration.pocket_connect_link" }}</a></p>
+            {{ end }}
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
@@ -220,61 +262,53 @@
             <label>
                 <input type="checkbox" name="readwise_enabled" value="1" {{ if .form.ReadwiseEnabled }}checked{{ end }}> {{ t "form.integration.readwise_activate" }}
             </label>
-    
+
             <label for="form-readwise-api-key">{{ t "form.integration.readwise_api_key" }}</label>
             <input type="text" name="readwise_api_key" id="form-readwise-api-key" value="{{ .form.ReadwiseAPIKey }}" spellcheck="false">
-    
+
             <p><a href="https://readwise.io/access_token" target="_blank">{{ t "form.integration.readwise_api_key_link" }}</a></p>
-    
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
         </div>
     </details>
 
-    <details {{ if .form.LinkdingEnabled }}open{{ end }}>
-        <summary>Linkding</summary>
+    <details {{ if .form.ShaarliEnabled }}open{{ end }}>
+        <summary>Shaarli</summary>
         <div class="form-section">
             <label>
-                <input type="checkbox" name="linkding_enabled" value="1" {{ if .form.LinkdingEnabled }}checked{{ end }}> {{ t "form.integration.linkding_activate" }}
-            </label>
-    
-            <label for="form-linkding-url">{{ t "form.integration.linkding_endpoint" }}</label>
-            <input type="url" name="linkding_url" id="form-linkding-url" value="{{ .form.LinkdingURL }}" placeholder="https://linkding.com" spellcheck="false">
-    
-            <label for="form-linkding-api-key">{{ t "form.integration.linkding_api_key" }}</label>
-            <input type="text" name="linkding_api_key" id="form-linkding-api-key" value="{{ .form.LinkdingAPIKey }}" spellcheck="false">
-    
-            <label for="form-linkding-tags">{{ t "form.integration.linkding_tags" }}</label>
-            <input type="text" name="linkding_tags" id="form-linkding-tags" value="{{ .form.LinkdingTags }}" spellcheck="false">
-    
-            <label>
-                <input type="checkbox" name="linkding_mark_as_unread" value="1" {{ if .form.LinkdingMarkAsUnread }}checked{{ end }}> {{ t "form.integration.linkding_bookmark" }}
+                <input type="checkbox" name="shaarli_enabled" value="1" {{ if .form.ShaarliEnabled }}checked{{ end }}> {{ t "form.integration.shaarli_activate" }}
             </label>
-    
+
+            <label for="form-shaarli-url">{{ t "form.integration.shaarli_endpoint" }}</label>
+            <input type="url" name="shaarli_url" id="form-shaarli-url" value="{{ .form.ShaarliURL }}" placeholder="https://shaarli.example.org" spellcheck="false">
+
+            <label for="form-shaarli-api-secret">{{ t "form.integration.shaarli_api_secret" }}</label>
+            <input type="password" name="shaarli_api_secret" id="form-shaarli-api-secret" value="{{ .form.ShaarliAPISecret }}" autocomplete="new-password">
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
         </div>
     </details>
 
-    <details {{ if .form.AppriseEnabled }}open{{ end }}>
-        <summary>Apprise</summary>
+    <details {{ if .form.ShioriEnabled }}open{{ end }}>
+        <summary>Shiori</summary>
         <div class="form-section">
             <label>
-                <input type="checkbox" name="apprise_enabled" value="1" {{ if .form.AppriseEnabled }}checked{{ end }}> {{ t "form.integration.apprise_activate" }}
-            </label>
-    
-            <label for="form-apprise-url">{{ t "form.integration.apprise_url" }}</label>
-            <input type="text" name="apprise_url" id="form-apprise-url" value="{{ .form.AppriseURL }}" placeholder="http://apprise:8080" spellcheck="false">
-    
-            <label for="form-apprise-services-url">{{ t "form.integration.apprise_services_url" }}
-                <a href="https://github.com/caronc/apprise/wiki" target="_blank">
-                    {{ icon "external-link" }}
-                </a>
+                <input type="checkbox" name="shiori_enabled" value="1" {{ if .form.ShioriEnabled }}checked{{ end }}> {{ t "form.integration.shiori_activate" }}
             </label>
-            <input type="text" name="apprise_services_url" id="form-apprise-services-urls" value="{{ .form.AppriseServicesURL }}" placeholder="tgram://<token>/<chat_id>/,matrix://" spellcheck="false">
-    
+
+            <label for="form-shiori-url">{{ t "form.integration.shiori_endpoint" }}</label>
+            <input type="url" name="shiori_url" id="form-shiori-url" value="{{ .form.ShioriURL }}" placeholder="https://shiori.example.org" spellcheck="false">
+
+            <label for="form-shiori-username">{{ t "form.integration.shiori_username" }}</label>
+            <input type="text" name="shiori_username" id="form-shiori-username" value="{{ .form.ShioriUsername }}" spellcheck="false">
+
+            <label for="form-shiori-password">{{ t "form.integration.shiori_password" }}</label>
+            <input type="password" name="shiori_password" id="form-shiori-password" value="{{ .form.ShioriPassword }}" autocomplete="new-password">
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
@@ -287,56 +321,44 @@
             <label>
                 <input type="checkbox" name="telegram_bot_enabled" value="1" {{ if .form.TelegramBotEnabled }}checked{{ end }}> {{ t "form.integration.telegram_bot_activate" }}
             </label>
-    
+
             <label for="form-telegram-bot-token">{{ t "form.integration.telegram_bot_token" }}</label>
             <input type="text" name="telegram_bot_token" id="form-telegram-bot-token" value="{{ .form.TelegramBotToken }}" placeholder="bot123456:Abcdefg" spellcheck="false">
-    
+
             <label for="form-telegram-chat-id">{{ t "form.integration.telegram_chat_id" }}</label>
             <input type="text" name="telegram_bot_chat_id" id="form-telegram-chat-id" value="{{ .form.TelegramBotChatID }}" spellcheck="false">
-    
+
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
             </div>
         </div>
     </details>
 
-    <details {{ if .form.MatrixBotEnabled }}open{{ end }}>
-        <summary>Matrix Bot</summary>
+    <details {{ if .form.WallabagEnabled }}open{{ end }}>
+        <summary>Wallabag</summary>
         <div class="form-section">
             <label>
-                <input type="checkbox" name="matrix_bot_enabled" value="1" {{ if .form.MatrixBotEnabled }}checked{{ end }}> {{ t "form.integration.matrix_bot_activate" }}
+                <input type="checkbox" name="wallabag_enabled" value="1" {{ if .form.WallabagEnabled }}checked{{ end }}> {{ t "form.integration.wallabag_activate" }}
             </label>
-    
-            <label for="form-matrix-bot-user">{{ t "form.integration.matrix_bot_user" }}</label>
-            <input type="text" name="matrix_bot_user" id="form-matrix-bot-user" value="{{ .form.MatrixBotUser }}" spellcheck="false">
-    
-            <label for="form-matrix-chat-password">{{ t "form.integration.matrix_bot_password" }}</label>
-            <input type="password" name="matrix_bot_password" id="form-matrix-password" value="{{ .form.MatrixBotPassword }}" spellcheck="false">
-    
-            <label for="form-matrix-url">{{ t "form.integration.matrix_bot_url" }}</label>
-            <input type="text" name="matrix_bot_url" id="form-matrix-url" value="{{ .form.MatrixBotURL }}" spellcheck="false">
-    
-            <label for="form-matrix-chat-id">{{ t "form.integration.matrix_bot_chat_id" }}</label>
-            <input type="text" name="matrix_bot_chat_id" id="form-matrix-chat-id" value="{{ .form.MatrixBotChatID }}" spellcheck="false">
-    
-            <div class="buttons">
-                <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
-            </div>
-        </div>
-    </details>
 
-    <details {{ if .form.ShaarliEnabled }}open{{ end }}>
-        <summary>Shaarli</summary>
-        <div class="form-section">
             <label>
-                <input type="checkbox" name="shaarli_enabled" value="1" {{ if .form.ShaarliEnabled }}checked{{ end }}> {{ t "form.integration.shaarli_activate" }}
+                <input type="checkbox" name="wallabag_only_url" value="1" {{ if .form.WallabagOnlyURL }}checked{{ end }}> {{ t "form.integration.wallabag_only_url" }}
             </label>
 
-            <label for="form-shaarli-url">{{ t "form.integration.shaarli_endpoint" }}</label>
-            <input type="url" name="shaarli_url" id="form-shaarli-url" value="{{ .form.ShaarliURL }}" placeholder="https://shaarli.example.org" spellcheck="false">
+            <label for="form-wallabag-url">{{ t "form.integration.wallabag_endpoint" }}</label>
+            <input type="url" name="wallabag_url" id="form-wallabag-url" value="{{ .form.WallabagURL }}" placeholder="http://v2.wallabag.org/" spellcheck="false">
 
-            <label for="form-shaarli-api-secret">{{ t "form.integration.shaarli_api_secret" }}</label>
-            <input type="password" name="shaarli_api_secret" id="form-shaarli-api-secret" value="{{ .form.ShaarliAPISecret }}" autocomplete="new-password">
+            <label for="form-wallabag-client-id">{{ t "form.integration.wallabag_client_id" }}</label>
+            <input type="text" name="wallabag_client_id" id="form-wallabag-client-id" value="{{ .form.WallabagClientID }}" spellcheck="false">
+
+            <label for="form-wallabag-client-secret">{{ t "form.integration.wallabag_client_secret" }}</label>
+            <input type="password" name="wallabag_client_secret" id="form-wallabag-client-secret" value="{{ .form.WallabagClientSecret }}" autocomplete="new-password">
+
+            <label for="form-wallabag-username">{{ t "form.integration.wallabag_username" }}</label>
+            <input type="text" name="wallabag_username" id="form-wallabag-username" value="{{ .form.WallabagUsername }}" spellcheck="false">
+
+            <label for="form-wallabag-password">{{ t "form.integration.wallabag_password" }}</label>
+            <input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}" autocomplete="new-password">
 
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
@@ -344,21 +366,20 @@
         </div>
     </details>
 
-    <details {{ if .form.ShioriEnabled }}open{{ end }}>
-        <summary>Shiori</summary>
+    <details {{ if .form.WebhookEnabled }}open{{ end }}>
+        <summary>Webhook</summary>
         <div class="form-section">
             <label>
-                <input type="checkbox" name="shiori_enabled" value="1" {{ if .form.ShioriEnabled }}checked{{ end }}> {{ t "form.integration.shiori_activate" }}
+                <input type="checkbox" name="webhook_enabled" value="1" {{ if .form.WebhookEnabled }}checked{{ end }}> {{ t "form.integration.webhook_activate" }}
             </label>
 
-            <label for="form-shiori-url">{{ t "form.integration.shiori_endpoint" }}</label>
-            <input type="url" name="shiori_url" id="form-shiori-url" value="{{ .form.ShioriURL }}" placeholder="https://shiori.example.org" spellcheck="false">
+            <label for="form-webhook-url">{{ t "form.integration.webhook_url" }}</label>
+            <input type="url" name="webhook_url" id="form-webhook-url" value="{{ .form.WebhookURL }}" placeholder="https://username:password@example.org" spellcheck="false">
 
-            <label for="form-shiori-username">{{ t "form.integration.shiori_username" }}</label>
-            <input type="text" name="shiori_username" id="form-shiori-username" value="{{ .form.ShioriUsername }}" spellcheck="false">
-
-            <label for="form-shiori-password">{{ t "form.integration.shiori_password" }}</label>
-            <input type="password" name="shiori_password" id="form-shiori-password" value="{{ .form.ShioriPassword }}" autocomplete="new-password">
+            {{ if .form.WebhookSecret }}
+            <label for="form-webhook-secret">{{ t "form.integration.webhook_secret" }}</label>
+            <input type="text" name="webhook_secret" id="form-webhook-secret" value="{{ .form.WebhookSecret }}" spellcheck="false" readonly>
+            {{ end }}
 
             <div class="buttons">
                 <button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>

+ 7 - 0
internal/ui/form/integration.go

@@ -69,6 +69,9 @@ type IntegrationForm struct {
 	ShaarliEnabled       bool
 	ShaarliURL           string
 	ShaarliAPISecret     string
+	WebhookEnabled       bool
+	WebhookURL           string
+	WebhookSecret        string
 }
 
 // Merge copy form values to the model.
@@ -129,6 +132,8 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
 	integration.ShaarliEnabled = i.ShaarliEnabled
 	integration.ShaarliURL = i.ShaarliURL
 	integration.ShaarliAPISecret = i.ShaarliAPISecret
+	integration.WebhookEnabled = i.WebhookEnabled
+	integration.WebhookURL = i.WebhookURL
 }
 
 // NewIntegrationForm returns a new IntegrationForm.
@@ -192,5 +197,7 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
 		ShaarliEnabled:       r.FormValue("shaarli_enabled") == "1",
 		ShaarliURL:           r.FormValue("shaarli_url"),
 		ShaarliAPISecret:     r.FormValue("shaarli_api_secret"),
+		WebhookEnabled:       r.FormValue("webhook_enabled") == "1",
+		WebhookURL:           r.FormValue("webhook_url"),
 	}
 }

+ 3 - 0
internal/ui/integration_show.go

@@ -84,6 +84,9 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
 		ShaarliEnabled:       integration.ShaarliEnabled,
 		ShaarliURL:           integration.ShaarliURL,
 		ShaarliAPISecret:     integration.ShaarliAPISecret,
+		WebhookEnabled:       integration.WebhookEnabled,
+		WebhookURL:           integration.WebhookURL,
+		WebhookSecret:        integration.WebhookSecret,
 	}
 
 	sess := session.New(h.store, request.SessionID(r))

+ 12 - 0
internal/ui/integration_update.go

@@ -67,6 +67,18 @@ func (h *handler) updateIntegration(w http.ResponseWriter, r *http.Request) {
 		integration.GoogleReaderPassword = ""
 	}
 
+	if integrationForm.WebhookEnabled {
+		if integrationForm.WebhookURL == "" {
+			integration.WebhookEnabled = false
+			integration.WebhookSecret = ""
+		} else if integration.WebhookSecret == "" {
+			integration.WebhookSecret = crypto.GenerateRandomStringHex(32)
+		}
+	} else {
+		integration.WebhookURL = ""
+		integration.WebhookSecret = ""
+	}
+
 	err = h.store.UpdateIntegration(integration)
 	if err != nil {
 		html.ServerError(w, r, err)