Commit inicial - WordPress Análisis de Precios Unitarios

- WordPress core y plugins
- Tema Twenty Twenty-Four configurado
- Plugin allow-unfiltered-html.php simplificado
- .gitignore configurado para excluir wp-config.php y uploads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-03 21:04:30 -06:00
commit a22573bf0b
24068 changed files with 4993111 additions and 0 deletions

View File

@@ -0,0 +1,199 @@
# Thrive Dashboard Outbound Webhooks (TD Webhooks)
## Overview
Outbound-only webhooks for sending form/user data to external endpoints when Thrive forms are submitted. Lives under Thrive Dashboard and is independent of Automator.
Quickstart (UI)
- Thrive Dashboard → Webhooks → Add New
- Fill Name, URL, Method, Request Format
- Add Body Mapping (Key/Value), Headers if needed
- Choose Trigger When (On Submit/On Success), Targeting, Consent
- Save and submit a test form; view Logs
## Storage
- Definitions: CPT `td_webhook`
- `post_title` → webhook name
- Post meta keys:
- `td_webhook_enabled` (bool)
- `td_webhook_url` (string)
- `td_webhook_method` (`get|post|put|patch|delete`)
- `td_webhook_request_format` (`form|json|xml`)
- `td_webhook_headers` (array of `{ key, value }`)
- `td_webhook_body_mapping` (array of `{ key, value }`, supports bracket keys)
- `td_webhook_trigger_when` (`on_submit|on_success`)
- `td_webhook_consent_required` (bool)
- `td_webhook_targeting` (object `{ scope, form_ids, post_ids, slugs }`)
- `td_webhook_advanced` (object `{ timeout, async, retry_policy }` reserved for future)
- Logs: option `td_webhooks_logs` structure `{ [webhook_id]: [ LogEntry, ... ] }`
- Settings: option `td_webhooks_settings`:
- `timeout` (seconds, default 8)
- `retention_per_id` (default 100)
- `ttl_days` (days, 0 disables)
- `allowlist`, `denylist` (domain patterns)
## Triggers
- `on_submit`: listens to `tcb_api_form_submit` (raw sanitized POST context)
- `on_success`: listens to `thrive_core_lead_signup` (after successful subscription)
## Mapping and templating
- Body mapping array of `{ key, value }`
- Bracket notation builds nested structures: `user[name]``{ user: { name: ... } }`
- Placeholders: `{{path}}` resolved via dot-notation on context (e.g., `{{data.email}}`, `{{user.user_email}}`)
Placeholder reference by trigger:
- On Submit (`tcb_api_form_submit`):
- `{{data.FIELD}}` for each form input name (e.g., `email`, `name`, `phone`)
- `{{data._tcb_id}}` form settings id; `{{data.page_slug}}`; `{{data.post_id}}`
- On Success (`thrive_core_lead_signup`):
- `{{data.email}}`, `{{data.first_name}}`, `{{data.last_name}}` (normalized lead data if present)
- `{{user.user_email}}`, `{{user.user_login}}` (WP user details when available)
## HTTP sending
- Uses WP HTTP API
- Formats:
- `json`: JSON body + `Content-Type: application/json`
- `form`: default; array encoded
- `xml`: simple XML serialization
- Headers merged from mapping; `Host`/`Content-Length` stripped
- Timeout default 8s (settings)
## Security
- Protocols allowed: `http`, `https`
- Block `localhost` and `127.0.0.1`
- Optional allowlist/denylist on host patterns
- Logs mask common secret header names
- Request/response bodies truncated to 2000 chars in logs
Consent behavior
- If `Require Consent` is enabled on the webhook and Trigger When is On Submit, the webhook only sends when the forms `user_consent` or `gdpr` flag is truthy.
- On Success implies upstream consent checks have passed; we still honor `Require Consent` but it will typically be satisfied.
Targeting rules
- Scope `all`: no filtering
- Scope `include`: send only if at least one matches among Form IDs (`_tcb_id`), Post IDs, or Slugs
- Scope `exclude`: skip if any matches among the above
## REST API
- Namespace: `td/v1`
- Webhooks
- `GET /webhooks` → list
- `POST /webhooks` → create
- `GET /webhooks/{id}` → read
- `PUT /webhooks/{id}` → update
- `DELETE /webhooks/{id}` → delete
- `GET /webhooks/{id}/logs` → logs
- `POST /webhooks/{id}/test` → send test using optional payload context
- Settings
- `GET /webhooks/settings`
- `PUT /webhooks/settings`
Auth: standard WP REST nonce + `TVE_DASH_CAPABILITY` capability.
Examples (curl)
Create a webhook
```bash
curl -X POST \
-H "X-WP-Nonce: $(wp_create_nonce wp_rest)" \
-H "Content-Type: application/json" \
-b cookie.txt -c cookie.txt \
--data '{
"name":"My Hook",
"enabled":true,
"url":"https://webhook.site/xxx",
"method":"post",
"request_format":"json",
"trigger_when":"on_submit",
"headers":[{"key":"X-App","value":"TD"}],
"body_mapping":[{"key":"email","value":"{{data.email}}"}],
"targeting":{"scope":"all"}
}' \
http://site.test/wp-json/td/v1/webhooks
```
Update
```bash
curl -X PUT \
-H "X-WP-Nonce: $(wp_create_nonce wp_rest)" \
-H "Content-Type: application/json" \
-b cookie.txt -c cookie.txt \
--data '{"enabled":false}' \
http://site.test/wp-json/td/v1/webhooks/123
```
Get logs
```bash
curl -H "X-WP-Nonce: $(wp_create_nonce wp_rest)" \
-b cookie.txt -c cookie.txt \
http://site.test/wp-json/td/v1/webhooks/123/logs
```
Test send
```bash
curl -X POST \
-H "X-WP-Nonce: $(wp_create_nonce wp_rest)" \
-H "Content-Type: application/json" \
-b cookie.txt -c cookie.txt \
--data '{"data":{"email":"john@example.com"}}' \
http://site.test/wp-json/td/v1/webhooks/123/test
```
## Admin UI
Menu: Thrive Dashboard → Webhooks
- Tabs: All Webhooks, Add/Edit, Logs, Settings
- Simple repeaters for headers and body mapping
Capabilities
- Access requires `tve-use-td` (administrators have it by default). If menu is missing, verify capability.
## Programmatic usage
```php
use TVE\Dashboard\Webhooks\TD_Webhooks_Repository;
$id = TD_Webhooks_Repository::create([
'name' => 'My Hook',
'enabled' => true,
'url' => 'https://example.com',
'method' => 'post',
'request_format' => 'json',
'body_mapping' => [ [ 'key' => 'email', 'value' => '{{data.email}}' ] ],
'trigger_when' => 'on_submit',
]);
```
Manual QA
- Create a webhook with JSON format and map `email` to `{{data.email}}`
- Add a TCB lead gen form with an Email field and submit
- Verify log entry status code and payload in Logs tab
Troubleshooting
- If menu doesnt show: check capability `tve-use-td` and that `TVE\Dashboard\Webhooks\Main::init()` is called (see `thrive-dashboard.php`)
- If requests fail: check site can reach external URL, verify allowlist/denylist, and inspect Logs tab
## Files
- `inc/webhooks/class-main.php`: bootstrap (register, menu, options, REST)
- `inc/webhooks/class-td-webhooks-repository.php`: CPT CRUD
- `inc/webhooks/class-td-webhooks-dispatcher.php`: hook listeners + selection
- `inc/webhooks/class-td-webhooks-templating.php`: payload builder
- `inc/webhooks/class-td-webhooks-sender.php`: HTTP request + logging
- `inc/webhooks/class-td-webhooks-logger.php`: option ring-buffer logs
- `inc/webhooks/class-td-webhooks-validator.php`: validations
- `inc/webhooks/class-td-webhooks-admin.php`: admin screens
- `inc/webhooks/class-td-webhooks-rest-controller.php`: REST endpoints
## Versioning & compatibility
- Default timeout and retention are configurable; no DB tables created
- Module is included by `thrive-dashboard/thrive-dashboard.php` and initializes on admin init

View File

