$texts */ private function __construct( public readonly ConsentPolicy $policy, public readonly ScriptMap $scripts, public readonly array $texts, public readonly string $privacyPolicyUrl, public readonly string $cookieDomain, public readonly bool $reopenButton, public readonly bool $logEnabled, public readonly int $logRetentionDays, public readonly ?SignalMap $consentMode, public readonly SignalMap $wpConsentApi, public readonly string $embedCategory, /** @var array provider slug => category id ('' = never gate) */ public readonly array $embedProviders, /** @var list */ public readonly array $gpcCategories, ) {} /** * Validates every setting, in the order below, and throws on the first * problem. Each private parser owns one setting and its error message. * * @param array $data */ public static function fromArray(array $data): self { $registry = self::categories($data['categories'] ?? null); $policy = self::policy($registry, $data['policy_version'] ?? null, $data['lifetime_days'] ?? null); $scripts = self::scripts($data['scripts'] ?? []); $texts = self::texts($data['texts'] ?? null); $privacyUrl = self::privacyPolicyUrl($data['privacy_policy_url'] ?? ''); $domain = self::cookieDomain($data['cookie_domain'] ?? ''); $reopen = self::reopenButton($data['reopen_button'] ?? true); [$logEnabled, $retention] = self::logging($data['log_enabled'] ?? true, $data['log_retention_days'] ?? 1095); $consentMode = self::consentMode($data['consent_mode'] ?? ['enabled' => false, 'signals' => []], $registry); $wpConsentApi = self::wpConsentApi($data['wp_consent_api'] ?? []); [$embedCategory, $embedProviders] = self::embeds($data['embeds'] ?? ['category' => '', 'providers' => []], $registry); $gpc = self::gpcCategories($data['gpc_categories'] ?? [], $registry); return new self( $policy, new ScriptMap($scripts, $registry), $texts, $privacyUrl, $domain, $reopen, $logEnabled, $retention, $consentMode, new SignalMap($wpConsentApi, $registry, self::WP_CONSENT_API_CATEGORIES, 'WP Consent API'), $embedCategory, $embedProviders, $gpc, ); } private static function categories(mixed $categories): CategoryRegistry { if (! is_array($categories) || ! array_is_list($categories)) { throw new InvalidConfiguration(esc_html__('"categories" must be a list.', 'mortar-consent')); } return new CategoryRegistry(...array_map( static fn(mixed $c): Category => is_array($c) ? Category::fromArray($c) : throw new InvalidConfiguration(esc_html__('Each category must be an array.', 'mortar-consent')), $categories, )); } private static function policy(CategoryRegistry $registry, mixed $version, mixed $lifetime): ConsentPolicy { if (! is_int($version) || ! is_int($lifetime)) { throw new InvalidConfiguration(esc_html__( '"policy_version" and "lifetime_days" must be integers.', 'mortar-consent', )); } return new ConsentPolicy($registry, $version, $lifetime); } /** @return array */ private static function scripts(mixed $scripts): array { if (! is_array($scripts)) { throw new InvalidConfiguration(esc_html__('"scripts" must be a map of handle => category.', 'mortar-consent')); } return $scripts; } /** @return array */ private static function texts(mixed $texts): array { if (! is_array($texts)) { throw new InvalidConfiguration(esc_html__('"texts" must be an array.', 'mortar-consent')); } $clean = []; foreach (self::TEXT_KEYS as $key) { $text = $texts[$key] ?? null; if (! is_string($text) || trim($text) === '' || strlen($text) > self::MAX_TEXT_LENGTH) { throw new InvalidConfiguration(esc_html(sprintf( /* translators: 1: text key, 2: maximum length in bytes */ __('Text "%1$s" must be a non-empty string under %2$d bytes.', 'mortar-consent'), $key, self::MAX_TEXT_LENGTH, ))); } $clean[$key] = $text; } return $clean; } private static function privacyPolicyUrl(mixed $url): string { if (! is_string($url) || ($url !== '' && ! self::isSafeUrl($url))) { throw new InvalidConfiguration(esc_html__( '"privacy_policy_url" must be an http(s) URL or a site-relative path.', 'mortar-consent', )); } return $url; } private static function cookieDomain(mixed $domain): string { $hostname = '/^\.?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/D'; if (! is_string($domain) || ($domain !== '' && preg_match($hostname, $domain) !== 1)) { throw new InvalidConfiguration(esc_html__( '"cookie_domain" must be empty or a lowercase hostname.', 'mortar-consent', )); } return $domain; } private static function reopenButton(mixed $reopen): bool { if (! is_bool($reopen)) { throw new InvalidConfiguration(esc_html__('"reopen_button" must be a boolean.', 'mortar-consent')); } return $reopen; } /** @return array{bool, int} whether the log is on, and its retention in days */ private static function logging(mixed $enabled, mixed $retention): array { $validRetention = is_int($retention) && $retention >= self::MIN_RETENTION_DAYS && $retention <= self::MAX_RETENTION_DAYS; if (! is_bool($enabled) || ! $validRetention) { throw new InvalidConfiguration(esc_html(sprintf( /* translators: 1: minimum days, 2: maximum days */ __('"log_enabled" must be a boolean and "log_retention_days" between %1$d and %2$d.', 'mortar-consent'), self::MIN_RETENTION_DAYS, self::MAX_RETENTION_DAYS, ))); } return [$enabled, $retention]; } /** The Consent Mode signal map when enabled, otherwise null. */ private static function consentMode(mixed $consentMode, CategoryRegistry $registry): ?SignalMap { if (! is_array($consentMode) || ! is_bool($consentMode['enabled'] ?? null) || ! is_array($consentMode['signals'] ?? null)) { throw new InvalidConfiguration(esc_html__( '"consent_mode" needs a boolean "enabled" and a "signals" map.', 'mortar-consent', )); } // Validated even when disabled, so turning it on later cannot fail. $signals = new SignalMap($consentMode['signals'], $registry, self::CONSENT_MODE_SIGNALS, 'Consent Mode'); return $consentMode['enabled'] ? $signals : null; } /** @return array */ private static function wpConsentApi(mixed $map): array { if (! is_array($map)) { throw new InvalidConfiguration(esc_html__( '"wp_consent_api" must be a map of WP Consent API category => category ids.', 'mortar-consent', )); } return $map; } /** @return list */ private static function gpcCategories(mixed $gpc, CategoryRegistry $registry): array { if (! is_array($gpc) || ! array_is_list($gpc)) { throw new InvalidConfiguration(esc_html__('"gpc_categories" must be a list of category ids.', 'mortar-consent')); } foreach ($gpc as $id) { if (! is_string($id) || ! $registry->isOptional($id)) { throw new InvalidConfiguration(esc_html(sprintf( /* translators: %s: category id */ __('"gpc_categories" refers to "%s", which is not an optional category.', 'mortar-consent'), is_string($id) ? $id : gettype($id), ))); } } return array_values(array_unique($gpc)); } /** The category an embed from this provider needs, or null to leave it alone. */ public function embedCategoryFor(string $provider): ?string { $category = $this->embedProviders[$provider] ?? $this->embedCategory; return $category === '' ? null : $category; } /** * @return array{string, array} */ private static function embeds(mixed $embeds, CategoryRegistry $registry): array { if (! is_array($embeds) || ! is_string($embeds['category'] ?? null) || ! is_array($embeds['providers'] ?? [])) { throw new InvalidConfiguration(esc_html__( '"embeds" needs a "category" string and an optional "providers" map.', 'mortar-consent', )); } $check = static function (string $id, string $what) use ($registry): void { if ($id !== '' && ! $registry->isOptional($id)) { throw new InvalidConfiguration(esc_html(sprintf( /* translators: 1: setting name, 2: category id */ __('%1$s refers to "%2$s", which is not an optional category.', 'mortar-consent'), $what, $id, ))); } }; $check($embeds['category'], 'The embed category'); $providers = []; foreach ($embeds['providers'] ?? [] as $provider => $id) { if (! is_string($provider) || preg_match('/^[a-z0-9-]{1,40}$/D', $provider) !== 1 || ! is_string($id)) { throw new InvalidConfiguration(esc_html__( 'Embed providers must map a provider slug to a category id (or "" to never gate it).', 'mortar-consent', )); } $check($id, sprintf('Embed provider "%s"', $provider)); $providers[$provider] = $id; } return [$embeds['category'], $providers]; } /** * The subset the browser runtime needs. Labels and copy are rendered * server-side, so they are not duplicated here. * * @return array */ public function toClientArray(): array { return [ 'cookie' => [ 'name' => ConsentCookieCodec::COOKIE_NAME, 'domain' => $this->cookieDomain, ], 'policyVersion' => $this->policy->version, 'lifetime' => $this->policy->lifetimeSeconds(), 'categories' => array_map( static fn(Category $c): array => ['id' => $c->id, 'required' => $c->required, 'cookies' => $c->cookieNames()], $this->policy->categories->all(), ), 'consentMode' => $this->consentMode?->toArray(), 'wpConsentApi' => $this->wpConsentApi->toArray(), 'gpc' => $this->gpcCategories, ]; } private static function isSafeUrl(string $url): bool { if (str_starts_with($url, '/') && ! str_starts_with($url, '//')) { return ! preg_match('/[\s\\\\]/', $url); } $scheme = parse_url($url, PHP_URL_SCHEME); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- this layer does not load WordPress. return in_array($scheme, ['http', 'https'], true) && filter_var($url, FILTER_VALIDATE_URL) !== false; } }