From 6bdeb4f3e1854d7f8b29ed38867fd97fd571dfb9 Mon Sep 17 00:00:00 2001 From: Joshua Fernandes <14798955+joshua1234511@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:36:31 +0530 Subject: [PATCH] Plugin system: sale document view hooks and a webhook CSRF exemption (#4605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): add sale document view hooks and a webhook CSRF exemption Plugins can already inject buttons into the receipt via view:sales_receipt_buttons, but the invoice, quote and work order views have no hook point, so a plugin that delivers sale documents can only reach one of the four. Add the matching hooks, keeping the same name/data shape: view:sales_invoice_buttons view:sales_quote_buttons view:sales_work_order_buttons Each passes ['saleId' => $sale_id_num] and sits in the same position as the existing receipt hook, so one plugin callback can serve all four and branch on the document type. Also allow a plugin to expose an inbound provider webhook. A server-to-server delivery carries no CSRF token and no session, so the global csrf filter now excludes the pattern plugins/*/webhook. Plugins are responsible for authenticating such requests themselves (typically by verifying the provider's signature header against a shared secret) — this is documented alongside the change. While editing that line, the except list becomes an array. CodeIgniter matches every entry as \A\z, so the previous 'login|migrate' string produced a single pattern whose inner alternatives were unanchored: 'login/anything' was CSRF-exempt. Listing the entries separately anchors each one, which closes that and keeps the new plugin pattern tight — plugins/whatsapp/webhook is exempt while plugins/whatsapp/send is not. * refactor(plugins): drop explanatory comments from csrf filter exceptions --------- Co-authored-by: Joshua Fernandes <“joshua.1234511@yahoo.in”> --- app/Config/Filters.php | 2 +- app/Plugins/README.md | 33 +++++++++++++++++++++++++++++++++ app/Views/sales/invoice.php | 1 + app/Views/sales/quote.php | 1 + app/Views/sales/work_order.php | 1 + 5 files changed, 37 insertions(+), 1 deletion(-) diff --git a/app/Config/Filters.php b/app/Config/Filters.php index ac6a94b4a..558be89aa 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -73,7 +73,7 @@ class Filters extends BaseFilters public array $globals = [ 'before' => [ 'honeypot', - 'csrf' => ['except' => 'login|migrate'], + 'csrf' => ['except' => ['login', 'migrate', 'plugins/*/webhook']], 'invalidchars', ], 'after' => [ diff --git a/app/Plugins/README.md b/app/Plugins/README.md index cb7f094b0..81dfb1c36 100644 --- a/app/Plugins/README.md +++ b/app/Plugins/README.md @@ -226,8 +226,16 @@ These hook points are currently defined in core views: | `view:customer_tab_nav` | `app/Views/customers/form.php:28` | `['customer' => $person_info]` | Add `
  • ` tab navigation entries to customer form | | `view:customer_tab_panels` | `app/Views/customers/form.php:326` | `['customer' => $person_info]` | Add `
    ` tab panel content to customer form | | `view:sales_receipt_buttons` | `app/Views/sales/receipt.php:51` | `['saleId' => $sale_id_num]` | Add buttons to the receipt view | +| `view:sales_invoice_buttons` | `app/Views/sales/invoice.php:59` | `['saleId' => $sale_id_num]` | Add buttons to the invoice view | +| `view:sales_quote_buttons` | `app/Views/sales/quote.php:55` | `['saleId' => $sale_id_num]` | Add buttons to the quote view | +| `view:sales_work_order_buttons` | `app/Views/sales/work_order.php:57` | `['saleId' => $sale_id_num]` | Add buttons to the work order view | | `view:sales_register_buttons` | `app/Views/sales/register.php` | *(none)* | Add UI controls to the register checkout area | +The four sales document hooks are deliberately symmetrical: a plugin that delivers a +sale document (print, email, messaging) can register the same callback for all four and +branch on the document type. Only `saleId` is passed, so resolve any further sale or +customer data inside the plugin. + ### Benefits - **Self-Contained**: Plugin UI stays in plugin directory @@ -604,6 +612,31 @@ Always use fully qualified controller names: This ensures routes work correctly regardless of autoloader state. +### Inbound Webhook Routes + +A plugin that integrates with an external provider may need to receive server-to-server +callbacks. Such a request carries no CSRF token and no session, so the route must be +both public and CSRF-exempt. + +Name the route exactly `plugins//webhook`. `app/Config/Filters.php` exempts +that pattern (`plugins/*/webhook`) from the global CSRF filter, so no core change is +needed per plugin: + +```php +// app/Plugins/ExamplePlugin/Config/Routes.php +$routes->get('plugins/example/webhook', '\App\Plugins\ExamplePlugin\Controllers\WebhookController::index'); +$routes->post('plugins/example/webhook', '\App\Plugins\ExamplePlugin\Controllers\WebhookController::index'); +``` + +The exemption is anchored, so only that exact path is exempt — sibling routes such as +`plugins/example/sync` keep full CSRF protection. + +**The plugin is responsible for authenticating these requests.** Because the endpoint is +unauthenticated by design, the webhook controller must extend `BaseController` (not +`Secure_Controller`) and verify the caller itself — typically by recomputing the +provider's signature header over the raw request body with a shared secret and comparing +using `hash_equals()`. Never trust a webhook payload that failed that check. + ## Internationalization (Language Files) Plugins can include their own language files, making them completely self-contained. This allows plugins to provide translations without modifying core language files. diff --git a/app/Views/sales/invoice.php b/app/Views/sales/invoice.php index ac0ec0dac..507013459 100644 --- a/app/Views/sales/invoice.php +++ b/app/Views/sales/invoice.php @@ -56,6 +56,7 @@ if (isset($error_message)) { $print_after_sale, 'selected_printer' => 'invoice_printer']) ?>