@@ -0,0 +1,233 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Main bootstrap for the Webhooks module.
*
* Responsibilities:
* - Load all Webhooks classes in this directory
* - Register the internal CPT used to persist webhooks
* - Wire admin menu and options
* - Initialize REST routes and runtime services (dispatcher, admin UI)
*/
class Main {
/**
* Entry point for the Webhooks module.
*
* - Includes all classes in this directory
* - Registers CPT and admin menu
* - Initializes options, dispatcher and admin UI
* - Registers REST routes
*
* @return void
*/
public static function init() {
static::includes();
// Register storage (CPT) and menu
add_action( 'init', [ __CLASS__, 'register_cpt' ] );
add_action( 'admin_menu', [ __CLASS__, 'register_menu' ] );
add_action( 'init', [ __CLASS__, 'init_options' ] );
// Wire pre-save handler so drafts get persisted during Save Work
add_action( 'init', [ __CLASS__, 'hook_content_pre_save' ] );
// Runtime services
TD_Webhooks_Dispatcher::init();
TD_Webhooks_Admin::init();
// REST API endpoints
add_action( 'rest_api_init', [ __CLASS__, 'rest_api_init' ] );
}
/**
* Require all PHP files next to this one, except this class file itself.
*
* This allows the module to self-bootstrap without hardcoding class includes.
*
* @return void
*/
public static function includes() {
// Ensure shared utils are loaded (HTTP error map)
$utils_file = dirname( __DIR__ ) . '/utils/class-tt-http-error-map.php';
if ( file_exists( $utils_file ) ) {
require_once $utils_file;
}
// Require all PHP files next to this one, except this class file itself.
foreach ( glob( __DIR__ . '/*.php' ) as $file ) {
if ( strpos( $file, 'class-main.php' ) !== false ) {
continue;
}
require_once $file;
}
}
/**
* Register all REST API routes for the Webhooks module.
*
* @return void
*/
public static function rest_api_init() {
$rest = new TD_Webhooks_Rest_Controller();
$rest->register_routes();
}
/**
* Register the internal `td_webhook` custom post type.
*
* This CPT is not exposed in the UI; it persists webhook configurations.
*
* @return void
*/
public static function register_cpt() {
$labels = [
'name' => __( 'Webhooks', 'thrive-dash' ),
'singular_name' => __( 'Webhook', 'thrive-dash' ),
];
$args = [
'labels' => $labels,
'public' => false,
'show_ui' => false,
'show_in_menu' => false,
'exclude_from_search' => true,
'show_in_nav_menus' => false,
'supports' => [ 'title' ],
'capability_type' => 'post',
'map_meta_cap' => true,
'rewrite' => false,
];
register_post_type( 'td_webhook', $args );
// Exclude from Thrive index
add_filter( 'tve_dash_exclude_post_types_from_index', static function( $post_types ) {
$post_types[] = 'td_webhook';
return $post_types;
} );
}
/**
* Add the Webhooks submenu under the Thrive Dashboard section.
*
* @return void
*/
public static function register_menu() {
// Only expose the Webhooks admin UI when TVE_DEBUG is enabled
if ( defined( 'TVE_DEBUG' ) && TVE_DEBUG ) {
add_submenu_page(
'tve_dash_section',
__( 'Webhooks', 'thrive-dash' ),
__( 'Webhooks', 'thrive-dash' ),
TVE_DASH_CAPABILITY,
'td_webhooks',
[ __CLASS__, 'render_admin_page' ]
);
}
}
/**
* Render the admin page wrapper. The concrete UI is delegated to TD_Webhooks_Admin.
*
* @return void
*/
public static function render_admin_page() {
TD_Webhooks_Admin::render();
}
/**
* Initialize default options for Webhooks settings and logs storage.
*
* - td_webhooks_settings: module settings (timeout, retention, TTL, allow/deny lists)
* - td_webhooks_logs: per-webhook execution logs
*
* @return void
*/
public static function init_options() {
// Do not create settings option unconditionally; defaults are provided via TD_Webhooks_Settings.
if ( get_option( 'td_webhooks_logs', null ) === null ) {
add_option( 'td_webhooks_logs', [], '', 'no' );
}
}
/**
* Hook into the editor content pre-save pipeline to persist any webhook drafts coming from TCB.
*/
public static function hook_content_pre_save() {
add_filter( 'tcb.content_pre_save', static function ( $response, $post_data ) {
// Expect an array of { form_identifier, webhook_id, draft } under 'webhooks'
if ( empty( $post_data['webhooks'] ) || ! is_array( $post_data['webhooks'] ) ) {
return $response;
}
$result = [];
foreach ( $post_data['webhooks'] as $item ) {
$draft = isset( $item['draft'] ) && is_array( $item['draft'] ) ? $item['draft'] : [];
$form_identifier = isset( $item['form_identifier'] ) ? sanitize_text_field( $item['form_identifier'] ) : '';
$form_id = isset( $item['form_id'] ) ? sanitize_text_field( $item['form_id'] ) : '';
$existing_id = isset( $item['webhook_id'] ) ? (int) $item['webhook_id'] : 0;
$delete_id = isset( $item['delete'] ) ? (int) $item['delete'] : 0;
// If deletion was requested, delete and return mapping only
if ( $delete_id > 0 && get_post_type( $delete_id ) === TD_Webhooks_Repository::POST_TYPE ) {
TD_Webhooks_Repository::delete( $delete_id );
$result[] = [
'id' => 0,
'name' => __( 'Deleted', 'thrive-dash' ),
'form_identifier' => $form_identifier,
'form_id' => $form_id,
'deleted' => $delete_id,
];
continue;
}
// Otherwise create or update based on presence of existing_id
if ( $existing_id > 0 && get_post_type( $existing_id ) === TD_Webhooks_Repository::POST_TYPE ) {
// ID Exists, Update the webhook.
TD_Webhooks_Repository::update( $existing_id, $draft );
$saved_id = $existing_id;
} else {
// ID Does Not Exist, Create the webhook.
$saved_id = TD_Webhooks_Repository::create( $draft );
}
if ( $saved_id ) {
$saved = TD_Webhooks_Repository::read( (int) $saved_id );
$result[] = [
'id' => (int) $saved_id,
'name' => isset( $saved['name'] ) ? $saved['name'] : '',
'form_identifier' => $form_identifier,
'form_id' => $form_id,
'deleted' => 0,
];
}
}
if ( ! empty( $result ) ) {
$response['webhooks'] = $result;
}
return $response;
}, 10, 2 );
}
}

View File

@@ -0,0 +1,311 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*
* Admin UI for managing Webhooks.
* Follows WordPress coding standards, uses proper escaping and translations.
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Admin UI for managing Webhooks.
*
* Provides CRUD over webhooks, settings management, and log viewing.
*/
class TD_Webhooks_Admin {
/**
* Hook admin_post actions for CRUD operations and settings save.
*
* @return void
*/
public static function init() {
// Register handlers for create/update and delete actions
add_action( 'admin_post_td_webhooks_save', [ __CLASS__, 'handle_save' ] );
add_action( 'admin_post_td_webhooks_delete', [ __CLASS__, 'handle_delete' ] );
}
/**
* Render the Webhooks admin page with tabs.
*
* @return void
*/
public static function render() {
if ( ! current_user_can( TVE_DASH_CAPABILITY ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'thrive-dash' ) );
}
// Visible only when TVE_DEBUG is true
if ( ! defined( 'TVE_DEBUG' ) || ! TVE_DEBUG ) {
wp_die( esc_html__( 'This page is available only in debug mode.', 'thrive-dash' ) );
}
// Determine active tab
$tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'list';
echo '<div class="wrap">';
echo '<h1>' . esc_html__( 'Webhooks', 'thrive-dash' ) . '</h1>';
// Render tab links
echo '<h2 class="nav-tab-wrapper">';
self::tab_link( 'list', __( 'All Webhooks', 'thrive-dash' ), $tab );
self::tab_link( 'edit', __( 'Add New', 'thrive-dash' ), $tab );
echo '</h2>';
// Switch by selected tab and render corresponding view
switch ( $tab ) {
case 'edit':
self::render_edit();
break;
case 'logs':
self::render_logs();
break;
case 'list':
default:
self::render_list();
}
echo '</div>';
}
/**
* Output a tab link.
*
* @param string $slug Tab slug
* @param string $label Tab label
* @param string $active Currently active tab
*
* @return void
*/
private static function tab_link( $slug, $label, $active ) {
// Build link URL and CSS class
$url = add_query_arg( [ 'page' => 'td_webhooks', 'tab' => $slug ], admin_url( 'admin.php' ) );
$class = 'nav-tab' . ( $active === $slug ? ' nav-tab-active' : '' );
// Output tab anchor
echo '<a class="' . esc_attr( $class ) . '" href="' . esc_url( $url ) . '">' . esc_html( $label ) . '</a>';
}
/**
* List existing webhooks in a table.
*
* @return void
*/
private static function render_list() {
// Fetch all webhooks and pass to list view
$items = TD_Webhooks_Repository::list();
self::render_view( 'list', [ 'items' => $items ] );
}
/**
* Render the create/update form for a webhook.
*
* @return void
*/
private static function render_edit() {
// Read resource and related data for the edit form
$id = isset( $_GET['id'] ) ? intval( $_GET['id'] ) : 0;
$item = $id ? TD_Webhooks_Repository::read( $id ) : [];
// Nonces for save/delete actions
$nonce = wp_create_nonce( 'td_webhooks_save' );
$delete_nonce = $id ? wp_create_nonce( 'td_webhooks_delete' ) : '';
// Normalize complex fields for the form
$targeting = (array) ( $item['targeting'] ?? [] );
$headers = is_array( $item['headers'] ?? null ) ? $item['headers'] : [];
$mapping = is_array( $item['body_mapping'] ?? null ) ? $item['body_mapping'] : [];
// Render edit form
self::render_view(
'edit',
[
'id' => $id,
'item' => $item,
'nonce' => $nonce,
'delete_nonce' => $delete_nonce,
'targeting' => $targeting,
'headers' => $headers,
'mapping' => $mapping,
]
);
}
/**
* Render logs table for a specific webhook.
*
* @return void
*/
private static function render_logs() {
// Identify webhook and load its logs from stored option
$id = isset( $_GET['id'] ) ? intval( $_GET['id'] ) : 0;
$all = get_option( 'td_webhooks_logs', [] );
$rows = (array) ( $all[ $id ] ?? [] );
// Render logs table
self::render_view( 'logs', [ 'rows' => $rows ] );
}
/**
* Include a view template with scoped variables.
*
* @param string $name
* @param array $vars
* @return void
*/
private static function render_view( $name, array $vars = [] ) {
// Resolve template file path for the requested view
$file = __DIR__ . '/views/' . $name . '.phtml';
if ( ! file_exists( $file ) ) {
return;
}
// Expose variables to the template in a safe manner
extract( $vars, EXTR_SKIP );
include $file;
}
/**
* Handle webhook create/update submission.
*
* @return void
*/
public static function handle_save() {
// Security checks
check_admin_referer( 'td_webhooks_save' );
if ( ! current_user_can( TVE_DASH_CAPABILITY ) ) {
wp_die( esc_html__( 'Permission denied', 'thrive-dash' ) );
}
// Identify resource and collect sanitized form data
$id = isset( $_POST['id'] ) ? intval( $_POST['id'] ) : 0;
$data = [
'name' => sanitize_text_field( $_POST['name'] ?? '' ),
'enabled' => ! empty( $_POST['enabled'] ),
'url' => esc_url_raw( $_POST['url'] ?? '' ),
'method' => sanitize_key( $_POST['method'] ?? 'post' ),
'request_format' => sanitize_key( $_POST['request_format'] ?? 'form' ),
'trigger_when' => sanitize_key( $_POST['trigger_when'] ?? 'on_submit' ),
'consent_required' => ! empty( $_POST['consent_required'] ),
'headers' => self::parse_pairs( $_POST['headers'] ?? [] ),
'body_mapping' => self::parse_pairs( $_POST['body_mapping'] ?? [] ),
'targeting' => self::parse_targeting( $_POST['targeting'] ?? [] ),
];
// Persist changes (update or create)
if ( $id ) {
TD_Webhooks_Repository::update( $id, $data );
} else {
$id = TD_Webhooks_Repository::create( $data );
}
// Redirect back to edit screen with success flag
wp_safe_redirect( add_query_arg( [ 'page' => 'td_webhooks', 'tab' => 'edit', 'id' => $id, 'updated' => 1 ], admin_url( 'admin.php' ) ) );
exit;
}
/**
* Handle webhook deletion.
*
* @return void
*/
public static function handle_delete() {
// Security checks
check_admin_referer( 'td_webhooks_delete' );
if ( ! current_user_can( TVE_DASH_CAPABILITY ) ) {
wp_die( esc_html__( 'Permission denied', 'thrive-dash' ) );
}
// Identify resource and delete if present
$id = isset( $_POST['id'] ) ? intval( $_POST['id'] ) : 0;
if ( $id ) {
TD_Webhooks_Repository::delete( $id );
}
// Redirect back to list with confirmation
wp_safe_redirect( add_query_arg( [ 'page' => 'td_webhooks', 'tab' => 'list', 'deleted' => 1 ], admin_url( 'admin.php' ) ) );
exit;
}
// View helper methods removed; templates are self-contained.
/**
* Normalize key/value arrays from the repeater POST data.
*
* @param array $pairs [ 'key' => string[], 'value' => string[] ]
*
* @return array[] Each row in shape [ 'key' => string, 'value' => string ]
*/
private static function parse_pairs( array $pairs ): array {
// Align keys and values arrays
$keys = (array) ( $pairs['key'] ?? [] );
$vals = (array) ( $pairs['value'] ?? [] );
$out = [];
$len = max( count( $keys ), count( $vals ) );
for ( $i = 0; $i < $len; $i++ ) {
$k = isset( $keys[$i] ) ? trim( (string) $keys[$i] ) : '';
$v = isset( $vals[$i] ) ? (string) $vals[$i] : '';
// Skip rows without a key
if ( $k === '' ) {
continue;
}
// Sanitize and keep raw-ish value (unslashed) as stored
$out[] = [ 'key' => sanitize_text_field( $k ), 'value' => wp_unslash( $v ) ];
}
return $out;
}
/**
* Normalize targeting POST payload as stored structure.
*
* @param array $t
*
* @return array { scope, form_ids[], post_ids[], slugs[] }
*/
private static function parse_targeting( array $t ): array {
// Normalize scope and targeted IDs
$scope = isset( $t['scope'] ) ? sanitize_key( $t['scope'] ) : '';
return [
'scope' => in_array( $scope, [ 'all', 'include', 'exclude' ], true ) ? $scope : '',
'form_ids' => self::array_from_csv( $t['form_ids'] ?? '' ),
'post_ids' => array_map( 'intval', self::array_from_csv( $t['post_ids'] ?? '' ) ),
'slugs' => self::array_from_csv( $t['slugs'] ?? '' ),
];
}
/**
* Convert CSV string into trimmed array of strings, ignoring empties.
*
* @param string $csv
*
* @return string[]
*/
private static function array_from_csv( $csv ): array {
// Convert (possibly empty) CSV to an array of trimmed strings
$csv = (string) $csv;
if ( $csv === '' ) {
return [];
}
$parts = array_map( 'trim', explode( ',', $csv ) );
$parts = array_filter( $parts, static function( $s ) { return $s !== '' ; } );
return array_values( $parts );
}
}

