'POST', 'callback' => [$this, 'record'], 'permission_callback' => '__return_true', // Public and nonce-free on purpose — the note at the end of Routes says // why. No `type` and no `sanitize_callback`: every field of // the body is checked by {@see Beacon} against a closed enum or // a ULID pattern, which is stricter than anything // `rest_sanitize_value_from_schema` would do to it — and a // declared schema that coerces is how a value stops meaning // what the client sent (ADR 0032). A validate_callback changes // nothing it accepts, so the batch's shape is declared here. 'args' => [ Beacon::EVENTS => [ 'description' => 'The batch of display events: Campaign ids and event kinds, nothing about the visitor.', 'validate_callback' => static fn ($value): bool => is_array($value) && array_is_list($value), ], ], ], ]); } /** * Count what arrived — or count nothing, and say so the same way. * * **204 for everything that is not rate-limited**, including a batch where * every event was dropped. There is nobody on the page to read a response: * this arrives through `navigator.sendBeacon` during `pagehide`, which * discards the response entirely and cannot retry. A 404 for an unpublished * Optin would be a status nothing reads, describing a condition the client * cannot fix, about a request the client has already forgotten. * * 429 is the exception, and it is honest rather than useful for the same * reason: it is the one refusal a caller sending traffic on purpose might * be watching for. */ public function record(WP_REST_Request $request): WP_REST_Response { $countable = BeaconTraffic::countable( (string) $request->get_header('sec-purpose'), (string) $request->get_header('purpose'), (string) $request->get_header('user-agent') ); if (!$countable) { return new WP_REST_Response(null, 204); } // `REMOTE_ADDR` and no forwarded header. `X-Forwarded-For` is // client-settable on any install not behind a proxy that overwrites // it, so a limit keyed on it is keyed on a value the rate-limited party // chooses. Behind a reverse proxy this buckets a whole site's visitors // together, which is why {@see RateLimit}'s ceiling is generous. // // `wp_unslash()` because WordPress adds slashes to every superglobal on // load. Unescaped, an address carrying one would hash to a different // bucket than the same address without — which is a rate limit with a // hole in it rather than a display bug. // // Sanitized, and deliberately NOT validated with FILTER_VALIDATE_IP: // {@see RateLimit} refuses an empty address, so a host whose // REMOTE_ADDR is not a bare IP — a port appended, an IPv6 zone, a // socket name — would have every request refused. The value is only // ever hashed into a bucket key; it is never printed or stored. $address = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')); // The clock is READ HERE and passed down, the same way the day is — // neither {@see RateLimit} nor {@see StatsRepository} owns one, so // neither has anything to stub. if (!$this->rateLimit->allows($address, time())) { return new WP_REST_Response(null, 429); } // The set is parsed ONCE, into ids. Asking `PublishedOptin::findInSet()` // per event would parse the whole set — building a `Targeting` and a // payload for every entry — once for each of up to twenty events, and // throw all of it away for an id comparison. That is the right shape for // the capture route, which asks about exactly one id; it is the wrong // one here. // // **And a [[Suspended]] Optin is not in it**, which is why this asks // for the SERVED ids rather than the published ones: ADR 0027 wants // no rows rather than zero-valued ones, and a page cached before the // dependency went away still carries the entry and still beacons. // // The capture route deliberately does NOT ask the same question. A // [[Lead]] somebody actually typed is the one genuinely unrecoverable // loss available here, and the [[Lead]] log is not where a // [[Conversion]] is counted (ADR 0019). $published = PublishedOptin::servableIdsIn($this->publishedSet->all(), $this->degradation); // Asked once for the whole batch. The events were coalesced over one // page view, so they belong to one moment — and a flush that straddles // midnight landing half on each day would be a worse answer than either // day alone. $today = StatDay::today(); foreach (Beacon::eventsIn($request->get_json_params()) as $event) { // The same membership check the capture endpoint makes. An Optin // that is not in the published set has no rendered form and no // Impression this server ever served, and a count against it names // an Optin the report-time join cannot interpret (ADR 0020). if (!isset($published[$event->optinId])) { continue; } $screenEvent = str_starts_with($event->kind->value, 'screen_'); if ($screenEvent) { if (preg_match('/^screen:([a-f0-9]{64}):([a-z][a-z0-9_-]{0,47})$/D', $event->scope, $parts) !== 1) { continue; } $definition = get_option('wconvert_flow_' . $event->optinId . '_' . $parts[1], []); if (!is_array($definition) || !isset($definition[$parts[2]])) { continue; } } elseif ($event->scope !== '') { continue; } if (in_array($event->kind, [\WConvert\Stats\StatKind::Conversion, \WConvert\Stats\StatKind::ResultClick], true)) { $optin = PublishedOptin::findInSet($this->publishedSet->all(), $event->optinId); $tree = $optin?->payload['template']['tree'] ?? []; $acts = \WConvert\Template\ConvertingAct::offeredIn($tree); // A message offers no act, so nothing it sends can be one (ADR 0163). if ($acts === [] || in_array(\WConvert\Template\ConvertingAct::AddToCart, $acts, true)) { continue; } $resultAt = array_search('result', array_column($tree['steps'] ?? [], 'kind'), true); if ($event->kind === \WConvert\Stats\StatKind::ResultClick && $resultAt === false) { continue; } if ($event->kind === \WConvert\Stats\StatKind::Conversion && !empty($tree['submissions']) && $resultAt === false) { continue; } } $this->stats->increment($event->optinId, $event->kind, $today, $event->scope); } return new WP_REST_Response(null, 204); } }