options = $options; $this->capabilityPolicy = $capabilityPolicy; $this->requestAdapter = $requestAdapter; $this->userIdProvider = $userIdProvider ?? self::defaultUserIdProvider(); $this->capabilityProvider = $capabilityProvider ?? self::defaultCapabilityProvider(); $this->clickEvents = $clickEvents; } /** * Record a click event. * * @param mixed $request WP_REST_Request, or array shim. * * @return array */ public function record( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_WRITE ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $event = $this->buildEvent( $body, $request ); if ( $event === null ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Invalid click payload.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx ); } if ( ! $this->passesSameOriginGuard( $request ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Click requests must originate from this site.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( ! $this->passesRateLimit( $request, $event ) ) { return $this->errorResponse( 'affilun_rate_limited', 'Too many click requests.', 429, $ctx, array( 'retry_after_seconds' => 60 ), true ); } if ( ! $this->isValidClickToken( $body, $event ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Invalid click token.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->clickEvents !== null ) { $this->clickEvents->record( $event ); } $events = $this->options->get( self::OPTION_EVENTS, array() ); if ( ! is_array( $events ) ) { $events = array(); } $events[] = $event; if ( count( $events ) > self::MAX_RECENT_EVENTS ) { $events = array_slice( $events, - self::MAX_RECENT_EVENTS ); } $this->options->update( self::OPTION_EVENTS, array_values( $events ) ); $counts = $this->options->get( self::OPTION_COUNTS, array() ); if ( ! is_array( $counts ) ) { $counts = array(); } $counts = $this->incrementAggregateCounts( $counts, $event ); $this->options->update( self::OPTION_COUNTS, $counts ); return $this->successResponse( SuccessEnvelope::fromContext( array( 'recorded' => true ), $ctx ), 201 ); } /** * Build a sanitized click event. * * @param array $body JSON body. * @param mixed $request Request. * * @return array|null */ private function buildEvent( array $body, mixed $request ): ?array { $provider = $this->readToken( $body, 'provider', 'unknown' ); $buttonKey = $this->readToken( $body, 'button_key', 'unknown' ); $targetUrl = $this->readString( $body, 'target_url' ); if ( $targetUrl === null || ! $this->isSafeHttpUrl( $targetUrl ) ) { return null; } $event = array( 'provider' => $provider, 'button_key' => $buttonKey, 'target_url_hash' => hash( 'sha256', $targetUrl ), 'product_id' => $this->readPositiveInt( $body, 'product_id' ), 'post_id' => $this->readPositiveInt( $body, 'post_id' ), 'created_at' => gmdate( 'c' ), 'metadata' => $this->buildMetadata( $request ), ); $variant = $this->readToken( $body, 'variant', '' ); if ( $variant !== '' ) { $event['variant'] = $variant; } return $event; } /** * Apply Origin/Referer same-site checks when site host is available. * * @param mixed $request Request. */ private function passesSameOriginGuard( mixed $request ): bool { $siteHost = self::siteHost(); if ( $siteHost === '' ) { return true; } $origin = $this->requestAdapter->header( $request, 'Origin' ); if ( is_string( $origin ) && trim( $origin ) !== '' ) { return self::hostFromUrl( $origin ) === $siteHost; } $referer = $this->requestAdapter->header( $request, 'Referer' ); if ( is_string( $referer ) && trim( $referer ) !== '' ) { return self::hostFromUrl( $referer ) === $siteHost; } return true; } /** * Apply a coarse per-minute limit for public click requests. * * @param mixed $request Request. * @param array $event Sanitized event. */ private function passesRateLimit( mixed $request, array $event ): bool { if ( $this->clickEvents !== null ) { $metadata = isset( $event['metadata'] ) && is_array( $event['metadata'] ) ? $event['metadata'] : array(); $since = new \DateTimeImmutable( '-60 seconds', new \DateTimeZone( 'UTC' ) ); $count = $this->clickEvents->countRecentMatching( self::metadataString( $metadata, 'user_agent_hash' ), self::metadataString( $metadata, 'referer_host' ), $since ); return $count < self::RATE_LIMIT_PER_MINUTE; } $bucket = gmdate( 'YmdHi' ) . ':' . $this->clientBucket( $request ); $stored = $this->options->get( self::OPTION_RATE_LIMIT, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } $normalized = array(); foreach ( $stored as $key => $value ) { if ( is_string( $key ) && is_int( $value ) && str_starts_with( $key, gmdate( 'YmdHi' ) . ':' ) ) { $normalized[ $key ] = $value; } } $count = $normalized[ $bucket ] ?? 0; if ( $count >= self::RATE_LIMIT_PER_MINUTE ) { $this->options->update( self::OPTION_RATE_LIMIT, $normalized ); return false; } $normalized[ $bucket ] = $count + 1; $this->options->update( self::OPTION_RATE_LIMIT, $normalized ); return true; } /** * Read a metadata string. * * @param array $metadata Metadata. * @param string $key Key. */ private static function metadataString( array $metadata, string $key ): string { $value = $metadata[ $key ] ?? null; return is_string( $value ) ? $value : ''; } /** * Build a privacy-light client bucket for rate limiting. * * @param mixed $request Request. */ private function clientBucket( mixed $request ): string { $userAgent = $this->requestAdapter->header( $request, 'User-Agent' ) ?? ''; $referer = $this->requestAdapter->header( $request, 'Referer' ) ?? ''; return hash( 'sha256', trim( $userAgent ) . '|' . trim( $referer ) ); } /** * Site host for same-origin checks. */ private static function siteHost(): string { $url = function_exists( 'home_url' ) ? (string) home_url() : ''; return self::hostFromUrl( $url ); } /** * Extract a normalized host from a URL. * * @param string $url URL. */ private static function hostFromUrl( string $url ): string { $host = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url, PHP_URL_HOST ) // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Pure-unit fallback when WordPress is unavailable. : parse_url( $url, PHP_URL_HOST ); return is_string( $host ) ? strtolower( rtrim( $host, '.' ) ) : ''; } /** * Verify that the click payload came from a rendered product card. * * @param array $body Payload. * @param array $event Sanitized event. */ private function isValidClickToken( array $body, array $event ): bool { if ( ! isset( $event['product_id'] ) || ! is_int( $event['product_id'] ) ) { return false; } $token = $this->readString( $body, 'click_token' ); $targetUrl = $this->readString( $body, 'target_url' ); if ( $token === null || $targetUrl === null ) { return false; } $postId = isset( $event['post_id'] ) && is_int( $event['post_id'] ) ? $event['post_id'] : null; return ClickToken::verify( $token, $event['product_id'], $targetUrl, $postId ); } /** * Build privacy-light request metadata. * * @param mixed $request Request. * * @return array */ private function buildMetadata( mixed $request ): array { $metadata = array(); $userAgent = $this->requestAdapter->header( $request, 'User-Agent' ); if ( is_string( $userAgent ) && trim( $userAgent ) !== '' ) { $metadata['user_agent_hash'] = hash( 'sha256', trim( $userAgent ) ); } $referer = $this->requestAdapter->header( $request, 'Referer' ); if ( is_string( $referer ) && preg_match( '~\Ahttps?://([^/:?#]+)~i', trim( $referer ), $matches ) === 1 ) { $metadata['referer_host'] = strtolower( $matches[1] ); } return $metadata; } /** * Increment aggregate count dimensions. * * @param array $counts Counts. * @param array $event Event. * * @return array */ private function incrementAggregateCounts( array $counts, array $event ): array { $normalized = array(); foreach ( $counts as $key => $value ) { if ( is_string( $key ) && is_int( $value ) ) { $normalized[ $key ] = $value; } } $keys = array( $event['provider'] . '|' . $event['button_key'], 'provider:' . $event['provider'], 'button:' . $event['button_key'], ); if ( isset( $event['product_id'] ) && is_int( $event['product_id'] ) ) { $keys[] = 'product:' . $event['product_id']; } if ( isset( $event['post_id'] ) && is_int( $event['post_id'] ) ) { $keys[] = 'post:' . $event['post_id']; } if ( isset( $event['variant'] ) && is_string( $event['variant'] ) ) { $keys[] = 'variant:' . $event['variant']; } foreach ( $keys as $key ) { if ( array_key_exists( $key, $normalized ) ) { ++$normalized[ $key ]; continue; } if ( count( $normalized ) >= self::MAX_AGGREGATE_KEYS ) { continue; } $normalized[ $key ] = 1; } return $normalized; } /** * Read a non-empty string field. * * @param array $body Payload. * @param string $key Field key. */ private function readString( array $body, string $key ): ?string { if ( ! isset( $body[ $key ] ) || ! is_string( $body[ $key ] ) ) { return null; } $trimmed = trim( $body[ $key ] ); return $trimmed === '' ? null : $trimmed; } /** * Read and validate a token field. * * @param array $body Payload. * @param string $key Field key. * @param string $fallback Fallback token. */ private function readToken( array $body, string $key, string $fallback ): string { $value = $this->readString( $body, $key ); if ( $value === null || preg_match( '/\A[a-z][a-z0-9_-]{0,63}\z/', $value ) !== 1 ) { return $fallback; } return $value; } /** * Read a positive integer field. * * @param array $body Payload. * @param string $key Field key. */ private function readPositiveInt( array $body, string $key ): ?int { $value = $body[ $key ] ?? null; if ( is_int( $value ) && $value > 0 ) { return $value; } if ( is_string( $value ) && preg_match( '/\A\d+\z/', $value ) === 1 ) { $parsed = (int) $value; return $parsed > 0 ? $parsed : null; } return null; } /** * Check whether a URL is an absolute HTTP(S) URL. * * @param string $url URL candidate. */ private function isSafeHttpUrl( string $url ): bool { return preg_match( '#\Ahttps?://#i', $url ) === 1; } /** * Build context. * * @param mixed $request Request. * @param list $candidateCaps Capabilities. */ private function buildContext( mixed $request, array $candidateCaps ): RequestContext { $userResolver = $this->userIdProvider; $capResolver = $this->capabilityProvider; $caps = array(); foreach ( $candidateCaps as $cap ) { if ( $capResolver( $cap ) === true ) { $caps[ $cap ] = 'true'; } } return $this->requestAdapter->buildRequestContext( $request, $userResolver(), $caps ); } /** * Build success response. * * @param SuccessEnvelope $envelope Success envelope. * @param int $status HTTP status. * * @return array */ private function successResponse( SuccessEnvelope $envelope, int $status ): array { return $envelope->toArray() + array( self::META_HTTP_STATUS => $status ); } /** * Build error response. * * @param string $code Error code. * @param string $message Error message. * @param int $status HTTP status. * @param RequestContext $ctx Request context. * @param array $details Details. * @param bool $retryable Retryable flag. * * @return array */ private function errorResponse( string $code, string $message, int $status, RequestContext $ctx, array $details = array(), bool $retryable = false ): array { return ( new ErrorEnvelope( $code, $message, $status, $details, $retryable, $ctx->requestId(), $ctx->correlationId(), gmdate( 'c' ) ) )->toArray() + array( self::META_HTTP_STATUS => $status ); } /** * @phpstan-return callable(): ?int */ private static function defaultUserIdProvider(): callable { return static function (): ?int { if ( function_exists( 'get_current_user_id' ) ) { $userId = (int) get_current_user_id(); return $userId > 0 ? $userId : null; } return null; }; } /** * @phpstan-return callable(string): bool */ private static function defaultCapabilityProvider(): callable { return static function ( string $capability ): bool { return function_exists( 'current_user_can' ) && current_user_can( $capability ); }; } }