Version 5.0.0 beta 1
This commit is contained in:
1 parent
25ddeb65d6
commit
15c7beabc5
6736 files changed
+627902
-497943
No files matched your search
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use Throwable;
|
||||
|
||||
interface CannotProcessHost extends Throwable
|
||||
{
|
||||
}
|
||||
Vendored
+162
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use Iterator;
|
||||
use Stringable;
|
||||
use const FILTER_FLAG_IPV4;
|
||||
use const FILTER_VALIDATE_IP;
|
||||
|
||||
final class Domain implements DomainName
|
||||
{
|
||||
/**
|
||||
* @throws SyntaxError
|
||||
*/
|
||||
private function __construct(private readonly RegisteredName $registeredName)
|
||||
{
|
||||
if (false !== filter_var($this->registeredName->value(), FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
throw SyntaxError::dueToUnsupportedType($this->registeredName->toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{registeredName: RegisteredName} $properties
|
||||
*/
|
||||
public static function __set_state(array $properties): self
|
||||
{
|
||||
return new self($properties['registeredName']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromIDNA2003(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
return new self(RegisteredName::fromIDNA2003($domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromIDNA2008(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
return new self(RegisteredName::fromIDNA2008($domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Iterator<string>
|
||||
*/
|
||||
public function getIterator(): Iterator
|
||||
{
|
||||
yield from $this->registeredName;
|
||||
}
|
||||
|
||||
public function isAscii(): bool
|
||||
{
|
||||
return $this->registeredName->isAscii();
|
||||
}
|
||||
|
||||
public function jsonSerialize(): ?string
|
||||
{
|
||||
return $this->registeredName->jsonSerialize();
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->registeredName);
|
||||
}
|
||||
|
||||
public function value(): ?string
|
||||
{
|
||||
return $this->registeredName->value();
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->registeredName->toString();
|
||||
}
|
||||
|
||||
public function label(int $key): ?string
|
||||
{
|
||||
return $this->registeredName->label($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function keys(?string $label = null): array
|
||||
{
|
||||
return $this->registeredName->keys($label);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function labels(): array
|
||||
{
|
||||
return $this->registeredName->labels();
|
||||
}
|
||||
|
||||
private function newInstance(RegisteredName $registeredName): self
|
||||
{
|
||||
if ($registeredName->value() === $this->registeredName->value()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($registeredName);
|
||||
}
|
||||
|
||||
public function toAscii(): self
|
||||
{
|
||||
return $this->newInstance($this->registeredName->toAscii());
|
||||
}
|
||||
|
||||
public function toUnicode(): self
|
||||
{
|
||||
return $this->newInstance($this->registeredName->toUnicode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function prepend(DomainNameProvider|Host|Stringable|string|int|null $label): self
|
||||
{
|
||||
return $this->newInstance($this->registeredName->prepend($label));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function append(DomainNameProvider|Host|Stringable|string|int|null $label): self
|
||||
{
|
||||
return $this->newInstance($this->registeredName->append($label));
|
||||
}
|
||||
|
||||
public function withLabel(int $key, DomainNameProvider|Host|Stringable|string|int|null $label): self
|
||||
{
|
||||
return $this->newInstance($this->registeredName->withLabel($key, $label));
|
||||
}
|
||||
|
||||
public function withoutLabel(int ...$keys): self
|
||||
{
|
||||
return $this->newInstance($this->registeredName->withoutLabel(...$keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function clear(): self
|
||||
{
|
||||
return $this->newInstance($this->registeredName->clear());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function slice(int $offset, ?int $length = null): self
|
||||
{
|
||||
return $this->newInstance($this->registeredName->slice($offset, $length));
|
||||
}
|
||||
}
|
||||
Vendored
+123
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use Iterator;
|
||||
use IteratorAggregate;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* @see https://tools.ietf.org/html/rfc1034#section-3.5
|
||||
* @see https://tools.ietf.org/html/rfc1123#section-2.1
|
||||
* @see https://tools.ietf.org/html/rfc5890
|
||||
*
|
||||
* @extends IteratorAggregate<string>
|
||||
*/
|
||||
interface DomainName extends Host, IteratorAggregate
|
||||
{
|
||||
/**
|
||||
* Tells whether the current domain is in its ascii form.
|
||||
*/
|
||||
public function isAscii(): bool;
|
||||
|
||||
/**
|
||||
* Retrieves a single domain label.
|
||||
*
|
||||
* If $key is non-negative, the returned value will be the label at $key position from the start.
|
||||
* If $key is negative, the returned value will be the label at $key position from the end.
|
||||
*
|
||||
* If no label is found the submitted $key the returned value will be null.
|
||||
*
|
||||
* @param int $key the label offset
|
||||
*/
|
||||
public function label(int $key): ?string;
|
||||
|
||||
/**
|
||||
* Returns the object labels.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function labels(): array;
|
||||
|
||||
/**
|
||||
* Returns the associated key for each label.
|
||||
*
|
||||
* If a value is specified only the keys associated with
|
||||
* the given value will be returned
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
public function keys(?string $label = null): array;
|
||||
|
||||
/**
|
||||
* The external iterator iterates over the DomainInterface labels
|
||||
* from the right-most label to the left-most label.
|
||||
*
|
||||
* @return Iterator<string>
|
||||
*/
|
||||
public function getIterator(): Iterator;
|
||||
|
||||
/**
|
||||
* Prepends a label to the domain.
|
||||
*
|
||||
* @see ::withLabel
|
||||
*/
|
||||
public function prepend(DomainNameProvider|Host|Stringable|string|int|null $label): self;
|
||||
|
||||
/**
|
||||
* Appends a label to the domain.
|
||||
*
|
||||
* @see ::withLabel
|
||||
*/
|
||||
public function append(DomainNameProvider|Host|Stringable|string|int|null $label): self;
|
||||
|
||||
/**
|
||||
* Returns an instance with the specified label added at the specified key.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance that contains the new label
|
||||
*
|
||||
* If $key is non-negative, the added label will be the label at $key position from the start.
|
||||
* If $key is negative, the added label will be the label at $key position from the end.
|
||||
*
|
||||
* @param DomainNameProvider|Host|Stringable|string|int|null $label a domain label
|
||||
*
|
||||
* @throws CannotProcessHost If the key is out of bounds
|
||||
* @throws CannotProcessHost If the label is converted to the NULL value
|
||||
*/
|
||||
public function withLabel(int $key, DomainNameProvider|Host|Stringable|string|int|null $label): self;
|
||||
|
||||
/**
|
||||
* Returns an instance with the label at the specified key removed.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance without the specified label
|
||||
*
|
||||
* If $key is non-negative, the removed label will be the label at $key position from the start.
|
||||
* If $key is negative, the removed label will be the label at $key position from the end.
|
||||
*
|
||||
*
|
||||
* @throws CannotProcessHost If the key is out of bounds
|
||||
*/
|
||||
public function withoutLabel(int ...$keys): self;
|
||||
|
||||
/**
|
||||
* Returns an instance with all labels removed.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance that contains no label
|
||||
*/
|
||||
public function clear(): self;
|
||||
|
||||
/**
|
||||
* Extracts a slice of $length elements starting at position $offset from the Collection.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance that contains the selected slice.
|
||||
*
|
||||
* If $length is null it returns all elements from $offset to the end of the Domain.
|
||||
*/
|
||||
public function slice(int $offset, ?int $length = null): self;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
interface DomainNameProvider
|
||||
{
|
||||
public function domain(): DomainName;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
interface DomainNameResolver
|
||||
{
|
||||
/**
|
||||
* Returns the effective TLD info for a given domain against an data source.
|
||||
*
|
||||
* If the effective TLD can not be resolved it returns a ResolvedDomainName with a null suffix
|
||||
* If the host is not a valid domain it returns a ResolvedDomainName with a null Domain name
|
||||
*/
|
||||
public function resolve(Host $host): ResolvedDomainName;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
interface EffectiveTopLevelDomain extends Host, DomainNameProvider
|
||||
{
|
||||
/**
|
||||
* Tells whether the effective TLD has a matching rule in a Public Suffix List.
|
||||
*/
|
||||
public function isKnown(): bool;
|
||||
|
||||
/**
|
||||
* Tells whether the effective TLD has a matching rule in the IANA Top Level Domain List.
|
||||
*/
|
||||
public function isIANA(): bool;
|
||||
|
||||
/**
|
||||
* Tells whether the effective TLD has a matching rule in the Public Suffix List.
|
||||
*/
|
||||
public function isPublicSuffix(): bool;
|
||||
|
||||
/**
|
||||
* Tells whether the effective TLD has a matching rule in a Public Suffix List ICANN Section.
|
||||
*/
|
||||
public function isICANN(): bool;
|
||||
|
||||
/**
|
||||
* Tells whether the effective TLD has a matching rule in a Public Suffix List Private Section.
|
||||
*/
|
||||
public function isPrivate(): bool;
|
||||
|
||||
/**
|
||||
* Returns an instance with the public suffix normalize to the submitted domain encoding algorithm.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance that contains the same domain information normalize against the submitted domainName
|
||||
*/
|
||||
public function normalize(DomainName $domain): self;
|
||||
}
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use Countable;
|
||||
use JsonSerializable;
|
||||
|
||||
/**
|
||||
* @see https://tools.ietf.org/html/rfc1034#section-3.5
|
||||
* @see https://tools.ietf.org/html/rfc1123#section-2.1
|
||||
* @see https://tools.ietf.org/html/rfc5890
|
||||
*/
|
||||
interface Host extends Countable, JsonSerializable
|
||||
{
|
||||
/**
|
||||
* Returns the domain content.
|
||||
*/
|
||||
public function value(): ?string;
|
||||
|
||||
/**
|
||||
* Returns the domain content as a string.
|
||||
*/
|
||||
public function toString(): string;
|
||||
|
||||
/**
|
||||
* Returns the domain content.
|
||||
*/
|
||||
public function jsonSerialize(): ?string;
|
||||
|
||||
/**
|
||||
* The labels total number.
|
||||
*/
|
||||
public function count(): int;
|
||||
|
||||
/**
|
||||
* Converts the domain to its IDNA ASCII form.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance with its content converted to its IDNA ASCII form
|
||||
*
|
||||
* @throws CannotProcessHost if the domain can not be converted to ASCII using IDN UTS46 algorithm
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function toAscii(): self;
|
||||
|
||||
/**
|
||||
* Converts the domain to its IDNA UTF8 form.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance with its content converted to its IDNA UTF8 form
|
||||
*
|
||||
* @throws CannotProcessHost if the domain can not be converted to Unicode using IDN UTS46 algorithm
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function toUnicode(): self;
|
||||
}
|
||||
Vendored
+135
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use UnexpectedValueException;
|
||||
use function defined;
|
||||
use function function_exists;
|
||||
use function idn_to_ascii;
|
||||
use function idn_to_utf8;
|
||||
use function preg_match;
|
||||
use function rawurldecode;
|
||||
use function strtolower;
|
||||
use const INTL_IDNA_VARIANT_UTS46;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/uidna_8h.html
|
||||
*/
|
||||
final class Idna
|
||||
{
|
||||
private const REGEXP_IDNA_PATTERN = '/[^\x20-\x7f]/';
|
||||
/**
|
||||
* IDNA options.
|
||||
*/
|
||||
public const IDNA_DEFAULT = 0;
|
||||
public const IDNA_ALLOW_UNASSIGNED = 1;
|
||||
public const IDNA_USE_STD3_RULES = 2;
|
||||
public const IDNA_CHECK_BIDI = 4;
|
||||
public const IDNA_CHECK_CONTEXTJ = 8;
|
||||
public const IDNA_NONTRANSITIONAL_TO_ASCII = 0x10;
|
||||
public const IDNA_NONTRANSITIONAL_TO_UNICODE = 0x20;
|
||||
public const IDNA_CHECK_CONTEXTO = 0x40;
|
||||
/**
|
||||
* IDNA errors.
|
||||
*/
|
||||
public const ERROR_EMPTY_LABEL = 1;
|
||||
public const ERROR_LABEL_TOO_LONG = 2;
|
||||
public const ERROR_DOMAIN_NAME_TOO_LONG = 4;
|
||||
public const ERROR_LEADING_HYPHEN = 8;
|
||||
public const ERROR_TRAILING_HYPHEN = 0x10;
|
||||
public const ERROR_HYPHEN_3_4 = 0x20;
|
||||
public const ERROR_LEADING_COMBINING_MARK = 0x40;
|
||||
public const ERROR_DISALLOWED = 0x80;
|
||||
public const ERROR_PUNYCODE = 0x100;
|
||||
public const ERROR_LABEL_HAS_DOT = 0x200;
|
||||
public const ERROR_INVALID_ACE_LABEL = 0x400;
|
||||
public const ERROR_BIDI = 0x800;
|
||||
public const ERROR_CONTEXTJ = 0x1000;
|
||||
public const ERROR_CONTEXTO_PUNCTUATION = 0x2000;
|
||||
public const ERROR_CONTEXTO_DIGITS = 0x4000;
|
||||
|
||||
/**
|
||||
* IDNA default options.
|
||||
*/
|
||||
public const IDNA2008_ASCII = self::IDNA_NONTRANSITIONAL_TO_ASCII
|
||||
| self::IDNA_CHECK_BIDI
|
||||
| self::IDNA_USE_STD3_RULES
|
||||
| self::IDNA_CHECK_CONTEXTJ;
|
||||
public const IDNA2008_UNICODE = self::IDNA_NONTRANSITIONAL_TO_UNICODE
|
||||
| self::IDNA_CHECK_BIDI
|
||||
| self::IDNA_USE_STD3_RULES
|
||||
| self::IDNA_CHECK_CONTEXTJ;
|
||||
public const IDNA2003_ASCII = self::IDNA_DEFAULT;
|
||||
public const IDNA2003_UNICODE = self::IDNA_DEFAULT;
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
private static function supportsIdna(): void
|
||||
{
|
||||
static $idnSupport;
|
||||
if (null === $idnSupport) {
|
||||
$idnSupport = function_exists('\idn_to_ascii') && defined('\INTL_IDNA_VARIANT_UTS46');
|
||||
}
|
||||
|
||||
if (!$idnSupport) {
|
||||
throw new UnexpectedValueException('IDN host can not be processed. Verify that ext/intl is installed for IDN support and that ICU is at least version 4.6.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the input to its IDNA ASCII form.
|
||||
*
|
||||
* This method returns the string converted to IDN ASCII form
|
||||
*
|
||||
* @throws SyntaxError if the string can not be converted to ASCII using IDN UTS46 algorithm
|
||||
*/
|
||||
public static function toAscii(string $domain, int $options): IdnaInfo
|
||||
{
|
||||
$domain = rawurldecode($domain);
|
||||
if (1 !== preg_match(self::REGEXP_IDNA_PATTERN, $domain)) {
|
||||
return IdnaInfo::fromIntl(['result' => strtolower($domain), 'isTransitionalDifferent' => false, 'errors' => 0]);
|
||||
}
|
||||
|
||||
self::supportsIdna();
|
||||
|
||||
/* @param-out array{errors: int, isTransitionalDifferent: bool, result: string} $idnaInfo */
|
||||
idn_to_ascii($domain, $options, INTL_IDNA_VARIANT_UTS46, $idnaInfo);
|
||||
|
||||
/** @var array{result:string, isTransitionalDifferent:bool, errors:int} $idnaInfo */
|
||||
$info = IdnaInfo::fromIntl($idnaInfo);
|
||||
if (0 !== $info->errors()) {
|
||||
throw SyntaxError::dueToIDNAError($domain, $info);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the input to its IDNA UNICODE form.
|
||||
*
|
||||
* This method returns the string converted to IDN UNICODE form
|
||||
*/
|
||||
public static function toUnicode(string $domain, int $options): IdnaInfo
|
||||
{
|
||||
if (!str_contains($domain, 'xn--')) {
|
||||
return IdnaInfo::fromIntl(['result' => $domain, 'isTransitionalDifferent' => false, 'errors' => 0]);
|
||||
}
|
||||
|
||||
self::supportsIdna();
|
||||
|
||||
/* @param-out array{errors: int, isTransitionalDifferent: bool, result: string} $idnaInfo */
|
||||
idn_to_utf8($domain, $options, INTL_IDNA_VARIANT_UTS46, $idnaInfo);
|
||||
|
||||
/** @var array{result:string, isTransitionalDifferent:bool, errors:int} $idnaInfo */
|
||||
$info = IdnaInfo::fromIntl($idnaInfo);
|
||||
if (0 !== $info->errors()) {
|
||||
return IdnaInfo::fromIntl(['result' => $domain, 'isTransitionalDifferent' => false, 'errors' => 0]);
|
||||
}
|
||||
|
||||
return $info;
|
||||
}
|
||||
}
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use function array_filter;
|
||||
use const ARRAY_FILTER_USE_KEY;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* @see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/uidna_8h.html
|
||||
*/
|
||||
final class IdnaInfo
|
||||
{
|
||||
private const ERRORS = [
|
||||
Idna::ERROR_EMPTY_LABEL => 'a non-final domain name label (or the whole domain name) is empty',
|
||||
Idna::ERROR_LABEL_TOO_LONG => 'a domain name label is longer than 63 bytes',
|
||||
Idna::ERROR_DOMAIN_NAME_TOO_LONG => 'a domain name is longer than 255 bytes in its storage form',
|
||||
Idna::ERROR_LEADING_HYPHEN => 'a label starts with a hyphen-minus ("-")',
|
||||
Idna::ERROR_TRAILING_HYPHEN => 'a label ends with a hyphen-minus ("-")',
|
||||
Idna::ERROR_HYPHEN_3_4 => 'a label contains hyphen-minus ("-") in the third and fourth positions',
|
||||
Idna::ERROR_LEADING_COMBINING_MARK => 'a label starts with a combining mark',
|
||||
Idna::ERROR_DISALLOWED => 'a label or domain name contains disallowed characters',
|
||||
Idna::ERROR_PUNYCODE => 'a label starts with "xn--" but does not contain valid Punycode',
|
||||
Idna::ERROR_LABEL_HAS_DOT => 'a label contains a dot=full stop',
|
||||
Idna::ERROR_INVALID_ACE_LABEL => 'An ACE label does not contain a valid label string',
|
||||
Idna::ERROR_BIDI => 'a label does not meet the IDNA BiDi requirements (for right-to-left characters)',
|
||||
Idna::ERROR_CONTEXTJ => 'a label does not meet the IDNA CONTEXTJ requirements',
|
||||
Idna::ERROR_CONTEXTO_DIGITS => 'a label does not meet the IDNA CONTEXTO requirements for digits',
|
||||
Idna::ERROR_CONTEXTO_PUNCTUATION => 'a label does not meet the IDNA CONTEXTO requirements for punctuation characters. Some punctuation characters "Would otherwise have been DISALLOWED" but are allowed in certain contexts',
|
||||
];
|
||||
|
||||
/** @var array<int, string> */
|
||||
public readonly array $errorList;
|
||||
|
||||
private function __construct(
|
||||
public readonly string $result,
|
||||
public readonly bool $isTransitionalDifferent,
|
||||
public readonly int $errors
|
||||
) {
|
||||
$this->errorList = array_filter(
|
||||
self::ERRORS,
|
||||
fn (int $error): bool => 0 !== ($error & $this->errors),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{result:string, isTransitionalDifferent:bool, errors:int} $infos
|
||||
*/
|
||||
public static function fromIntl(array $infos): self
|
||||
{
|
||||
return new self($infos['result'], $infos['isTransitionalDifferent'], $infos['errors']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{result:string, isTransitionalDifferent:bool, errors:int} $properties
|
||||
*/
|
||||
public static function __set_state(array $properties): self
|
||||
{
|
||||
return self::fromIntl($properties);
|
||||
}
|
||||
|
||||
public function result(): string
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
|
||||
public function isTransitionalDifferent(): bool
|
||||
{
|
||||
return $this->isTransitionalDifferent;
|
||||
}
|
||||
|
||||
public function errors(): int
|
||||
{
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
public function error(int $error): ?string
|
||||
{
|
||||
return $this->errorList[$error] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function errorList(): array
|
||||
{
|
||||
return $this->errorList;
|
||||
}
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (C) 2013 Jeremy Kendall, http://about.me/jeremykendall
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
interface PublicSuffixList extends DomainNameResolver
|
||||
{
|
||||
/**
|
||||
* Returns PSL info for a given domain against the PSL rules for cookie domain detection.
|
||||
*
|
||||
* @throws SyntaxError if the domain is invalid
|
||||
* @throws UnableToResolveDomain if the effective TLD can not be resolved
|
||||
*/
|
||||
public function getCookieDomain(Host $host): ResolvedDomainName;
|
||||
|
||||
/**
|
||||
* Returns PSL info for a given domain against the PSL rules for ICANN domain detection.
|
||||
*
|
||||
* @throws SyntaxError if the domain is invalid
|
||||
* @throws UnableToResolveDomain if the domain does not contain a ICANN Effective TLD
|
||||
*/
|
||||
public function getICANNDomain(Host $host): ResolvedDomainName;
|
||||
|
||||
/**
|
||||
* Returns PSL info for a given domain against the PSL rules for private domain detection.
|
||||
*
|
||||
* @throws SyntaxError if the domain is invalid
|
||||
* @throws UnableToResolveDomain if the domain does not contain a private Effective TLD
|
||||
*/
|
||||
public function getPrivateDomain(Host $host): ResolvedDomainName;
|
||||
}
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use Iterator;
|
||||
use Stringable;
|
||||
use function array_count_values;
|
||||
use function array_keys;
|
||||
use function array_reverse;
|
||||
use function array_slice;
|
||||
use function count;
|
||||
use function explode;
|
||||
use function filter_var;
|
||||
use function implode;
|
||||
use function in_array;
|
||||
use function ksort;
|
||||
use function preg_match;
|
||||
use function rawurldecode;
|
||||
use function strtolower;
|
||||
use const FILTER_FLAG_IPV4;
|
||||
use const FILTER_VALIDATE_IP;
|
||||
|
||||
final class RegisteredName implements DomainName
|
||||
{
|
||||
private const IDNA_2003 = 'IDNA_2003';
|
||||
private const IDNA_2008 = 'IDNA_2008';
|
||||
private const REGEXP_IDN_PATTERN = '/[^\x20-\x7f]/';
|
||||
// Note that unreserved is purposely missing . as it is used to separate labels.
|
||||
private const REGEXP_REGISTERED_NAME = '/(?(DEFINE)
|
||||
(?<unreserved>[a-z0-9_~\-])
|
||||
(?<sub_delims>[!$&\'()*+,;=])
|
||||
(?<encoded>%[A-F0-9]{2})
|
||||
(?<reg_name>(?:(?&unreserved)|(?&sub_delims)|(?&encoded)){1,63})
|
||||
)
|
||||
^(?:(?®_name)\.){0,126}(?®_name)\.?$/ix';
|
||||
private const REGEXP_URI_DELIMITERS = '/[:\/?#\[\]@ ]/';
|
||||
|
||||
/** @var list<string> */
|
||||
private readonly array $labels;
|
||||
private readonly ?string $domain;
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
private function __construct(private readonly string $type, DomainNameProvider|Host|Stringable|string|int|null $domain)
|
||||
{
|
||||
$this->domain = $this->parseDomain($domain);
|
||||
$this->labels = null === $this->domain ? [] : array_reverse(explode('.', $this->domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{domain:string|null, type:string} $properties
|
||||
*
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function __set_state(array $properties): self
|
||||
{
|
||||
return new self($properties['type'], $properties['domain']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromIDNA2003(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
return new self(self::IDNA_2003, $domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromIDNA2008(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
return new self(self::IDNA_2008, $domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
private function parseDomain(DomainNameProvider|Host|Stringable|string|int|null $domain): ?string
|
||||
{
|
||||
return $this->parseValue(match (true) {
|
||||
$domain instanceof DomainNameProvider => $domain->domain()->value(),
|
||||
$domain instanceof Host => $domain->toUnicode()->value(),
|
||||
default => $domain,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and format the domain to ensure it is valid.
|
||||
* Returns an array containing the formatted domain name labels
|
||||
* and the domain transitional information.
|
||||
*
|
||||
* For example: parse('wWw.uLb.Ac.be') should return ['www.ulb.ac.be', ['be', 'ac', 'ulb', 'www']];.
|
||||
*
|
||||
* @throws SyntaxError If the host is not a domain
|
||||
* @throws SyntaxError If the domain is not a host
|
||||
*/
|
||||
private function parseValue(Stringable|string|int|null $domain): ?string
|
||||
{
|
||||
if (null === $domain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($domain instanceof Stringable) {
|
||||
$domain = (string) $domain;
|
||||
}
|
||||
|
||||
$domain = (string) $domain;
|
||||
if ('' === $domain) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$res = filter_var($domain, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
|
||||
if (false !== $res) {
|
||||
return $res;
|
||||
}
|
||||
|
||||
$formattedDomain = rawurldecode($domain);
|
||||
return match (true) {
|
||||
1 === preg_match(self::REGEXP_REGISTERED_NAME, $formattedDomain) => strtolower($formattedDomain),
|
||||
// a domain name can not contain URI delimiters or space
|
||||
1 === preg_match(self::REGEXP_URI_DELIMITERS, $formattedDomain) => throw SyntaxError::dueToInvalidCharacters($domain),
|
||||
// if the domain name does not contain UTF-8 chars then it is malformed
|
||||
1 !== preg_match(self::REGEXP_IDN_PATTERN, $formattedDomain) => throw SyntaxError::dueToMalformedValue($domain),
|
||||
default => $this->domainToUnicode($this->domainToAscii($formattedDomain)),
|
||||
};
|
||||
}
|
||||
|
||||
private function domainToAscii(string $domain): string
|
||||
{
|
||||
return Idna::toAscii(
|
||||
$domain,
|
||||
self::IDNA_2003 === $this->type ? Idna::IDNA2003_ASCII : Idna::IDNA2008_ASCII
|
||||
)->result();
|
||||
}
|
||||
|
||||
private function domainToUnicode(string $domain): string
|
||||
{
|
||||
return Idna::toUnicode(
|
||||
$domain,
|
||||
self::IDNA_2003 === $this->type ? Idna::IDNA2003_UNICODE : Idna::IDNA2008_UNICODE
|
||||
)->result();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Iterator<string>
|
||||
*/
|
||||
public function getIterator(): Iterator
|
||||
{
|
||||
yield from $this->labels;
|
||||
}
|
||||
|
||||
public function isAscii(): bool
|
||||
{
|
||||
return null === $this->domain || 1 !== preg_match(self::REGEXP_IDN_PATTERN, $this->domain);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->labels);
|
||||
}
|
||||
|
||||
public function value(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return (string) $this->domain;
|
||||
}
|
||||
|
||||
public function label(int $key): ?string
|
||||
{
|
||||
if ($key < 0) {
|
||||
$key += count($this->labels);
|
||||
}
|
||||
|
||||
return $this->labels[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function keys(?string $label = null): array
|
||||
{
|
||||
if (null === $label) {
|
||||
return array_keys($this->labels);
|
||||
}
|
||||
|
||||
return array_keys($this->labels, $label, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function labels(): array
|
||||
{
|
||||
return $this->labels;
|
||||
}
|
||||
|
||||
public function toAscii(): self
|
||||
{
|
||||
if (null === $this->domain) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$domain = $this->domainToAscii($this->domain);
|
||||
if ($domain === $this->domain) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($this->type, $domain);
|
||||
}
|
||||
|
||||
public function toUnicode(): self
|
||||
{
|
||||
if (null === $this->domain) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$domain = $this->domainToUnicode($this->domain);
|
||||
if ($domain === $this->domain) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($this->type, $domain);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a subdomain to update the domain part.
|
||||
*/
|
||||
private function normalize(DomainNameProvider|Host|Stringable|string|int|null $domain): ?string
|
||||
{
|
||||
if ($domain instanceof DomainNameProvider) {
|
||||
$domain = $domain->domain();
|
||||
}
|
||||
|
||||
if ($domain instanceof Host) {
|
||||
$domain = $domain->value();
|
||||
}
|
||||
|
||||
if (null === $domain) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$domain = (string) $domain;
|
||||
|
||||
return match (true) {
|
||||
null === $this->domain => $domain,
|
||||
$this->isAscii() => $this->domainToAscii($domain),
|
||||
default => $this->domainToUnicode($domain),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function prepend(DomainNameProvider|Host|Stringable|string|int|null $label): self
|
||||
{
|
||||
return $this->withLabel(count($this->labels), $label);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function append(DomainNameProvider|Host|Stringable|string|int|null $label): self
|
||||
{
|
||||
return $this->withLabel(- count($this->labels) - 1, $label);
|
||||
}
|
||||
|
||||
public function withLabel(int $key, DomainNameProvider|Host|Stringable|string|int|null $label): self
|
||||
{
|
||||
$nbLabels = count($this->labels);
|
||||
if ($key < - $nbLabels - 1 || $key > $nbLabels) {
|
||||
throw SyntaxError::dueToInvalidLabelKey($this, $key);
|
||||
}
|
||||
|
||||
if (0 > $key) {
|
||||
$key = $nbLabels + $key;
|
||||
}
|
||||
|
||||
$label = $this->normalize($label);
|
||||
|
||||
if (($this->labels[$key] ?? null) === $label) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$labels = $this->labels;
|
||||
$labels[$key] = $label;
|
||||
ksort($labels);
|
||||
|
||||
return new self($this->type, implode('.', array_reverse($labels)));
|
||||
}
|
||||
|
||||
public function withoutLabel(int ...$keys): self
|
||||
{
|
||||
$nbLabels = count($this->labels);
|
||||
foreach ($keys as &$offset) {
|
||||
if (- $nbLabels > $offset || $nbLabels - 1 < $offset) {
|
||||
throw SyntaxError::dueToInvalidLabelKey($this, $offset);
|
||||
}
|
||||
|
||||
if (0 > $offset) {
|
||||
$offset += $nbLabels;
|
||||
}
|
||||
}
|
||||
unset($offset);
|
||||
|
||||
$deletedKeys = array_keys(array_count_values($keys));
|
||||
$labels = [];
|
||||
foreach ($this->labels as $offset => $label) {
|
||||
if (!in_array($offset, $deletedKeys, true)) {
|
||||
$labels[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
if ($labels === $this->labels) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($this->type, [] === $labels ? null : implode('.', array_reverse($labels)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function clear(): self
|
||||
{
|
||||
if (null === $this->domain) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($this->type, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function slice(int $offset, ?int $length = null): self
|
||||
{
|
||||
$nbLabels = count($this->labels);
|
||||
if ($offset < - $nbLabels || $offset > $nbLabels) {
|
||||
throw SyntaxError::dueToInvalidLabelKey($this, $offset);
|
||||
}
|
||||
|
||||
$labels = array_slice($this->labels, $offset, $length, true);
|
||||
if ($labels === $this->labels) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($this->type, [] === $labels ? null : implode('.', array_reverse($labels)));
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use Stringable;
|
||||
use function count;
|
||||
|
||||
final class ResolvedDomain implements ResolvedDomainName
|
||||
{
|
||||
private readonly DomainName $secondLevelDomain;
|
||||
private readonly DomainName $registrableDomain;
|
||||
private readonly DomainName $subDomain;
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly DomainName $domain,
|
||||
private readonly EffectiveTopLevelDomain $suffix
|
||||
) {
|
||||
[
|
||||
'registrableDomain' => $this->registrableDomain,
|
||||
'secondLevelDomain' => $this->secondLevelDomain,
|
||||
'subDomain' => $this->subDomain,
|
||||
] = $this->parse();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromICANN(DomainNameProvider|Host|Stringable|string|int|null $domain, int $suffixLength): self
|
||||
{
|
||||
$domain = self::setDomainName($domain);
|
||||
|
||||
return new self($domain, Suffix::fromICANN($domain->slice(0, $suffixLength)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromPrivate(DomainNameProvider|Host|Stringable|string|int|null $domain, int $suffixLength): self
|
||||
{
|
||||
$domain = self::setDomainName($domain);
|
||||
|
||||
return new self($domain, Suffix::fromPrivate($domain->slice(0, $suffixLength)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromIANA(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
$domain = self::setDomainName($domain);
|
||||
|
||||
return new self($domain, Suffix::fromIANA($domain->label(0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromUnknown(DomainNameProvider|Host|Stringable|string|int|null $domain, int $suffixLength = 0): self
|
||||
{
|
||||
$domain = self::setDomainName($domain);
|
||||
|
||||
return new self($domain, Suffix::fromUnknown($domain->slice(0, $suffixLength)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{domain:DomainName, suffix:EffectiveTopLevelDomain} $properties
|
||||
*
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function __set_state(array $properties): self
|
||||
{
|
||||
return new self($properties['domain'], $properties['suffix']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
private static function setDomainName(DomainNameProvider|Host|Stringable|string|int|null $domain): DomainName
|
||||
{
|
||||
return match (true) {
|
||||
$domain instanceof DomainNameProvider => $domain->domain(),
|
||||
$domain instanceof DomainName => $domain,
|
||||
default => RegisteredName::fromIDNA2008($domain),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure the Value Object is always in a valid state.
|
||||
*
|
||||
* @throws UnableToResolveDomain|CannotProcessHost If the suffix can not be attached to the domain
|
||||
*
|
||||
* @return array{registrableDomain: DomainName, secondLevelDomain: DomainName, subDomain: DomainName}
|
||||
*/
|
||||
private function parse(): array
|
||||
{
|
||||
$suffixValue = $this->suffix->value();
|
||||
if (null === $suffixValue) {
|
||||
$nullDomain = $this->domain->clear();
|
||||
|
||||
return [
|
||||
'registrableDomain' => $nullDomain,
|
||||
'secondLevelDomain' => $nullDomain,
|
||||
'subDomain' => $nullDomain,
|
||||
];
|
||||
}
|
||||
|
||||
if (2 > count($this->domain)) {
|
||||
throw UnableToResolveDomain::dueToUnresolvableDomain($this->domain);
|
||||
}
|
||||
|
||||
if ($this->domain->value() === $suffixValue) {
|
||||
throw UnableToResolveDomain::dueToIdenticalValue($this->domain);
|
||||
}
|
||||
|
||||
$length = count($this->suffix);
|
||||
|
||||
return [
|
||||
'registrableDomain' => $this->domain->slice(0, $length + 1),
|
||||
'secondLevelDomain' => $this->domain->slice($length, 1),
|
||||
'subDomain' => RegisteredName::fromIDNA2008($this->domain->value())->slice($length + 1),
|
||||
];
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->domain);
|
||||
}
|
||||
|
||||
public function domain(): DomainName
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function registrableDomain(): DomainName
|
||||
{
|
||||
return $this->registrableDomain;
|
||||
}
|
||||
|
||||
public function secondLevelDomain(): DomainName
|
||||
{
|
||||
return $this->secondLevelDomain;
|
||||
}
|
||||
|
||||
public function subDomain(): DomainName
|
||||
{
|
||||
return $this->subDomain;
|
||||
}
|
||||
|
||||
public function suffix(): EffectiveTopLevelDomain
|
||||
{
|
||||
return $this->suffix;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): ?string
|
||||
{
|
||||
return $this->domain->jsonSerialize();
|
||||
}
|
||||
|
||||
public function value(): ?string
|
||||
{
|
||||
return $this->domain->value();
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->domain->toString();
|
||||
}
|
||||
|
||||
public function toAscii(): self
|
||||
{
|
||||
return new self($this->domain->toAscii(), $this->suffix->toAscii());
|
||||
}
|
||||
|
||||
public function toUnicode(): self
|
||||
{
|
||||
return new self($this->domain->toUnicode(), $this->suffix->toUnicode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function withSuffix(DomainNameProvider|Host|Stringable|string|int|null $suffix): self
|
||||
{
|
||||
if (!$suffix instanceof EffectiveTopLevelDomain) {
|
||||
$suffix = Suffix::fromUnknown($suffix);
|
||||
}
|
||||
|
||||
return new self(
|
||||
$this->domain->slice(count($this->suffix))->append($suffix),
|
||||
$suffix->normalize($this->domain)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost|UnableToResolveDomain
|
||||
*/
|
||||
public function withSubDomain(DomainNameProvider|Host|Stringable|string|int|null $subDomain): self
|
||||
{
|
||||
if (null === $this->suffix->value()) {
|
||||
throw UnableToResolveDomain::dueToMissingRegistrableDomain($this->domain);
|
||||
}
|
||||
|
||||
$subDomain = RegisteredName::fromIDNA2008($subDomain);
|
||||
if ($this->subDomain->value() === $subDomain->value()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($this->registrableDomain->prepend($subDomain), $this->suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost|UnableToResolveDomain
|
||||
*/
|
||||
public function withSecondLevelDomain(DomainNameProvider|Host|Stringable|string|int|null $label): self
|
||||
{
|
||||
if (null === $this->suffix->value()) {
|
||||
throw UnableToResolveDomain::dueToMissingRegistrableDomain($this->domain);
|
||||
}
|
||||
|
||||
$label = RegisteredName::fromIDNA2008($label);
|
||||
if (1 !== count($label)) {
|
||||
throw UnableToResolveDomain::dueToInvalidSecondLevelDomain($label);
|
||||
}
|
||||
|
||||
$newRegistrableDomain = $this->registrableDomain->withoutLabel(-1)->prepend($label);
|
||||
if ($newRegistrableDomain->value() === $this->registrableDomain->value()) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
return new self($newRegistrableDomain->prepend($this->subDomain), $this->suffix);
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
interface ResolvedDomainName extends Host, DomainNameProvider
|
||||
{
|
||||
/**
|
||||
* Returns the domain effective tld component.
|
||||
*/
|
||||
public function suffix(): EffectiveTopLevelDomain;
|
||||
|
||||
/**
|
||||
* Returns the second level domain component.
|
||||
*/
|
||||
public function secondLevelDomain(): DomainName;
|
||||
|
||||
/**
|
||||
* Returns the registrable domain component.
|
||||
*/
|
||||
public function registrableDomain(): DomainName;
|
||||
|
||||
/**
|
||||
* Returns the subdomain component.
|
||||
*/
|
||||
public function subDomain(): DomainName;
|
||||
|
||||
/**
|
||||
* Returns an instance with the specified subdomain added.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance that contains the new subdomain
|
||||
*
|
||||
* @throws CannotProcessHost If the Sub domain can not be added to the current Domain
|
||||
*/
|
||||
public function withSubDomain(DomainName $subDomain): self;
|
||||
|
||||
/**
|
||||
* Returns an instance with the specified second level domain label added.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance that contains the second level domain label
|
||||
*
|
||||
* @throws CannotProcessHost If the second level domain label can not be added
|
||||
*/
|
||||
public function withSecondLevelDomain(DomainName $label): self;
|
||||
|
||||
/**
|
||||
* Returns an instance with the specified public suffix added.
|
||||
*
|
||||
* This method MUST retain the state of the current instance, and return
|
||||
* an instance that contains the new public suffix
|
||||
*
|
||||
* If the domain already has a public suffix it will be replaced by the new value
|
||||
* otherwise the public suffix content is added to or remove from the current domain.
|
||||
*/
|
||||
public function withSuffix(EffectiveTopLevelDomain $suffix): self;
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
interface ResourceUri
|
||||
{
|
||||
public const PUBLIC_SUFFIX_LIST_URI = 'https://publicsuffix.org/list/public_suffix_list.dat';
|
||||
|
||||
public const TOP_LEVEL_DOMAIN_LIST_URI = 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt';
|
||||
}
|
||||
Vendored
+306
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use SplFileObject;
|
||||
use SplTempFileObject;
|
||||
use Stringable;
|
||||
use function array_pop;
|
||||
use function explode;
|
||||
use function preg_match;
|
||||
use function substr;
|
||||
|
||||
final class Rules implements PublicSuffixList
|
||||
{
|
||||
private const ICANN_DOMAINS = 'ICANN_DOMAINS';
|
||||
private const PRIVATE_DOMAINS = 'PRIVATE_DOMAINS';
|
||||
private const UNKNOWN_DOMAINS = 'UNKNOWN_DOMAINS';
|
||||
|
||||
private const REGEX_PSL_SECTION = ',^// ===(?<point>BEGIN|END) (?<type>ICANN|PRIVATE) DOMAINS===,';
|
||||
private const PSL_SECTION = [
|
||||
'ICANN' => [
|
||||
'BEGIN' => self::ICANN_DOMAINS,
|
||||
'END' => '',
|
||||
],
|
||||
'PRIVATE' => [
|
||||
'BEGIN' => self::PRIVATE_DOMAINS,
|
||||
'END' => '',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array{ICANN_DOMAINS: array<array>, PRIVATE_DOMAINS: array<array>} $rules
|
||||
*/
|
||||
private function __construct(private readonly array $rules)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new instance from a file path.
|
||||
*
|
||||
* @param null|resource $context
|
||||
*
|
||||
* @throws UnableToLoadResource If the rules can not be loaded from the path
|
||||
* @throws UnableToLoadPublicSuffixList If the rules contain in the resource are invalid
|
||||
*/
|
||||
public static function fromPath(string $path, $context = null): self
|
||||
{
|
||||
return self::fromString(Stream::getContentAsString($path, $context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new instance from a string.
|
||||
*
|
||||
* @throws UnableToLoadPublicSuffixList If the rules contain in the resource are invalid
|
||||
*/
|
||||
public static function fromString(Stringable|string $content): self
|
||||
{
|
||||
return new self(self::parse((string) $content));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the Public Suffix List into an associative, multidimensional array.
|
||||
*
|
||||
* @return array{ICANN_DOMAINS: array<array>, PRIVATE_DOMAINS: array<array>}
|
||||
*/
|
||||
private static function parse(string $content): array
|
||||
{
|
||||
$rules = [self::ICANN_DOMAINS => [], self::PRIVATE_DOMAINS => []];
|
||||
$section = '';
|
||||
$file = new SplTempFileObject();
|
||||
$file->fwrite($content);
|
||||
$file->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY);
|
||||
/** @var string $line */
|
||||
foreach ($file as $line) {
|
||||
$section = self::getSection($section, $line);
|
||||
if (in_array($section, [self::PRIVATE_DOMAINS, self::ICANN_DOMAINS], true) && !str_contains($line, '//')) {
|
||||
$rules[$section] = self::addRule($rules[$section], explode('.', $line));
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the section type for a given line.
|
||||
*/
|
||||
private static function getSection(string $section, string $line): string
|
||||
{
|
||||
if (1 === preg_match(self::REGEX_PSL_SECTION, $line, $matches)) {
|
||||
return self::PSL_SECTION[$matches['type']][$matches['point']];
|
||||
}
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive method to build the array representation of the Public Suffix List.
|
||||
*
|
||||
* This method is based heavily on the code found in generateEffectiveTLDs.php
|
||||
*
|
||||
* @see https://github.com/usrflo/registered-domain-libs/blob/master/generateEffectiveTLDs.php
|
||||
* A copy of the Apache License, Version 2.0, is provided with this
|
||||
* distribution
|
||||
*
|
||||
* @param array<array> $list Initially an empty array, this eventually becomes the array representation of a
|
||||
* Public Suffix List section
|
||||
* @param array<string> $ruleParts One line (rule) from the Public Suffix List exploded on '.', or the remaining
|
||||
* portion of that array during recursion
|
||||
*
|
||||
* @throws UnableToLoadPublicSuffixList if the domain name can not be converted using IDN to ASCII algorithm
|
||||
*
|
||||
* @return array<array>
|
||||
*/
|
||||
private static function addRule(array $list, array $ruleParts): array
|
||||
{
|
||||
// Adheres to canonicalization rule from the "Formal Algorithm" section
|
||||
// of https://publicsuffix.org/list/
|
||||
// "The domain and all rules must be canonicalized in the normal way
|
||||
// for hostnames - lower-case, Punycode (RFC 3492)."
|
||||
try {
|
||||
/** @var string $line */
|
||||
$line = array_pop($ruleParts);
|
||||
$rule = Idna::toAscii($line, Idna::IDNA2008_ASCII)->result();
|
||||
} catch (CannotProcessHost $exception) {
|
||||
throw UnableToLoadPublicSuffixList::dueToInvalidRule($line ?? null, $exception);
|
||||
}
|
||||
|
||||
$isDomain = true;
|
||||
if (str_starts_with($rule, '!')) {
|
||||
$rule = substr($rule, 1);
|
||||
$isDomain = false;
|
||||
}
|
||||
|
||||
$list[$rule] = $list[$rule] ?? ($isDomain ? [] : ['!' => '']);
|
||||
if ($isDomain && [] !== $ruleParts) {
|
||||
/** @var array<array-key, array> $tmpList */
|
||||
$tmpList = $list[$rule];
|
||||
$list[$rule] = self::addRule($tmpList, $ruleParts);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{rules:array{ICANN_DOMAINS: array<array>, PRIVATE_DOMAINS: array<array>}} $properties
|
||||
*/
|
||||
public static function __set_state(array $properties): self
|
||||
{
|
||||
return new self($properties['rules']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|DomainNameProvider|Host|string|Stringable|null $host a type that supports instantiating a Domain from.
|
||||
*/
|
||||
public function resolve($host): ResolvedDomainName
|
||||
{
|
||||
try {
|
||||
return $this->getCookieDomain($host);
|
||||
} catch (UnableToResolveDomain $exception) {
|
||||
return ResolvedDomain::fromUnknown($exception->domain());
|
||||
} catch (SyntaxError) {
|
||||
return ResolvedDomain::fromUnknown(Domain::fromIDNA2008(null));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|DomainNameProvider|Host|string|Stringable|null $host the domain value
|
||||
*/
|
||||
public function getCookieDomain($host): ResolvedDomainName
|
||||
{
|
||||
$domain = $this->validateDomain($host);
|
||||
[$suffixLength, $section] = $this->resolveSuffix($domain, self::UNKNOWN_DOMAINS);
|
||||
|
||||
return match (true) {
|
||||
self::ICANN_DOMAINS === $section => ResolvedDomain::fromICANN($domain, $suffixLength),
|
||||
self::PRIVATE_DOMAINS === $section => ResolvedDomain::fromPrivate($domain, $suffixLength),
|
||||
default => ResolvedDomain::fromUnknown($domain, $suffixLength),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|DomainNameProvider|Host|string|Stringable|null $host a type that supports instantiating a Domain from.
|
||||
*/
|
||||
public function getICANNDomain($host): ResolvedDomainName
|
||||
{
|
||||
$domain = $this->validateDomain($host);
|
||||
[$suffixLength, $section] = $this->resolveSuffix($domain, self::ICANN_DOMAINS);
|
||||
if (self::ICANN_DOMAINS !== $section) {
|
||||
throw UnableToResolveDomain::dueToMissingSuffix($domain, 'ICANN');
|
||||
}
|
||||
|
||||
return ResolvedDomain::fromICANN($domain, $suffixLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|DomainNameProvider|Host|string|Stringable|null $host a type that supports instantiating a Domain from.
|
||||
*/
|
||||
public function getPrivateDomain($host): ResolvedDomainName
|
||||
{
|
||||
$domain = $this->validateDomain($host);
|
||||
[$suffixLength, $section] = $this->resolveSuffix($domain, self::PRIVATE_DOMAINS);
|
||||
if (self::PRIVATE_DOMAINS !== $section) {
|
||||
throw UnableToResolveDomain::dueToMissingSuffix($domain, 'private');
|
||||
}
|
||||
|
||||
return ResolvedDomain::fromPrivate($domain, $suffixLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the domain is valid and is resolvable.
|
||||
*
|
||||
* @throws SyntaxError If the domain is invalid
|
||||
* @throws UnableToResolveDomain If the domain can not be resolved
|
||||
*/
|
||||
private function validateDomain(int|DomainNameProvider|Host|string|Stringable|null $domain): DomainName
|
||||
{
|
||||
if ($domain instanceof DomainNameProvider) {
|
||||
$domain = $domain->domain();
|
||||
}
|
||||
|
||||
if (!$domain instanceof DomainName) {
|
||||
$domain = Domain::fromIDNA2008($domain);
|
||||
}
|
||||
|
||||
if ('' === $domain->label(0)) {
|
||||
throw UnableToResolveDomain::dueToUnresolvableDomain($domain);
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length and the section of the resolved effective top level domain.
|
||||
*
|
||||
* @param Rules::UNKNOWN_DOMAINS|Rules::ICANN_DOMAINS|Rules::PRIVATE_DOMAINS $section
|
||||
*
|
||||
* @return array{0: int, 1:Rules::UNKNOWN_DOMAINS|Rules::ICANN_DOMAINS|Rules::PRIVATE_DOMAINS}
|
||||
*/
|
||||
private function resolveSuffix(DomainName $domain, string $section): array
|
||||
{
|
||||
$icannSuffixLength = $this->getPublicSuffixLengthFromSection($domain, self::ICANN_DOMAINS);
|
||||
if (1 > $icannSuffixLength) {
|
||||
return [1, self::UNKNOWN_DOMAINS];
|
||||
}
|
||||
|
||||
if (self::ICANN_DOMAINS === $section) {
|
||||
return [$icannSuffixLength, self::ICANN_DOMAINS];
|
||||
}
|
||||
|
||||
$privateSuffixLength = $this->getPublicSuffixLengthFromSection($domain, self::PRIVATE_DOMAINS);
|
||||
if ($privateSuffixLength > $icannSuffixLength) {
|
||||
return [$privateSuffixLength, self::PRIVATE_DOMAINS];
|
||||
}
|
||||
|
||||
return [$icannSuffixLength, self::ICANN_DOMAINS];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public suffix label count for a domain name according to a PSL section.
|
||||
*/
|
||||
private function getPublicSuffixLengthFromSection(DomainName $domain, string $section): int
|
||||
{
|
||||
$rules = $this->rules[$section];
|
||||
$labelCount = 0;
|
||||
foreach ($domain->toAscii() as $label) {
|
||||
//match exception rule
|
||||
if (isset($rules[$label]['!'])) {
|
||||
break;
|
||||
}
|
||||
|
||||
//match wildcard rule
|
||||
if (array_key_exists('*', $rules)) {
|
||||
++$labelCount;
|
||||
break;
|
||||
}
|
||||
|
||||
//no match found
|
||||
if (!array_key_exists($label, $rules)) {
|
||||
// Suffix MUST be fully matched else no suffix is found for private domain
|
||||
if (self::PRIVATE_DOMAINS === $section && self::hasRemainingRules($rules)) {
|
||||
$labelCount = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
++$labelCount;
|
||||
/** @var array<array-key, mixed> $rules */
|
||||
$rules = $rules[$label];
|
||||
}
|
||||
|
||||
return $labelCount;
|
||||
}
|
||||
|
||||
private static function hasRemainingRules(array $rules): bool
|
||||
{
|
||||
foreach ($rules as $rule) {
|
||||
if ([] !== $rule) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeInterface;
|
||||
use Pdp\ResourceUri;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Stringable;
|
||||
|
||||
final class PsrStorageFactory implements
|
||||
ResourceUri,
|
||||
PublicSuffixListStorageFactory,
|
||||
TopLevelDomainListStorageFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly ClientInterface $client,
|
||||
private readonly RequestFactoryInterface $requestFactory
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DateInterval|DateTimeInterface|Stringable|int|string|null $cacheTtl
|
||||
*/
|
||||
public function createPublicSuffixListStorage(string $cachePrefix = '', $cacheTtl = null): PublicSuffixListStorage
|
||||
{
|
||||
return new RulesStorage(
|
||||
new PublicSuffixListPsr16Cache($this->cache, $cachePrefix, $cacheTtl),
|
||||
new PublicSuffixListPsr18Client($this->client, $this->requestFactory)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DateInterval|DateTimeInterface|Stringable|int|string|null $cacheTtl
|
||||
*/
|
||||
public function createTopLevelDomainListStorage(string $cachePrefix = '', $cacheTtl = null): TopLevelDomainListStorage
|
||||
{
|
||||
return new TopLevelDomainsStorage(
|
||||
new TopLevelDomainListPsr16Cache($this->cache, $cachePrefix, $cacheTtl),
|
||||
new TopLevelDomainListPsr18Client($this->client, $this->requestFactory)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use Pdp\PublicSuffixList;
|
||||
|
||||
interface PublicSuffixListCache
|
||||
{
|
||||
/**
|
||||
* Retrieves the Public Suffix List from Cache.
|
||||
*/
|
||||
public function fetch(string $uri): ?PublicSuffixList;
|
||||
|
||||
/**
|
||||
* Caches the Public Suffix List.
|
||||
*
|
||||
* If a local cache already exists, it will be overwritten.
|
||||
*
|
||||
* Returns true if the action was successful, false otherwise
|
||||
*/
|
||||
public function remember(string $uri, PublicSuffixList $publicSuffixList): bool;
|
||||
|
||||
/**
|
||||
* Deletes the Public Suffix List entry.
|
||||
*
|
||||
* Returns true if the action was successful, false otherwise
|
||||
*/
|
||||
public function forget(string $uri): bool;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use Pdp\PublicSuffixList;
|
||||
use Pdp\UnableToLoadPublicSuffixList;
|
||||
|
||||
interface PublicSuffixListClient
|
||||
{
|
||||
/**
|
||||
* @throws UnableToLoadPublicSuffixList
|
||||
*/
|
||||
public function get(string $uri): PublicSuffixList;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeInterface;
|
||||
use Pdp\PublicSuffixList;
|
||||
use Psr\SimpleCache\CacheException;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
use function md5;
|
||||
use function strtolower;
|
||||
|
||||
final class PublicSuffixListPsr16Cache implements PublicSuffixListCache
|
||||
{
|
||||
private readonly ?DateInterval $cacheTtl;
|
||||
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly string $cachePrefix = '',
|
||||
DateInterval|DateTimeInterface|Stringable|int|string|null $cacheTtl = null
|
||||
) {
|
||||
$this->cacheTtl = TimeToLive::convert($cacheTtl);
|
||||
}
|
||||
|
||||
public function fetch(string $uri): ?PublicSuffixList
|
||||
{
|
||||
$cacheKey = $this->cacheKey($uri);
|
||||
$publicSuffixList = $this->cache->get($cacheKey);
|
||||
if (null === $publicSuffixList) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$publicSuffixList instanceof PublicSuffixList) {
|
||||
$this->cache->delete($cacheKey);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $publicSuffixList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cache key according to the source URL.
|
||||
*/
|
||||
private function cacheKey(string $str): string
|
||||
{
|
||||
return $this->cachePrefix.md5(strtolower($str));
|
||||
}
|
||||
|
||||
public function remember(string $uri, PublicSuffixList $publicSuffixList): bool
|
||||
{
|
||||
try {
|
||||
return $this->cache->set($this->cacheKey($uri), $publicSuffixList, $this->cacheTtl);
|
||||
} catch (Throwable $exception) {
|
||||
if ($exception instanceof CacheException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function forget(string $uri): bool
|
||||
{
|
||||
return $this->cache->delete($this->cacheKey($uri));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use Pdp\PublicSuffixList;
|
||||
use Pdp\Rules;
|
||||
use Pdp\UnableToLoadResource;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
|
||||
final class PublicSuffixListPsr18Client implements PublicSuffixListClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClientInterface $client,
|
||||
private readonly RequestFactoryInterface $requestFactory
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $uri): PublicSuffixList
|
||||
{
|
||||
$request = $this->requestFactory->createRequest('GET', $uri);
|
||||
try {
|
||||
$response = $this->client->sendRequest($request);
|
||||
} catch (ClientExceptionInterface $exception) {
|
||||
throw UnableToLoadResource::dueToUnavailableService($uri, $exception);
|
||||
}
|
||||
|
||||
if (400 <= $response->getStatusCode()) {
|
||||
throw UnableToLoadResource::dueToUnexpectedStatusCode($uri, $response->getStatusCode());
|
||||
}
|
||||
|
||||
return Rules::fromString($response->getBody());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
interface PublicSuffixListStorage extends PublicSuffixListClient
|
||||
{
|
||||
public function delete(string $uri): bool;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeInterface;
|
||||
use Stringable;
|
||||
|
||||
interface PublicSuffixListStorageFactory
|
||||
{
|
||||
/**
|
||||
* @param DateInterval|DateTimeInterface|Stringable|int|string|null $cacheTtl storage TTL object should implement the __toString method
|
||||
*/
|
||||
public function createPublicSuffixListStorage(string $cachePrefix = '', $cacheTtl = null): PublicSuffixListStorage;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use Pdp\PublicSuffixList;
|
||||
|
||||
final class RulesStorage implements PublicSuffixListStorage
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PublicSuffixListCache $cache,
|
||||
private readonly PublicSuffixListClient $client
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $uri): PublicSuffixList
|
||||
{
|
||||
$publicSuffixList = $this->cache->fetch($uri);
|
||||
if (null !== $publicSuffixList) {
|
||||
return $publicSuffixList;
|
||||
}
|
||||
|
||||
$publicSuffixList = $this->client->get($uri);
|
||||
|
||||
$this->cache->remember($uri, $publicSuffixList);
|
||||
|
||||
return $publicSuffixList;
|
||||
}
|
||||
|
||||
public function delete(string $uri): bool
|
||||
{
|
||||
return $this->cache->forget($uri);
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use InvalidArgumentException;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
use function filter_var;
|
||||
use const FILTER_VALIDATE_INT;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class TimeToLive
|
||||
{
|
||||
public static function fromDurationString(string $duration): DateInterval
|
||||
{
|
||||
try {
|
||||
set_error_handler(fn () => true);
|
||||
$interval = DateInterval::createFromDateString($duration);
|
||||
restore_error_handler();
|
||||
if (!$interval instanceof DateInterval) {
|
||||
throw new InvalidArgumentException(
|
||||
'The ttl value "'.$duration.'" can not be parsable by `DateInterval::createFromDateString`.'
|
||||
);
|
||||
}
|
||||
|
||||
} catch (Throwable $exception) {
|
||||
if (!$exception instanceof InvalidArgumentException) {
|
||||
throw new InvalidArgumentException(
|
||||
'The ttl value "'.$duration.'" can not be parsable by `DateInterval::createFromDateString`.',
|
||||
0,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $interval;
|
||||
}
|
||||
|
||||
public static function until(DateTimeInterface $date): DateInterval
|
||||
{
|
||||
return (new DateTimeImmutable('NOW', $date->getTimezone()))->diff($date, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a DateInterval relative to the current date and time.
|
||||
*
|
||||
* DEPRECATION WARNING! This method will be removed in the next major point release
|
||||
*
|
||||
* @deprecated 6.1.0 deprecated
|
||||
* @codeCoverageIgnore
|
||||
* @see TimeToLive::until
|
||||
*/
|
||||
public static function fromDateTimeInterface(DateTimeInterface $date): DateInterval
|
||||
{
|
||||
return self::until($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a DateInterval from string parsing.
|
||||
*
|
||||
* DEPRECATION WARNING! This method will be removed in the next major point release
|
||||
*
|
||||
* @deprecated 6.1.0 deprecated
|
||||
* @see TimeToLive::fromDurationString
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @throws InvalidArgumentException if the value can not be parsable
|
||||
*
|
||||
*/
|
||||
public static function fromScalar(Stringable|int|string $duration): DateInterval
|
||||
{
|
||||
return self::fromDurationString((string) $duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the input data into a DateInterval object or null.
|
||||
*
|
||||
* @throws InvalidArgumentException if the value can not be computed
|
||||
*/
|
||||
public static function convert(DateInterval|DateTimeInterface|Stringable|int|string|null $ttl): ?DateInterval
|
||||
{
|
||||
if ($ttl instanceof Stringable) {
|
||||
$ttl = (string) $ttl;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
$ttl instanceof DateInterval || null === $ttl => $ttl,
|
||||
$ttl instanceof DateTimeInterface => self::until($ttl),
|
||||
is_int($ttl) => self::fromDurationString($ttl.' seconds'),
|
||||
false !== ($seconds = filter_var($ttl, FILTER_VALIDATE_INT)) => self::fromDurationString($seconds.' seconds'),
|
||||
default => self::fromDurationString($ttl),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use Pdp\TopLevelDomainList;
|
||||
|
||||
interface TopLevelDomainListCache
|
||||
{
|
||||
/**
|
||||
* Retrieves the Top Level Domain List from the Cache.
|
||||
*/
|
||||
public function fetch(string $uri): ?TopLevelDomainList;
|
||||
|
||||
/**
|
||||
* Cache the Top Level Domain List.
|
||||
*
|
||||
* If a local cache already exists, it will be overwritten.
|
||||
*
|
||||
* Returns true if the action was successful, false otherwise
|
||||
*/
|
||||
public function remember(string $uri, TopLevelDomainList $topLevelDomainList): bool;
|
||||
|
||||
/**
|
||||
* Deletes the Top Level Domain List entry.
|
||||
*
|
||||
* Returns true if the action was successful, false otherwise
|
||||
*/
|
||||
public function forget(string $uri): bool;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use Pdp\TopLevelDomainList;
|
||||
use Pdp\UnableToLoadTopLevelDomainList;
|
||||
|
||||
interface TopLevelDomainListClient
|
||||
{
|
||||
/**
|
||||
* @throws UnableToLoadTopLevelDomainList
|
||||
*/
|
||||
public function get(string $uri): TopLevelDomainList;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeInterface;
|
||||
use Pdp\TopLevelDomainList;
|
||||
use Psr\SimpleCache\CacheException;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Stringable;
|
||||
use Throwable;
|
||||
use function md5;
|
||||
use function strtolower;
|
||||
|
||||
final class TopLevelDomainListPsr16Cache implements TopLevelDomainListCache
|
||||
{
|
||||
private readonly ?DateInterval $cacheTtl;
|
||||
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly string $cachePrefix = '',
|
||||
DateInterval|DateTimeInterface|Stringable|int|string|null $cacheTtl = null
|
||||
) {
|
||||
$this->cacheTtl = TimeToLive::convert($cacheTtl);
|
||||
}
|
||||
|
||||
public function fetch(string $uri): ?TopLevelDomainList
|
||||
{
|
||||
$cacheKey = $this->cacheKey($uri);
|
||||
$topLevelDomainList = $this->cache->get($cacheKey);
|
||||
if (null === $topLevelDomainList) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$topLevelDomainList instanceof TopLevelDomainList) {
|
||||
$this->cache->delete($cacheKey);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $topLevelDomainList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cache key according to the source URL.
|
||||
*/
|
||||
private function cacheKey(string $str): string
|
||||
{
|
||||
return $this->cachePrefix.md5(strtolower($str));
|
||||
}
|
||||
|
||||
public function remember(string $uri, TopLevelDomainList $topLevelDomainList): bool
|
||||
{
|
||||
try {
|
||||
return $this->cache->set($this->cacheKey($uri), $topLevelDomainList, $this->cacheTtl);
|
||||
} catch (Throwable $exception) {
|
||||
if ($exception instanceof CacheException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function forget(string $uri): bool
|
||||
{
|
||||
return $this->cache->delete($this->cacheKey($uri));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use Pdp\TopLevelDomainList;
|
||||
use Pdp\TopLevelDomains;
|
||||
use Pdp\UnableToLoadResource;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Http\Message\RequestFactoryInterface;
|
||||
|
||||
final class TopLevelDomainListPsr18Client implements TopLevelDomainListClient
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClientInterface $client,
|
||||
private readonly RequestFactoryInterface $requestFactory
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $uri): TopLevelDomainList
|
||||
{
|
||||
$request = $this->requestFactory->createRequest('GET', $uri);
|
||||
try {
|
||||
$response = $this->client->sendRequest($request);
|
||||
} catch (ClientExceptionInterface $exception) {
|
||||
throw UnableToLoadResource::dueToUnavailableService($uri, $exception);
|
||||
}
|
||||
|
||||
if (400 <= $response->getStatusCode()) {
|
||||
throw UnableToLoadResource::dueToUnexpectedStatusCode($uri, $response->getStatusCode());
|
||||
}
|
||||
|
||||
return TopLevelDomains::fromString($response->getBody());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
interface TopLevelDomainListStorage extends TopLevelDomainListClient
|
||||
{
|
||||
public function delete(string $uri): bool;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeInterface;
|
||||
use Stringable;
|
||||
|
||||
interface TopLevelDomainListStorageFactory
|
||||
{
|
||||
/**
|
||||
* @param DateInterval|DateTimeInterface|Stringable|int|string|null $cacheTtl storage TTL object should implement the __toString method
|
||||
*/
|
||||
public function createTopLevelDomainListStorage(string $cachePrefix = '', $cacheTtl = null): TopLevelDomainListStorage;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp\Storage;
|
||||
|
||||
use Pdp\TopLevelDomainList;
|
||||
|
||||
final class TopLevelDomainsStorage implements TopLevelDomainListStorage
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TopLevelDomainListCache $cache,
|
||||
private readonly TopLevelDomainListClient $client
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $uri): TopLevelDomainList
|
||||
{
|
||||
$topLevelDomains = $this->cache->fetch($uri);
|
||||
if (null !== $topLevelDomains) {
|
||||
return $topLevelDomains;
|
||||
}
|
||||
|
||||
$topLevelDomains = $this->client->get($uri);
|
||||
|
||||
$this->cache->remember($uri, $topLevelDomains);
|
||||
|
||||
return $topLevelDomains;
|
||||
}
|
||||
|
||||
public function delete(string $uri): bool
|
||||
{
|
||||
return $this->cache->forget($uri);
|
||||
}
|
||||
}
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use function fclose;
|
||||
use function fopen;
|
||||
use function stream_get_contents;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class Stream
|
||||
{
|
||||
/**
|
||||
* Returns the content as string from a path.
|
||||
*
|
||||
* @param null|resource $context
|
||||
*
|
||||
* @throws UnableToLoadPublicSuffixList If the rules can not be loaded from the path
|
||||
*/
|
||||
public static function getContentAsString(string $path, $context = null): string
|
||||
{
|
||||
$stream = self::fromPath($path, $context);
|
||||
if (false === $stream) {
|
||||
throw UnableToLoadResource::dueToInvalidUri($path);
|
||||
}
|
||||
|
||||
/** @var string $content */
|
||||
$content = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|resource $context
|
||||
*
|
||||
* @return false|resource
|
||||
*/
|
||||
private static function fromPath(string $path, $context = null)
|
||||
{
|
||||
$args = [$path, 'r'];
|
||||
if (null !== $context) {
|
||||
$args = [...$args, false, $context];
|
||||
}
|
||||
set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true);
|
||||
$stream = fopen(...$args);
|
||||
restore_error_handler();
|
||||
|
||||
return $stream;
|
||||
}
|
||||
}
|
||||
Vendored
+171
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use Stringable;
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
final class Suffix implements EffectiveTopLevelDomain
|
||||
{
|
||||
private const ICANN = 'ICANN';
|
||||
private const PRIVATE = 'PRIVATE';
|
||||
private const IANA = 'IANA';
|
||||
private const UNKNOWN = 'UNKNOWN';
|
||||
|
||||
private function __construct(
|
||||
private readonly DomainName $domain,
|
||||
private readonly string $section
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{domain:DomainName, section:Suffix::ICANN|Suffix::PRIVATE|Suffix::IANA|Suffix::UNKNOWN} $properties
|
||||
*/
|
||||
public static function __set_state(array $properties): self
|
||||
{
|
||||
return new self($properties['domain'], $properties['section']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromICANN(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
$domain = self::setDomainName($domain);
|
||||
if (1 > count($domain)) {
|
||||
throw SyntaxError::dueToInvalidSuffix($domain, self::ICANN);
|
||||
}
|
||||
|
||||
return new self($domain, self::ICANN);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromPrivate(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
$domain = self::setDomainName($domain);
|
||||
if (1 > count($domain)) {
|
||||
throw SyntaxError::dueToInvalidSuffix($domain, self::PRIVATE);
|
||||
}
|
||||
|
||||
return new self($domain, self::PRIVATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromIANA(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
$domain = self::setDomainName($domain);
|
||||
if (1 !== count($domain)) {
|
||||
throw SyntaxError::dueToInvalidSuffix($domain, self::IANA);
|
||||
}
|
||||
|
||||
return new self($domain, self::IANA);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public static function fromUnknown(DomainNameProvider|Host|Stringable|string|int|null $domain): self
|
||||
{
|
||||
return new self(self::setDomainName($domain), self::UNKNOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
private static function setDomainName(int|DomainNameProvider|Host|string|Stringable|null $domain): DomainName
|
||||
{
|
||||
if ($domain instanceof DomainNameProvider) {
|
||||
$domain = $domain->domain();
|
||||
}
|
||||
|
||||
if (!$domain instanceof DomainName) {
|
||||
$domain = RegisteredName::fromIDNA2008($domain);
|
||||
}
|
||||
|
||||
if ('' === $domain->label(0)) {
|
||||
throw SyntaxError::dueToInvalidSuffix($domain);
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
public function isKnown(): bool
|
||||
{
|
||||
return self::UNKNOWN !== $this->section;
|
||||
}
|
||||
|
||||
public function isIANA(): bool
|
||||
{
|
||||
return self::IANA === $this->section;
|
||||
}
|
||||
|
||||
public function isPublicSuffix(): bool
|
||||
{
|
||||
return in_array($this->section, [self::ICANN, self::PRIVATE], true);
|
||||
}
|
||||
|
||||
public function isICANN(): bool
|
||||
{
|
||||
return self::ICANN === $this->section;
|
||||
}
|
||||
|
||||
public function isPrivate(): bool
|
||||
{
|
||||
return self::PRIVATE === $this->section;
|
||||
}
|
||||
|
||||
public function domain(): DomainName
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->domain);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): ?string
|
||||
{
|
||||
return $this->domain->jsonSerialize();
|
||||
}
|
||||
|
||||
public function value(): ?string
|
||||
{
|
||||
return $this->domain->value();
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->domain->toString();
|
||||
}
|
||||
|
||||
public function toAscii(): self
|
||||
{
|
||||
return new self($this->domain->toAscii(), $this->section);
|
||||
}
|
||||
|
||||
public function toUnicode(): self
|
||||
{
|
||||
return new self($this->domain->toUnicode(), $this->section);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws CannotProcessHost
|
||||
*/
|
||||
public function normalize(DomainName $domain): self
|
||||
{
|
||||
$newDomain = $domain->clear()->append($this->toUnicode());
|
||||
if ($domain->isAscii()) {
|
||||
$newDomain = $newDomain->toAscii();
|
||||
}
|
||||
|
||||
return new self($newDomain, $this->section);
|
||||
}
|
||||
}
|
||||
Vendored
+54
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class SyntaxError extends InvalidArgumentException implements CannotProcessHost
|
||||
{
|
||||
private function __construct(string $message, private readonly ?IdnaInfo $idnaInfo = null)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public static function dueToInvalidCharacters(string $domain): self
|
||||
{
|
||||
return new self('The host `'.$domain.'` is invalid: it contains invalid characters.');
|
||||
}
|
||||
|
||||
public static function dueToMalformedValue(string $domain): self
|
||||
{
|
||||
return new self('The host `'.$domain.'` is malformed; Verify its length and/or characters.');
|
||||
}
|
||||
|
||||
public static function dueToIDNAError(string $domain, IdnaInfo $idnaInfo): self
|
||||
{
|
||||
return new self('The host `'.$domain.'` is invalid for IDN conversion.', $idnaInfo);
|
||||
}
|
||||
|
||||
public static function dueToInvalidSuffix(Host $publicSuffix, string $type = ''): self
|
||||
{
|
||||
if ('' === $type) {
|
||||
return new self('The suffix `"'.($publicSuffix->value() ?? 'NULL').'"` is invalid.');
|
||||
}
|
||||
|
||||
return new self('The suffix `"'.($publicSuffix->value() ?? 'NULL').'"` is an invalid `'.$type.'` suffix.');
|
||||
}
|
||||
|
||||
public static function dueToUnsupportedType(string $domain): self
|
||||
{
|
||||
return new self('The domain `'.$domain.'` is invalid: this is an IPv4 host.');
|
||||
}
|
||||
|
||||
public static function dueToInvalidLabelKey(Host $domain, int $key): self
|
||||
{
|
||||
return new self('the given key `'.$key.'` is invalid for the domain `'.($domain->value() ?? 'NULL').'`.');
|
||||
}
|
||||
|
||||
public function idnaInfo(): ?IdnaInfo
|
||||
{
|
||||
return $this->idnaInfo;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use Countable;
|
||||
use DateTimeImmutable;
|
||||
use Iterator;
|
||||
use IteratorAggregate;
|
||||
|
||||
/**
|
||||
* @extends IteratorAggregate<string>
|
||||
*/
|
||||
interface TopLevelDomainList extends Countable, DomainNameResolver, IteratorAggregate
|
||||
{
|
||||
/**
|
||||
* Returns the Version ID.
|
||||
*/
|
||||
public function version(): string;
|
||||
|
||||
/**
|
||||
* Returns the List Last Modified Date.
|
||||
*/
|
||||
public function lastUpdated(): DateTimeImmutable;
|
||||
|
||||
|
||||
public function count(): int;
|
||||
|
||||
/**
|
||||
* Tells whether the list is empty.
|
||||
*/
|
||||
public function isEmpty(): bool;
|
||||
|
||||
/**
|
||||
* @return Iterator<string>
|
||||
*/
|
||||
public function getIterator(): Iterator;
|
||||
|
||||
/**
|
||||
* Returns PSL info for a given domain against the PSL rules for ICANN domain detection.
|
||||
*
|
||||
* @throws SyntaxError if the domain is invalid
|
||||
* @throws UnableToResolveDomain if the domain does not contain a IANA Effective TLD
|
||||
*/
|
||||
public function getIANADomain(Host $host): ResolvedDomainName;
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Iterator;
|
||||
use SplFileObject;
|
||||
use SplTempFileObject;
|
||||
use Stringable;
|
||||
use function count;
|
||||
use function in_array;
|
||||
use function preg_match;
|
||||
use function trim;
|
||||
|
||||
final class TopLevelDomains implements TopLevelDomainList
|
||||
{
|
||||
private const IANA_DATE_FORMAT = 'D M d H:i:s Y e';
|
||||
private const REGEXP_HEADER_LINE = '/^\# Version (?<version>\d+), Last Updated (?<date>.*?)$/';
|
||||
|
||||
/**
|
||||
* @param array<string, int> $records
|
||||
*/
|
||||
private function __construct(
|
||||
private readonly array $records,
|
||||
private readonly string $version,
|
||||
private readonly DateTimeImmutable $lastUpdated
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new instance from a file path.
|
||||
*
|
||||
* @param null|resource $context
|
||||
*
|
||||
* @throws UnableToLoadResource If the rules can not be loaded from the path
|
||||
* @throws UnableToLoadTopLevelDomainList If the content is invalid or can not be correctly parsed and converted
|
||||
*/
|
||||
public static function fromPath(string $path, $context = null): self
|
||||
{
|
||||
return self::fromString(Stream::getContentAsString($path, $context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new instance from a string.
|
||||
*
|
||||
* @throws UnableToLoadTopLevelDomainList if the content is invalid or can not be correctly parsed and converted
|
||||
*/
|
||||
public static function fromString(Stringable|string $content): self
|
||||
{
|
||||
$data = self::parse((string) $content);
|
||||
|
||||
return new self($data['records'], $data['version'], $data['lastUpdated']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the IANA Top Level Domain List into a TopLevelDomains associative array.
|
||||
*
|
||||
* @throws UnableToLoadTopLevelDomainList if the content is invalid or can not be correctly parsed and converted
|
||||
*
|
||||
* @return array{version:string, lastUpdated:DateTimeImmutable, records:array<string,int>}
|
||||
*/
|
||||
public static function parse(string $content): array
|
||||
{
|
||||
$data = [];
|
||||
$file = new SplTempFileObject();
|
||||
$file->fwrite($content);
|
||||
$file->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY);
|
||||
/** @var string $line */
|
||||
foreach ($file as $line) {
|
||||
$line = trim($line);
|
||||
if ([] === $data) {
|
||||
$data = self::extractHeader($line) + ['records' => []];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!str_contains($line, '#')) {
|
||||
$data['records'][self::extractRootZone($line)] = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw UnableToLoadTopLevelDomainList::dueToInvalidLine($line);
|
||||
}
|
||||
|
||||
if (isset($data['version'], $data['lastUpdated'], $data['records'])) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
throw UnableToLoadTopLevelDomainList::dueToFailedConversion();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract IANA Top Level Domain List header info.
|
||||
*
|
||||
* @throws UnableToLoadTopLevelDomainList if the Header line is invalid
|
||||
*
|
||||
* @return array{version:string, lastUpdated:DateTimeImmutable}
|
||||
*/
|
||||
private static function extractHeader(string $content): array
|
||||
{
|
||||
if (1 !== preg_match(self::REGEXP_HEADER_LINE, $content, $matches)) {
|
||||
throw UnableToLoadTopLevelDomainList::dueToInvalidVersionLine($content);
|
||||
}
|
||||
|
||||
/** @var DateTimeImmutable $date */
|
||||
$date = DateTimeImmutable::createFromFormat(self::IANA_DATE_FORMAT, $matches['date']);
|
||||
|
||||
return [
|
||||
'version' => $matches['version'],
|
||||
'lastUpdated' => $date,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract IANA Root Zone.
|
||||
*
|
||||
* @throws UnableToLoadTopLevelDomainList If the Top Level Domain is invalid
|
||||
*/
|
||||
private static function extractRootZone(string $content): string
|
||||
{
|
||||
try {
|
||||
$tld = Suffix::fromIANA($content);
|
||||
} catch (CannotProcessHost $exception) {
|
||||
throw UnableToLoadTopLevelDomainList::dueToInvalidTopLevelDomain($content, $exception);
|
||||
}
|
||||
|
||||
return $tld->toAscii()->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{records:array<string, int>, version:string, lastUpdated:DateTimeImmutable} $properties
|
||||
*/
|
||||
public static function __set_state(array $properties): self
|
||||
{
|
||||
return new self($properties['records'], $properties['version'], $properties['lastUpdated']);
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function lastUpdated(): DateTimeImmutable
|
||||
{
|
||||
return $this->lastUpdated;
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->records);
|
||||
}
|
||||
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return [] === $this->records;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Iterator<string>
|
||||
*/
|
||||
public function getIterator(): Iterator
|
||||
{
|
||||
yield from array_keys($this->records);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|DomainNameProvider|Host|string|Stringable|null $host a type that supports instantiating a Domain from.
|
||||
*/
|
||||
public function resolve($host): ResolvedDomainName
|
||||
{
|
||||
try {
|
||||
$domain = $this->validateDomain($host);
|
||||
if ($this->containsTopLevelDomain($domain)) {
|
||||
return ResolvedDomain::fromIANA($domain);
|
||||
}
|
||||
return ResolvedDomain::fromUnknown($domain);
|
||||
} catch (UnableToResolveDomain $exception) {
|
||||
return ResolvedDomain::fromUnknown($exception->domain());
|
||||
} catch (SyntaxError $exception) {
|
||||
return ResolvedDomain::fromUnknown(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the domain is valid and is resolvable.
|
||||
*
|
||||
* @throws SyntaxError If the domain is invalid
|
||||
* @throws UnableToResolveDomain If the domain can not be resolved
|
||||
*/
|
||||
private function validateDomain(int|DomainNameProvider|Host|string|Stringable|null $domain): DomainName
|
||||
{
|
||||
if ($domain instanceof DomainNameProvider) {
|
||||
$domain = $domain->domain();
|
||||
}
|
||||
|
||||
if (!$domain instanceof DomainName) {
|
||||
$domain = Domain::fromIDNA2008($domain);
|
||||
}
|
||||
|
||||
$label = $domain->label(0);
|
||||
if (in_array($label, [null, ''], true)) {
|
||||
throw UnableToResolveDomain::dueToUnresolvableDomain($domain);
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
private function containsTopLevelDomain(DomainName $domain): bool
|
||||
{
|
||||
return isset($this->records[$domain->toAscii()->label(0)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|DomainNameProvider|Host|string|Stringable|null $host a domain in a type that can be converted into a DomainInterface instance
|
||||
*/
|
||||
public function getIANADomain($host): ResolvedDomainName
|
||||
{
|
||||
$domain = $this->validateDomain($host);
|
||||
if (!$this->containsTopLevelDomain($domain)) {
|
||||
throw UnableToResolveDomain::dueToMissingSuffix($domain, 'IANA');
|
||||
}
|
||||
|
||||
return ResolvedDomain::fromIANA($domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
final class UnableToLoadPublicSuffixList extends InvalidArgumentException implements CannotProcessHost
|
||||
{
|
||||
public static function dueToInvalidRule(?string $line, Throwable $exception): self
|
||||
{
|
||||
return new self('The following rule "'.($line ?? 'NULL').'" could not be processed because it is invalid.', 0, $exception);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class UnableToLoadResource extends RuntimeException implements CannotProcessHost
|
||||
{
|
||||
public static function dueToInvalidUri(string $uri): self
|
||||
{
|
||||
return new self('`'.$uri.'`: failed to open stream; No such URI, file or directory.');
|
||||
}
|
||||
|
||||
public static function dueToUnavailableService(string $uri, Throwable $exception): self
|
||||
{
|
||||
return new self('Could not access the URI: `'.$uri.'`.', 0, $exception);
|
||||
}
|
||||
|
||||
public static function dueToUnexpectedStatusCode(string $uri, int $statusCode): self
|
||||
{
|
||||
return new self('Invalid response from URI: `'.$uri.'`.', $statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
final class UnableToLoadTopLevelDomainList extends InvalidArgumentException implements CannotProcessHost
|
||||
{
|
||||
public static function dueToInvalidTopLevelDomain(string $content, ?Throwable $exception = null): self
|
||||
{
|
||||
return new self('Invalid Top Level Domain: '.$content, 0, $exception);
|
||||
}
|
||||
|
||||
public static function dueToInvalidVersionLine(string $line): self
|
||||
{
|
||||
return new self('Invalid Version line: '.$line);
|
||||
}
|
||||
|
||||
public static function dueToFailedConversion(): self
|
||||
{
|
||||
return new self('Invalid content: Top Level Domain List conversion failed.');
|
||||
}
|
||||
|
||||
public static function dueToInvalidLine(string $line): self
|
||||
{
|
||||
return new self('Invalid line content: '.$line);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pdp;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class UnableToResolveDomain extends InvalidArgumentException implements CannotProcessHost
|
||||
{
|
||||
private ?DomainName $domain;
|
||||
|
||||
private function __construct(string $message, ?DomainName $domain = null)
|
||||
{
|
||||
parent::__construct($message);
|
||||
$this->domain = $domain;
|
||||
}
|
||||
|
||||
public static function dueToInvalidSecondLevelDomain(DomainName $domain): self
|
||||
{
|
||||
return new self('The submitted Second Level Domain is invalid `'.($domain->value() ?? 'NULL').'`; it should contains only one label.', $domain);
|
||||
}
|
||||
|
||||
public static function dueToIdenticalValue(DomainName $domain): self
|
||||
{
|
||||
return new self('The public suffix and the domain name are identical `'.$domain->toString().'`.', $domain);
|
||||
}
|
||||
|
||||
public static function dueToMissingSuffix(DomainName $domain, string $type): self
|
||||
{
|
||||
return new self('The domain "'.($domain->value() ?? 'NULL').'" does not contain a "'.$type.'" TLD.', $domain);
|
||||
}
|
||||
|
||||
public static function dueToUnresolvableDomain(DomainName $domain): self
|
||||
{
|
||||
return new self('The domain "'.($domain->value() ?? 'NULL').'" can not contain a public suffix.', $domain);
|
||||
}
|
||||
|
||||
public static function dueToMissingRegistrableDomain(DomainName $domain): self
|
||||
{
|
||||
return new self('A subdomain can not be added to a domain "'.($domain->value() ?? 'NULL').'" without a registrable domain part.', $domain);
|
||||
}
|
||||
|
||||
public function domain(): ?DomainName
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
}
|
||||
Vendored
+97
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "jeremykendall/php-domain-parser",
|
||||
"description": "Public Suffix List and IANA Root Zone Database based Domain parsing implemented in PHP.",
|
||||
"homepage": "https://github.com/jeremykendall/php-domain-parser",
|
||||
"support": {
|
||||
"issues": "https://github.com/jeremykendall/php-domain-parser/issues",
|
||||
"source": "https://github.com/jeremykendall/php-domain-parser"
|
||||
},
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeremy Kendall",
|
||||
"homepage": "https://about.me/jeremykendall",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Ignace Nyamagana Butera",
|
||||
"homepage": "https://nyamsprod.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Contributors",
|
||||
"homepage": "https://github.com/jeremykendall/php-domain-parser/graphs/contributors"
|
||||
}
|
||||
],
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nyamsprod"
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"Public Suffix List",
|
||||
"Top Level Domains",
|
||||
"domain parsing",
|
||||
"icann",
|
||||
"iana",
|
||||
"idn",
|
||||
"tld",
|
||||
"psl"
|
||||
],
|
||||
"require": {
|
||||
"php": "^8.1",
|
||||
"ext-filter": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.53.0",
|
||||
"guzzlehttp/guzzle": "^7.8.1",
|
||||
"guzzlehttp/psr7": "^1.6 || ^2.6.2",
|
||||
"phpstan/phpstan": "^1.10.66",
|
||||
"phpstan/phpstan-phpunit": "^1.3.16",
|
||||
"phpstan/phpstan-strict-rules": "^1.5.3",
|
||||
"phpunit/phpunit": "^10.5.15 || ^11.1.1",
|
||||
"psr/http-factory": "^1.0.2",
|
||||
"psr/simple-cache": "^1.0.1",
|
||||
"symfony/cache": "^v5.0.0 || ^6.4.6"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/http-client-implementation": "To use the storage functionnality which depends on PSR-18",
|
||||
"psr/http-factory-implementation": "To use the storage functionnality which depends on PSR-17",
|
||||
"psr/simple-cache-implementation": "To use the storage functionnality which depends on PSR-16",
|
||||
"league/uri": "To parse URL and validate host"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Pdp\\": "src/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"phpcs": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix -vvv --diff --dry-run --allow-risky=yes --ansi",
|
||||
"phpcs:fix": "php-cs-fixer fix -vvv --allow-risky=yes --ansi",
|
||||
"phpstan": "phpstan analyse -l max -c phpstan.neon src --memory-limit=256M --ansi",
|
||||
"phpunit": "XDEBUG_MODE=coverage phpunit --coverage-text",
|
||||
"test": [
|
||||
"@phpunit",
|
||||
"@phpstan",
|
||||
"@phpcs"
|
||||
]
|
||||
},
|
||||
"scripts-descriptions": {
|
||||
"phpcs": "Runs coding style test suite",
|
||||
"phpcs:fix": "Fix the package coding style",
|
||||
"phpstan": "Runs complete codebase static analysis",
|
||||
"phpunit": "Runs unit and functional testing",
|
||||
"test": "Runs the complete test suite"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "6.x-dev"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
Vendored
+1449
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user