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
36 changes: 36 additions & 0 deletions src/ValueObject/Fb.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,30 @@

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);

Check warning on line 42 in src/ValueObject/Fb.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "RoundingFamily": @@ @@ private ?string $appendix = null; public function __construct() { - $this->creationTime = (int) ceil(microtime(true) * 1000); + $this->creationTime = (int) round(microtime(true) * 1000); } /** * @throws \InvalidArgumentException if the $value is not the correct format

Check warning on line 42 in src/ValueObject/Fb.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "RoundingFamily": @@ @@ private ?string $appendix = null; public function __construct() { - $this->creationTime = (int) ceil(microtime(true) * 1000); + $this->creationTime = (int) floor(microtime(true) * 1000); } /** * @throws \InvalidArgumentException if the $value is not the correct format
}

/**
Expand Down Expand Up @@ -73,7 +89,7 @@

Assert::integer($creationTime);
Assert::greaterThanEq($creationTime, 1_075_590_000_000); // Facebooks founding date xD
Assert::lessThanEq($creationTime, (time() + 1) * 1000);

Check warning on line 92 in src/ValueObject/Fb.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "IncrementInteger": @@ @@ Assert::integer($creationTime); Assert::greaterThanEq($creationTime, 1075590000000); // Facebooks founding date xD - Assert::lessThanEq($creationTime, (time() + 1) * 1000); + Assert::lessThanEq($creationTime, (time() + 2) * 1000); $obj = clone $this; $obj->creationTime = $creationTime; return $obj;

$obj = clone $this;
$obj->creationTime = $creationTime;
Expand All @@ -84,11 +100,31 @@
public function getCreationTimeAsDateTime(): \DateTimeImmutable
{
$dateTime = \DateTimeImmutable::createFromFormat('U.v', (string) ($this->creationTime / 1000));
Assert::notFalse($dateTime);

Check warning on line 103 in src/ValueObject/Fb.php

View workflow job for this annotation

GitHub Actions / Mutation tests (8.3, highest)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ public function getCreationTimeAsDateTime(): \DateTimeImmutable { $dateTime = \DateTimeImmutable::createFromFormat('U.v', (string) ($this->creationTime / 1000)); - Assert::notFalse($dateTime); + return $dateTime; } public function getAppendix(): ?string

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);
Expand Down
20 changes: 17 additions & 3 deletions src/ValueObject/Fbc.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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"',
Expand All @@ -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;
}

/**
Expand Down
19 changes: 11 additions & 8 deletions src/ValueObject/Fbp.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,30 @@ 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));
}

return (new 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
Expand Down
59 changes: 59 additions & 0 deletions tests/ValueObject/FbTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string}>
*/
public static function invalidAppendixes(): \Generator
{
yield 'empty' => [''];
yield 'too short' => ['A'];
yield 'too long' => ['AQECAQMBX'];
yield 'illegal character' => ['AQECAQM.'];
}
}
57 changes: 56 additions & 1 deletion tests/ValueObject/FbcTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string, string}>
*/
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());
}
}
37 changes: 37 additions & 0 deletions tests/ValueObject/FbpTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string, string}>
*/
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());
}
}
Loading