Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion includes/class-webdecoy-activator.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class WebDecoy_Activator
/**
* Database version for migrations
*/
private const DB_VERSION = '2.2.0';
private const DB_VERSION = '2.3.0';

/**
* Plugin activation
Expand Down Expand Up @@ -129,13 +129,29 @@ private static function create_tables(): void
KEY created_at (created_at)
) $charset_collate;";

// AI referral counts (2.10.0): visits AI products sent, by platform and
// landing path, waiting to be sent to WebDecoy Cloud. Counts only;
// nothing about a visitor. ref_key is sha1(platform + path), so the
// key stays within index limits on older MySQL; batch_id is '' for
// open counts and the claim id of a batch being sent.
$sql_ai_referrals = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}webdecoy_ai_referrals (
ref_key CHAR(40) NOT NULL,
batch_id VARCHAR(36) NOT NULL DEFAULT '',
platform VARCHAR(50) NOT NULL,
landing_path VARCHAR(500) NOT NULL,
referrals BIGINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (ref_key, batch_id),
KEY batch_id (batch_id)
) $charset_collate;";

require_once ABSPATH . 'wp-admin/includes/upgrade.php';

dbDelta($sql_blocked);
dbDelta($sql_detections);
dbDelta($sql_rate_limits);
dbDelta($sql_checkout);
dbDelta($sql_violation_queue);
dbDelta($sql_ai_referrals);
}

/**
Expand Down Expand Up @@ -272,6 +288,7 @@ public static function uninstall(): void
wp_clear_scheduled_hook('webdecoy_cleanup_expired');
wp_clear_scheduled_hook('webdecoy_sync_blocked_ips');
wp_clear_scheduled_hook('webdecoy_flush_violations');
wp_clear_scheduled_hook('webdecoy_flush_ai_referrals');
wp_clear_scheduled_hook('webdecoy_sync_entitlements');
wp_clear_scheduled_hook('webdecoy_sync_actor_feed');
}
Expand Down
188 changes: 188 additions & 0 deletions includes/class-webdecoy-ai-referrals.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
<?php

declare(strict_types=1);

if (!defined('ABSPATH')) {
exit;
}

/**
* Counts page visits that AI products (ChatGPT, Claude, Perplexity, Gemini,
* Copilot and others) send to this site, for WebDecoy's AI Traffic page.
*
* Aggregate counts only: an AI platform, a landing path and a number. Nothing
* about a visitor (IP, user agent, cookie, query string, full referrer) is
* stored or sent. Only a browser loading a page counts: a GET whose fetch
* metadata says it is a document navigation. Pages served from a full-page
* cache never reach PHP and are not counted, so the figure is a floor.
*
* Counts accumulate in one small table and are sent every fifteen minutes when
* the site is connected to WebDecoy Cloud. Each send claims the open counts
* under a batch id; a failed send keeps that id and is retried with it, and
* WebDecoy counts a batch id once.
*/
class WebDecoy_AI_Referrals
{
public const CRON_HOOK = 'webdecoy_flush_ai_referrals';
private const ENDPOINT = 'https://in.webdecoy.com/api/v1/sdk/ai-referrals';
private const MAX_PATH = 500;
/** Rows sent per batch; the endpoint takes up to 500. */
private const BATCH_SIZE = 500;
/** Distinct platform and path pairs kept before new ones are dropped. */
private const MAX_ROWS = 5000;

public static function table(): string
{
global $wpdb;
return $wpdb->prefix . 'webdecoy_ai_referrals';
}

/**
* Whether counting runs: connected to WebDecoy Cloud, and not turned off
* with the webdecoy_count_ai_referrals filter.
*/
public static function enabled(string $apiKey): bool
{
return $apiKey !== '' && (bool) apply_filters('webdecoy_count_ai_referrals', true);
}

/** Wire the counter and its flush, scheduling the flush if it is not. */
public static function register(string $apiKey): void
{
if (!self::enabled($apiKey)) {
return;
}
add_action('template_redirect', [self::class, 'observe'], 1);
add_action(self::CRON_HOOK, static function () use ($apiKey): void {
self::flush($apiKey);
});
if (function_exists('wp_next_scheduled') && !wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time() + 900, 'fifteen_minutes', self::CRON_HOOK);
}
}

