Add generic webhook integration

This commit is contained in:
Frédéric Guillot 2023-09-08 22:45:17 -07:00
parent 32d33104a4
commit 48f6885f44
39 changed files with 527 additions and 324 deletions

View File

@ -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))
}

View File

@ -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
},
}

View File

@ -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
}
// 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)
client := apprise.NewClient(
appriseServiceURLs,
integration.AppriseURL,
)
err := telegrambot.PushEntry(entry, userIntegrations.TelegramBotToken, userIntegrations.TelegramBotChatID)
if err != nil {
logger.Error("[Integration] push entry to telegram bot failed: %v", err)
}
}
if err := client.SendNotification(entry); err != nil {
logger.Error("[Integration] push entry to apprise 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)
}
}
}
}
}

View File

@ -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
}

View File

@ -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...",

View File

@ -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": "Αποθήκευση...",

View File

@ -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…",

View File

@ -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...",

View File

@ -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...",

View File

@ -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...",

View File

@ -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": "सहेजा जा रहा है...",

View File

@ -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...",

View File

@ -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...",

View File

@ -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": "保存中…",

View File

@ -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...",

View File

@ -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...",

View File

@ -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...",

View File

@ -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": "Сохранение…",

View File

@ -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...",

View File

@ -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": "Зберігаю...",

View File

@ -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": "保存中…",

View File

@ -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": "儲存中…",

View File

@ -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

View File

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

View File

@ -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()

View File

@ -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) != "" {

View File

@ -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,

View File

@ -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
}

View File

@ -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
}

View File

@ -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()

View File

@ -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[") {

View File

@ -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, `,`))

View File

@ -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.

View File

@ -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 {

View File

@ -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,
)

View File

@ -13,179 +13,23 @@
<div class="alert alert-error">{{ t .errorMessage }}</div>
{{ end }}
<details {{ if .form.FeverEnabled }}open{{ end }}>
<summary>Fever</summary>
<details {{ if .form.AppriseEnabled }}open{{ end }}>
<summary>Apprise</summary>
<div class="form-section">
<label>
<input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "form.integration.fever_activate" }}
<input type="checkbox" name="apprise_enabled" value="1" {{ if .form.AppriseEnabled }}checked{{ end }}> {{ t "form.integration.apprise_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>
</div>
</details>
<details {{ if .form.GoogleReaderEnabled }}open{{ end }}>
<summary>Google Reader</summary>
<div class="form-section">
<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>
<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">
<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 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>
<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>
<input type="text" name="apprise_services_url" id="form-apprise-services-urls" value="{{ .form.AppriseServicesURL }}" placeholder="tgram://<token>/<chat_id>/,matrix://" spellcheck="false">
<details {{ if .form.InstapaperEnabled }}open{{ end }}>
<summary>Instapaper</summary>
<div class="form-section">
<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>
<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>
</div>
</details>
<details {{ if .form.WallabagEnabled }}open{{ end }}>
<summary>Wallabag</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" }}
</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">
<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.NotionEnabled }}open{{ end }}>
<summary>Notion</summary>
<div class="form-section">
<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>
</div>
</details>
<details {{ if .form.NunuxKeeperEnabled }}open{{ end }}>
<summary>Nunux Keeper</summary>
<div class="form-section">
<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>
@ -198,34 +42,77 @@
<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.ReadwiseEnabled }}open{{ end }}>
<summary>Readwise Reader</summary>
<details {{ if .form.FeverEnabled }}open{{ end }}>
<summary>Fever</summary>
<div class="form-section">
<label>
<input type="checkbox" name="readwise_enabled" value="1" {{ if .form.ReadwiseEnabled }}checked{{ end }}> {{ t "form.integration.readwise_activate" }}
<input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "form.integration.fever_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>
<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>
</div>
</details>
<details {{ if .form.GoogleReaderEnabled }}open{{ end }}>
<summary>Google Reader</summary>
<div class="form-section">
<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.InstapaperEnabled }}open{{ end }}>
<summary>Instapaper</summary>
<div class="form-section">
<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>
@ -238,62 +125,20 @@
<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" }}
</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.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="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>
</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.TelegramBotEnabled }}open{{ end }}>
<summary>Telegram Bot</summary>
<div class="form-section">
<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>
@ -306,19 +151,123 @@
<label>
<input type="checkbox" name="matrix_bot_enabled" value="1" {{ if .form.MatrixBotEnabled }}checked{{ end }}> {{ t "form.integration.matrix_bot_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">
<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>
</div>
</details>
<details {{ if .form.NotionEnabled }}open{{ end }}>
<summary>Notion</summary>
<div class="form-section">
<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>
</div>
</details>
<details {{ if .form.NunuxKeeperEnabled }}open{{ end }}>
<summary>Nunux Keeper</summary>
<div class="form-section">
<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.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>
</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>
</div>
</details>
<details {{ if .form.ReadwiseEnabled }}open{{ end }}>
<summary>Readwise Reader</summary>
<div class="form-section">
<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>
@ -365,6 +314,78 @@
</div>
</div>
</details>
<details {{ if .form.TelegramBotEnabled }}open{{ end }}>
<summary>Telegram Bot</summary>
<div class="form-section">
<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.WallabagEnabled }}open{{ end }}>
<summary>Wallabag</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" }}
</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">
<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.WebhookEnabled }}open{{ end }}>
<summary>Webhook</summary>
<div class="form-section">
<label>
<input type="checkbox" name="webhook_enabled" value="1" {{ if .form.WebhookEnabled }}checked{{ end }}> {{ t "form.integration.webhook_activate" }}
</label>
<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">
{{ 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>
</div>
</div>
</details>
</form>
<h3>{{ t "page.integration.bookmarklet" }}</h3>

View File

@ -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"),
}
}

View File

@ -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))

View File

@ -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)