From 9990afb7227efbb68bca9bb204d581e87c6d8d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Guillot?= Date: Sun, 10 Sep 2023 17:47:05 -0700 Subject: [PATCH] Add webhook event for saving entry --- internal/integration/integration.go | 11 ++- internal/integration/webhook/webhook.go | 120 +++++++++++++++++++++--- internal/storage/integration.go | 3 +- internal/ui/entry_save.go | 6 +- 4 files changed, 121 insertions(+), 19 deletions(-) diff --git a/internal/integration/integration.go b/internal/integration/integration.go index cd2baf79..7c7c3b22 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -167,6 +167,15 @@ func SendEntry(entry *model.Entry, integration *model.Integration) { logger.Error("[Integration] Unable to send entry #%d to Shaarli for user #%d: %v", entry.ID, integration.UserID, err) } } + + if integration.WebhookEnabled { + logger.Debug("[Integration] Sending entry #%d %q for user #%d to Webhook URL: %s", entry.ID, entry.URL, integration.UserID, integration.WebhookURL) + + webhookClient := webhook.NewClient(integration.WebhookURL, integration.WebhookSecret) + if err := webhookClient.SendSaveEntryWebhookEvent(entry); err != nil { + logger.Error("[Integration] UserID #%d: %v", integration.UserID, err) + } + } } // PushEntries pushes a list of entries to activated third-party providers during feed refreshes. @@ -184,7 +193,7 @@ func PushEntries(feed *model.Feed, entries model.Entries, userIntegrations *mode logger.Debug("[Integration] Sending %d entries for user #%d to Webhook URL: %s", len(entries), userIntegrations.UserID, userIntegrations.WebhookURL) webhookClient := webhook.NewClient(userIntegrations.WebhookURL, userIntegrations.WebhookSecret) - if err := webhookClient.SendWebhook(feed, entries); err != nil { + if err := webhookClient.SendNewEntriesWebhookEvent(feed, entries); err != nil { logger.Error("[Integration] sending entries to webhook failed: %v", err) } } diff --git a/internal/integration/webhook/webhook.go b/internal/integration/webhook/webhook.go index 766c54a3..edc5a840 100644 --- a/internal/integration/webhook/webhook.go +++ b/internal/integration/webhook/webhook.go @@ -15,7 +15,12 @@ import ( "miniflux.app/v2/internal/version" ) -const defaultClientTimeout = 10 * time.Second +const ( + defaultClientTimeout = 10 * time.Second + + NewEntriesEventType = "new_entries" + SaveEntryEventType = "save_entry" +) type Client struct { webhookURL string @@ -26,17 +31,71 @@ func NewClient(webhookURL, webhookSecret string) *Client { return &Client{webhookURL, webhookSecret} } -func (c *Client) SendWebhook(feed *model.Feed, entries model.Entries) error { - if c.webhookURL == "" { - return fmt.Errorf(`webhook: missing webhook URL`) - } +func (c *Client) SendSaveEntryWebhookEvent(entry *model.Entry) error { + return c.makeRequest(SaveEntryEventType, &WebhookSaveEntryEvent{ + EventType: SaveEntryEventType, + Entry: &WebhookEntry{ + ID: entry.ID, + UserID: entry.UserID, + FeedID: entry.FeedID, + Status: entry.Status, + Hash: entry.Hash, + Title: entry.Title, + URL: entry.URL, + CommentsURL: entry.CommentsURL, + Date: entry.Date, + CreatedAt: entry.CreatedAt, + ChangedAt: entry.ChangedAt, + Content: entry.Content, + Author: entry.Author, + ShareCode: entry.ShareCode, + Starred: entry.Starred, + ReadingTime: entry.ReadingTime, + Enclosures: entry.Enclosures, + Tags: entry.Tags, + Feed: &WebhookFeed{ + ID: entry.Feed.ID, + UserID: entry.Feed.UserID, + FeedURL: entry.Feed.FeedURL, + SiteURL: entry.Feed.SiteURL, + Title: entry.Feed.Title, + CheckedAt: entry.Feed.CheckedAt, + }, + }, + }) +} +func (c *Client) SendNewEntriesWebhookEvent(feed *model.Feed, entries model.Entries) error { if len(entries) == 0 { return nil } - webhookEvent := &WebhookEvent{ - // Send only a subset of the fields to avoid leaking sensitive data. + var webhookEntries []*WebhookEntry + for _, entry := range entries { + webhookEntries = append(webhookEntries, &WebhookEntry{ + ID: entry.ID, + UserID: entry.UserID, + FeedID: entry.FeedID, + Status: entry.Status, + Hash: entry.Hash, + Title: entry.Title, + URL: entry.URL, + CommentsURL: entry.CommentsURL, + Date: entry.Date, + CreatedAt: entry.CreatedAt, + ChangedAt: entry.ChangedAt, + Content: entry.Content, + Author: entry.Author, + ShareCode: entry.ShareCode, + Starred: entry.Starred, + ReadingTime: entry.ReadingTime, + Enclosures: entry.Enclosures, + Tags: entry.Tags, + }) + } + + return c.makeRequest(NewEntriesEventType, &WebhookNewEntriesEvent{ + EventType: NewEntriesEventType, Feed: &WebhookFeed{ ID: feed.ID, UserID: feed.UserID, @@ -45,10 +104,16 @@ func (c *Client) SendWebhook(feed *model.Feed, entries model.Entries) error { Title: feed.Title, CheckedAt: feed.CheckedAt, }, - Entries: entries, + Entries: webhookEntries, + }) +} + +func (c *Client) makeRequest(eventType string, payload any) error { + if c.webhookURL == "" { + return fmt.Errorf(`webhook: missing webhook URL`) } - requestBody, err := json.Marshal(webhookEvent) + requestBody, err := json.Marshal(payload) if err != nil { return fmt.Errorf("webhook: unable to encode request body: %v", err) } @@ -61,6 +126,7 @@ func (c *Client) SendWebhook(feed *model.Feed, entries model.Entries) error { 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)) + request.Header.Set("X-Miniflux-Event-Type", eventType) httpClient := &http.Client{Timeout: defaultClientTimeout} response, err := httpClient.Do(request) @@ -70,7 +136,7 @@ func (c *Client) SendWebhook(feed *model.Feed, entries model.Entries) error { 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 fmt.Errorf("webhook: incorrect response status code %d for url %s", response.StatusCode, c.webhookURL) } return nil @@ -85,7 +151,35 @@ type WebhookFeed struct { CheckedAt time.Time `json:"checked_at"` } -type WebhookEvent struct { - Feed *WebhookFeed `json:"feed"` - Entries model.Entries `json:"entries"` +type WebhookEntry struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + FeedID int64 `json:"feed_id"` + Status string `json:"status"` + Hash string `json:"hash"` + Title string `json:"title"` + URL string `json:"url"` + CommentsURL string `json:"comments_url"` + Date time.Time `json:"published_at"` + CreatedAt time.Time `json:"created_at"` + ChangedAt time.Time `json:"changed_at"` + Content string `json:"content"` + Author string `json:"author"` + ShareCode string `json:"share_code"` + Starred bool `json:"starred"` + ReadingTime int `json:"reading_time"` + Enclosures model.EnclosureList `json:"enclosures"` + Tags []string `json:"tags"` + Feed *WebhookFeed `json:"feed,omitempty"` +} + +type WebhookNewEntriesEvent struct { + EventType string `json:"event_type"` + Feed *WebhookFeed `json:"feed"` + Entries []*WebhookEntry `json:"entries"` +} + +type WebhookSaveEntryEvent struct { + EventType string `json:"event_type"` + Entry *WebhookEntry `json:"entry"` } diff --git a/internal/storage/integration.go b/internal/storage/integration.go index cf02b4af..8f2587c2 100644 --- a/internal/storage/integration.go +++ b/internal/storage/integration.go @@ -428,7 +428,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) { linkding_enabled='t' OR apprise_enabled='t' OR shiori_enabled='t' OR - shaarli_enabled='t' + shaarli_enabled='t' OR + webhook_enabled='t' ) ` if err := s.db.QueryRow(query, userID).Scan(&result); err != nil { diff --git a/internal/ui/entry_save.go b/internal/ui/entry_save.go index 20cb9244..33005062 100644 --- a/internal/ui/entry_save.go +++ b/internal/ui/entry_save.go @@ -29,15 +29,13 @@ func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) { return } - settings, err := h.store.Integration(request.UserID(r)) + userIntegrations, err := h.store.Integration(request.UserID(r)) if err != nil { json.ServerError(w, r, err) return } - go func() { - integration.SendEntry(entry, settings) - }() + go integration.SendEntry(entry, userIntegrations) json.Created(w, r, map[string]string{"message": "saved"}) }