diff --git a/src/ValueObject/Fb.php b/src/ValueObject/Fb.php index 060bcea..f895c21 100644 --- a/src/ValueObject/Fb.php +++ b/src/ValueObject/Fb.php @@ -16,11 +16,27 @@ abstract class Fb private int $subdomainIndex = self::SUBDOMAIN_INDEX_FACEBOOK_COM; + /** + * The character set and the two lengths Meta's own parameter builder uses for the appendix + * + * @see https://github.com/facebook/capi-param-builder-php `APPENDIX_LENGTH_V1` and `APPENDIX_LENGTH_V2` + */ + private const REGEXP_APPENDIX = '/^[A-Za-z0-9_-]{2,8}$/'; + /** * Creation time is the UNIX time since epoch in milliseconds when the _fbp cookie was saved */ private int $creationTime; + /** + * The trailing segment Meta appends to the cookie value, e.g. the 'AQECAQMB' in + * fb.1.1788781160733.IwAR1a-b_c.AQECAQMB + * + * We do not interpret it, but we do keep it: a value read from a cookie has to be written back unchanged, + * otherwise every request rewrites the cookie into a different shape than the browser pixel expects + */ + private ?string $appendix = null; + public function __construct() { $this->creationTime = (int) ceil(microtime(true) * 1000); @@ -89,6 +105,26 @@ public function getCreationTimeAsDateTime(): \DateTimeImmutable return $dateTime; } + public function getAppendix(): ?string + { + return $this->appendix; + } + + /** + * @return static + */ + public function withAppendix(?string $appendix): self + { + if (null !== $appendix) { + Assert::regex($appendix, self::REGEXP_APPENDIX); + } + + $obj = clone $this; + $obj->appendix = $appendix; + + return $obj; + } + public function getCreationTimeAsSeconds(): int { return (int) ($this->creationTime / 1000); diff --git a/src/ValueObject/Fbc.php b/src/ValueObject/Fbc.php index d4024e0..46872f5 100644 --- a/src/ValueObject/Fbc.php +++ b/src/ValueObject/Fbc.php @@ -9,7 +9,14 @@ */ final class Fbc extends Fb { - private const REGEXP_FBC = '/^fb\.([012])\.(\d{13})\.([a-zA-Z0-9]+)$/'; + /** + * Click ids are base64url, so they contain - and _, and Meta appends an optional trailing segment + * + * Must match strings like: + * - fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA + * - fb.1.1788781160733.IwAR1a-b_c.AQECAQMB + */ + private const REGEXP_FBC = '/^fb\.([012])\.(\d{13})\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_-]{2,8}))?$/'; private string $clickId; @@ -22,7 +29,6 @@ public function __construct(string $clickId) public static function fromString(string $value): self { - // Must match strings like: fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA if (preg_match(self::REGEXP_FBC, $value, $matches) !== 1) { throw new \InvalidArgumentException(sprintf( 'The value "%s" didn\'t match the expected pattern for fbc: "%s"', @@ -34,12 +40,20 @@ public static function fromString(string $value): self return (new self($matches[3])) ->withSubdomainIndex((int) $matches[1]) ->withCreationTime((int) $matches[2]) + ->withAppendix(($matches[4] ?? '') === '' ? null : $matches[4]) ; } public function value(): string { - return sprintf('fb.%d.%d.%s', $this->getSubdomainIndex(), $this->getCreationTime(), $this->clickId); + $value = sprintf('fb.%d.%d.%s', $this->getSubdomainIndex(), $this->getCreationTime(), $this->clickId); + + $appendix = $this->getAppendix(); + if (null !== $appendix) { + $value .= '.' . $appendix; + } + + return $value; } /** diff --git a/src/ValueObject/Fbp.php b/src/ValueObject/Fbp.php index 3786289..abe856b 100644 --- a/src/ValueObject/Fbp.php +++ b/src/ValueObject/Fbp.php @@ -21,9 +21,9 @@ public function __construct() public static function fromString(string $value): self { - // Must match something like this: fb.1.1656874832584.1088522659 + // Must match something like this: fb.1.1656874832584.1088522659 or fb.1.1656874832584.1088522659.AQEAAQMB // NOTICE we match for 13 digits for the creation time. That number will be 14 digits in year 2286, so I guess it's safe to test for a specific number of digits ;) - if (preg_match('/^fb\.([012])\.(\d{13})\.(\d+)$/', $value, $matches) !== 1) { + if (preg_match('/^fb\.([012])\.(\d{13})\.(\d+)(?:\.([A-Za-z0-9_-]{2,8}))?$/', $value, $matches) !== 1) { throw new \InvalidArgumentException(sprintf('The value "%s" didn\'t match the expected pattern for fbp', $value)); } @@ -31,17 +31,20 @@ public static function fromString(string $value): self ->withSubdomainIndex((int) $matches[1]) ->withCreationTime((int) $matches[2]) ->withRandomNumber((int) $matches[3]) + ->withAppendix(($matches[4] ?? '') === '' ? null : $matches[4]) ; } public function value(): string { - return sprintf( - 'fb.%d.%d.%d', - $this->getSubdomainIndex(), - $this->getCreationTime(), - $this->getRandomNumber(), - ); + $value = sprintf('fb.%d.%d.%d', $this->getSubdomainIndex(), $this->getCreationTime(), $this->getRandomNumber()); + + $appendix = $this->getAppendix(); + if (null !== $appendix) { + $value .= '.' . $appendix; + } + + return $value; } public function getRandomNumber(): int diff --git a/tests/ValueObject/FbTest.php b/tests/ValueObject/FbTest.php index ea4bac6..78f6793 100644 --- a/tests/ValueObject/FbTest.php +++ b/tests/ValueObject/FbTest.php @@ -131,4 +131,63 @@ public function it_rejects_a_creation_time_that_is_neither_an_integer_nor_a_date (new Fbp())->withCreationTime('1656874832584'); // @phpstan-ignore argument.type } + + /** + * @test + */ + public function it_has_no_appendix_by_default(): void + { + $fb = new Fbp(); + + self::assertNull($fb->getAppendix()); + self::assertStringEndsWith((string) $fb->getRandomNumber(), $fb->value()); + } + + /** + * @test + */ + public function it_has_an_immutable_appendix_setter(): void + { + $fb = new Fbp(); + $newFb = $fb->withAppendix('AQECAQMB'); + + self::assertNotSame($fb, $newFb); + self::assertNull($fb->getAppendix()); + self::assertSame('AQECAQMB', $newFb->getAppendix()); + self::assertStringEndsWith('.AQECAQMB', $newFb->value()); + } + + /** + * @test + */ + public function it_removes_the_appendix(): void + { + $fb = (new Fbp())->withAppendix('AQECAQMB')->withAppendix(null); + + self::assertNull($fb->getAppendix()); + self::assertStringEndsNotWith('.AQECAQMB', $fb->value()); + } + + /** + * @test + * + * @dataProvider invalidAppendixes + */ + public function it_rejects_an_invalid_appendix(string $appendix): void + { + $this->expectException(\InvalidArgumentException::class); + + (new Fbp())->withAppendix($appendix); + } + + /** + * @return \Generator + */ + public static function invalidAppendixes(): \Generator + { + yield 'empty' => ['']; + yield 'too short' => ['A']; + yield 'too long' => ['AQECAQMBX']; + yield 'illegal character' => ['AQECAQM.']; + } } diff --git a/tests/ValueObject/FbcTest.php b/tests/ValueObject/FbcTest.php index ce7e1ab..8c073e1 100644 --- a/tests/ValueObject/FbcTest.php +++ b/tests/ValueObject/FbcTest.php @@ -81,6 +81,61 @@ public static function wrongInputs(): \Generator { yield ['wrong input']; yield ['afb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA']; - yield ['fb.1.1657051589577.IwAR0rmfgHgxjdKoEopat9y2SPzyjGgfHm9AhdqygToWvarP59nPq15T07MiA_']; + yield 'click id with an illegal character' => ['fb.1.1657051589577.IwAR0rmfgHgx!']; + yield 'empty click id' => ['fb.1.1657051589577.']; + yield 'appendix too short' => ['fb.1.1657051589577.IwAR0rmfgHgx.A']; + yield 'appendix too long' => ['fb.1.1657051589577.IwAR0rmfgHgx.AQECAQMBX']; + yield 'six segments' => ['fb.1.1657051589577.IwAR0rmfgHgx.AQ.AQ']; + } + + /** + * Real click ids are base64url, so they contain - and _ + * + * @test + */ + public function it_parses_a_base64url_click_id(): void + { + $str = 'fb.1.1657051589577.IwZXh0bgNhZW0CMTAAAR-uK_5w'; + $fbc = Fbc::fromString($str); + + self::assertSame('IwZXh0bgNhZW0CMTAAAR-uK_5w', $fbc->getClickId()); + self::assertSame($str, $fbc->value()); + } + + /** + * Meta's own parameter builder writes a trailing appendix segment, and so does the browser pixel. We do not + * interpret it, but a value read from a cookie has to be written back unchanged + * + * @test + * + * @dataProvider valuesWithAnAppendix + */ + public function it_round_trips_an_appendix(string $str, string $appendix): void + { + $fbc = Fbc::fromString($str); + + self::assertSame($appendix, $fbc->getAppendix()); + self::assertSame('IwAR1a-b_c', $fbc->getClickId()); + self::assertSame($str, $fbc->value()); + } + + /** + * @return \Generator + */ + public static function valuesWithAnAppendix(): \Generator + { + yield 'two characters' => ['fb.1.1788781160733.IwAR1a-b_c.AQ', 'AQ']; + yield 'eight characters' => ['fb.1.1788781160733.IwAR1a-b_c.AQECAQMB', 'AQECAQMB']; + } + + /** + * @test + */ + public function it_keeps_the_appendix_through_the_immutable_setters(): void + { + $fbc = Fbc::fromString('fb.1.1657051589577.IwAR1a-b_c.AQECAQMB'); + + self::assertSame('AQECAQMB', $fbc->withClickId('Other')->getAppendix()); + self::assertSame('fb.2.1657051589577.IwAR1a-b_c.AQECAQMB', $fbc->withSubdomainIndex(2)->value()); } } diff --git a/tests/ValueObject/FbpTest.php b/tests/ValueObject/FbpTest.php index 2b0d0d5..537de3b 100644 --- a/tests/ValueObject/FbpTest.php +++ b/tests/ValueObject/FbpTest.php @@ -63,4 +63,41 @@ public static function wrongInputs(): \Generator yield ['fb.1.1656874832584.1088522659a']; yield ['afb.1.1656874832584.1088522659']; } + + /** + * Meta's own parameter builder writes a trailing appendix segment, and so does the browser pixel. Rejecting + * such a cookie means generating a brand new fbp on every request, which stops the server side events from + * matching the browser ones + * + * @test + * + * @dataProvider valuesWithAnAppendix + */ + public function it_round_trips_an_appendix(string $str, string $appendix): void + { + $fbp = Fbp::fromString($str); + + self::assertSame($appendix, $fbp->getAppendix()); + self::assertSame(1088522659, $fbp->getRandomNumber()); + self::assertSame($str, $fbp->value()); + } + + /** + * @return \Generator + */ + public static function valuesWithAnAppendix(): \Generator + { + yield 'two characters' => ['fb.1.1656874832584.1088522659.AQ', 'AQ']; + yield 'eight characters' => ['fb.1.1656874832584.1088522659.AQEAAQMB', 'AQEAAQMB']; + } + + /** + * @test + */ + public function it_keeps_the_appendix_through_the_immutable_setters(): void + { + $fbp = Fbp::fromString('fb.1.1656874832584.1088522659.AQEAAQMB'); + + self::assertSame('fb.1.1656874832584.123123.AQEAAQMB', $fbp->withRandomNumber(123123)->value()); + } }