View File

@@ -0,0 +1,116 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Orchestrates webhook triggering based on product events and targeting rules.
*/
class TD_Webhooks_Dispatcher {
/**
* Subscribe to relevant product events.
*
* @return void
*/
public static function init() {
// Subscribe to TCB form submissions
add_action( 'tcb_api_form_submit', [ __CLASS__, 'on_submit' ], 10, 1 );
}
/**
* Handle immediate form submit event coming from TCB.
*
* @param array $post Raw submission data
* @return void
*/
public static function on_submit( array $post ) {
// Build event context from raw submission
$context = [
'trigger_when' => 'on_submit',
'form_id' => isset( $post['_tcb_id'] ) ? sanitize_text_field( $post['_tcb_id'] ) : '',
'post_id' => isset( $post['post_id'] ) ? intval( $post['post_id'] ) : 0,
'slug' => isset( $post['page_slug'] ) ? sanitize_title( $post['page_slug'] ) : '',
'user_consent' => ! empty( $post['user_consent'] ) || ! empty( $post['gdpr'] ),
'data' => $post,
'user' => self::build_user_context(),
];
// If a specific webhook is bound to the form, prioritize sending that one
if ( ! empty( $post['_td_webhook_id'] ) ) {
$id = intval( $post['_td_webhook_id'] );
$wh = TD_Webhooks_Repository::read( $id );
if ( ! empty( $wh ) && ! empty( $wh['enabled'] ) ) {
if ( self::is_consent_ok( $wh, $context ) ) {
// Direct send and short-circuit any global dispatch
TD_Webhooks_Sender::send( $wh, $context );
return; // Do not fall back to global dispatch
}
}
}
}
/**
* Verify consent, when required, for on_submit events.
*
* @param array $webhook
* @param array $context
* @return bool
*/
private static function is_consent_ok( array $webhook, array $context ): bool {
if ( ! empty( $webhook['consent_required'] ) && ( $context['trigger_when'] === 'on_submit' ) ) {
return ! empty( $context['user_consent'] );
}
return true;
}
/**
* Build a normalized current user context for templating.
* Includes user meta fields such as first/last name and last_login.
*
* @return array|null
*/
private static function build_user_context() {
if ( ! is_user_logged_in() ) {
return null;
}
$user = wp_get_current_user();
if ( empty( $user ) || empty( $user->ID ) ) {
return null;
}
$meta = get_user_meta( $user->ID );
$first_name = isset( $meta['first_name'][0] ) ? (string) $meta['first_name'][0] : '';
$last_name = isset( $meta['last_name'][0] ) ? (string) $meta['last_name'][0] : '';
$last_login = '';
// Format the last_login date.
if ( isset( $meta['tve_last_login'][0] ) && $meta['tve_last_login'][0] !== '' ) {
$last_login = wp_date( 'Y-m-d H:i:s', (int) $meta['tve_last_login'][0] );
}
// Return the user context.
return [
'ID' => (int) $user->ID,
'user_login' => (string) $user->user_login,
'user_email' => (string) $user->user_email,
'user_registered' => (string) $user->user_registered,
'first_name' => $first_name,
'last_name' => $last_name,
'last_login' => $last_login,
];
}
}

View File

@@ -0,0 +1,89 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Lightweight logger for per-webhook execution entries stored in options.
*
* Option shape (td_webhooks_logs):
* - [ webhook_id => [ entry, entry, ... ] ] with newest entries first
* - entry: { timestamp, webhook_id, status_code, duration_ms, request, response, trigger_when, ... }
*/
class TD_Webhooks_Logger {
/**
* Append a log entry for a webhook id with retention and TTL enforcement.
*
* @param int $webhook_id
* @param array $entry { status_code, duration_ms, request, response, trigger_when, ... }
* @return void
*/
public static function log( int $webhook_id, array $entry ): void {
// Load bucket storage
$all = get_option( 'td_webhooks_logs', [] );
if ( ! is_array( $all ) ) {
$all = [];
}
// Normalize entry structure
$entry['timestamp'] = current_time( 'mysql' );
$entry['webhook_id'] = $webhook_id;
$entry['request'] = isset( $entry['request'] ) && is_array( $entry['request'] ) ? $entry['request'] : [];
$entry['response'] = isset( $entry['response'] ) && is_array( $entry['response'] ) ? $entry['response'] : [];
// Ensure bucket for this webhook
if ( empty( $all[ $webhook_id ] ) || ! is_array( $all[ $webhook_id ] ) ) {
$all[ $webhook_id ] = [];
}
// Prepend new entry (newest first)
array_unshift( $all[ $webhook_id ], $entry );
// Retention: keep only the newest N
$limit = (int) TD_Webhooks_Settings::get( 'retention_per_id', 100 );
if ( $limit > 0 && count( $all[ $webhook_id ] ) > $limit ) {
$all[ $webhook_id ] = array_slice( $all[ $webhook_id ], 0, $limit );
}
// TTL: drop entries older than N days
$ttl_days = (int) TD_Webhooks_Settings::get( 'ttl_days', 0 );
if ( $ttl_days > 0 ) {
$cutoff = strtotime( '-' . $ttl_days . ' days' );
$all[ $webhook_id ] = self::filter_recent_by_cutoff( $all[ $webhook_id ], $cutoff );
}
// Persist storage
update_option( 'td_webhooks_logs', $all, 'no' );
}
/**
* Return only rows newer than the provided cutoff timestamp.
*
* @param array $bucket
* @param int $cutoff Unix timestamp cutoff
* @return array
*/
private static function filter_recent_by_cutoff( array $bucket, int $cutoff ): array {
$kept = [];
foreach ( $bucket as $row ) {
$timestamp = isset( $row['timestamp'] ) ? strtotime( (string) $row['timestamp'] ) : time();
// If the timestamp is newer than the cutoff, add the row to the kept array.
if ( $timestamp >= $cutoff ) {
$kept[] = $row;
}
}
// Reset the array keys.
return array_values( $kept );
}
}

