false, 'consent' => false, 'site_id' => '', 'mode' => 'iframe', 'placement' => 'automatic', 'post_types' => ['post'], 'language' => 'auto', 'verification_token' => '', 'bound_host' => '', ], is_array($saved) ? $saved : []); } public function host(): string { $host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); return str_starts_with($host, 'www.') ? substr($host, 4) : $host; } public function origin(): string { $parts = wp_parse_url(home_url('/')); return ($parts['scheme'] ?? 'https').'://'.($parts['host'] ?? '').(isset($parts['port']) ? ':'.$parts['port'] : ''); } public function connected(): bool { $settings = $this->all(); return $settings['consent'] && self::uuid($settings['site_id']) && $settings['bound_host'] === $this->host(); } public function ready(): bool { return $this->all()['enabled'] && $this->connected(); } public function locale(): string { $language = $this->all()['language']; $language = $language === 'auto' ? substr(strtolower(determine_locale()), 0, 2) : $language; return in_array($language, self::LANGUAGES, true) ? $language : 'en'; } public static function uuid(mixed $value): bool { return is_string($value) && (bool) preg_match('/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/D', $value); } public function credential(string $name): string { if (!in_array($name, ['api_key', 'webhook_secret'], true)) { return ''; } $constant = 'KOMMENTTI_'.strtoupper($name); if (defined($constant)) { return is_string(constant($constant)) ? constant($constant) : ''; } $secrets = get_option('kommentti_secrets', []); $ciphertext = is_array($secrets) ? ($secrets[$name] ?? '') : ''; if (!$ciphertext || !function_exists('openssl_decrypt')) { return ''; } return $this->decrypt($ciphertext, $name); } private function decrypt(string $ciphertext, string $name): string { if (!function_exists('openssl_decrypt')) { return ''; } $decoded = base64_decode($ciphertext, true); if (!$decoded || strlen($decoded) < 30 || $decoded[0] !== "\x01") { return ''; } $plaintext = openssl_decrypt(substr($decoded, 29), 'aes-256-gcm', $this->encryption_key(), OPENSSL_RAW_DATA, substr($decoded, 1, 12), substr($decoded, 13, 16), $name); return is_string($plaintext) ? $plaintext : ''; } public function moderation_key(): string { $stored = get_user_meta(get_current_user_id(), 'kommentti_moderation_'.get_current_blog_id(), true); $site = $this->all()['site_id']; if (!is_array($stored) || ($stored['site'] ?? '') !== $site || ($stored['host'] ?? '') !== $this->host() || !is_string($stored['encrypted'] ?? null)) { return ''; } return $this->decrypt($stored['encrypted'], 'moderation:'.$site.':'.get_current_user_id()); } public function save_moderation_key(string $key, bool $clear = false): void { if (!current_user_can('moderate_comments') || !$this->connected()) { throw new RuntimeException(esc_html__('A connected site and comment moderation permission are required.', 'kommentti')); } $meta = 'kommentti_moderation_'.get_current_blog_id(); if ($clear) { delete_user_meta(get_current_user_id(), $meta); } elseif ($key !== '') { if (!preg_match('/^kmt_mod_[A-Za-z0-9]{64}$/D', $key)) { throw new InvalidArgumentException(esc_html__('Enter your personal on-site moderation key.', 'kommentti')); } $site = $this->all()['site_id']; update_user_meta(get_current_user_id(), $meta, [ 'site' => $site, 'host' => $this->host(), 'encrypted' => $this->encrypt($key, 'moderation:'.$site.':'.get_current_user_id()), ]); } } private function encrypt(string $plaintext, string $name): string { if (!function_exists('openssl_encrypt')) { throw new RuntimeException(esc_html__('OpenSSL is required to save secrets. You can also configure them in wp-config.php.', 'kommentti')); } $iv = random_bytes(12); $tag = ''; $encrypted = openssl_encrypt($plaintext, 'aes-256-gcm', $this->encryption_key(), OPENSSL_RAW_DATA, $iv, $tag, $name); if (!is_string($encrypted)) { throw new RuntimeException(esc_html__('The secret could not be saved securely.', 'kommentti')); } return base64_encode("\x01".$iv.$tag.$encrypted); } private function encryption_key(): string { return hash('sha256', wp_salt('auth').'|'.wp_salt('secure_auth').'|'.get_current_blog_id(), true); } public function save(array $input): void { if (!current_user_can('manage_options')) { throw new RuntimeException(esc_html__('Administrator permission is required.', 'kommentti')); } $previous = $this->all(); $text = static fn(string $name): string => is_string($input[$name] ?? null) ? trim($input[$name]) : ''; $siteId = strtolower($text('site_id')); if ($siteId !== '' && !self::uuid($siteId)) { throw new InvalidArgumentException(esc_html__('Enter the site ID from your Kommentti dashboard.', 'kommentti')); } $mode = $text('mode'); $placement = $text('placement'); $language = $text('language'); if (!in_array($mode, ['iframe', 'server'], true) || !in_array($placement, ['automatic', 'manual'], true) || !in_array($language, ['auto', ...self::LANGUAGES], true)) { throw new InvalidArgumentException(esc_html__('Choose a valid display setting.', 'kommentti')); } $postTypes = is_array($input['post_types'] ?? null) ? array_values(array_intersect(array_filter($input['post_types'], 'is_string'), get_post_types(['public' => true]))) : []; $postTypes = array_values(array_diff($postTypes, ['attachment'])); $enabled = ($input['enabled'] ?? '') === '1'; $consent = ($input['consent'] ?? '') === '1'; if ($enabled && (!$siteId || !$consent || !$postTypes)) { throw new InvalidArgumentException(esc_html__('To enable comments, enter a site ID, select post types and allow the Kommentti service connection.', 'kommentti')); } $origin = wp_parse_url($this->origin()); if ($enabled && (($origin['scheme'] ?? '') !== 'https' || isset($origin['port']))) { throw new InvalidArgumentException(esc_html__('Kommentti requires a public HTTPS website without a custom port.', 'kommentti')); } $verification = $text('verification_token'); if ($verification !== '' && !preg_match('/^[A-Za-z0-9]{48}$/D', $verification)) { throw new InvalidArgumentException(esc_html__('Paste only the 48-character verification value from the site dashboard.', 'kommentti')); } $secrets = $siteId === $previous['site_id'] ? get_option('kommentti_secrets', []) : []; $secrets = is_array($secrets) ? $secrets : []; foreach (['api_key' => '/^kmt_pub_[A-Za-z0-9]{64}$/D', 'webhook_secret' => '/^kmt_wh_[A-Za-z0-9]{64}$/D'] as $name => $pattern) { if (defined('KOMMENTTI_'.strtoupper($name))) { continue; } if (($input['clear_'.$name] ?? '') === '1') { unset($secrets[$name]); } elseif ($text($name) !== '') { if (!preg_match($pattern, $text($name))) { throw new InvalidArgumentException(esc_html__('The API key or webhook secret has an invalid format.', 'kommentti')); } $secrets[$name] = $this->encrypt($text($name), $name); } } if ($enabled && $mode === 'server' && empty($secrets['api_key']) && !defined('KOMMENTTI_API_KEY')) { throw new InvalidArgumentException(esc_html__('Server display requires a read-only API key from a Publisher or Media Network site.', 'kommentti')); } update_option('kommentti_secrets', $secrets, false); update_option('kommentti_settings', [ 'enabled' => $enabled, 'consent' => $consent, 'site_id' => $siteId, 'mode' => $mode, 'placement' => $placement, 'post_types' => $postTypes, 'language' => $language, 'verification_token' => $verification, 'bound_host' => $this->host(), ], false); update_option('kommentti_cache_generation', wp_generate_uuid4(), false); delete_transient('kommentti_api_backoff'); delete_transient('kommentti_connection_status'); } }