conditions, array() ); return array( 'id' => (int) $row->id, 'name' => (string) $row->name, 'url' => (string) $row->url, 'secret' => (string) $row->secret, 'events' => array_values( self::decode( $row->events ) ), 'format' => in_array( $row->format, self::FORMATS, true ) ? $row->format : 'json', 'headers' => self::decode( $row->headers ), 'conditions' => array( 'match' => isset( $conditions['match'] ) && 'any' === $conditions['match'] ? 'any' : 'all', 'rules' => isset( $conditions['rules'] ) && is_array( $conditions['rules'] ) ? array_values( $conditions['rules'] ) : array(), ), 'options' => self::decode( $row->options ), 'active' => (bool) $row->active, 'paused_at' => $row->paused_at ? (string) $row->paused_at : null, 'consecutive_failures' => (int) $row->consecutive_failures, 'last_success_at' => $row->last_success_at ? (string) $row->last_success_at : null, 'last_failure_at' => $row->last_failure_at ? (string) $row->last_failure_at : null, 'created_at' => (string) $row->created_at, 'updated_at' => (string) $row->updated_at, ); } /** * All endpoints, cached per request and in the object cache. * * @return array[] */ public static function all() { if ( null !== self::$all ) { return self::$all; } $cached = wp_cache_get( self::CACHE_KEY, self::CACHE_GROUP ); if ( is_array( $cached ) ) { self::$all = $cached; return $cached; } global $wpdb; $table = Installer::endpoints_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Custom table, cached above. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id ASC', $table ) ); self::$all = array_values( array_filter( array_map( array( __CLASS__, 'hydrate' ), (array) $rows ) ) ); wp_cache_set( self::CACHE_KEY, self::$all, self::CACHE_GROUP, 300 ); return self::$all; } /** * Get one endpoint. * * @param int $id Endpoint ID. * @return array|null */ public static function get( $id ) { foreach ( self::all() as $endpoint ) { if ( (int) $id === $endpoint['id'] ) { return $endpoint; } } return null; } /** * Whether an endpoint should receive live events. * * @param array $endpoint Endpoint. * @return bool */ public static function is_live( array $endpoint ) { return $endpoint['active'] && null === $endpoint['paused_at']; } /** * Endpoints that are active (paused ones still queue events, which are sent once the endpoint resumes). * * @return array[] */ public static function active() { return array_values( array_filter( self::all(), static function ( $endpoint ) { return $endpoint['active']; } ) ); } /** * Active endpoints subscribed to an event. * * @param string $event Event key. * @return array[] */ public static function subscribed_to( $event ) { return array_values( array_filter( self::active(), static function ( $endpoint ) use ( $event ) { return self::matches( $endpoint['events'], $event ); } ) ); } /** * Whether any active endpoint wants this event. Used before building expensive payloads. * * @param string $event Event key. * @return bool */ public static function has_subscribers( $event ) { return ! empty( self::subscribed_to( $event ) ); } /** * Whether a subscription list matches an event. Supports "*" and prefix wildcards like "order.*". * * @param string[] $subscriptions Subscribed patterns. * @param string $event Event key. * @return bool */ public static function matches( array $subscriptions, $event ) { foreach ( $subscriptions as $pattern ) { $pattern = (string) $pattern; if ( '*' === $pattern || $pattern === $event ) { return true; } if ( '.*' === substr( $pattern, -2 ) && 0 === strpos( $event, substr( $pattern, 0, -1 ) ) ) { return true; } } return false; } /** * Sanitize an event key or subscription pattern. * * @param string $key Key. * @return string Empty string when invalid. */ public static function sanitize_event_key( $key ) { if ( ! is_scalar( $key ) ) { return ''; } $key = strtolower( trim( (string) $key ) ); return preg_match( '/^(\*|[a-z0-9_]+(\.[a-z0-9_]+)*(\.\*)?)$/', $key ) && strlen( $key ) <= 100 ? $key : ''; } /** * Validate a URL for use as an endpoint. * * @param string $url URL. * @return string|\WP_Error Clean URL. */ public static function validate_url( $url ) { $raw = is_scalar( $url ) ? trim( (string) $url ) : ''; $url = preg_match( '#^https?://#i', $raw ) ? esc_url_raw( $raw, array( 'http', 'https' ) ) : ''; if ( '' === $url || ! filter_var( $url, FILTER_VALIDATE_URL ) || ! wp_parse_url( $url, PHP_URL_HOST ) ) { return new \WP_Error( 'hookbird_invalid_url', __( 'Please enter a valid http or https URL.', 'hookbird' ) ); } if ( ! Settings::allow_local_urls() && ! wp_http_validate_url( $url ) ) { return new \WP_Error( 'hookbird_unsafe_url', __( 'This URL points to a local or private address, or uses a non-standard port. To allow it (for example a self-hosted n8n on port 5678), enable "Allow local and private network URLs" in Hookbird settings.', 'hookbird' ) ); } return $url; } /** * Validate and normalise endpoint input. * * @param array $data Raw data. * @param array|null $existing Existing endpoint for partial updates. * @return array|\WP_Error */ public static function sanitize( array $data, $existing = null ) { if ( $existing ) { $data = array_merge( array( 'name' => $existing['name'], 'url' => $existing['url'], 'events' => $existing['events'], 'format' => $existing['format'], 'headers' => $existing['headers'], 'conditions' => $existing['conditions'], 'options' => $existing['options'], 'active' => $existing['active'], ), $data ); } $url = self::validate_url( isset( $data['url'] ) ? $data['url'] : '' ); if ( is_wp_error( $url ) ) { return $url; } $events = isset( $data['events'] ) ? $data['events'] : array(); if ( is_string( $events ) ) { $events = explode( ',', $events ); } $events = array_values( array_unique( array_filter( array_map( array( __CLASS__, 'sanitize_event_key' ), (array) $events ) ) ) ); if ( empty( $events ) ) { return new \WP_Error( 'hookbird_no_events', __( 'Select at least one event.', 'hookbird' ) ); } $format = isset( $data['format'] ) && in_array( $data['format'], self::FORMATS, true ) ? $data['format'] : 'json'; $data['options'] = isset( $data['options'] ) && is_array( $data['options'] ) ? $data['options'] : array(); foreach ( array( 'preset', 'chat_id' ) as $option ) { if ( isset( $data['options'][ $option ] ) && ! is_scalar( $data['options'][ $option ] ) ) { unset( $data['options'][ $option ] ); } } $options = array(); $preset = isset( $data['options']['preset'] ) ? sanitize_key( (string) $data['options']['preset'] ) : ''; $presets = Formatter::presets(); if ( isset( $presets[ $preset ] ) && $presets[ $preset ]['format'] === $format ) { $options['preset'] = $preset; } if ( 'telegram' === $format ) { $chat_id = isset( $data['options']['chat_id'] ) ? preg_replace( '/[^0-9A-Za-z_@\-]/', '', (string) $data['options']['chat_id'] ) : ''; if ( '' === $chat_id ) { return new \WP_Error( 'hookbird_no_chat_id', __( 'Telegram needs a chat ID.', 'hookbird' ) ); } $options['chat_id'] = $chat_id; } $name = isset( $data['name'] ) && is_scalar( $data['name'] ) ? sanitize_text_field( (string) $data['name'] ) : ''; return array( 'name' => '' !== $name ? mb_substr( $name, 0, 191 ) : (string) wp_parse_url( $url, PHP_URL_HOST ), 'url' => $url, 'events' => $events, 'format' => $format, 'headers' => self::sanitize_headers( isset( $data['headers'] ) ? $data['headers'] : array() ), 'conditions' => self::sanitize_conditions( isset( $data['conditions'] ) ? $data['conditions'] : array() ), 'options' => $options, 'active' => isset( $data['active'] ) && wp_validate_boolean( $data['active'] ) ? 1 : 0, ); } /** * Sanitize custom headers given as an array or "Name: value" lines. * * @param array|string $headers Headers. * @return array */ public static function sanitize_headers( $headers ) { $clean = array(); $reserved = array( 'content-type', 'content-length', 'host', 'user-agent', 'webhook-id', 'webhook-timestamp', 'webhook-signature' ); $lines = is_array( $headers ) ? $headers : preg_split( '/\r\n|\r|\n/', (string) $headers ); foreach ( (array) $lines as $key => $line ) { if ( is_string( $key ) ) { $name = $key; $value = $line; } else { if ( false === strpos( (string) $line, ':' ) ) { continue; } list( $name, $value ) = array_map( 'trim', explode( ':', (string) $line, 2 ) ); } if ( ! is_scalar( $name ) || ! is_scalar( $value ) ) { continue; } $name = preg_replace( '/[^A-Za-z0-9\-]/', '', (string) $name ); if ( '' === $name || in_array( strtolower( $name ), $reserved, true ) || count( $clean ) >= 20 ) { continue; } $clean[ $name ] = sanitize_text_field( (string) $value ); } return $clean; } /** * Sanitize condition rules. * * @param array $conditions Raw conditions with "match" and "rules". * @return array */ public static function sanitize_conditions( $conditions ) { $conditions = is_array( $conditions ) ? $conditions : array(); $rules = array(); $raw_rules = isset( $conditions['rules'] ) && is_array( $conditions['rules'] ) ? $conditions['rules'] : array(); foreach ( $raw_rules as $rule ) { if ( ! is_array( $rule ) || count( $rules ) >= self::MAX_RULES ) { continue; } $field = isset( $rule['field'] ) && is_scalar( $rule['field'] ) ? preg_replace( '/[^A-Za-z0-9_.\-]/', '', (string) $rule['field'] ) : ''; $op = isset( $rule['op'] ) && is_scalar( $rule['op'] ) ? sanitize_key( (string) $rule['op'] ) : ''; if ( '' === $field || ! array_key_exists( $op, Conditions::operators() ) ) { continue; } $rules[] = array( 'field' => $field, 'op' => $op, 'value' => isset( $rule['value'] ) && is_scalar( $rule['value'] ) ? sanitize_text_field( (string) $rule['value'] ) : '', ); } return array( 'match' => isset( $conditions['match'] ) && 'any' === $conditions['match'] ? 'any' : 'all', 'rules' => $rules, ); } /** * Row data for insert or update. * * @param array $clean Sanitized data. * @return array */ private static function to_row( array $clean ) { return array( 'name' => $clean['name'], 'url' => $clean['url'], 'events' => wp_json_encode( $clean['events'] ), 'format' => $clean['format'], 'headers' => wp_json_encode( (object) $clean['headers'] ), 'conditions' => wp_json_encode( $clean['conditions'] ), 'options' => wp_json_encode( (object) $clean['options'] ), 'active' => $clean['active'], 'updated_at' => current_time( 'mysql', true ), ); } /** * Create an endpoint. * * @param array $data Raw data. * @param string $secret Optional existing secret (used by import). * @return int|\WP_Error New ID. */ public static function create( array $data, $secret = '' ) { global $wpdb; $clean = self::sanitize( $data ); if ( is_wp_error( $clean ) ) { return $clean; } $row = self::to_row( $clean ); $row['secret'] = preg_match( '/^whsec_[A-Za-z0-9+\/=]{16,}$/', (string) $secret ) ? $secret : self::generate_secret(); $row['created_at'] = $row['updated_at']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom table. $ok = $wpdb->insert( Installer::endpoints_table(), $row ); self::flush_cache(); if ( ! $ok ) { return new \WP_Error( 'hookbird_db', __( 'Could not save the endpoint.', 'hookbird' ) ); } $id = (int) $wpdb->insert_id; /** * Fires after an endpoint is created. * * @param int $id Endpoint ID. */ do_action( 'hookbird_endpoint_created', $id ); return $id; } /** * Update an endpoint. Missing fields keep their current values. * * @param int $id Endpoint ID. * @param array $data Raw data. * @return true|\WP_Error */ public static function update( $id, array $data ) { global $wpdb; $existing = self::get( $id ); if ( ! $existing ) { return new \WP_Error( 'hookbird_not_found', __( 'Endpoint not found.', 'hookbird' ) ); } $clean = self::sanitize( $data, $existing ); if ( is_wp_error( $clean ) ) { return $clean; } $row = self::to_row( $clean ); if ( $clean['active'] && ! $existing['active'] ) { // Turning an endpoint back on also clears a circuit breaker pause. $row['paused_at'] = null; $row['consecutive_failures'] = 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, cache flushed below. $wpdb->update( Installer::endpoints_table(), $row, array( 'id' => (int) $id ) ); self::flush_cache(); return true; } /** * Raw column update without validation (internal state such as counters). * * @param int $id Endpoint ID. * @param array $fields Columns. */ public static function update_state( $id, array $fields ) { global $wpdb; $fields['updated_at'] = current_time( 'mysql', true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table, cache flushed below. $wpdb->update( Installer::endpoints_table(), $fields, array( 'id' => (int) $id ) ); self::flush_cache(); } /** * Enable or disable an endpoint. Enabling also resumes it after a circuit breaker pause. * * @param int $id Endpoint ID. * @param bool $active New state. */ public static function set_active( $id, $active ) { $fields = array( 'active' => $active ? 1 : 0 ); if ( $active ) { $fields['paused_at'] = null; $fields['consecutive_failures'] = 0; } self::update_state( $id, $fields ); } /** * Resume a paused endpoint. * * @param int $id Endpoint ID. */ public static function resume( $id ) { self::update_state( $id, array( 'active' => 1, 'paused_at' => null, 'consecutive_failures' => 0, ) ); } /** * Rotate the signing secret. * * @param int $id Endpoint ID. * @return string New secret. */ public static function rotate_secret( $id ) { $secret = self::generate_secret(); self::update_state( $id, array( 'secret' => $secret ) ); return $secret; } /** * Delete an endpoint and cancel its pending deliveries. * * @param int $id Endpoint ID. */ public static function delete( $id ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table. $wpdb->delete( Installer::endpoints_table(), array( 'id' => (int) $id ), array( '%d' ) ); Deliveries::cancel_pending_for_endpoint( (int) $id ); self::flush_cache(); /** * Fires after an endpoint is deleted. * * @param int $id Endpoint ID. */ do_action( 'hookbird_endpoint_deleted', (int) $id ); } /** * Clear cached endpoint lists. */ public static function flush_cache() { self::$all = null; wp_cache_delete( self::CACHE_KEY, self::CACHE_GROUP ); } /** * Mask a URL for display, since chat webhook URLs contain tokens. * * @param string $url URL. * @return string */ public static function mask_url( $url ) { $parts = wp_parse_url( $url ); if ( empty( $parts['host'] ) ) { return ''; } $path = isset( $parts['path'] ) ? $parts['path'] : ''; $tail = strlen( $path ) > 12 ? '/…' . substr( $path, -4 ) : $path; return $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ) . $tail; } /** * Public representation for listings: secret and URL masked. * * @param array $endpoint Endpoint. * @param bool $reveal Include the full secret and URL. * @return array */ public static function to_public( array $endpoint, $reveal = false ) { if ( ! $reveal ) { $endpoint['secret'] = substr( $endpoint['secret'], 0, 10 ) . '********'; $endpoint['url'] = self::mask_url( $endpoint['url'] ); } $endpoint['paused'] = null !== $endpoint['paused_at']; return $endpoint; } }