View File

@@ -0,0 +1,207 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Storage layer for Webhooks based on a private CPT.
*
* Persists webhook configuration in post meta using the `td_webhook` post type.
*/
class TD_Webhooks_Repository {
const POST_TYPE = 'td_webhook';
/**
* Meta key prefix used for all webhook meta fields.
*/
private const META_PREFIX = 'td_webhook_';
/**
* List of meta field names (without prefix).
* Used for both reading and writing post meta.
*
* @var string[]
*/
private static $META_FIELDS = [
'enabled',
'url',
'method',
'request_format',
'headers',
'body_mapping',
'trigger_when',
'consent_required',
'targeting',
'advanced',
];
/**
* Build a full meta key from a field name.
*/
private static function meta_key( string $field ): string {
return self::META_PREFIX . $field;
}
/**
* Create a webhook and return its post ID.
*
* @param array $data
* @return int 0 on failure
*/
public static function create( array $data ): int {
// Prepare post array for the private CPT
$postarr = [
'post_type' => self::POST_TYPE,
'post_status' => 'publish',
'post_title' => sanitize_text_field( $data['name'] ?? '' ),
];
$post_id = wp_insert_post( $postarr );
// Bail out on error
if ( is_wp_error( $post_id ) || empty( $post_id ) ) {
return 0;
}
// Persist meta fields
self::save_meta( $post_id, $data );
return (int) $post_id;
}
/**
* Read a webhook by post ID.
*
* @param int $post_id
* @return array Empty array if not found
*/
public static function read( int $post_id ): array {
$post = get_post( $post_id );
// Return empty if not a webhook CPT
if ( ! $post || $post->post_type !== self::POST_TYPE ) {
return [];
}
// Merge basic fields with meta
return array_merge(
[
'id' => $post->ID,
'name' => $post->post_title,
],
self::get_meta( $post->ID )
);
}
/**
* Update a webhook by post ID.
*
* @param int $post_id
* @param array $data
* @return bool False if not a webhook
*/
public static function update( int $post_id, array $data ): bool {
$post = get_post( $post_id );
// Ensure the post is a webhook CPT
if ( ! $post || $post->post_type !== self::POST_TYPE ) {
return false;
}
// Only the name is stored in the post title. Other fields are stored in post meta.
if ( isset( $data['name'] ) ) {
wp_update_post( [ 'ID' => $post_id, 'post_title' => sanitize_text_field( $data['name'] ) ] );
}
// Persist meta fields
self::save_meta( $post_id, $data );
return true;
}
/**
* Delete a webhook by post ID.
*
* @param int $post_id
* @return bool
*/
public static function delete( int $post_id ): bool {
// Validate CPT type before delete
if ( get_post_type( $post_id ) !== self::POST_TYPE ) {
return false;
}
wp_delete_post( $post_id, true );
return true;
}
/**
* List webhooks filtered by optional WP_Query args.
*
* @param array $args
* @return array[]
*/
public static function list( array $args = [] ): array {
// Compose query args
$parsed_args = array_merge(
[
'post_type' => self::POST_TYPE,
'posts_per_page' => -1,
'post_status' => 'any',
'fields' => 'ids',
],
$args
);
$query = new \WP_Query( $parsed_args );
$items = [];
// Loop through results and map to stored structure
foreach ( $query->posts as $post_id ) {
$items[] = self::read( (int) $post_id );
}
return $items;
}
/**
* Persist webhook meta fields.
*
* @param int $post_id
* @param array $data
* @return void
*/
private static function save_meta( int $post_id, array $data ) {
// Update only provided fields; keep others untouched
foreach ( self::$META_FIELDS as $field ) {
if ( array_key_exists( $field, $data ) ) {
update_post_meta( $post_id, self::meta_key( $field ), $data[ $field ] );
}
}
}
/**
* Retrieve all webhook meta as a normalized array keyed by field name.
*
* @param int $post_id
* @return array
*/
private static function get_meta( int $post_id ): array {
$out = [];
foreach ( self::$META_FIELDS as $field ) {
$out[ $field ] = get_post_meta( $post_id, self::meta_key( $field ), true );
}
return $out;
}
}

View File

