diff --git a/xExtension-Webhook/LICENSE b/xExtension-Webhook/LICENSE new file mode 100644 index 00000000..9cf10627 --- /dev/null +++ b/xExtension-Webhook/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/xExtension-Webhook/README.md b/xExtension-Webhook/README.md new file mode 100644 index 00000000..5f3f7573 --- /dev/null +++ b/xExtension-Webhook/README.md @@ -0,0 +1,242 @@ +# FreshRSS Webhook Extension + +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![FreshRSS](https://img.shields.io/badge/FreshRSS-1.20.0+-green.svg)](https://freshrss.org/) + +A powerful FreshRSS extension that automatically sends webhook notifications when RSS entries match specified keywords. Perfect for integrating with Discord, Slack, Telegram, or any service that supports webhooks. + +## 🚀 Features + +- **Automated Notifications**: Automatically sends webhooks when new RSS entries match your keywords +- **Flexible Pattern Matching**: Search in titles, feed names, authors, or content +- **Multiple HTTP Methods**: Supports GET, POST, PUT, DELETE, PATCH, OPTIONS, and HEAD +- **Configurable Formats**: Send data as JSON or form-encoded +- **Template System**: Customizable webhook payloads with placeholders +- **Comprehensive Logging**: Detailed logging for debugging and monitoring +- **Error Handling**: Robust error handling with graceful fallbacks +- **Test Functionality**: Built-in test feature to verify webhook configuration + +## 📋 Requirements + +- FreshRSS 1.20.0 or later +- PHP 8.1 or later +- cURL extension enabled + +## 🔧 Installation + +1. Download the extension files +2. Upload the `xExtension-Webhook` folder to your FreshRSS `extensions` directory +3. Enable the extension in FreshRSS admin panel under Extensions + +## ⚙️ Configuration + +### Basic Setup + +1. Go to **Administration** → **Extensions** → **Webhook** +2. Configure the following settings: + +#### Keywords + +Enter keywords to match against RSS entries (one per line): + +```text +breaking news +security alert +your-project-name +``` + +#### Search Options + +- **Search in Title**: Match keywords in article titles +- **Search in Feed**: Match keywords in feed names +- **Search in Authors**: Match keywords in author names +- **Search in Content**: Match keywords in article content + +#### Webhook Settings + +- **Webhook URL**: Your webhook endpoint URL +- **HTTP Method**: Choose from GET, POST, PUT, DELETE, etc. +- **Body Type**: JSON or Form-encoded +- **Headers**: Custom HTTP headers (one per line) + +### Webhook Body Template + +Customize the webhook payload using placeholders: + +```json +{ + "title": "__TITLE__", + "feed": "__FEED__", + "url": "__URL__", + "content": "__CONTENT__", + "date": "__DATE__", + "timestamp": "__DATE_TIMESTAMP__", + "authors": "__AUTHORS__", + "tags": "__TAGS__" +} +``` + +#### Available Placeholders + +| Placeholder | Description | +|-------------|-------------| +| `__TITLE__` | Article title | +| `__FEED__` | Feed name | +| `__URL__` | Article URL | +| `__CONTENT__` | Article content | +| `__DATE__` | Publication date | +| `__DATE_TIMESTAMP__` | Unix timestamp | +| `__AUTHORS__` | Article authors | +| `__TAGS__` | Article tags | + +## 🎯 Use Cases + +### Discord Webhook + +```json +{ + "content": "New article: **__TITLE__**", + "embeds": [{ + "title": "__TITLE__", + "url": "__URL__", + "description": "__CONTENT__", + "color": 3447003, + "footer": { + "text": "__FEED__" + } + }] +} +``` + +### Slack Webhook + +```json +{ + "text": "New article from __FEED__", + "attachments": [{ + "title": "__TITLE__", + "title_link": "__URL__", + "text": "__CONTENT__", + "color": "good" + }] +} +``` + +### Custom API Integration + +```json +{ + "event": "new_article", + "data": { + "title": "__TITLE__", + "url": "__URL__", + "feed": "__FEED__", + "timestamp": "__DATE_TIMESTAMP__" + } +} +``` + +## 🔍 Pattern Matching + +The extension supports both regex patterns and simple string matching: + +### Regex Patterns + +```text +/security.*/i +/\b(urgent|critical)\b/i +``` + +### Simple Strings + +```text +breaking news +security alert +``` + +## 🛠️ Advanced Configuration + +### Custom Headers + +Add authentication or custom headers: + +```text +Authorization: Bearer your-token-here +X-Custom-Header: custom-value +User-Agent: FreshRSS-Webhook/1.0 +``` + +### Error Handling + +- Failed webhooks are logged for debugging +- Network timeouts are handled gracefully +- Invalid configurations are validated + +### Performance + +- Only sends webhooks when patterns match +- Efficient pattern matching with fallbacks +- Minimal impact on RSS processing + +## 🐛 Troubleshooting + +### Common Issues + +**Webhooks not sending:** +- Check that keywords are configured +- Verify webhook URL is accessible +- Enable logging to see detailed information + +**Pattern not matching:** +- Test with simple string patterns first +- Check regex syntax if using regex patterns +- Verify search options are enabled + +**Authentication errors:** +- Check custom headers configuration +- Verify webhook endpoint accepts your format + +### Debugging + +Enable logging in the extension settings to see detailed information about: +- Pattern matching results +- HTTP request details +- Response codes and errors + +## 📝 Changelog + +### Version 0.1.1 + +- Initial release +- Automated webhook notifications +- Pattern matching in multiple fields +- Configurable HTTP methods and formats +- Comprehensive error handling and logging +- Template-based webhook payloads + +## 🤝 Contributing + +This extension was developed to address [FreshRSS Issue #1513](https://github.com/FreshRSS/FreshRSS/issues/1513). + +Contributions are welcome! Please: +1. Fork the repository +2. Create a feature branch +3. Follow FreshRSS coding standards +4. Add tests for new functionality +5. Submit a pull request + +## 📄 License + +This extension is licensed under the [GNU Affero General Public License v3.0](LICENSE). + +## 🙏 Acknowledgments + +- FreshRSS development team for the excellent extension system +- Community members who requested and tested this feature +- Contributors to the original feature request + +## 📞 Support + +- [FreshRSS Documentation](https://freshrss.github.io/FreshRSS/) +- [GitHub Issues](https://github.com/FreshRSS/Extensions/issues) +- [FreshRSS Community](https://github.com/FreshRSS/FreshRSS/discussions) diff --git a/xExtension-Webhook/configure.phtml b/xExtension-Webhook/configure.phtml new file mode 100644 index 00000000..824f0099 --- /dev/null +++ b/xExtension-Webhook/configure.phtml @@ -0,0 +1,199 @@ + + + + +
+ + +
+ ⚙️ +
+ + +
+ +
+ +
+ + + +
+
+ + +
+ +
+ + + + + + + +
+ attributeBool('search_in_title') ? 'checked="checked"' : '' ?> /> + + + attributeBool('search_in_feed') ? 'checked="checked"' : '' ?> /> + + + attributeBool('search_in_authors') ? 'checked="checked"' : '' ?> /> + + + attributeBool('search_in_content') ? 'checked="checked"' : '' ?> /> + +
+
+
+ +
+ +
+ attributeBool('mark_as_read') ? 'checked="checked"' : '' ?> /> +
+
+ +
+
+ + + +
+ 🌐 +
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+ + + + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
__TITLE__
__URL__
__CONTENT__
__DATE__
__DATE_TIMESTAMP__
__AUTHORS__
__THUMBNAIL_URL__
__FEED__
__TAGS__
+
+ +
+
+ +
+
+ + + +
+ + +
+ +
+ +
+
+
+ +
+ getWebhookBodyType() === 'json' ? 'checked' : '' ?>> + + + getWebhookBodyType() === 'form' ? 'checked' : '' ?>> + + +
+ + +
+
+
+
+ +
+
+ + +
+
+ + + +
+
+ +
diff --git a/xExtension-Webhook/extension.php b/xExtension-Webhook/extension.php new file mode 100644 index 00000000..598a989a --- /dev/null +++ b/xExtension-Webhook/extension.php @@ -0,0 +1,453 @@ +"; + + /** + * Default HTTP headers for webhook requests + * + * @var string[] + */ + public array $webhook_headers = ["User-Agent: FreshRSS", "Content-Type: application/x-www-form-urlencoded"]; + + /** + * Default webhook request body template + * + * Supports placeholders like __TITLE__, __FEED__, __URL__, etc. + * + * @var string + */ + public string $webhook_body = '{ + "title": "__TITLE__", + "feed": "__FEED__", + "url": "__URL__", + "created": "__DATE_TIMESTAMP__" +}'; + + /** + * Initialize the extension + * + * Registers translation files and hooks into FreshRSS entry processing. + * + * @return void + */ + #[\Override] + public function init(): void { + $this->registerTranslates(); + $this->registerHook("entry_before_insert", [$this, "processArticle"]); + } + + /** + * Handle configuration form submission + * + * Processes configuration form data, saves settings, and optionally + * sends a test webhook request. + * + * @return void + * @throws Minz_PermissionDeniedException + */ + public function handleConfigureAction(): void { + $this->registerTranslates(); + + if (Minz_Request::isPost()) { + $conf = [ + "keywords" => array_filter(Minz_Request::paramTextToArray("keywords")), + "search_in_title" => Minz_Request::paramString("search_in_title"), + "search_in_feed" => Minz_Request::paramString("search_in_feed"), + "search_in_authors" => Minz_Request::paramString("search_in_authors"), + "search_in_content" => Minz_Request::paramString("search_in_content"), + "mark_as_read" => Minz_Request::paramBoolean("mark_as_read"), + "ignore_updated" => Minz_Request::paramBoolean("ignore_updated"), + + "webhook_url" => Minz_Request::paramString("webhook_url"), + "webhook_method" => Minz_Request::paramString("webhook_method"), + "webhook_headers" => array_filter(Minz_Request::paramTextToArray("webhook_headers")), + "webhook_body" => html_entity_decode(Minz_Request::paramString("webhook_body")), + "webhook_body_type" => Minz_Request::paramString("webhook_body_type"), + "enable_logging" => Minz_Request::paramBoolean("enable_logging"), + ]; + $this->setSystemConfiguration($conf); + $logsEnabled = $conf["enable_logging"]; + $this->logsEnabled = $conf["enable_logging"]; + + logWarning($logsEnabled, "saved config: ✅ " . json_encode($conf)); + + if (Minz_Request::paramString("test_request")) { + try { + sendReq( + $conf["webhook_url"], + $conf["webhook_method"], + $conf["webhook_body_type"], + $conf["webhook_body"], + $conf["webhook_headers"], + $conf["enable_logging"], + "Test request from configuration" + ); + } catch (Throwable $err) { + logError($logsEnabled, "Test request failed: {$err->getMessage()}"); + } + } + } + } + + /** + * Process article and send webhook if patterns match + * + * Analyzes RSS entries against configured keyword patterns and sends + * webhook notifications for matching entries. Supports pattern matching + * in titles, feeds, authors, and content. + * + * @param FreshRSS_Entry $entry The RSS entry to process + * + * @throws FreshRSS_Context_Exception + * @throws Minz_PermissionDeniedException + * + * @return FreshRSS_Entry The processed entry (potentially marked as read) + */ + public function processArticle($entry): FreshRSS_Entry { + if (!is_object($entry)) { + return $entry; + } + + if (FreshRSS_Context::userConf()->attributeBool('ignore_updated') && $entry->isUpdated()) { + logWarning(true, "⚠️ ignore_updated: " . $entry->link() . " ♦♦ " . $entry->title()); + return $entry; + } + + $searchInTitle = FreshRSS_Context::userConf()->attributeBool('search_in_title') ?? false; + $searchInFeed = FreshRSS_Context::userConf()->attributeBool('search_in_feed') ?? false; + $searchInAuthors = FreshRSS_Context::userConf()->attributeBool('search_in_authors') ?? false; + $searchInContent = FreshRSS_Context::userConf()->attributeBool('search_in_content') ?? false; + + $patterns = FreshRSS_Context::userConf()->attributeArray('keywords') ?? []; + $markAsRead = FreshRSS_Context::userConf()->attributeBool('mark_as_read') ?? false; + $logsEnabled = FreshRSS_Context::userConf()->attributeBool('enable_logging') ?? false; + $this->logsEnabled = $logsEnabled; + + // Validate patterns + if (!is_array($patterns) || empty($patterns)) { + logError($logsEnabled, "❗️ No keywords defined in Webhook extension settings."); + return $entry; + } + + $title = "❗️NOT INITIALIZED"; + $link = "❗️NOT INITIALIZED"; + $additionalLog = ""; + + try { + $title = $entry->title(); + $link = $entry->link(); + + foreach ($patterns as $pattern) { + $matchFound = false; + + if ($searchInTitle && $this->isPatternFound("/{$pattern}/", $title)) { + logWarning($logsEnabled, "matched item by title ✔️ \"{$title}\" ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ title \"{$title}\" ❖ link: {$link}"; + $matchFound = true; + } + + if (!$matchFound && $searchInFeed && is_object($entry->feed()) && $this->isPatternFound("/{$pattern}/", $entry->feed()->name())) { + logWarning($logsEnabled, "matched item with pattern: /{$pattern}/ ❖ feed \"{$entry->feed()->name()}\", (title: \"{$title}\") ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ feed \"{$entry->feed()->name()}\", (title: \"{$title}\") ❖ link: {$link}"; + $matchFound = true; + } + + if (!$matchFound && $searchInAuthors && $this->isPatternFound("/{$pattern}/", $entry->authors(true))) { + logWarning($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ authors \"{$entry->authors(true)}\", (title: {$title}) ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ authors \"{$entry->authors(true)}\", (title: {$title}) ❖ link: {$link}"; + $matchFound = true; + } + + if (!$matchFound && $searchInContent && $this->isPatternFound("/{$pattern}/", $entry->content())) { + logWarning($logsEnabled, "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}"); + $additionalLog = "✔️ matched item with pattern: /{$pattern}/ ❖ content (title: \"{$title}\") ❖ link: {$link}"; + $matchFound = true; + } + + if ($matchFound) { + break; + } + } + + if ($markAsRead) { + $entry->_isRead($markAsRead); + } + + // Only send webhook if a pattern was matched + if (!empty($additionalLog)) { + $this->sendArticle($entry, $additionalLog); + } + + } catch (Throwable $err) { + logError($logsEnabled, "Error during processing article ({$link} ❖ \"{$title}\") ERROR: {$err->getMessage()}"); + } + + return $entry; + } + + /** + * Send article data via webhook + * + * Prepares and sends webhook notification with article data. + * Replaces template placeholders with actual entry values. + * + * @param FreshRSS_Entry $entry The RSS entry to send + * @param string $additionalLog Additional context for logging + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ + private function sendArticle(FreshRSS_Entry $entry, string $additionalLog = ""): void { + try { + $bodyStr = FreshRSS_Context::userConf()->attributeString('webhook_body'); + + // Replace placeholders with actual values + $replacements = [ + "__TITLE__" => $this->toSafeJsonStr($entry->title()), + "__FEED__" => $this->toSafeJsonStr($entry->feed()->name()), + "__URL__" => $this->toSafeJsonStr($entry->link()), + "__CONTENT__" => $this->toSafeJsonStr($entry->content()), + "__DATE__" => $this->toSafeJsonStr($entry->date()), + "__DATE_TIMESTAMP__" => $this->toSafeJsonStr($entry->date(true)), + "__AUTHORS__" => $this->toSafeJsonStr($entry->authors(true)), + "__TAGS__" => $this->toSafeJsonStr($entry->tags(true)), + ]; + + $bodyStr = str_replace(array_keys($replacements), array_values($replacements), $bodyStr); + + sendReq( + FreshRSS_Context::userConf()->attributeString('webhook_url'), + FreshRSS_Context::userConf()->attributeString('webhook_method'), + FreshRSS_Context::userConf()->attributeString('webhook_body_type'), + $bodyStr, + FreshRSS_Context::userConf()->attributeArray('webhook_headers'), + FreshRSS_Context::userConf()->attributeBool('enable_logging'), + $additionalLog, + ); + } catch (Throwable $err) { + logError($this->logsEnabled, "ERROR in sendArticle: {$err->getMessage()}"); + } + } + + /** + * Convert string/int to safe JSON string + * + * Sanitizes input values for safe inclusion in JSON payloads + * by removing quotes and decoding HTML entities. + * + * @param string|int $str Input value to sanitize + * + * @return string Sanitized string safe for JSON inclusion + */ + private function toSafeJsonStr(string|int $str): string { + if (is_numeric($str)) { + return (string)$str; + } + + // Remove quotes and decode HTML entities + return str_replace('"', '', html_entity_decode((string)$str)); + } + + /** + * Check if pattern is found in text + * + * Attempts regex matching first, then falls back to simple string search. + * Handles regex errors gracefully and logs issues. + * + * @param string $pattern Search pattern (may include regex delimiters) + * @param string $text Text to search in + * + * @return bool True if pattern is found, false otherwise + */ + private function isPatternFound(string $pattern, string $text): bool { + if (empty($text) || empty($pattern)) { + return false; + } + + try { + // Try regex match first + if (preg_match($pattern, $text) === 1) { + return true; + } + + // Fallback to string search (remove regex delimiters) + $cleanPattern = trim($pattern, '/'); + return str_contains($text, $cleanPattern); + + } catch (Throwable $err) { + logError($this->logsEnabled, "ERROR in isPatternFound: (pattern: {$pattern}) {$err->getMessage()}"); + return false; + } + } + + /** + * Get keywords configuration as formatted string + * + * Returns the configured keywords as a newline-separated string + * for display in the configuration form. + * + * @throws FreshRSS_Context_Exception + * + * @return string Keywords separated by newlines + */ + public function getKeywordsData(): string { + $keywords = FreshRSS_Context::userConf()->attributeArray('keywords') ?? []; + return implode(PHP_EOL, $keywords); + } + + /** + * Get webhook headers configuration as formatted string + * + * Returns the configured HTTP headers as a newline-separated string + * for display in the configuration form. + * + * @throws FreshRSS_Context_Exception + * + * @return string HTTP headers separated by newlines + */ + public function getWebhookHeaders(): string { + $headers = FreshRSS_Context::userConf()->attributeArray('webhook_headers'); + return implode( + PHP_EOL, + is_array($headers) ? $headers : ($this->webhook_headers ?? []), + ); + } + + /** + * Get configured webhook URL + * + * Returns the configured webhook URL or the default if none is set. + * + * @throws FreshRSS_Context_Exception + * + * @return string The webhook URL + */ + public function getWebhookUrl(): string { + return FreshRSS_Context::userConf()->attributeString('webhook_url') ?? $this->webhook_url; + } + + /** + * Get configured webhook body template + * + * Returns the configured webhook body template or the default if none is set. + * + * @throws FreshRSS_Context_Exception + * + * @return string The webhook body template + */ + public function getWebhookBody(): string { + $body = FreshRSS_Context::userConf()->attributeString('webhook_body'); + return ($body === null || $body === '') ? $this->webhook_body : $body; + } + + /** + * Get configured webhook body type + * + * Returns the configured body type (json/form) or the default if none is set. + * + * @throws FreshRSS_Context_Exception + * + * @return string The webhook body type + */ + public function getWebhookBodyType(): string { + return FreshRSS_Context::userConf()->attributeString('webhook_body_type') ?? $this->webhook_body_type->value; + } +} + +/** + * Backward compatibility alias for logWarning function + * + * @deprecated Use logWarning() instead + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function _LOG(bool $logEnabled, $data): void { + logWarning($logEnabled, $data); +} + +/** + * Backward compatibility alias for logError function + * + * @deprecated Use logError() instead + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function _LOG_ERR(bool $logEnabled, $data): void { + logError($logEnabled, $data); +} diff --git a/xExtension-Webhook/i18n/en/ext.php b/xExtension-Webhook/i18n/en/ext.php new file mode 100644 index 00000000..ced0e41d --- /dev/null +++ b/xExtension-Webhook/i18n/en/ext.php @@ -0,0 +1,45 @@ + array( + 'event_settings' => 'Event settings', + 'show_hide' => 'show/hide', + 'webhook_settings' => 'Webhook settings', + 'more_options' => 'More options (headers, format,…):', + 'save_and_send_test_req' => 'Save and send test request', + 'description' => 'Webhooks allow external services to be notified when certain events happen.\nWhen the specified events happen, we\'ll send a HTTP request (usually POST) to the URL you provide.', + 'keywords' => 'Keywords in the new article', + 'search_in' => 'Search in article\'s:', + 'search_in_title' => 'title', + 'search_in_feed' => 'feed', + 'search_in_content' => 'content', + 'search_in_all' => 'all', + 'search_in_none' => 'none', + 'keywords_description' => 'Each line is checked individually. In addition to normal texts, RegEx expressions can also be defined (they are evaluated using the PHP function preg_match).', + 'search_in_title_label' => '🪧 title *   ', + 'search_in_feed_label' => '💼 feed   ', + 'search_in_authors_label' => '👥 authors   ', + 'search_in_content_label' => '📄 content   ', + 'mark_as_read' => 'Mark as read', + 'mark_as_read_description' => 'Mark the article as read after sending the webhook.', + 'mark_as_read_label' => 'Mark as read', + 'http_body' => 'HTTP Body', + 'http_body_description' => 'Must be valid JSON or form data (x-www-form-urlencoded)', + 'http_body_placeholder_summary' => 'You can use special placeholders that will be replaced by the actual values:', + 'http_body_placeholder_title' => 'Placeholder', + 'http_body_placeholder_description' => 'Description', + 'http_body_placeholder_title_description' => 'Article title', + 'http_body_placeholder_url_description' => 'HTML-encoded link of the article', + 'http_body_placeholder_content_description' => 'Content of the article (HTML format)', + 'http_body_placeholder_authors_description' => 'Authors of the article', + 'http_body_placeholder_feed_description' => 'Feed of the article', + 'http_body_placeholder_tags_description' => 'Article tags (string, separated by " #")', + 'http_body_placeholder_date_description' => 'Date of the article (string)', + 'http_body_placeholder_date_timestamp_description' => 'Date of the article as timestamp (number)', + 'http_body_placeholder_thumbnail_url_description' => 'Thumbnail (image) URL', + 'webhook_headers' => 'HTTP Headers
(one per line)', + 'http_body_type' => 'HTTP Body type', + 'more_info' => 'More info:', + 'more_info_description' => 'When header contains Content-type: application/x-www-form-urlencoded the keys and values are encoded in key-value tuples separated by "&", with a "=" between the key and the value. Non-alphanumeric characters in both keys and values are URL encoded' + ), +); diff --git a/xExtension-Webhook/metadata.json b/xExtension-Webhook/metadata.json new file mode 100644 index 00000000..6cba9dde --- /dev/null +++ b/xExtension-Webhook/metadata.json @@ -0,0 +1,8 @@ +{ + "name": "Webhook", + "author": "Lukas Melega, Ryahn", + "description": "Send custom webhook when new article appears (and matches custom criteria)", + "version": "0.1.1", + "entrypoint": "Webhook", + "type": "system" +} diff --git a/xExtension-Webhook/request.php b/xExtension-Webhook/request.php new file mode 100644 index 00000000..5a4143d1 --- /dev/null +++ b/xExtension-Webhook/request.php @@ -0,0 +1,309 @@ +getMessage()} | URL: {$url} | Body: {$body}"); + throw $err; + } finally { + curl_close($ch); + } +} + +/** + * Configure cURL HTTP method settings + * + * Sets the appropriate cURL options based on the HTTP method. + * + * @param CurlHandle $ch The cURL handle + * @param string $method HTTP method in uppercase + * + * @return void + */ +function configureHttpMethod(CurlHandle $ch, string $method): void { + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + switch ($method) { + case 'POST': + curl_setopt($ch, CURLOPT_POST, true); + break; + case 'PUT': + curl_setopt($ch, CURLOPT_PUT, true); + break; + case 'GET': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET'); + break; + case 'DELETE': + case 'PATCH': + case 'OPTIONS': + case 'HEAD': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + break; + } +} + +/** + * Process HTTP body based on content type + * + * Converts the request body to the appropriate format based on the body type. + * Supports JSON and form-encoded data. + * + * @param string $body Raw body content as JSON string + * @param string $bodyType Content type ('json' or 'form') + * @param string $method HTTP method + * @param bool $logEnabled Whether logging is enabled + * + * @throws JsonException When JSON processing fails + * @throws InvalidArgumentException When unsupported body type is provided + * @throws Minz_PermissionDeniedException + * + * @return string|null Processed body content or null if no body needed + */ +function processHttpBody(string $body, string $bodyType, string $method, bool $logEnabled): ?string { + if (empty($body) || $method === 'GET') { + return null; + } + + try { + $bodyObject = json_decode($body, true, 256, JSON_THROW_ON_ERROR); + + return match ($bodyType) { + 'json' => json_encode($bodyObject, JSON_THROW_ON_ERROR), + 'form' => http_build_query($bodyObject ?? []), + default => throw new InvalidArgumentException("Unsupported body type: {$bodyType}") + }; + } catch (JsonException $err) { + logError($logEnabled, "JSON processing error: {$err->getMessage()} | Body: {$body}"); + throw $err; + } +} + +/** + * Configure HTTP headers for the request + * + * Sets appropriate Content-Type headers if none are provided, + * based on the body type. + * + * @param string[] $headers Array of custom headers + * @param string $bodyType Content type ('json' or 'form') + * + * @return string[] Final array of headers to use + */ +function configureHeaders(array $headers, string $bodyType): array { + if (empty($headers)) { + return match ($bodyType) { + 'form' => ['Content-Type: application/x-www-form-urlencoded'], + 'json' => ['Content-Type: application/json'], + default => [] + }; + } + + return $headers; +} + +/** + * Log the outgoing HTTP request details + * + * Logs comprehensive information about the request being sent, + * including URL, method, body, and headers. + * + * @param bool $logEnabled Whether logging is enabled + * @param string $additionalLog Additional context information + * @param string $method HTTP method + * @param string $url Target URL + * @param string $bodyType Content type + * @param string|null $body Processed request body + * @param string[] $headers Array of HTTP headers + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function logRequest( + bool $logEnabled, + string $additionalLog, + string $method, + string $url, + string $bodyType, + ?string $body, + array $headers +): void { + if (!$logEnabled) { + return; + } + + $cleanUrl = urldecode($url); + $cleanBody = $body ? str_replace('\/', '/', $body) : ''; + $headersJson = json_encode($headers); + + $logMessage = trim("{$additionalLog} ♦♦ sendReq ⏩ {$method}: {$cleanUrl} ♦♦ {$bodyType} ♦♦ {$cleanBody} ♦♦ {$headersJson}"); + + logWarning($logEnabled, $logMessage); +} + +/** + * Execute cURL request and handle response + * + * Executes the configured cURL request and handles both success + * and error responses with appropriate logging. + * + * @param CurlHandle $ch The configured cURL handle + * @param bool $logEnabled Whether logging is enabled + * + * @throws RuntimeException When cURL execution fails + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function executeRequest(CurlHandle $ch, bool $logEnabled): void { + $response = curl_exec($ch); + + if (curl_errno($ch)) { + $error = curl_error($ch); + logError($logEnabled, "cURL error: {$error}"); + throw new RuntimeException("cURL error: {$error}"); + } + + $info = curl_getinfo($ch); + $httpCode = $info['http_code'] ?? 'unknown'; + + logWarning($logEnabled, "Response ✅ ({$httpCode}) {$response}"); +} + +/** + * Log warning message using FreshRSS logging system + * + * Safely logs warning messages through the FreshRSS Minz_Log system + * with proper class existence checking. + * + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log (will be converted to string) + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function logWarning(bool $logEnabled, $data): void { + if ($logEnabled && class_exists('Minz_Log')) { + Minz_Log::warning("[WEBHOOK] " . $data); + } +} + +/** + * Log error message using FreshRSS logging system + * + * Safely logs error messages through the FreshRSS Minz_Log system + * with proper class existence checking. + * + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log (will be converted to string) + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function logError(bool $logEnabled, $data): void { + if ($logEnabled && class_exists('Minz_Log')) { + Minz_Log::error("[WEBHOOK]❌ " . $data); + } +} + +/** + * Backward compatibility alias for logWarning function + * + * @deprecated Use logWarning() instead + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function LOG_WARN(bool $logEnabled, $data): void { + logWarning($logEnabled, $data); +} + +/** + * Backward compatibility alias for logError function + * + * @deprecated Use logError() instead + * @param bool $logEnabled Whether logging is enabled + * @param mixed $data Data to log + * + * @throws Minz_PermissionDeniedException + * + * @return void + */ +function LOG_ERR(bool $logEnabled, $data): void { + logError($logEnabled, $data); +}