/** Count the current request if it is a page visit an AI product sent. */
public static function observe(): void
{
// phpcs:disable WordPress.Security.ValidatedSanitizedInput -- compared and classified, never stored or echoed
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? ''));
$mode = (string) ($_SERVER['HTTP_SEC_FETCH_MODE'] ?? '');
$dest = (string) ($_SERVER['HTTP_SEC_FETCH_DEST'] ?? '');
$referer = wp_unslash((string) ($_SERVER['HTTP_REFERER'] ?? ''));
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
// phpcs:enable
$platform = self::classifyRequest($method, $mode, $dest, $referer, $uri);
if ($platform === '') {
return;
}
self::increment($platform, self::landingPath($uri));
}

/**
* The AI platform a request came from, or '' when it is not a page visit
* an AI product sent. Pure, for tests.
*/
public static function classifyRequest(string $method, string $mode, string $dest, string $referer, string $uri): string
{
if ($method !== 'GET' || $mode !== 'navigate' || ($dest !== '' && $dest !== 'document')) {
return '';
}
return \WebDecoy\LlmReferral::classify($referer, 'https://site.invalid' . $uri);
}

/** The path of a request URI, cut to the width WebDecoy stores. */
public static function landingPath(string $uri): string
{
$path = (string) (parse_url($uri, PHP_URL_PATH) ?: '/');
if ($path === '' || $path[0] !== '/') {
$path = '/' . $path;
}
if (strlen($path) > self::MAX_PATH) {
$path = substr($path, 0, self::MAX_PATH);
}
return mb_check_encoding($path, 'UTF-8') ? $path : '/';
}

private static function increment(string $platform, string $path): void
{
global $wpdb;
$table = self::table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix
$rows = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
$key = sha1($platform . "\n" . $path);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$exists = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE ref_key = %s AND batch_id = ''", $key));
if ($rows >= self::MAX_ROWS && $exists === 0) {
return; // Bounded: a new pair is dropped rather than growing the table.
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix
$wpdb->query($wpdb->prepare(
"INSERT INTO {$table} (ref_key, batch_id, platform, landing_path, referrals) VALUES (%s, '', %s, %s, 1)
ON DUPLICATE KEY UPDATE referrals = referrals + 1",
$key,
$platform,
$path
));
// phpcs:enable
}

/**
* Send counted referrals. A batch that failed before is resent under its
* own id first; otherwise the open counts are claimed under a new one.
*/
public static function flush(string $apiKey): void
{
if ($apiKey === '' || !function_exists('wp_remote_post')) {
return;
}
global $wpdb;
$table = self::table();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$batch = (string) $wpdb->get_var("SELECT batch_id FROM {$table} WHERE batch_id <> '' LIMIT 1");
if ($batch === '') {
$batch = wp_generate_uuid4();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$claimed = (int) $wpdb->query($wpdb->prepare("UPDATE {$table} SET batch_id = %s WHERE batch_id = '' LIMIT %d", $batch, self::BATCH_SIZE));
if ($claimed === 0) {
return;
}
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results($wpdb->prepare("SELECT platform, landing_path, referrals FROM {$table} WHERE batch_id = %s", $batch));
$referrals = [];
foreach ((array) $rows as $row) {
$referrals[] = [
'platform' => (string) $row->platform,
'path' => (string) $row->landing_path,
'count' => (int) $row->referrals,
];
}
if ($referrals === [] || self::send($apiKey, $batch, $referrals)) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->delete($table, ['batch_id' => $batch]);
}
}

/**
* True when the batch needs no retry: accepted, or refused for a reason a
* retry cannot fix (a 4xx, such as a key not scoped to one site).
*
* @param array<int, array{platform: string, path: string, count: int}> $referrals
*/
private static function send(string $apiKey, string $batch, array $referrals): bool
{
$response = wp_remote_post(self::ENDPOINT, [
'timeout' => 5,
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $apiKey,
],
'body' => wp_json_encode(['report_id' => $batch, 'source' => 'wordpress', 'referrals' => $referrals]),
]);
if (is_wp_error($response)) {
return false;
}
$code = (int) wp_remote_retrieve_response_code($response);
return $code >= 200 && $code < 500;
}
}
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ What is sent, and when:
* When you click "Connect to WebDecoy Cloud": your browser is redirected to app.webdecoy.com to approve the connection (carrying your site URL, site name, a one-time nonce, and your monthly-report preference). After you approve, the plugin exchanges a one-time token with api.webdecoy.com (sending the token, your site URL and the nonce) to receive the site's API keys. Cancelling sends nothing further.
* After connecting: the plugin fetches your plan entitlements from ingest.webdecoy.com (authenticated with your API key) twice daily.
* When a detection or rule violation occurs: the visitor's IP address, user agent, request path, threat score and detection flags are sent to ingest.webdecoy.com so the event appears in your cloud dashboard.
* When a visitor arrives from an AI product such as ChatGPT, Claude, Perplexity or Gemini: the plugin adds one to a count for that AI product and the landing page's path, and sends those counts to ingest.webdecoy.com every fifteen minutes so they appear on your AI Traffic page. Only the AI product's name, the path and the count are sent; nothing about the visitor. Turn it off with the `webdecoy_count_ai_referrals` filter.
* When you use an IP-reputation filter rule (e.g. ip.abuse_score, ip.tor): the visitor's IP address is sent to ingest.webdecoy.com to look up reputation/geo data.
* When validating your key or forwarding a WooCommerce checkout detection: your API key, organization ID and the detection data above are sent to api.webdecoy.com / ingest.webdecoy.com.