@@ -0,0 +1,512 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* REST controller for Webhooks management and testing.
*
* Routes under namespace td/v1:
* - GET /webhooks → list
* - POST /webhooks → create
* - GET /webhooks/{id} → read
* - PUT /webhooks/{id} → update (also PATCH)
* - DELETE /webhooks/{id} → delete
* - GET /webhooks/{id}/logs → retrieve logs
* - POST /webhooks/{id}/test → test send with synthetic context
* - GET /webhooks/settings → get settings
* - PUT /webhooks/settings → save settings (also PATCH)
*/
class TD_Webhooks_Rest_Controller {
const REST_NAMESPACE = 'td/v1';
const ROUTE_BASE = '/webhooks';
/**
* Register all REST routes for Webhooks.
*
* @return void
*/
public function register_routes() {
// Collection routes: list and create
register_rest_route( self::REST_NAMESPACE, self::ROUTE_BASE, [
[
'methods' => 'GET',
'callback' => [ $this, 'list_webhooks' ],
'permission_callback' => [ $this, 'can_manage' ],
],
[
'methods' => 'POST',
'callback' => [ $this, 'create_webhook' ],
'permission_callback' => [ $this, 'can_manage' ],
],
] );
// Single resource routes: read, update, delete
register_rest_route( self::REST_NAMESPACE, self::ROUTE_BASE . '/(?P<id>\\d+)', [
[
'methods' => 'GET',
'callback' => [ $this, 'get_webhook' ],
'permission_callback' => [ $this, 'can_manage' ],
],
[
'methods' => 'PUT,PATCH',
'callback' => [ $this, 'update_webhook' ],
'permission_callback' => [ $this, 'can_manage' ],
],
[
'methods' => 'DELETE',
'callback' => [ $this, 'delete_webhook' ],
'permission_callback' => [ $this, 'can_manage' ],
],
] );
// Logs for a single webhook
register_rest_route( self::REST_NAMESPACE, self::ROUTE_BASE . '/(?P<id>\\d+)/logs', [
[
'methods' => 'GET',
'callback' => [ $this, 'get_logs' ],
'permission_callback' => [ $this, 'can_manage' ],
],
] );
// Test send for a persisted webhook
register_rest_route( self::REST_NAMESPACE, self::ROUTE_BASE . '/(?P<id>\\d+)/test', [
[
'methods' => 'POST',
'callback' => [ $this, 'test_webhook' ],
'permission_callback' => [ $this, 'can_manage' ],
],
] );
// Inline test without saving a CPT
register_rest_route( self::REST_NAMESPACE, self::ROUTE_BASE . '/test', [
[
'methods' => 'POST',
'callback' => [ $this, 'test_webhook_inline' ],
'permission_callback' => [ $this, 'can_manage' ],
],
] );
// Global module settings get/update
register_rest_route( self::REST_NAMESPACE, self::ROUTE_BASE . '/settings', [
[
'methods' => 'GET',
'callback' => [ $this, 'get_settings' ],
'permission_callback' => [ $this, 'can_manage' ],
],
] );
}
/**
* Capability check for managing Webhooks endpoints.
*/
public function can_manage(): bool {
return current_user_can( \TVE_DASH_CAPABILITY );
}
/**
* List all webhooks.
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public function list_webhooks( WP_REST_Request $request ): WP_REST_Response {
return new WP_REST_Response( TD_Webhooks_Repository::list(), 200 );
}
/**
* Create a webhook from request payload.
*
* @param WP_REST_Request $request
* @return WP_REST_Response|WP_Error
*/
public function create_webhook( WP_REST_Request $request ) {
// Sanitize incoming payload to expected format
$data = $this->sanitize_webhook_input( $request->get_json_params() ?: $request->get_params() );
// Validate payload; bail early on failure
if ( ! TD_Webhooks_Validator::validate_webhook( $data ) ) {
return new WP_Error( 'td_invalid', __( 'Invalid webhook data', 'thrive-dash' ), [ 'status' => 400 ] );
}
// Persist webhook and return the created resource
$id = TD_Webhooks_Repository::create( $data );
if ( ! $id ) {
return new WP_Error( 'td_create_failed', __( 'Failed to create webhook', 'thrive-dash' ), [ 'status' => 500 ] );
}
return new WP_REST_Response( TD_Webhooks_Repository::read( $id ), 201 );
}
/**
* Retrieve a webhook by id.
*
* @param WP_REST_Request $request
* @return WP_REST_Response|WP_Error
*/
public function get_webhook( WP_REST_Request $request ) {
// Load webhook by id
$id = (int) $request['id'];
$data = TD_Webhooks_Repository::read( $id );
// 404 if not found
if ( empty( $data ) ) {
return new WP_Error( 'td_not_found', __( 'Webhook not found', 'thrive-dash' ), [ 'status' => 404 ] );
}
// Return resource
return new WP_REST_Response( $data, 200 );
}
/**
* Update a webhook by id.
*
* @param WP_REST_Request $request
* @return WP_REST_Response|WP_Error
*/
public function update_webhook( WP_REST_Request $request ) {
// Identify resource and sanitize payload
$id = (int) $request['id'];
$data = $this->sanitize_webhook_input( $request->get_json_params() ?: $request->get_params() );
// Ensure resource exists
if ( empty( TD_Webhooks_Repository::read( $id ) ) ) {
return new WP_Error( 'td_not_found', __( 'Webhook not found', 'thrive-dash' ), [ 'status' => 404 ] );
}
// Validate incoming changes
if ( ! TD_Webhooks_Validator::validate_webhook( $data ) ) {
return new WP_Error( 'td_invalid', __( 'Invalid webhook data', 'thrive-dash' ), [ 'status' => 400 ] );
}
// Persist update and return fresh representation
TD_Webhooks_Repository::update( $id, $data );
return new WP_REST_Response( TD_Webhooks_Repository::read( $id ), 200 );
}
/**
* Delete a webhook by id.
*
* @param WP_REST_Request $request
* @return WP_REST_Response|WP_Error
*/
public function delete_webhook( WP_REST_Request $request ) {
// Identify resource
$id = (int) $request['id'];
// Ensure resource exists
if ( empty( TD_Webhooks_Repository::read( $id ) ) ) {
return new WP_Error( 'td_not_found', __( 'Webhook not found', 'thrive-dash' ), [ 'status' => 404 ] );
}
// Delete and acknowledge
TD_Webhooks_Repository::delete( $id );
return new WP_REST_Response( [ 'deleted' => true ], 200 );
}
/**
* Return execution logs for a webhook.
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public function get_logs( WP_REST_Request $request ) {
// Load logs grouped by webhook id
$id = (int) $request['id'];
$all = get_option( 'td_webhooks_logs', [] );
// Extract logs for the specific webhook (falls back to empty array)
$logs = (array) ( $all[ $id ] ?? [] );
return new WP_REST_Response( $logs, 200 );
}
/**
* Trigger a test send for a webhook id.
* The payload context can be provided in JSON body.
*
* @param WP_REST_Request $request
* @return WP_REST_Response|WP_Error
*/
public function test_webhook( WP_REST_Request $request ) {
// Load webhook configuration by id
$id = (int) $request['id'];
$wh = TD_Webhooks_Repository::read( $id );
// 404 if not found
if ( empty( $wh ) ) {
return new WP_Error( 'td_not_found', __( 'Webhook not found', 'thrive-dash' ), [ 'status' => 404 ] );
}
// Build a minimal test context using provided JSON body or defaults
$payload_context = $request->get_json_params() ?: [];
$context = [
'trigger_when' => 'on_submit',
'form_id' => $payload_context['form_id'] ?? '',
'post_id' => (int) ( $payload_context['post_id'] ?? 0 ),
'slug' => sanitize_title( $payload_context['slug'] ?? '' ),
'user_consent' => true,
'data' => (array) ( $payload_context['data'] ?? [] ),
'user' => wp_get_current_user(),
];
// Send immediately (no queue) and report success
TD_Webhooks_Sender::send( $wh, $context );
return new WP_REST_Response( [ 'queued' => false, 'sent' => true ], 200 );
}
public function test_webhook_inline( WP_REST_Request $request ) {
// Accept either {config, context} or a flat webhook config
$raw = $request->get_json_params() ?: [];
$config = (array) ( $raw['config'] ?? $raw );
// Fill a default name if missing
if ( empty( $config['name'] ) ) {
$config['name'] = 'Inline Test';
}
// Normalize and sanitize the provided webhook config
$wh = $this->sanitize_webhook_input( $config );
// Build a minimal test context
$ctx_in = (array) ( $raw['context'] ?? [] );
$context = [
'trigger_when' => 'on_submit',
'form_id' => $ctx_in['form_id'] ?? '',
'post_id' => (int) ( $ctx_in['post_id'] ?? 0 ),
'slug' => sanitize_title( $ctx_in['slug'] ?? '' ),
'user_consent' => true,
'data' => (array) ( $ctx_in['data'] ?? [] ),
'user' => wp_get_current_user(),
];
// Prepare HTTP client and request components
$http = _wp_http_get_object();
$url = trim( (string) ( $wh['url'] ?? '' ) );
$method = strtoupper( (string) ( $wh['method'] ?? 'POST' ) );
$format = strtolower( (string) ( $wh['request_format'] ?? 'form' ) );
$headers = (array) ( $wh['headers'] ?? [] );
$mapping = (array) ( $wh['body_mapping'] ?? [] );
// Build request payload and resolve header placeholders
$payload = TD_Webhooks_Templating::build_payload( $mapping, $context );
$header_map = TD_Webhooks_Sender::flat_key_value_pairs( $headers, $context );
// Encode body according to the selected request format
$body = $payload;
if ( $method !== 'GET' ) {
switch ( $format ) {
case 'json':
$body = wp_json_encode( $payload );
$header_map['content-type'] = 'application/json';
break;
case 'xml':
$body = TD_Webhooks_Sender::xml_encode( $payload );
break;
case 'form':
default:
// leave as array
break;
}
}
// Remove hop-by-hop headers that WP HTTP will set
unset( $header_map['host'], $header_map['content-length'] );
// Finalize request args
$timeout = (int) TD_Webhooks_Settings::get( 'timeout', 8 );
$args = [ 'method' => $method, 'body' => $body, 'headers' => $header_map, 'timeout' => $timeout ];
// Perform request and capture timing
$start = microtime( true );
$response = $http->request( $url, $args );
$duration = (int) round( ( microtime( true ) - $start ) * 1000 );
// Transport-level failure (DNS, timeout, SSL, etc.)
if ( is_wp_error( $response ) ) {
$code = $response->get_error_code();
$message = $response->get_error_message();
$diagnostics = [
'http_status' => 0,
'code' => $code,
'message' => $message,
'meaning' => \TVE\Dashboard\Utils\TT_HTTP_Error_Map::meaning( [ 'code' => $code, 'http_status' => 0, 'message' => $message ] ),
'duration_ms' => $duration,
'request' => [
'method' => $method,
'url' => $url,
'headers_included' => ! empty( $header_map ),
'body_bytes' => is_scalar( $body ) ? strlen( (string) $body ) : strlen( wp_json_encode( $body ) ),
],
'response' => [
'status_text' => '',
'headers' => new \stdClass(),
'body' => $message,
'body_size_bytes' => strlen( (string) $message ),
],
];
return new WP_REST_Response( [ 'ok' => false, 'error' => $diagnostics ], 400 );
}
// Extract response details
$status = (int) wp_remote_retrieve_response_code( $response );
$status_t = (string) wp_remote_retrieve_response_message( $response );
$headers = (array) wp_remote_retrieve_headers( $response );
$body_str = (string) wp_remote_retrieve_body( $response );
// Non-2xx HTTP status: return diagnostics
if ( $status < 200 || $status >= 300 ) {
$diagnostics = [
'http_status' => $status,
'code' => '',
'message' => $status_t,
'meaning' => \TVE\Dashboard\Utils\TT_HTTP_Error_Map::meaning( [ 'http_status' => $status, 'message' => $status_t ] ),
'duration_ms' => $duration,
'request' => [
'method' => $method,
'url' => $url,
'headers_included' => ! empty( $header_map ),
'body_bytes' => is_scalar( $body ) ? strlen( (string) $body ) : strlen( wp_json_encode( $body ) ),
],
'response' => [
'status_text' => $status_t,
'headers' => (object) $headers,
'body' => $body_str,
'body_size_bytes' => strlen( $body_str ),
],
];
return new WP_REST_Response( [ 'ok' => false, 'error' => $diagnostics ], $status ?: 400 );
}
// Success
return new WP_REST_Response( [ 'ok' => true, 'sent' => true, 'duration_ms' => $duration ], 200 );
}
/**
* Get global Webhooks settings.
*
* @return WP_REST_Response
*/
public function get_settings(): WP_REST_Response {
return new WP_REST_Response( TD_Webhooks_Settings::get(), 200 );
}
// Save settings endpoint removed: settings are configured via filters
/**
* Sanitize webhook input payload to stored shape.
*
* @param array $in
* @return array
*/
private function sanitize_webhook_input( array $in ): array {
// Produce a normalized, stored shape for a webhook configuration
return [
'name' => sanitize_text_field( $in['name'] ?? '' ),
'enabled' => ! empty( $in['enabled'] ),
'url' => esc_url_raw( $in['url'] ?? '' ),
'method' => sanitize_key( $in['method'] ?? 'post' ),
'request_format' => sanitize_key( $in['request_format'] ?? 'form' ),
'trigger_when' => sanitize_key( $in['trigger_when'] ?? 'on_submit' ),
'consent_required' => ! empty( $in['consent_required'] ),
'headers' => $this->normalize_pairs( $in['headers'] ?? [] ),
'body_mapping' => $this->normalize_pairs( $in['body_mapping'] ?? [] ),
'targeting' => $this->normalize_targeting( $in['targeting'] ?? [] ),
];
}
/**
* Normalize key/value array into the expected list structure.
*
* @param mixed $pairs
* @return array
*/
private function normalize_pairs( $pairs ): array {
$out = [];
if ( is_array( $pairs ) ) {
foreach ( $pairs as $row ) {
// Skip entries without a key; sanitize values
if ( empty( $row['key'] ) ) {
continue;
}
$out[] = [ 'key' => sanitize_text_field( $row['key'] ), 'value' => is_scalar( $row['value'] ?? '' ) ? wp_unslash( (string) $row['value'] ) : '' ];
}
}
return $out;
}
/**
* Normalize targeting structure from request payload.
*
* @param mixed $t
* @return array
*/
private function normalize_targeting( $t ): array {
// Normalize base structure and scope
$t = is_array( $t ) ? $t : [];
$scope = isset( $t['scope'] ) ? sanitize_key( $t['scope'] ) : '';
$scope = in_array( $scope, [ 'all', 'include', 'exclude' ], true ) ? $scope : '';
// Collect targeted form IDs (strings)
$form_ids = [];
if ( isset( $t['form_ids'] ) ) {
$form_ids = is_array( $t['form_ids'] ) ? $t['form_ids'] : $this->csv_or_array( $t['form_ids'] );
}
// Collect targeted post IDs (ints)
$post_ids = [];
if ( isset( $t['post_ids'] ) ) {
$post_ids = is_array( $t['post_ids'] ) ? $t['post_ids'] : $this->csv_or_array( $t['post_ids'] );
$post_ids = array_map( 'intval', $post_ids );
}
// Collect targeted slugs
$slugs = [];
if ( isset( $t['slugs'] ) ) {
$slugs = is_array( $t['slugs'] ) ? $t['slugs'] : $this->csv_or_array( $t['slugs'] );
}
return [ 'scope' => $scope, 'form_ids' => array_values( $form_ids ), 'post_ids' => array_values( $post_ids ), 'slugs' => array_values( $slugs ) ];
}
/**
* Convert CSV string or array input into normalized array of strings.
*
* @param mixed $val
* @return array
*/
private function csv_or_array( $val ): array {
if ( is_array( $val ) ) {
return array_values( array_filter( array_map( 'trim', $val ), static function( $s ) { return $s !== ''; } ) );
}
$val = (string) $val;
if ( $val === '' ) {
return [];
}
$parts = array_map( 'trim', explode( ',', $val ) );
return array_values( array_filter( $parts, static function( $s ) { return $s !== ''; } ) );
}
}

