diff --git a/README.md b/README.md index 88bb458..58bec77 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,35 @@ $event->userData->fbc = Fbc::fromString($_COOKIE['_fbc']); $event->userData->fbp = Fbp::fromString($_COOKIE['_fbp']); ``` +### Resolving the cookies with Meta's parameter builder + +Instead of reading and parsing the cookies yourself, you can hand the raw request to the `CookieResolver`, which +delegates to Meta's own [parameter builder](https://github.com/facebook/capi-param-builder-php) +(`facebook/capi-param-builder-php`). It validates existing cookie values and upgrades them to the format Meta writes +today, builds a new `fbc` from the `fbclid` query parameter, generates an `fbp` when the request has none, and tells +you which cookies to set on the response: + +```php +use Setono\MetaConversionsApi\Cookie\CookieResolver; + +$resolver = new CookieResolver(); +$resolvedCookies = $resolver->resolve($_SERVER['HTTP_HOST'], $_GET, $_COOKIE); + +$event->userData->fbc = $resolvedCookies->fbc; +$event->userData->fbp = $resolvedCookies->fbp; + +foreach ($resolvedCookies->cookiesToSet as $cookie) { + setcookie($cookie->name, $cookie->value, [ + 'expires' => time() + $cookie->maxAge, + 'path' => '/', + 'domain' => $cookie->domain ?? '', + ]); +} +``` + +On a multi-domain setup, pass your domains so the cookie domain is derived correctly, e.g. +`new CookieResolver(['example.co.uk'])`. + ## Using your own HTTP client By default the client auto-discovers a PSR-18 client and PSR-17 factories. To inject your own (e.g. a preconfigured diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 73f107c..6f78df4 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -9,4 +9,6 @@ ->addPathToExclude(__DIR__ . '/tests') ->ignoreErrorsOnPackage('psr/http-client-implementation', [ErrorType::UNUSED_DEPENDENCY]) ->ignoreErrorsOnPackage('psr/http-factory-implementation', [ErrorType::UNUSED_DEPENDENCY]) + // loaded by \FacebookAds\ParamBuilder via require_once; it does not comply with the package's PSR-4 mapping, so it cannot be autoloaded + ->ignoreUnknownClasses(['FacebookAds\CookieSettings']) ; diff --git a/composer.json b/composer.json index e10bcf7..1ea5934 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "require": { "php": ">=8.1", "ext-json": "*", + "facebook/capi-param-builder-php": "^1.3.1", "facebook/php-business-sdk": "^25.0 || ^26.0", "php-http/discovery": "^1.20", "psr/http-client": "^1.0", diff --git a/phpstan.dist.neon b/phpstan.dist.neon index ca7eec6..8235631 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,5 +1,9 @@ parameters: level: max + # CookieSettings is loaded by ParamBuilder via require_once and does not comply with the + # package's PSR-4 mapping, so PHPStan cannot autoload it + scanFiles: + - vendor/facebook/capi-param-builder-php/php/capi-param-builder/src/model/CookieSettings.php treatPhpDocTypesAsCertain: false paths: - src diff --git a/src/Cookie/Cookie.php b/src/Cookie/Cookie.php new file mode 100644 index 0000000..c12758e --- /dev/null +++ b/src/Cookie/Cookie.php @@ -0,0 +1,33 @@ +name = $name; + $this->value = $value; + $this->maxAge = $maxAge; + $this->domain = $domain; + } +} diff --git a/src/Cookie/CookieResolver.php b/src/Cookie/CookieResolver.php new file mode 100644 index 0000000..fa7d9aa --- /dev/null +++ b/src/Cookie/CookieResolver.php @@ -0,0 +1,96 @@ +|ETLDPlus1Resolver|null */ + private array|ETLDPlus1Resolver|null $domains; + + /** + * @param list|ETLDPlus1Resolver|null $domains a list of your domains, used to derive the cookie domain + * (e.g. ['example.co.uk']), or your own eTLD+1 resolver. + * If null, the registrable domain is guessed from the host + */ + public function __construct(array|ETLDPlus1Resolver|null $domains = null) + { + $this->domains = $domains; + } + + public function resolve( + string $host, + array $query, + array $cookies, + ?string $referer = null, + ?string $xForwardedFor = null, + ?string $remoteAddress = null, + ): ResolvedCookies { + $paramBuilder = new ParamBuilder($this->domains); + $paramBuilder->processRequest($host, $query, $cookies, $referer, $xForwardedFor, $remoteAddress); + + $fbc = $paramBuilder->getFbc(); + Assert::nullOrString($fbc); + + $fbp = $paramBuilder->getFbp(); + Assert::nullOrString($fbp); + + $cookieSettings = $paramBuilder->getCookiesToSet(); + Assert::isArray($cookieSettings); + + $cookiesToSet = []; + foreach ($cookieSettings as $cookieSetting) { + Assert::isInstanceOf($cookieSetting, CookieSettings::class); + Assert::string($cookieSetting->name); + Assert::string($cookieSetting->value); + Assert::integer($cookieSetting->max_age); + Assert::nullOrString($cookieSetting->domain); + + $cookiesToSet[] = new Cookie($cookieSetting->name, $cookieSetting->value, $cookieSetting->max_age, $cookieSetting->domain); + } + + return new ResolvedCookies( + null === $fbc ? null : self::parseFbc($fbc), + null === $fbp ? null : self::parseFbp($fbp), + $cookiesToSet, + ); + } + + /** + * Meta's parameter builder only validates the segment count and the appendix of an existing cookie, + * so a malformed cookie can be passed through. Such a value cannot be represented as a value object + * and is returned as null + */ + private static function parseFbc(string $value): ?Fbc + { + try { + return Fbc::fromString($value); + } catch (\InvalidArgumentException) { + return null; + } + } + + private static function parseFbp(string $value): ?Fbp + { + try { + return Fbp::fromString($value); + } catch (\InvalidArgumentException) { + return null; + } + } +} diff --git a/src/Cookie/CookieResolverInterface.php b/src/Cookie/CookieResolverInterface.php new file mode 100644 index 0000000..7471331 --- /dev/null +++ b/src/Cookie/CookieResolverInterface.php @@ -0,0 +1,27 @@ + $query the query parameters of the current request, e.g. $_GET + * @param array $cookies the cookies of the current request, e.g. $_COOKIE + * @param string|null $referer the Referer header of the current request, if any + * @param string|null $xForwardedFor the X-Forwarded-For header of the current request, if any + * @param string|null $remoteAddress the remote address of the current request, if any + */ + public function resolve( + string $host, + array $query, + array $cookies, + ?string $referer = null, + ?string $xForwardedFor = null, + ?string $remoteAddress = null, + ): ResolvedCookies; +} diff --git a/src/Cookie/ResolvedCookies.php b/src/Cookie/ResolvedCookies.php new file mode 100644 index 0000000..6e9846b --- /dev/null +++ b/src/Cookie/ResolvedCookies.php @@ -0,0 +1,38 @@ + + */ + public readonly array $cookiesToSet; + + /** + * @param list $cookiesToSet + */ + public function __construct(?Fbc $fbc, ?Fbp $fbp, array $cookiesToSet) + { + $this->fbc = $fbc; + $this->fbp = $fbp; + $this->cookiesToSet = $cookiesToSet; + } +} diff --git a/tests/Cookie/CookieResolverTest.php b/tests/Cookie/CookieResolverTest.php new file mode 100644 index 0000000..2348f13 --- /dev/null +++ b/tests/Cookie/CookieResolverTest.php @@ -0,0 +1,132 @@ +resolve('www.example.com', [], ['_fbc' => $fbc, '_fbp' => $fbp]); + + self::assertNotNull($resolvedCookies->fbc); + self::assertSame($fbc, $resolvedCookies->fbc->value()); + self::assertNotNull($resolvedCookies->fbp); + self::assertSame($fbp, $resolvedCookies->fbp->value()); + self::assertSame([], $resolvedCookies->cookiesToSet); + } + + /** + * @test + */ + public function it_upgrades_a_four_segment_cookie_with_an_appendix_and_sets_it(): void + { + $fbp = 'fb.1.1656874832584.1088522659'; + + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbp' => $fbp]); + + self::assertNotNull($resolvedCookies->fbp); + self::assertMatchesRegularExpression('/^fb\.1\.1656874832584\.1088522659\.[A-Za-z0-9_-]{8}$/', $resolvedCookies->fbp->value()); + self::assertNotNull($resolvedCookies->fbp->getAppendix()); + + $cookie = self::cookie($resolvedCookies, '_fbp'); + self::assertSame($resolvedCookies->fbp->value(), $cookie->value); + self::assertSame(90 * 24 * 3600, $cookie->maxAge); + self::assertSame('example.com', $cookie->domain); + } + + /** + * @test + */ + public function it_builds_an_fbc_from_the_fbclid_query_parameter(): void + { + $before = (int) floor(microtime(true) * 1000); + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', ['fbclid' => 'IwAR1a-b_c'], []); + $after = (int) ceil(microtime(true) * 1000); + + self::assertNotNull($resolvedCookies->fbc); + self::assertSame('IwAR1a-b_c', $resolvedCookies->fbc->getClickId()); + self::assertSame(1, $resolvedCookies->fbc->getSubdomainIndex()); + self::assertGreaterThanOrEqual($before, $resolvedCookies->fbc->getCreationTime()); + self::assertLessThanOrEqual($after, $resolvedCookies->fbc->getCreationTime()); + self::assertNotNull($resolvedCookies->fbc->getAppendix()); + + self::assertSame($resolvedCookies->fbc->value(), self::cookie($resolvedCookies, '_fbc')->value); + } + + /** + * @test + */ + public function it_generates_an_fbp_when_the_request_has_none(): void + { + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], []); + + self::assertNull($resolvedCookies->fbc); + self::assertNotNull($resolvedCookies->fbp); + self::assertSame(1, $resolvedCookies->fbp->getSubdomainIndex()); + self::assertNotNull($resolvedCookies->fbp->getAppendix()); + + self::assertSame($resolvedCookies->fbp->value(), self::cookie($resolvedCookies, '_fbp')->value); + } + + /** + * @test + */ + public function it_regenerates_the_fbp_when_the_existing_cookie_has_an_invalid_appendix(): void + { + // a two character appendix must be one of the language tokens Meta supports, so ZZ is invalid + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbp' => 'fb.1.1656874832584.1088522659.ZZ']); + + self::assertNotNull($resolvedCookies->fbp); + self::assertGreaterThan(1656874832584, $resolvedCookies->fbp->getCreationTime()); + + self::assertSame($resolvedCookies->fbp->value(), self::cookie($resolvedCookies, '_fbp')->value); + } + + /** + * @test + */ + public function it_returns_null_for_a_cookie_that_cannot_be_represented_as_a_value_object(): void + { + // Meta's parameter builder only validates the segment count, so these pass through it, + // but they are not valid fbc/fbp values + $resolvedCookies = (new CookieResolver())->resolve('www.example.com', [], ['_fbc' => 'a.b.c.d', '_fbp' => 'e.f.g.h']); + + self::assertNull($resolvedCookies->fbc); + self::assertNull($resolvedCookies->fbp); + self::assertStringStartsWith('a.b.c.d.', self::cookie($resolvedCookies, '_fbc')->value); + self::assertStringStartsWith('e.f.g.h.', self::cookie($resolvedCookies, '_fbp')->value); + } + + /** + * @test + */ + public function it_uses_the_given_domains_to_derive_the_cookie_domain_and_subdomain_index(): void + { + $resolvedCookies = (new CookieResolver(['example.co.uk']))->resolve('shop.example.co.uk', [], []); + + self::assertNotNull($resolvedCookies->fbp); + self::assertSame(2, $resolvedCookies->fbp->getSubdomainIndex()); + self::assertSame('example.co.uk', self::cookie($resolvedCookies, '_fbp')->domain); + } + + private static function cookie(ResolvedCookies $resolvedCookies, string $name): Cookie + { + foreach ($resolvedCookies->cookiesToSet as $cookie) { + if ($cookie->name === $name) { + return $cookie; + } + } + + self::fail(sprintf('No cookie named "%s" was set', $name)); + } +}