Expand Down
122 changes: 122 additions & 0 deletions sdk/src/LlmReferral.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace WebDecoy;

if (!defined('ABSPATH')) {
exit;
}

/**
* Recognises a visit an AI product sent: a Referer from an AI chat or search
* platform, or, with no Referer, a campaign tag naming one.
*
* The platform table (registry/llm-platforms.generated.php) is generated from
* WebDecoy's own classifier, and this is a port of it, pinned case for case by
* the generated golden vectors (tests/LlmReferralTest.php), so the plugin
* counts exactly what WebDecoy's other sensors count.
*/
final class LlmReferral
{
/** Campaign parameters that can name an AI product, in precedence order. */
private const TAG_KEYS = ['utm_source', 'ref', 'utm_medium'];

/** @var array<string, string>|null */
private static $platforms = null;

/** @return array<string, string> referrer hostname => platform */
public static function platforms(): array
{
if (self::$platforms === null) {
$loaded = require __DIR__ . '/registry/llm-platforms.generated.php';
self::$platforms = is_array($loaded) ? $loaded : [];
}
return self::$platforms;
}

/**
* The platform a referral came from, or '' when it is not an AI referral.
* A non-empty Referer decides on its own even when it names no AI
* product: a campaign tag is easy to forge and must not overrule it.
*/
public static function classify(string $referer, string $pageUrl): string
{
$platforms = self::platforms();
$referer = trim($referer);
if ($referer !== '') {
$host = parse_url($referer, PHP_URL_HOST);
if (!is_string($host) || $host === '') {
return '';
}
return $platforms[strtolower($host)] ?? '';
}
$query = parse_url($pageUrl, PHP_URL_QUERY);
if (!is_string($query) || $query === '') {
return '';
}
$params = self::queryValues($query);
foreach (self::TAG_KEYS as $key) {
foreach ($params[$key] ?? [] as $value) {
$platform = self::platformForTag($value);
if ($platform !== '') {
return $platform;
}
}
}
return '';
}

/**
* Every value of every parameter, in order. parse_str() keeps only the
* last of a repeated key, and a tag must be read the way browsers and the
* other sensors read it.
*
* @return array<string, string[]>
*/
private static function queryValues(string $query): array
{
$out = [];
foreach (explode('&', $query) as $pair) {
if ($pair === '') {
continue;
}
$parts = explode('=', $pair, 2);
$key = urldecode(str_replace('+', ' ', $parts[0]));
$out[$key][] = urldecode(str_replace('+', ' ', $parts[1] ?? ''));
}
return $out;
}

private static function squash(string $s): string
{
return str_replace([' ', '-', '_', '.'], '', $s);
}

private static function platformForTag(string $raw): string
{
$platforms = self::platforms();
$value = strtolower(trim($raw));
if ($value === '') {
return '';
}
if (strpos($value, '://') !== false) {
$host = parse_url($value, PHP_URL_HOST);
if (is_string($host) && $host !== '') {
$value = strtolower($host);
}
}
$value = rtrim($value, '.');
if (isset($platforms[$value])) {
return $platforms[$value];
}
$normalized = self::squash($value);
foreach ($platforms as $domain => $name) {
$bare = str_replace('.', '', preg_replace('/^www\./', '', $domain) ?? $domain);
if ($normalized === self::squash(strtolower($name)) || $normalized === $bare) {
return $name;
}
}
return '';
}
}
Loading
Loading