View File

@@ -0,0 +1,264 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Prepare and send outbound HTTP requests for webhooks, with logging.
*/
class TD_Webhooks_Sender {
/**
* Build payload, headers and perform HTTP request. Logs the outcome.
*
* @param array $webhook Webhook configuration (id, url, method, request_format, headers, body_mapping, ...)
* @param array $context Event context (trigger_when, form_id, post_id, slug, data, user, user_consent)
* @return void
*/
public static function send( array $webhook, array $context ): void {
$url = trim( (string) ( $webhook['url'] ?? '' ) );
$method = strtolower( (string) ( $webhook['method'] ?? 'post' ) );
$format = strtolower( (string) ( $webhook['request_format'] ?? 'form' ) );
$headers = (array) ( $webhook['headers'] ?? [] );
$mapping = (array) ( $webhook['body_mapping'] ?? [] );
// If the URL is empty or not allowed, return.
if ( empty( $url ) || ! self::is_url_allowed( $url ) ) {
return;
}
// Build payload and resolve header placeholders
$payload = TD_Webhooks_Templating::build_payload( $mapping, $context );
$header_map = self::flat_key_value_pairs( $headers, $context );
// Encode request body depending on selected format
$body = $payload;
if ( $method !== 'get' ) {
switch ( $format ) {
case 'json':
$body = TD_Webhooks_Templating::json_encode( $payload );
$header_map['content-type'] = 'application/json';
break;
case 'xml':
$body = self::xml_encode( $payload );
break;
case 'form':
default:
// leave as array
break;
}
}
// Strip hop-by-hop headers
unset( $header_map['host'], $header_map['content-length'] );
// Build request args
$timeout = (int) TD_Webhooks_Settings::get( 'timeout', 8 );
$args = [
'method' => strtoupper( $method ),
'body' => $body,
'headers' => $header_map,
'timeout' => $timeout,
];
// Execute HTTP request and capture timing
$http = _wp_http_get_object();
$start = microtime( true );
$response = $http->request( $url, $args );
// Calculate the duration of the request.
$duration = (int) round( ( microtime( true ) - $start ) * 1000 );
// Extract response status/body for logging purposes
$status = is_wp_error( $response ) ? $response->get_error_code() : wp_remote_retrieve_response_code( $response );
$body_str = is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_body( $response );
// Record execution outcome
TD_Webhooks_Logger::log( (int) ( $webhook['id'] ?? 0 ), [
'status_code' => $status,
'duration_ms' => $duration,
'request' => self::snapshot_request( $url, $args ),
'response' => self::snapshot_response( $response, $body_str ),
'trigger_when'=> $context['trigger_when'] ?? '',
] );
}
/**
* Create a request snapshot safe for logging (masked + truncated).
*
* @param string $url
* @param array $args
* @return array
*/
private static function snapshot_request( string $url, array $args ): array {
return [
'url' => $url,
'method' => $args['method'] ?? 'GET',
'headers' => self::mask_array( (array) ( $args['headers'] ?? [] ) ),
'body' => self::truncate( is_scalar( $args['body'] ?? '' ) ? (string) $args['body'] : TD_Webhooks_Templating::json_encode( $args['body'] ) ),
];
}
/**
* Create a response snapshot safe for logging (masked + truncated).
*
* @param mixed $response
* @param string $body
* @return array
*/
private static function snapshot_response( $response, string $body ): array {
$headers = is_wp_error( $response ) ? [] : wp_remote_retrieve_headers( $response );
return [
'headers' => self::mask_array( (array) $headers ),
'body' => self::truncate( $body ),
];
}
/**
* Mask sensitive keys from an associative array.
*
* @param array $input
* @return array
*/
private static function mask_array( array $input ): array {
$sensitive = [ 'authorization', 'api_key', 'api-key', 'token', 'x-api-key', 'x-auth-token' ];
$out = [];
foreach ( $input as $k => $v ) {
$lk = strtolower( (string) $k );
$out[ $k ] = in_array( $lk, $sensitive, true ) ? '***' : $v;
}
return $out;
}
/**
* Truncate long strings for log safety.
*
* @param string $s
* @return string
*/
private static function truncate( string $s ): string {
if ( strlen( $s ) > 2000 ) {
return substr( $s, 0, 2000 ) . '…';
}
return $s;
}
/**
* Convert [ ['key' => 'A', 'value' => 'B'], ... ] to [ 'a' => 'B' ] with lowercase keys.
*
* @param array $pairs
* @return array
*/
public static function flat_key_value_pairs( array $pairs, array $context = [] ): array {
$out = [];
foreach ( $pairs as $row ) {
// If the key is not set or is empty, skip.
if ( ! isset( $row['key'] ) || $row['key'] === '' ) {
continue;
}
$key = strtolower( (string) $row['key'] );
$val = $row['value'] ?? '';
if ( is_string( $val ) ) {
// Resolve {{placeholders}} inside header values using same rules as payload
$val = TD_Webhooks_Templating::resolve_placeholders( $val, $context );
}
$out[ $key ] = is_scalar( $val ) ? (string) $val : TD_Webhooks_Templating::json_encode( $val );
}
return $out;
}
/**
* Enforce scheme/host and optional allow/deny lists from settings.
*
* @param string $url
* @return bool
*/
private static function is_url_allowed( string $url ): bool {
$parts = wp_parse_url( $url );
// If the scheme is not http or https, return false.
if ( empty( $parts['scheme'] ) || ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true ) ) {
return false;
}
$host = $parts['host'];
// If the host is empty or localhost, return false.
if ( empty( $host ) || in_array( strtolower( $host ), [ 'localhost', '127.0.0.1' ], true ) ) {
return false;
}
// Optionally enforce allow/deny lists
$allow = TD_Webhooks_Settings::get( 'allowlist', [] );
$deny = TD_Webhooks_Settings::get( 'denylist', [] );
if ( ! empty( $deny ) ) {
foreach ( $deny as $pattern ) {
if ( $pattern && fnmatch( $pattern, $host ) ) {
return false;
}
}
}
if ( ! empty( $allow ) ) {
foreach ( $allow as $pattern ) {
if ( $pattern && fnmatch( $pattern, $host ) ) {
return true;
}
}
return false;
}
return true;
}
/**
* Encode an array as XML suitable for POSTing.
*
* @param mixed $data
* @param \SimpleXMLElement|null $xml
* @return string
*/
public static function xml_encode( $data, \SimpleXMLElement $xml = null ) {
// If the XML is null, create a new SimpleXMLElement.
if ( $xml === null ) {
$xml = new \SimpleXMLElement( '<root/>' );
}
// Loop through the data and add the key and value to the XML.
foreach ( (array) $data as $key => $value ) {
// If the key is numeric, set it to 'item'.
$key = is_numeric( $key ) ? 'item' : $key;
// If the value is an array, add the key and value to the XML.
if ( is_array( $value ) ) {
$child = $xml->addChild( $key );
// Recursively add the key and value to the XML.
self::xml_encode( $value, $child );
} else {
// Add the key and value to the XML.
$xml->addChild( $key, htmlspecialchars( (string) $value ) );
}
}
// Return the XML as a string.
return $xml->asXML();
}
}

View File

@@ -0,0 +1,51 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Settings helper for Webhooks module.
*
* - Returns effective settings from a single method
* - Allows overrides via a single filter
*/
class TD_Webhooks_Settings {
/**
* Get effective settings (defaults + filter only).
*
* @return array
*/
public static function get( $key = null, $fallback = null ) {
// Base values; callers can override via the filter below
$settings = [
'timeout' => 8,
'retention_per_id' => 100,
'ttl_days' => 0,
'allowlist' => [],
'denylist' => [],
];
/**
* Filter effective Webhooks settings derived from base values.
*
* @param array $settings
*/
$settings = (array) apply_filters( 'td_webhooks_settings', $settings );
if ( $key === null ) {
return $settings;
}
return array_key_exists( $key, $settings ) ? $settings[ $key ] : $fallback;
}
}

