Plugin system: sale document view hooks and a webhook CSRF exemption (#4605)

* 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<pattern>\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”>
This commit is contained in:
Joshua Fernandes
2026-07-29 04:36:31 +05:30
committed by GitHub
parent 01f332ecb5
commit 6bdeb4f3e1
5 changed files with 37 additions and 1 deletions

View File

@@ -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' => [

View File

@@ -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 `<li>` tab navigation entries to customer form |
| `view:customer_tab_panels` | `app/Views/customers/form.php:326` | `['customer' => $person_info]` | Add `<div>` 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/<plugin_id>/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.

View File

@@ -56,6 +56,7 @@ if (isset($error_message)) {
<?= view('partial/print_receipt', ['print_after_sale' => $print_after_sale, 'selected_printer' => 'invoice_printer']) ?>
<div class="print_hide" id="control_buttons" style="text-align: right;">
<?= pluginContent('sales_invoice_buttons', ['saleId' => $sale_id_num]) ?>
<a href="javascript:printdoc();">
<div class="btn btn-info btn-sm" id="show_print_button"><?= '<span class="glyphicon glyphicon-print">&nbsp;</span>' . lang('Common.print') ?></div>
</a>

View File

@@ -52,6 +52,7 @@ if (isset($error_message)) {
<?= view('partial/print_receipt', ['print_after_sale' => $print_after_sale, 'selected_printer' => 'invoice_printer']) ?>
<div class="print_hide" id="control_buttons" style="text-align: right;">
<?= pluginContent('sales_quote_buttons', ['saleId' => $sale_id_num]) ?>
<a href="javascript:printdoc();">
<div class="btn btn-info btn-sm" id="show_print_button"><?= '<span class="glyphicon glyphicon-print">&nbsp;</span>' . lang('Common.print') ?></div>
</a>

View File

@@ -54,6 +54,7 @@ if (isset($error_message)) {
<?= view('partial/print_receipt', ['print_after_sale' => $print_after_sale, 'selected_printer' => 'invoice_printer']) ?>
<div class="print_hide" id="control_buttons" style="text-align: right;">
<?= pluginContent('sales_work_order_buttons', ['saleId' => $sale_id_num]) ?>
<a href="javascript:printdoc();">
<div class="btn btn-info btn-sm" id="show_print_button"><?= '<span class="glyphicon glyphicon-print">&nbsp;</span>' . lang('Common.print') ?></div>
</a>