Files
desk-moloni/modules/desk_moloni/controllers/WebhookController.php
Emanuel Almeida c19f6fd9ee fix(perfexcrm module): align version to 3.0.1, unify entrypoint, and harden routes/views
- Bump DESK_MOLONI version to 3.0.1 across module
- Normalize hooks to after_client_* and instantiate PerfexHooks safely
- Fix OAuthController view path and API client class name
- Add missing admin views for webhook config/logs; adjust view loading
- Harden client portal routes and admin routes mapping
- Make Dashboard/Logs/Queue tolerant to optional model methods
- Align log details query with existing schema; avoid broken joins

This makes the module operational in Perfex (admin + client), reduces 404s,
and avoids fatal errors due to inconsistent tables/methods.
2025-09-11 17:38:45 +01:00

418 lines
15 KiB
PHP

<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Webhook Controller for Moloni Integration
*
* Handles incoming webhooks from Moloni ERP system
* Processes events and triggers appropriate sync operations
*
* @package DeskMoloni
* @author Descomplicar®
* @copyright 2025 Descomplicar
* @version 3.0.0
*/
class WebhookController extends CI_Controller
{
public function __construct()
{
parent::__construct();
// Load required libraries
$this->load->library('desk_moloni/moloni_api_client');
$this->load->library('desk_moloni/error_handler');
// Set JSON content type
$this->output->set_content_type('application/json');
}
/**
* Main webhook endpoint for Moloni events
*
* Accepts POST requests from Moloni webhook system
* URL: admin/desk_moloni/webhook/receive
*/
public function receive()
{
try {
// Only accept POST requests
if ($this->input->method() !== 'post') {
throw new Exception('Only POST requests allowed', 405);
}
// Get raw POST data
$raw_input = file_get_contents('php://input');
if (empty($raw_input)) {
throw new Exception('Empty webhook payload', 400);
}
// Decode JSON payload
$payload = json_decode($raw_input, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid JSON payload: ' . json_last_error_msg(), 400);
}
// Get webhook signature for verification
$signature = $this->input->get_request_header('X-Moloni-Signature') ??
$this->input->get_request_header('X-Webhook-Signature');
// Log webhook received
log_activity('Desk-Moloni: Webhook received - Event: ' . ($payload['event'] ?? 'unknown'));
// Rate limiting check for webhooks
if (!$this->check_webhook_rate_limit()) {
throw new Exception('Webhook rate limit exceeded', 429);
}
// Process webhook through API client
$success = $this->moloni_api_client->process_webhook($payload, $signature);
if ($success) {
// Return success response
$this->output
->set_status_header(200)
->set_output(json_encode([
'status' => 'success',
'message' => 'Webhook processed successfully',
'event' => $payload['event'] ?? null,
'timestamp' => date('Y-m-d H:i:s')
]));
} else {
throw new Exception('Webhook processing failed', 500);
}
} catch (Exception $e) {
// Log error
$this->error_handler->log_error(
'webhook_processing',
$e->getMessage(),
[
'payload' => $payload ?? null,
'signature' => $signature ?? null,
'ip_address' => $this->input->ip_address(),
'user_agent' => $this->input->user_agent()
],
'medium'
);
// Return error response
$http_code = is_numeric($e->getCode()) ? $e->getCode() : 500;
$this->output
->set_status_header($http_code)
->set_output(json_encode([
'status' => 'error',
'message' => $e->getMessage(),
'timestamp' => date('Y-m-d H:i:s')
]));
}
}
/**
* Webhook configuration endpoint for administrators
*
* Allows admins to configure webhook settings
* URL: admin/desk_moloni/webhook/configure
*/
public function configure()
{
// Check if user has admin permissions
if (!has_permission('desk_moloni', '', 'edit')) {
access_denied('Desk-Moloni Webhook Configuration');
}
if ($this->input->post()) {
$this->handle_webhook_configuration();
}
$data = [];
// Get current webhook settings
$data['webhook_url'] = site_url('admin/desk_moloni/webhook/receive');
$data['webhook_secret'] = get_option('desk_moloni_webhook_secret');
$data['webhook_enabled'] = (bool)get_option('desk_moloni_webhook_enabled', false);
$data['webhook_events'] = explode(',', get_option('desk_moloni_webhook_events', 'customer.created,customer.updated,product.created,product.updated,invoice.created,invoice.updated'));
// Available webhook events
$data['available_events'] = [
'customer.created' => 'Customer Created',
'customer.updated' => 'Customer Updated',
'customer.deleted' => 'Customer Deleted',
'product.created' => 'Product Created',
'product.updated' => 'Product Updated',
'product.deleted' => 'Product Deleted',
'invoice.created' => 'Invoice Created',
'invoice.updated' => 'Invoice Updated',
'invoice.paid' => 'Invoice Paid',
'estimate.created' => 'Estimate Created',
'estimate.updated' => 'Estimate Updated'
];
// Get webhook statistics
$data['webhook_stats'] = $this->get_webhook_statistics();
// Generate CSRF token
$data['csrf_token'] = $this->security->get_csrf_hash();
// Load view
$data['title'] = _l('desk_moloni_webhook_configuration');
$this->load->view('admin/includes/header', $data);
$this->load->view('admin/modules/desk_moloni/webhook_configuration', $data);
$this->load->view('admin/includes/footer');
}
/**
* Test webhook endpoint
*
* Allows testing webhook functionality
* URL: admin/desk_moloni/webhook/test
*/
public function test()
{
// Check permissions
if (!has_permission('desk_moloni', '', 'view')) {
access_denied('Desk-Moloni Webhook Test');
}
try {
// Create test webhook payload
$test_payload = [
'event' => 'test.webhook',
'data' => [
'test' => true,
'timestamp' => time(),
'message' => 'This is a test webhook from Desk-Moloni'
],
'webhook_id' => uniqid('test_'),
'created_at' => date('Y-m-d H:i:s')
];
// Process test webhook
$success = $this->moloni_api_client->process_webhook($test_payload);
if ($success) {
set_alert('success', _l('webhook_test_successful'));
} else {
set_alert('danger', _l('webhook_test_failed'));
}
} catch (Exception $e) {
set_alert('danger', _l('webhook_test_error') . ': ' . $e->getMessage());
}
redirect(admin_url('desk_moloni/webhook/configure'));
}
/**
* Webhook logs endpoint
*
* Displays webhook processing logs
* URL: admin/desk_moloni/webhook/logs
*/
public function logs()
{
// Check permissions
if (!has_permission('desk_moloni', '', 'view')) {
access_denied('Desk-Moloni Webhook Logs');
}
$data = [];
// Load logs model
// Use API client logging or fallback if API log model is unavailable
if (file_exists(APPPATH . 'modules/desk_moloni/models/Desk_moloni_api_log_model.php')) {
$this->load->model('desk_moloni/desk_moloni_api_log_model');
}
// Get webhook logs (last 7 days)
$data['logs'] = isset($this->desk_moloni_api_log_model) ? $this->desk_moloni_api_log_model->get_logs([
'endpoint_like' => 'webhook%',
'start_date' => date('Y-m-d', strtotime('-7 days')),
'end_date' => date('Y-m-d'),
'limit' => 100,
'order_by' => 'timestamp DESC'
]) : [];
// Get log statistics
$data['log_stats'] = [
'total_webhooks' => count($data['logs']),
'successful' => count(array_filter($data['logs'], function($log) { return empty($log['error']); })),
'failed' => count(array_filter($data['logs'], function($log) { return !empty($log['error']); })),
'last_24h' => count(array_filter($data['logs'], function($log) {
return strtotime($log['timestamp']) > (time() - 86400);
}))
];
// Load view
$data['title'] = _l('desk_moloni_webhook_logs');
$this->load->view('admin/includes/header', $data);
$this->load->view('admin/modules/desk_moloni/webhook_logs', $data);
$this->load->view('admin/includes/footer');
}
/**
* Health check endpoint for webhooks
*
* URL: admin/desk_moloni/webhook/health
*/
public function health()
{
try {
$health_data = [
'status' => 'healthy',
'webhook_enabled' => (bool)get_option('desk_moloni_webhook_enabled', false),
'webhook_secret_configured' => !empty(get_option('desk_moloni_webhook_secret')),
'timestamp' => date('Y-m-d H:i:s'),
'checks' => []
];
// Check webhook configuration
if (!$health_data['webhook_enabled']) {
$health_data['status'] = 'warning';
$health_data['checks'][] = 'Webhooks are disabled';
}
if (!$health_data['webhook_secret_configured']) {
$health_data['status'] = 'warning';
$health_data['checks'][] = 'Webhook secret not configured';
}
// Check recent webhook activity
$this->load->model('desk_moloni_api_log_model');
$recent_webhooks = $this->desk_moloni_api_log_model->get_logs([
'endpoint_like' => 'webhook%',
'start_date' => date('Y-m-d', strtotime('-1 hour')),
'limit' => 10
]);
$health_data['recent_activity'] = [
'webhooks_last_hour' => count($recent_webhooks),
'last_webhook' => !empty($recent_webhooks) ? $recent_webhooks[0]['timestamp'] : null
];
$this->output
->set_status_header(200)
->set_output(json_encode($health_data));
} catch (Exception $e) {
$this->output
->set_status_header(500)
->set_output(json_encode([
'status' => 'error',
'message' => $e->getMessage(),
'timestamp' => date('Y-m-d H:i:s')
]));
}
}
/**
* Handle webhook configuration form submission
*/
private function handle_webhook_configuration()
{
try {
// Validate CSRF token
if (!$this->security->get_csrf_hash()) {
throw new Exception('Invalid CSRF token');
}
// Get form data
$webhook_enabled = (bool)$this->input->post('webhook_enabled');
$webhook_secret = $this->input->post('webhook_secret', true);
$webhook_events = $this->input->post('webhook_events') ?? [];
// Validate webhook secret
if ($webhook_enabled && empty($webhook_secret)) {
throw new Exception('Webhook secret is required when webhooks are enabled');
}
if (!empty($webhook_secret) && strlen($webhook_secret) < 16) {
throw new Exception('Webhook secret must be at least 16 characters long');
}
// Save configuration
update_option('desk_moloni_webhook_enabled', $webhook_enabled);
update_option('desk_moloni_webhook_secret', $webhook_secret);
update_option('desk_moloni_webhook_events', implode(',', $webhook_events));
// Log configuration change
log_activity('Desk-Moloni: Webhook configuration updated by ' . get_staff_full_name());
set_alert('success', _l('webhook_configuration_saved'));
} catch (Exception $e) {
log_activity('Desk-Moloni: Webhook configuration failed - ' . $e->getMessage());
set_alert('danger', _l('webhook_configuration_failed') . ': ' . $e->getMessage());
}
}
/**
* Check webhook rate limiting
*
* @return bool True if within rate limits
*/
private function check_webhook_rate_limit()
{
$rate_limit_key = 'webhook_rate_limit_' . $this->input->ip_address();
$current_count = $this->session->userdata($rate_limit_key) ?? 0;
$max_webhooks_per_minute = 60; // Configurable
if ($current_count >= $max_webhooks_per_minute) {
return false;
}
// Increment counter with 1 minute expiry
$this->session->set_userdata($rate_limit_key, $current_count + 1);
return true;
}
/**
* Get webhook processing statistics
*
* @return array Statistics data
*/
private function get_webhook_statistics()
{
$this->load->model('desk_moloni_api_log_model');
// Get statistics for last 30 days
$logs = $this->desk_moloni_api_log_model->get_logs([
'endpoint_like' => 'webhook%',
'start_date' => date('Y-m-d', strtotime('-30 days')),
'limit' => 1000
]);
$stats = [
'total_webhooks' => count($logs),
'successful' => 0,
'failed' => 0,
'by_event' => [],
'by_day' => []
];
foreach ($logs as $log) {
// Count success/failure
if (empty($log['error'])) {
$stats['successful']++;
} else {
$stats['failed']++;
}
// Count by event type
if (isset($log['endpoint'])) {
$event = str_replace('webhook:', '', $log['endpoint']);
$stats['by_event'][$event] = ($stats['by_event'][$event] ?? 0) + 1;
}
// Count by day
$day = date('Y-m-d', strtotime($log['timestamp']));
$stats['by_day'][$day] = ($stats['by_day'][$day] ?? 0) + 1;
}
return $stats;
}
}