View File

@@ -0,0 +1,198 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Simple templating and payload shaping for webhook requests.
*/
class TD_Webhooks_Templating {
/**
* JSON encode options: no escaped slashes or unicode.
*
* @var int
*/
const JSON_OPTIONS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
/**
* Build request payload from mapping and context
*
* @param array $mapping [ [ 'key' => 'user[name]', 'value' => '{{data.name}}' ], ... ]
* @param array $context Context containing 'data', 'user', etc.
*
* @return array
*/
public static function build_payload( array $mapping, array $context ): array {
$fields = [];
foreach ( $mapping as $field ) {
// If the key is not set or is empty, skip.
if ( empty( $field['key'] ) ) {
continue;
}
$original_key = str_replace( ']', '', (string) $field['key'] );
$reference = &$fields;
// Navigate through the fields using the original key.
foreach ( explode( '[', $original_key ) as $key ) {
// If the key is empty, skip.
if ( $key === '' ) {
continue;
}
// If the key does not exist, create an empty array.
if ( ! array_key_exists( $key, $reference ) ) {
$reference[ $key ] = [];
}
$reference = &$reference[ $key ];
}
$value = $field['value'] ?? '';
// If value starts and ends with %, set it to empty.
// %FIELD% is used by automator, we are not using it.
if ( is_string( $value ) && preg_match( '/^%.*%$/', $value ) ) {
$value = '';
}
// If value is a single placeholder, resolve raw so arrays remain arrays (for checkboxes / multi-select)
if ( is_string( $value ) && preg_match( '/^\s*\{\{\s*([^}]+)\s*\}\}\s*$/', $value, $matches ) ) {
$value = self::get_by_path( $context, trim( $matches[1] ) ) ?? '';
} else {
// Resolve the placeholders.
$value = self::resolve_placeholders( $value, $context );
}
$reference = $value;
unset( $reference );
}
return $fields;
}
/**
* Resolve {{path}} placeholders using dot-notation into the provided context.
* If value is not a string, return as-is.
*
* @param mixed $value
* @param array $context
*
* @return mixed
*/
public static function resolve_placeholders( $value, array $context ) {
if ( ! is_string( $value ) ) {
return $value;
}
// Replace {{ path }} placeholders using a callback
$callback = function( $matches ) use ( &$context ) {
return self::placeholder_replace_callback( $matches, $context );
};
return preg_replace_callback( '/\{\{\s*([^}]+)\s*\}\}/', $callback, $value );
}
/**
* Callback used by resolve_placeholders to replace a single {{ path }} match.
*
* @param array $matches
* @param array $context
* @return string
*/
private static function placeholder_replace_callback( array $matches, array $context ): string {
$path = trim( $matches[1] ?? '' );
$found = self::get_by_path( $context, $path );
if ( $found === null ) {
return '';
}
if ( is_scalar( $found ) ) {
return (string) $found;
}
return self::json_encode( $found );
}
/**
* Get value from nested array/object using dot notation (e.g., data.email, user.email)
*
* @param array $source
* @param string $path
*
* @return mixed|null
*/
public static function get_by_path( array $source, string $path ) {
// Split the dot-notation path into sanitized, non-empty segments
// a.b.c -> [ 'a', 'b', 'c' ]
$segments = array_filter( array_map( 'trim', explode( '.', $path ) ), static function( $s ) { return $s !== ''; } );
$cursor = $source;
foreach ( $segments as $segment ) {
if ( is_array( $cursor ) ) {
$resolved = self::resolve_array_segment( $cursor, $segment );
if ( $resolved === null ) {
return null;
}
$cursor = $resolved;
} elseif ( is_object( $cursor ) && isset( $cursor->$segment ) ) {
$cursor = $cursor->$segment;
} else {
return null;
}
}
// Return the resolved value after traversing the full path
return $cursor;
}
/**
* Attempt to resolve an array segment, accounting for PHP checkbox name conventions (foo vs foo[]).
*
* @param array $cursor Current array level being traversed
* @param string $seg Segment name
*
* @return mixed|null Returns the resolved value or null if not found
*/
private static function resolve_array_segment( array $cursor, string $seg ) {
if ( array_key_exists( $seg, $cursor ) ) {
return $cursor[ $seg ];
}
// Normalize checkbox-style names: allow foo[] vs foo
$alt = ( substr( $seg, -2 ) === '[]' ) ? substr( $seg, 0, -2 ) : ( $seg . '[]' );
// If the alternate checkbox-style key exists, prefer it
if ( array_key_exists( $alt, $cursor ) ) {
return $cursor[ $alt ];
}
return null;
}
/**
* JSON encode data with webhook-friendly options.
*
* Encodes without escaping slashes or unicode characters,
* making URLs and international text more readable.
*
* @param mixed $data Data to encode
* @return string JSON string
*/
public static function json_encode( $data ): string {
$json = wp_json_encode( $data, self::JSON_OPTIONS );
return ($json === false) ? '' : $json;
}
}

View File

