*/ public static function catalogue(): array { return array( 'audit.finished' => array( 'label' => __( 'Site audit finished', 'ai-rank-suite' ), 'detail' => __( 'Score, totals by severity, pages checked, run id.', 'ai-rank-suite' ) ), 'links.finished' => array( 'label' => __( 'Link scan finished', 'ai-rank-suite' ), 'detail' => __( 'Links found, broken, redirected.', 'ai-rank-suite' ) ), 'score.recalculated' => array( 'label' => __( 'AI search score recalculated', 'ai-rank-suite' ), 'detail' => __( 'Overall score and its dimensions.', 'ai-rank-suite' ) ), 'visibility.finished' => array( 'label' => __( 'Answer simulation run finished', 'ai-rank-suite' ), 'detail' => __( 'Prompts checked, mentions, run id.', 'ai-rank-suite' ) ), 'autopilot.applied' => array( 'label' => __( 'Autopilot applied a change', 'ai-rank-suite' ), 'detail' => __( 'Post id, task, before and after — one call per change.', 'ai-rank-suite' ) ), 'content.analyzed' => array( 'label' => __( 'Page analysed', 'ai-rank-suite' ), 'detail' => __( 'Post id and its GEO/SEO scores. Frequent during bulk runs.', 'ai-rank-suite' ) ), 'cache.purged' => array( 'label' => __( 'Caches purged', 'ai-rank-suite' ), 'detail' => __( 'Which caches were cleared.', 'ai-rank-suite' ) ), // Code that runs on the site changed hands: who, when and what — the // trail a security review asks for first. 'snippet.saved' => array( 'label' => __( 'Code snippet created or changed', 'ai-rank-suite' ), 'detail' => __( 'Snippet id, name, kind, whether it is active, and the user who saved it.', 'ai-rank-suite' ) ), 'snippet.activated' => array( 'label' => __( 'Code snippet switched on', 'ai-rank-suite' ), 'detail' => __( 'Snippet id, name, kind and the user who switched it on. Runs from the next request.', 'ai-rank-suite' ) ), 'webhook.test' => array( 'label' => __( 'Test ping', 'ai-rank-suite' ), 'detail' => __( 'Sent by the Test button.', 'ai-rank-suite' ) ), ); } /** * Announce an event: WordPress actions first, then every subscribed * webhook. Delivery is deferred to shutdown so the request that did the * work never waits on somebody else's server. * * @param string $name Event name from the catalogue. * @param array $payload Event data. */ public static function emit( string $name, array $payload = array() ): void { if ( ! isset( self::catalogue()[ $name ] ) ) { return; } $envelope = array( 'event' => $name, 'site' => home_url( '/' ), 'time' => time(), 'version' => defined( 'AIRANKSUITE_VERSION' ) ? AIRANKSUITE_VERSION : '', 'data' => $payload, ); /** * Fires for every plugin event. * * @param string $name Event name. * @param array $envelope Full envelope (event, site, time, data). */ do_action( 'airanksuite/event', $name, $envelope ); do_action( 'airanksuite/event/' . $name, $envelope ); $targets = array_filter( self::hooks(), static fn( array $h ) => ! empty( $h['active'] ) && in_array( $name, (array) $h['events'], true ) ); if ( empty( $targets ) ) { return; } $deliver = static function () use ( $targets, $envelope ): void { foreach ( $targets as $hook ) { self::deliver( $hook, $envelope ); } }; if ( did_action( 'shutdown' ) || ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) { $deliver(); } else { add_action( 'shutdown', $deliver, 99 ); } } /** * One signed POST. The signature is HMAC-SHA256 of the raw body with the * hook's secret, in `X-AIRS-Signature` — the shape GitHub and Stripe use, * so every consumer already knows how to verify it. * * @param array $hook Webhook record. * @param array $envelope Payload. * @return array{ok: bool, status: int, ms: int, error: string} */ public static function deliver( array $hook, array $envelope ): array { $body = (string) wp_json_encode( $envelope ); $secret = isset( $hook['secret'] ) ? (string) Secret::decrypt( (string) $hook['secret'] ) : ''; $start = microtime( true ); $response = wp_remote_post( (string) $hook['url'], array( 'timeout' => 8, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json', 'User-Agent' => 'AI-Rank Suite-Webhook/' . ( defined( 'AIRANKSUITE_VERSION' ) ? AIRANKSUITE_VERSION : '0' ), 'X-AIRS-Event' => (string) $envelope['event'], 'X-AIRS-Delivery' => wp_generate_uuid4(), 'X-AIRS-Signature' => 'sha256=' . hash_hmac( 'sha256', $body, $secret ), ), 'body' => $body, ) ); $ms = (int) round( ( microtime( true ) - $start ) * 1000 ); if ( $response instanceof WP_Error ) { $result = array( 'ok' => false, 'status' => 0, 'ms' => $ms, 'error' => $response->get_error_message() ); } else { $code = (int) wp_remote_retrieve_response_code( $response ); $result = array( 'ok' => $code >= 200 && $code < 300, 'status' => $code, 'ms' => $ms, 'error' => $code >= 300 ? substr( (string) wp_remote_retrieve_body( $response ), 0, 200 ) : '' ); } self::log( (string) $hook['id'], (string) $envelope['event'], $result ); self::touch( (string) $hook['id'], $result ); return $result; } // ---------------------------------------------------------- storage -- /** @return array> */ public static function hooks(): array { $hooks = get_option( self::OPTION, array() ); return is_array( $hooks ) ? array_values( $hooks ) : array(); } /** * Hooks as the API returns them: secret masked, never decrypted. * * @return array> */ public static function public_hooks(): array { return array_map( static function ( array $h ): array { $h['has_secret'] = ! empty( $h['secret'] ); unset( $h['secret'] ); return $h; }, self::hooks() ); } /** * Create or update. * * @param array $input id?, url, events[], secret?, active? * @return array|WP_Error The stored hook (masked). */ public static function save( array $input ) { $url = esc_url_raw( trim( (string) ( $input['url'] ?? '' ) ) ); if ( '' === $url || ! preg_match( '#^https?://#i', $url ) ) { return new WP_Error( 'airanksuite_webhook_url', __( 'A webhook needs an http(s) URL.', 'ai-rank-suite' ), array( 'status' => 400 ) ); } if ( ! self::url_is_public( $url ) ) { return new WP_Error( 'airanksuite_webhook_url', __( 'That address is private or local — a webhook must point at a public host.', 'ai-rank-suite' ), array( 'status' => 400 ) ); } $events = array_values( array_intersect( array_map( 'strval', (array) ( $input['events'] ?? array() ) ), array_keys( self::catalogue() ) ) ); if ( empty( $events ) ) { return new WP_Error( 'airanksuite_webhook_events', __( 'Pick at least one event.', 'ai-rank-suite' ), array( 'status' => 400 ) ); } $hooks = self::hooks(); $id = (string) ( $input['id'] ?? '' ); $index = null; foreach ( $hooks as $i => $h ) { if ( $id !== '' && $h['id'] === $id ) { $index = $i; } } if ( null === $index && count( $hooks ) >= self::MAX_HOOKS ) { /* translators: %d: maximum number of webhooks. */ return new WP_Error( 'airanksuite_webhook_max', sprintf( __( 'Up to %d webhooks.', 'ai-rank-suite' ), self::MAX_HOOKS ), array( 'status' => 400 ) ); } $current = null === $index ? array( 'id' => wp_generate_uuid4(), 'created' => time(), 'secret' => '' ) : $hooks[ $index ]; $secret = (string) ( $input['secret'] ?? '' ); if ( '' !== $secret ) { $current['secret'] = Secret::encrypt( $secret ); } elseif ( empty( $current['secret'] ) ) { // No secret given: mint one, and hand it back exactly once. $minted = wp_generate_password( 32, false ); $current['secret'] = Secret::encrypt( $minted ); $current['minted'] = $minted; } $current['url'] = $url; $current['events'] = $events; $current['active'] = ! isset( $input['active'] ) || ! empty( $input['active'] ); $current['label'] = sanitize_text_field( (string) ( $input['label'] ?? '' ) ); if ( null === $index ) { $hooks[] = $current; } else { $hooks[ $index ] = $current; } update_option( self::OPTION, array_values( $hooks ), false ); $out = $current; unset( $out['secret'] ); $out['has_secret'] = true; return $out; } public static function delete( string $id ): void { $hooks = array_values( array_filter( self::hooks(), static fn( array $h ) => $h['id'] !== $id ) ); update_option( self::OPTION, $hooks, false ); } /** @return array|null */ public static function find( string $id ): ?array { foreach ( self::hooks() as $h ) { if ( $h['id'] === $id ) { return $h; } } return null; } /** * Refuse loopback, private ranges and bare hostnames — a webhook that * points at the server itself or at the internal network is the classic * SSRF hole, and nothing legitimate needs it. */ private static function url_is_public( string $url ): bool { $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ); if ( '' === $host || 'localhost' === $host || str_ends_with( $host, '.local' ) || str_ends_with( $host, '.internal' ) ) { return false; } if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return (bool) filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } return str_contains( $host, '.' ); } /** * @param array{ok: bool, status: int, ms: int, error: string} $result Delivery result. */ private static function log( string $hook_id, string $event, array $result ): void { $log = get_option( self::LOG, array() ); $log = is_array( $log ) ? $log : array(); array_unshift( $log, array( 'hook' => $hook_id, 'event' => $event, 'time' => time(), 'ok' => $result['ok'], 'status' => $result['status'], 'ms' => $result['ms'], 'error' => $result['error'], ) ); update_option( self::LOG, array_slice( $log, 0, self::LOG_MAX ), false ); } /** @return array> */ public static function deliveries(): array { $log = get_option( self::LOG, array() ); return is_array( $log ) ? $log : array(); } /** * @param array{ok: bool, status: int, ms: int, error: string} $result Delivery result. */ private static function touch( string $hook_id, array $result ): void { $hooks = self::hooks(); foreach ( $hooks as $i => $h ) { if ( $h['id'] === $hook_id ) { $hooks[ $i ]['last'] = time(); $hooks[ $i ]['last_ok'] = $result['ok']; $hooks[ $i ]['last_status'] = $result['status']; $hooks[ $i ]['failures'] = $result['ok'] ? 0 : ( (int) ( $h['failures'] ?? 0 ) ) + 1; } } update_option( self::OPTION, $hooks, false ); } }