@@ -0,0 +1,82 @@
<?php
/**
* Thrive Themes - https://thrivethemes.com
*
* @package thrive-dashboard
*/
namespace TVE\Dashboard\Webhooks;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Silence is golden!
}
/**
* Validation helpers for webhook configuration integrity.
*/
class TD_Webhooks_Validator {
/**
* Validate entire webhook structure.
*
* @param array $webhook
* @return bool
*/
public static function validate_webhook( array $webhook ): bool {
// Validate URL, HTTP method, and mapping arrays
return self::validate_url( (string) ( $webhook['url'] ?? '' ) )
&& self::validate_method( (string) ( $webhook['method'] ?? 'post' ) )
&& self::validate_mapping( (array) ( $webhook['headers'] ?? [] ) )
&& self::validate_mapping( (array) ( $webhook['body_mapping'] ?? [] ) );
}
/**
* Validate URL to be http(s) and have a host.
*
* @param string $url
* @return bool
*/
public static function validate_url( string $url ): bool {
if ( empty( $url ) ) {
return false;
}
$parts = wp_parse_url( $url );
if ( empty( $parts['scheme'] ) || ! in_array( strtolower( $parts['scheme'] ), [ 'http', 'https' ], true ) ) {
return false;
}
if ( empty( $parts['host'] ) ) {
return false;
}
return true;
}
/**
* Validate supported HTTP method.
*
* @param string $method
* @return bool
*/
public static function validate_method( string $method ): bool {
return in_array( strtolower( $method ), [ 'get', 'post', 'put', 'patch', 'delete' ], true );
}
/**
* Validate mapping rows contain non-empty keys.
*
* @param array $mapping
* @return bool
*/
public static function validate_mapping( array $mapping ): bool {
foreach ( $mapping as $row ) {
if ( empty( $row['key'] ) ) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,171 @@
<?php
/** @var int $id */
/** @var array $item */
/** @var string $nonce */
/** @var string $delete_nonce */
/** @var array $targeting */
/** @var array $headers */
/** @var array $mapping */
?>
<?php if ($id) : ?>
<div class="td-webhooks-sidebar-actions" style="float:right; margin-top:10px;">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('<?php echo esc_js(__('Are you sure you want to delete this webhook?', 'thrive-dash')); ?>');" style="display:inline">
<input type="hidden" name="action" value="td_webhooks_delete" />
<input type="hidden" name="_wpnonce" value="<?php echo esc_attr($delete_nonce); ?>" />
<input type="hidden" name="id" value="<?php echo esc_attr((string) $id); ?>" />
<button type="submit" class="button-link delete" title="<?php echo esc_attr__('Delete webhook', 'thrive-dash'); ?>" style="color:#b32d2e; text-decoration:none;">
<span class="dashicons dashicons-trash"></span>
</button>
</form>
</div>
<?php endif; ?>
<script>
// Enhance headers/body mapping repeaters with add/remove controls
jQuery( function( $ ) {
function addRow( tableSelector, keyName, valueName ) {
var $tbody = $( tableSelector ).find( 'tbody' );
if ( ! $tbody.length ) { return; }
var $tr = $( '<tr/>' );
$tr.append( '<td><input type="text" name="' + keyName + '[]" value="" class="regular-text" /></td>' );
$tr.append( '<td><input type="text" name="' + valueName + '[]" value="" class="regular-text" /></td>' );
$tr.append( '<td><button type="button" class="button link-delete td-webhooks-remove-row" aria-label="Remove row">&times;</button></td>' );
$tbody.append( $tr );
}
// Add header row
$( document ).on( 'click', '#td-webhooks-add-header', function( e ) {
e.preventDefault();
addRow( '#td-webhooks-headers', 'headers[key]', 'headers[value]' );
} );
// Add body mapping row
$( document ).on( 'click', '#td-webhooks-add-mapping', function( e ) {
e.preventDefault();
addRow( '#td-webhooks-body-mapping', 'body_mapping[key]', 'body_mapping[value]' );
} );
// Remove current row
$( document ).on( 'click', '.td-webhooks-remove-row', function( e ) {
e.preventDefault();
$( this ).closest( 'tr' ).remove();
} );
} );
</script>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
<input type="hidden" name="action" value="td_webhooks_save" />
<input type="hidden" name="_wpnonce" value="<?php echo esc_attr($nonce); ?>" />
<input type="hidden" name="id" value="<?php echo esc_attr((string) $id); ?>" />
<table class="form-table">
<tbody>
<tr>
<th scope="row"><label for="name"><?php echo esc_html(__('Name', 'thrive-dash')); ?></label></th>
<td><input type="text" class="regular-text" name="name" id="name" value="<?php echo esc_attr((string) ($item['name'] ?? '')); ?>" /></td>
</tr>
<tr>
<th scope="row"><?php echo esc_html(__('Enabled', 'thrive-dash')); ?></th>
<td><label><input type="checkbox" name="enabled" value="1" <?php echo checked(! empty($item['enabled']), true, false); ?> /> <?php echo esc_html__('Yes', 'thrive-dash'); ?></label></td>
</tr>
<tr>
<th scope="row"><label for="url"><?php echo esc_html(__('Webhook URL', 'thrive-dash')); ?></label></th>
<td><input type="text" class="regular-text" name="url" id="url" value="<?php echo esc_attr((string) ($item['url'] ?? '')); ?>" /></td>
</tr>
<?php $method = strtolower($item['method'] ?? 'post'); ?>
<tr>
<th scope="row"><label for="method"><?php echo esc_html(__('Method', 'thrive-dash')); ?></label></th>
<td>
<select name="method" id="method">
<?php foreach (['post' => 'POST', 'get' => 'GET', 'put' => 'PUT', 'patch' => 'PATCH', 'delete' => 'DELETE'] as $val => $text) : ?>
<option value="<?php echo esc_attr((string) $val); ?>" <?php echo selected((string) $method, (string) $val, false); ?>><?php echo esc_html($text); ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<?php $req_format = strtolower($item['request_format'] ?? 'form'); ?>
<tr>
<th scope="row"><label for="request_format"><?php echo esc_html(__('Request Format', 'thrive-dash')); ?></label></th>
<td>
<select name="request_format" id="request_format">
<?php foreach (['form' => 'form', 'json' => 'json', 'xml' => 'xml'] as $val => $text) : ?>
<option value="<?php echo esc_attr((string) $val); ?>" <?php echo selected((string) $req_format, (string) $val, false); ?>><?php echo esc_html($text); ?></option>
<?php endforeach; ?>
</select>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Headers', 'thrive-dash'); ?></th>
<td>
<?php $h_count = max(1, count((array) $headers)); ?>
<table class="widefat striped" id="td-webhooks-headers">
<thead>
<tr>
<th style="padding-left: 8px"><?php echo esc_html__('Key', 'thrive-dash'); ?></th>
<th style="padding-left: 8px"><?php echo esc_html__('Value', 'thrive-dash'); ?></th>
<th style="width:100px; padding-left: 8px"><?php echo esc_html__('Actions', 'thrive-dash'); ?></th>
</tr>
</thead>
<tbody>
<?php for ($i = 0; $i < $h_count; $i++) : $k = $headers[$i]['key'] ?? '';
$v = $headers[$i]['value'] ?? ''; ?>
<tr>
<td><input type="text" name="headers[key][]" value="<?php echo esc_attr((string) $k); ?>" class="regular-text" /></td>
<td><input type="text" name="headers[value][]" value="<?php echo esc_attr((string) $v); ?>" class="regular-text" /></td>
<td><button type="button" class="button link-delete td-webhooks-remove-row" aria-label="<?php echo esc_attr__('Remove header row', 'thrive-dash'); ?>">&times;</button></td>
</tr>
<?php endfor; ?>
</tbody>
</table>
<p>
<button type="button" class="button" id="td-webhooks-add-header"><?php echo esc_html__('Add header', 'thrive-dash'); ?></button>
</p>
<p class="description"><?php echo esc_html__('Leave empty key/value rows unused.', 'thrive-dash'); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php echo esc_html__('Body Mapping', 'thrive-dash'); ?></th>
<td>
<?php $m_count = max(1, count((array) $mapping)); ?>
<table class="widefat striped" id="td-webhooks-body-mapping">
<thead>
<tr>
<th style="padding-left: 8px"><?php echo esc_html__('Key', 'thrive-dash'); ?></th>
<th style="padding-left: 8px"><?php echo esc_html__('Value', 'thrive-dash'); ?></th>
<th style="width:100px; padding-left: 8px"><?php echo esc_html__('Actions', 'thrive-dash'); ?></th>
</tr>
</thead>
<tbody>
<?php for ($i = 0; $i < $m_count; $i++) : $k = $mapping[$i]['key'] ?? '';
$v = $mapping[$i]['value'] ?? ''; ?>
<tr>
<td><input type="text" name="body_mapping[key][]" value="<?php echo esc_attr((string) $k); ?>" class="regular-text" /></td>
<td><input type="text" name="body_mapping[value][]" value="<?php echo esc_attr((string) $v); ?>" class="regular-text" /></td>
<td><button type="button" class="button link-delete td-webhooks-remove-row" aria-label="<?php echo esc_attr__('Remove mapping row', 'thrive-dash'); ?>">&times;</button></td>
</tr>
<?php endfor; ?>
</tbody>
</table>
<p>
<button type="button" class="button" id="td-webhooks-add-mapping"><?php echo esc_html__('Add mapping', 'thrive-dash'); ?></button>
</p>
<p class="description"><?php echo esc_html__('Leave empty key/value rows unused.', 'thrive-dash'); ?></p>
</td>
</tr>
</tbody>
</table>
<?php submit_button($id ? __('Update Webhook', 'thrive-dash') : __('Create Webhook', 'thrive-dash')); ?>
</form>
<?php if ($id) : ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('<?php echo esc_js(__('Are you sure?', 'thrive-dash')); ?>');">
<input type="hidden" name="action" value="td_webhooks_delete" />
<input type="hidden" name="_wpnonce" value="<?php echo esc_attr($delete_nonce); ?>" />
<input type="hidden" name="id" value="<?php echo esc_attr((string) $id); ?>" />
<?php submit_button(__('Delete', 'thrive-dash'), 'delete'); ?>
</form>
<?php endif; ?>

View File

@@ -0,0 +1,40 @@
<?php
/** @var array $items */
$edit_url = function( $id ) { return add_query_arg( [ 'page' => 'td_webhooks', 'tab' => 'edit', 'id' => $id ], admin_url( 'admin.php' ) ); };
$logs_url = function( $id ) { return add_query_arg( [ 'page' => 'td_webhooks', 'tab' => 'logs', 'id' => $id ], admin_url( 'admin.php' ) ); };
?>
<table class="widefat fixed striped">
<thead>
<tr>
<th><?php echo esc_html__( 'Name', 'thrive-dash' ); ?></th>
<th><?php echo esc_html__( 'URL', 'thrive-dash' ); ?></th>
<th><?php echo esc_html__( 'Enabled', 'thrive-dash' ); ?></th>
<th><?php echo esc_html__( 'Actions', 'thrive-dash' ); ?></th>
</tr>
</thead>
<tbody>
<?php if ( empty( $items ) ) : ?>
<tr><td colspan="4"><?php echo esc_html__( 'No webhooks found.', 'thrive-dash' ); ?></td></tr>
<?php else : foreach ( $items as $it ) : ?>
<?php $enabled_text = ! empty( $it['enabled'] ) ? __( 'Yes', 'thrive-dash' ) : __( 'No', 'thrive-dash' ); ?>
<tr>
<td><?php echo esc_html( $it['name'] ?? '' ); ?></td>
<td><?php echo esc_html( $it['url'] ?? '' ); ?></td>
<td><?php echo esc_html( $enabled_text ); ?></td>
<td>
<a class="button" href="<?php echo esc_url( $edit_url( $it['id'] ) ); ?>"><?php echo esc_html__( 'Edit', 'thrive-dash' ); ?></a>
<a class="button" href="<?php echo esc_url( $logs_url( $it['id'] ) ); ?>"><?php echo esc_html__( 'Logs', 'thrive-dash' ); ?></a>
<?php $delete_nonce = wp_create_nonce( 'td_webhooks_delete' ); ?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline;margin-left:6px;" onsubmit="return confirm('<?php echo esc_js( __( 'Are you sure?', 'thrive-dash' ) ); ?>');">
<input type="hidden" name="action" value="td_webhooks_delete" />
<input type="hidden" name="_wpnonce" value="<?php echo esc_attr( $delete_nonce ); ?>" />
<input type="hidden" name="id" value="<?php echo esc_attr( (string) $it['id'] ); ?>" />
<button type="submit" class="button delete"><?php echo esc_html__( 'Delete', 'thrive-dash' ); ?></button>
</form>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>

View File

@@ -0,0 +1,31 @@
<?php /** @var array $rows */ ?>
<h2><?php echo esc_html__( 'Logs', 'thrive-dash' ); ?></h2>
<table class="widefat fixed striped">
<thead>
<tr>
<th><?php echo esc_html__( 'Time', 'thrive-dash' ); ?></th>
<th><?php echo esc_html__( 'Status', 'thrive-dash' ); ?></th>
<th><?php echo esc_html__( 'Duration (ms)', 'thrive-dash' ); ?></th>
<th><?php echo esc_html__( 'Details', 'thrive-dash' ); ?></th>
</tr>
</thead>
<tbody>
<?php if ( empty( $rows ) ) : ?>
<tr><td colspan="4"><?php echo esc_html__( 'No logs yet.', 'thrive-dash' ); ?></td></tr>
<?php else : foreach ( $rows as $row ) : ?>
<tr>
<td><?php echo esc_html( $row['timestamp'] ?? '' ); ?></td>
<td><?php echo esc_html( (string) ( $row['status_code'] ?? '' ) ); ?></td>
<td><?php echo esc_html( (string) ( $row['duration_ms'] ?? '' ) ); ?></td>
<td>
<details>
<summary><?php echo esc_html__( 'View', 'thrive-dash' ); ?></summary>
<pre><?php echo esc_html( print_r( $row, true ) ); ?></pre>
</details>
</td>
</tr>
<?php endforeach; endif; ?>
</tbody>
</table>