diff --git a/acp/wpn_acp_module.php b/acp/wpn_acp_module.php index 88131ab..3b7e106 100644 --- a/acp/wpn_acp_module.php +++ b/acp/wpn_acp_module.php @@ -61,9 +61,6 @@ class wpn_acp_module /** @var array $errors */ protected $errors = []; - /** @var string Hide/replace private key with asterisks */ - public const MASKED_PRIVATE_KEY = '********'; - /** * Main ACP module * @@ -142,7 +139,8 @@ public function display_settings() $this->template->assign_vars([ 'S_WEBPUSH_ENABLE' => $this->config['wpn_webpush_enable'], 'WEBPUSH_VAPID_PUBLIC' => $this->config['wpn_webpush_vapid_public'], - 'WEBPUSH_VAPID_PRIVATE' => $this->config['wpn_webpush_vapid_private'] ? self::MASKED_PRIVATE_KEY : '', + 'S_VAPID_KEYS_CONFIGURED' => (bool) ($this->config['wpn_webpush_vapid_public'] && $this->config['wpn_webpush_vapid_private']), + 'S_VAPID_PRIVATE_CONFIGURED' => (bool) $this->config['wpn_webpush_vapid_private'], 'S_WEBPUSH_DROPDOWN_SUBSCRIBE' => $this->config['wpn_webpush_dropdown_subscribe'], 'S_WEBPUSH_METHOD_ENABLED' => $this->config['wpn_webpush_method_enabled'], 'S_WEBPUSH_POPUP_PROMPT' => $this->config['wpn_webpush_popup_prompt'], @@ -174,8 +172,8 @@ public function save_settings() 'wpn_webpush_popup_prompt' => ['validate' => 'bool'], ]; - // Do not validate and update private key field if the content is ******** and the key was already set - if ($config_array['wpn_webpush_vapid_private'] === self::MASKED_PRIVATE_KEY && $this->config['wpn_webpush_vapid_private']) + // An empty private key keeps the stored credential. Stored private keys are never sent to the browser. + if (($config_array['wpn_webpush_vapid_private'] ?? '') === '' && $this->config['wpn_webpush_vapid_private']) { unset($display_settings['wpn_webpush_vapid_private'], $config_array['wpn_webpush_vapid_private']); } @@ -209,6 +207,7 @@ public function display_pwa_settings() $this->template->assign_vars([ 'S_PWA_SHOW_INSTALL_BANNER' => (bool) $this->config['pwa_show_install_banner'], 'PWA_SHORT_NAME' => $this->config['pwa_short_name'], + 'PWA_SHORT_NAME_PLACEHOLDER'=> utf8_substr(ext::decode_entities($this->config['sitename'], ENT_QUOTES), 0, 12), 'PWA_ICON_SMALL' => $this->config['pwa_icon_small'], 'PWA_ICON_LARGE' => $this->config['pwa_icon_large'], 'STYLES' => $this->get_styles(), diff --git a/adm/style/wpn_acp.css b/adm/style/wpn_acp.css new file mode 100644 index 0000000..5d9f2db --- /dev/null +++ b/adm/style/wpn_acp.css @@ -0,0 +1,256 @@ +.vapid-credentials { + background: #f7fbff; + border: 1px solid #b8d3e6; + border-radius: 7px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.vapid-credentials-header { + display: flex; + align-items: center; + padding: 18px; + gap: 14px; +} + +.vapid-credentials-icon { + font-size: 18px; + background: #105289; + border-radius: 50%; + color: #ffffff; + display: flex; + flex: 0 0 42px; + justify-content: center; + align-items: center; + height: 42px; +} + +.vapid-credentials-summary { + flex: 1 1 auto; +} + +.vapid-credentials-summary h2 { + font-size: 1.25rem; + color: #115098; + display: inline; + margin: 0 8px 0 0; +} + +.vapid-credentials-summary p { + line-height: 1.45; + color: #536471; + margin: 7px 0 0; +} + +.vapid-status-badge { + font-size: 72%; + font-weight: bold; + vertical-align: 2px; + letter-spacing: 0.02em; + text-transform: uppercase; + border-radius: 10px; + display: inline-block; + padding: 3px 8px; +} + +.vapid-status-badge.is-configured { + background: #d9ead3; + color: #276221; +} + +.vapid-status-badge.needs-setup { + background: #fff0c2; + color: #725400; +} + +.vapid-status-badge.is-pending { + background: #d8eafa; + color: #105289; +} + +.vapid-generate-button, +.vapid-toggle-private { + font-family: "Lucida Grande", Verdana, Helvetica, Arial, sans-serif; + width: auto; + cursor: pointer; +} + +.vapid-generate-button { + font-size: 90%; + font-weight: bold; + background: linear-gradient(#2f82bd, #1d6397); + border: 1px solid #174f7a; + border-radius: 4px; + color: #ffffff; + flex: 0 0 auto; + padding: 8px 12px; +} + +.vapid-generate-button:hover, +.vapid-generate-button:focus { + background: linear-gradient(#368fcf, #246fa5); + border-color: #0e3f65; + outline: 2px solid #b8d3e6; +} + +.vapid-generate-button:disabled { + opacity: 0.65; + cursor: wait; +} + +.vapid-generation-status { + background: #e5f3df; + border-left: 3px solid #4f8a3c; + color: #276221; + display: none; + margin: 0 18px 14px 74px; + padding: 8px 10px; +} + +.vapid-generation-status.has-message { + display: block; +} + +.vapid-generation-status.error { + background: #f8e1e1; + border-left-color: #bc2a4d; + color: #8a1f39; +} + +.vapid-credentials-fields { + background: #ffffff; + border-top: 1px solid #d7e5ef; + padding: 12px 18px 2px; +} + +.vapid-credentials-fields dl:last-child { + margin-bottom: 4px; +} + +.vapid-credentials-fields input[type="text"], +.vapid-credentials-fields input[type="password"] { + box-sizing: border-box; + width: 100%; + max-width: 520px; +} + +.vapid-private-field { + display: flex; + align-items: center; + gap: 5px; +} + +.vapid-private-field input { + flex: 1 1 auto; +} + +.vapid-toggle-private { + background: #efefef; + border: 1px solid #666666; + border-radius: 3px; + color: #333333; + flex: 0 0 auto; + padding: 3px 7px; +} + +.vapid-toggle-private:hover, +.vapid-toggle-private:focus { + border-color: #bc2a4d; + color: #bc2a4d; +} + +.vapid-rotation-warning { + font-size: 85%; + background: #fff8df; + border-top: 1px solid #eadca7; + color: #6b5712; + margin: 0; + padding: 10px 18px; +} + +.phpbb_alert.vapid-confirm-dialog { + border-radius: 7px; + top: 12vh; + overflow: hidden; + box-sizing: border-box; + width: 520px; + max-width: calc(100% - 40px); + max-height: calc(100vh - 40px); + padding: 0; +} + +.vapid-confirm-dialog .alert_close { + position: absolute; + z-index: 1; + top: 14px; + right: 14px; + margin: 0; +} + +.vapid-confirm-dialog .alert_text { + overflow-y: auto; + box-sizing: border-box; + max-height: calc(100vh - 40px); + padding: 24px; +} + +.vapid-confirm-message { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.vapid-confirm-icon { + font-size: 18px; + background: #fff0c2; + border-radius: 50%; + color: #8a6500; + display: flex; + flex: 0 0 42px; + justify-content: center; + align-items: center; + height: 42px; +} + +.vapid-confirm-message h3 { + font-size: 1.1rem; + color: #333333; + margin: 2px 28px 8px 0; +} + +.vapid-confirm-message p { + line-height: 1.5; + color: #536471; + margin: 0; + padding: 0; +} + +.vapid-confirm-dialog fieldset.vapid-confirm-actions { + border-top: 1px solid #d7e5ef; + margin-top: 20px; + padding-top: 16px; +} + +.vapid-confirm-actions input { + min-width: 72px; + margin: 0 3px; +} + +@media only screen and (max-width: 700px), only screen and (max-device-width: 700px) { + .vapid-credentials-header { + flex-wrap: wrap; + align-items: flex-start; + } + + .vapid-credentials-summary { + flex-basis: calc(100% - 60px); + } + + .vapid-generate-button { + width: 100%; + } + + .vapid-generation-status { + margin-left: 18px; + } +} diff --git a/adm/style/wpn_acp.js b/adm/style/wpn_acp.js index 32e000e..24c1607 100644 --- a/adm/style/wpn_acp.js +++ b/adm/style/wpn_acp.js @@ -1,7 +1,5 @@ /* global phpbb */ -'use strict'; - /** * Convert raw key ArrayBuffer to base64 string. * @@ -9,6 +7,7 @@ * @returns {string} Base64 encoded raw key string */ phpbb.rawKeyToBase64 = (rawKey) => { + 'use strict'; const keyBuffer = new Uint8Array(rawKey); let keyText = ''; const keyLength = keyBuffer.byteLength; @@ -26,54 +25,126 @@ phpbb.rawKeyToBase64 = (rawKey) => { * @returns {string} Base64URL encoded string */ phpbb.base64UrlEncode = (base64String) => { + 'use strict'; return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }; /** - * This callback generates the VAPID keys for the web push notification service. + * Generate a VAPID key pair with public and private key strings. + * + * @returns {Promise<{privateKey: string, publicKey: string}>} */ -phpbb.addAjaxCallback('generate_vapid_keys', () => { - - /** - * Generate VAPID keypair with public and private key string - * - * @returns {Promise<{privateKey: string, publicKey: string}|null>} - */ - async function generateVAPIDKeys() { +phpbb.generateVAPIDKeys = async() => { + 'use strict'; + const keyPair = await window.crypto.subtle.generateKey( + { + name: 'ECDH', + namedCurve: 'P-256', + }, + true, + [ 'deriveKey', 'deriveBits' ] + ); + + const privateKeyJwk = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey); + const publicKeyBuffer = await window.crypto.subtle.exportKey('raw', keyPair.publicKey); + + return { + privateKey: privateKeyJwk.d, + publicKey: phpbb.base64UrlEncode(phpbb.rawKeyToBase64(publicKeyBuffer)) + }; +}; + +document.addEventListener('DOMContentLoaded', () => { + 'use strict'; + const credentials = document.querySelector('#vapid-credentials'); + const generateButton = document.querySelector('#generate-vapid-keys'); + const publicKeyInput = document.querySelector('#webpush_vapid_public'); + const privateKeyInput = document.querySelector('#webpush_vapid_private'); + const togglePrivateButton = document.querySelector('#toggle-vapid-private'); + const statusBadge = document.querySelector('#vapid-status-badge'); + const generationStatus = document.querySelector('#vapid-generation-status'); + const settingsForm = document.querySelector('#webpushnotifications_settings'); + const confirmationTemplate = document.querySelector('#vapid-rotation-confirm-template'); + const confirmationDialog = document.querySelector('#phpbb_confirm'); + + if (!credentials || !generateButton || !publicKeyInput || !privateKeyInput || !confirmationTemplate || !confirmationDialog) { + return; + } + + const initialBadgeText = statusBadge.textContent.trim(); + const initialBadgeClass = statusBadge.className; + + const hidePrivateKey = () => { + privateKeyInput.type = 'password'; + togglePrivateButton.setAttribute('aria-pressed', 'false'); + togglePrivateButton.setAttribute('aria-label', togglePrivateButton.dataset.showLabel); + togglePrivateButton.querySelector('.icon').className = 'icon fa-eye fa-fw'; + }; + + togglePrivateButton.addEventListener('click', () => { + const showKey = privateKeyInput.type === 'password'; + privateKeyInput.type = showKey ? 'text' : 'password'; + togglePrivateButton.setAttribute('aria-pressed', showKey ? 'true' : 'false'); + togglePrivateButton.setAttribute('aria-label', showKey ? togglePrivateButton.dataset.hideLabel : togglePrivateButton.dataset.showLabel); + togglePrivateButton.querySelector('.icon').className = showKey ? 'icon fa-eye-slash fa-fw' : 'icon fa-eye fa-fw'; + }); + + const generateAndDisplayKeys = async() => { + const buttonIcon = generateButton.querySelector('.icon'); + generateButton.disabled = true; + generateButton.setAttribute('aria-busy', 'true'); + buttonIcon.classList.add('fa-spin'); + generationStatus.className = 'vapid-generation-status'; + generationStatus.textContent = ''; + try { - // Generate a new key pair using the Subtle Crypto API - const keyPair = await crypto.subtle.generateKey( - { - name: 'ECDH', - namedCurve: 'P-256', - }, - true, - ['deriveKey', 'deriveBits'] - ); - - const privateKeyJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); - const privateKeyString = privateKeyJwk.d; - - const publicKeyBuffer = await crypto.subtle.exportKey('raw', keyPair.publicKey); - const publicKeyString = phpbb.base64UrlEncode(phpbb.rawKeyToBase64(publicKeyBuffer)); - - return { - privateKey: privateKeyString, - publicKey: publicKeyString - }; + const keyPair = await phpbb.generateVAPIDKeys(); + publicKeyInput.value = keyPair.publicKey; + privateKeyInput.value = keyPair.privateKey; + hidePrivateKey(); + + statusBadge.className = 'vapid-status-badge is-pending'; + statusBadge.textContent = generateButton.dataset.generatedLabel; + generationStatus.className = 'vapid-generation-status has-message'; + generationStatus.textContent = generateButton.dataset.generatedMessage; } catch (error) { - console.error('Error generating keys with SubtleCrypto:', error); - return null; + console.error('Error generating VAPID keys with SubtleCrypto:', error); + generationStatus.className = 'vapid-generation-status has-message error'; + generationStatus.textContent = generateButton.dataset.errorMessage; + } finally { + generateButton.disabled = false; + generateButton.removeAttribute('aria-busy'); + buttonIcon.classList.remove('fa-spin'); } - } + }; - generateVAPIDKeys().then(keyPair => { - if (!keyPair) { + generateButton.addEventListener('click', () => { + if (credentials.dataset.keysConfigured === 'true') { + confirmationDialog.classList.add('vapid-confirm-dialog'); + confirmationDialog.setAttribute('role', 'dialog'); + confirmationDialog.setAttribute('aria-modal', 'true'); + confirmationDialog.setAttribute('aria-labelledby', 'vapid-confirm-title'); + + phpbb.confirm(confirmationTemplate.innerHTML, (confirmed) => { + if (confirmed) { + void generateAndDisplayKeys(); + } + }); + + confirmationDialog.querySelector('input[name="cancel"]').focus(); return; } - const publicKeyInput = document.querySelector('#webpush_vapid_public'); - const privateKeyInput = document.querySelector('#webpush_vapid_private'); - publicKeyInput.value = keyPair.publicKey; - privateKeyInput.value = keyPair.privateKey; + + void generateAndDisplayKeys(); + }); + + settingsForm.addEventListener('reset', () => { + window.setTimeout(() => { + hidePrivateKey(); + statusBadge.className = initialBadgeClass; + statusBadge.textContent = initialBadgeText; + generationStatus.className = 'vapid-generation-status'; + generationStatus.textContent = ''; + }, 0); }); }); diff --git a/adm/style/wpn_acp_pwa.html b/adm/style/wpn_acp_pwa.html index ce4e063..bf45709 100644 --- a/adm/style/wpn_acp_pwa.html +++ b/adm/style/wpn_acp_pwa.html @@ -20,7 +20,7 @@

{{ lang('WARNING') }}

{{ lang('GENERAL_SETTINGS') }}

{{ lang('PWA_SHORT_NAME_EXPLAIN') }}
-
+

{{ lang('PWA_ICON_SMALL_EXPLAIN', iconDir) }}
diff --git a/adm/style/wpn_acp_settings.html b/adm/style/wpn_acp_settings.html index f1fa5af..e01f053 100644 --- a/adm/style/wpn_acp_settings.html +++ b/adm/style/wpn_acp_settings.html @@ -1,4 +1,5 @@ {% include 'overall_header.html' %} +{% INCLUDECSS '@phpbb_webpushnotifications/wpn_acp.css' %} {% INCLUDEJS '@phpbb_webpushnotifications/wpn_acp.js' %}

{{ lang('ACP_WEBPUSH_EXT_SETTINGS') }}

@@ -18,16 +19,7 @@

{{ lang('WARNING') }}


{{ lang('WEBPUSH_ENABLE_EXPLAIN') }}
- -
-
-
-

{{ lang('WEBPUSH_VAPID_PUBLIC_EXPLAIN') }}
-
-
-
-

{{ lang('WEBPUSH_VAPID_PRIVATE_EXPLAIN') }}
-
+

{{ lang('WEBPUSH_DROPDOWN_SUBSCRIBE_EXPLAIN') }}
@@ -45,6 +37,72 @@

{{ lang('WARNING') }}

+ +
+ {{ lang('WEBPUSH_VAPID_CREDENTIALS') }} +
+
+ +
+

{{ lang('WEBPUSH_VAPID_CREDENTIALS') }}

+ + {{ S_VAPID_KEYS_CONFIGURED ? lang('WEBPUSH_VAPID_KEYS_CONFIGURED') : lang('WEBPUSH_VAPID_KEYS_REQUIRED') }} + +

{{ lang('WEBPUSH_VAPID_CREDENTIALS_EXPLAIN') }}

+
+ +
+ +
+ +
+
+

{{ lang('WEBPUSH_VAPID_PUBLIC_EXPLAIN') }}
+
+
+
+

{{ lang('WEBPUSH_VAPID_PRIVATE_EXPLAIN') }}
+
+
+ + +
+
+
+
+ +

{{ lang('WEBPUSH_VAPID_ROTATION_WARNING') }}

+
+
  @@ -52,4 +110,18 @@

{{ lang('WARNING') }}

+ + {% include 'overall_footer.html' %} diff --git a/language/en/webpushnotifications_module_acp.php b/language/en/webpushnotifications_module_acp.php index 2747580..0abce04 100644 --- a/language/en/webpushnotifications_module_acp.php +++ b/language/en/webpushnotifications_module_acp.php @@ -42,11 +42,26 @@ 'ACP_WEBPUSH_SETTINGS_EXPLAIN' => 'Here you can enable Web Push for board notifications. Web Push is a protocol for the real-time delivery of events to user agents, commonly referred to as push messages. It is compatible with the majority of modern browsers on both desktop and mobile devices. Users can opt to receive Web Push alerts in their browser by subscribing and enabling their preferred notifications in the UCP.

To support push notifications on Apple mobile devices, your site must function as a Progressive Web App (PWA). Apple requires users to add the site to their device’s home screen before push notifications can be enabled. Configure app appearance under PWA settings.', 'WEBPUSH_ENABLE' => 'Enable Web Push', 'WEBPUSH_ENABLE_EXPLAIN' => 'Allow users to receive notifications in their browser or device via Web Push. To utilise Web Push, you must input or generate valid VAPID identification keys.', - 'WEBPUSH_GENERATE_VAPID_KEYS' => 'Generate Identification keys', + 'WEBPUSH_VAPID_CREDENTIALS' => 'VAPID credentials', + 'WEBPUSH_VAPID_CREDENTIALS_EXPLAIN'=> 'These keys identify your board to push services. Generate a new key pair or enter an existing pair below.', + 'WEBPUSH_VAPID_KEYS_CONFIGURED'=> 'Keys configured', + 'WEBPUSH_VAPID_KEYS_REQUIRED' => 'Setup required', + 'WEBPUSH_VAPID_KEYS_PENDING' => 'Unsaved keys', + 'WEBPUSH_GENERATE_VAPID_KEYS' => 'Generate key pair', + 'WEBPUSH_REGENERATE_VAPID_KEYS'=> 'Generate new key pair', + 'WEBPUSH_ROTATE_VAPID_KEYS_TITLE'=> 'Generate a new VAPID key pair?', + 'WEBPUSH_ROTATE_VAPID_KEYS_CONFIRM'=> 'Saving the new keys will invalidate all existing Web Push subscriptions. Users will need to subscribe again.', + 'WEBPUSH_VAPID_KEYS_GENERATED'=> 'New key pair generated. Submit this form to activate it.', + 'WEBPUSH_VAPID_KEYS_GENERATION_FAILED'=> 'Could not generate a key pair in this browser. Enter a valid pair manually or try a supported browser.', + 'WEBPUSH_VAPID_ROTATION_WARNING'=> 'Important: Changing either key invalidates all existing Web Push subscriptions.', 'WEBPUSH_VAPID_PUBLIC' => 'Server identification public key', - 'WEBPUSH_VAPID_PUBLIC_EXPLAIN' => 'The Voluntary Application Server Identification (VAPID) public key is shared to authenticate push messages from your site.
Caution: Modifying the VAPID public key will automatically render all Web Push subscriptions invalid.', + 'WEBPUSH_VAPID_PUBLIC_EXPLAIN' => 'Shared with push services to authenticate messages sent from your board.', 'WEBPUSH_VAPID_PRIVATE' => 'Server identification private key', - 'WEBPUSH_VAPID_PRIVATE_EXPLAIN' => 'The Voluntary Application Server Identification (VAPID) private key is used to generate authenticated push messages dispatched from your site. The VAPID private key must form a valid public-private key pair alongside the VAPID public key.
Caution: Modifying the VAPID private key will automatically render all Web Push subscriptions invalid.', + 'WEBPUSH_VAPID_PRIVATE_EXPLAIN' => 'Signs outgoing push messages. It must be paired with its corresponding public key and kept confidential.', + 'WEBPUSH_VAPID_PRIVATE_KEEP' => 'Leave blank to keep the stored private key', + 'WEBPUSH_VAPID_PRIVATE_ENTER' => 'Enter a private key or generate a key pair', + 'WEBPUSH_SHOW_PRIVATE_KEY' => 'Show private key', + 'WEBPUSH_HIDE_PRIVATE_KEY' => 'Hide private key', 'WEBPUSH_METHOD_ENABLED' => 'Enable user-based web push notifications by default', 'WEBPUSH_METHOD_ENABLED_EXPLAIN'=> 'When this setting is enabled, users who have also enabled and allowed browser notifications will start receiving them automatically. They can visit the UCP Notification settings to disable any unwanted notifications.

If this setting is disabled, users will not receive any notifications, even if they have enabled push notifications, until they visit the UCP Notification settings to allow the specific notifications they wish to receive.', 'WEBPUSH_DROPDOWN_SUBSCRIBE' => 'Show web push settings in the notification dropdown', diff --git a/language/ru/webpushnotifications_module_acp.php b/language/ru/webpushnotifications_module_acp.php index b3d9ab8..9fa204b 100644 --- a/language/ru/webpushnotifications_module_acp.php +++ b/language/ru/webpushnotifications_module_acp.php @@ -42,11 +42,26 @@ 'ACP_WEBPUSH_SETTINGS_EXPLAIN' => 'Здесь вы можете включить браузерные push—уведомления. Браузерные push—уведомления — это протокол мгновенной доставки пользователю сообщений о различных событиях. Они совместимы с большинством современных браузеров как на настольных, так и на мобильных устройствах. Пользователи могут включить их в своих браузерах и выбрать предпочтительные виды браузерных уведомлений в личном разделе.
Для работы браузерных push—уведомлений необходимо ввести или сгенерировать ключи идентификации ниже.

Для того, чтобы браузерные push—уведомления стали доступны на мобильных устройствах Apple, конференция должна функционировать как прогрессивное веб—приложение (PWA). Параметры прогрессивного веб—приложения (краткое имя сайта и значки для его отображения на домашнем экране) можно задать в разделе Настройки PWA. После этого пользователи смогут добавлять сайт конференции на домашний экран своего мобильного устройства Apple и получать браузерные push—уведомления.', 'WEBPUSH_ENABLE' => 'Включить браузерные push—уведомления', 'WEBPUSH_ENABLE_EXPLAIN' => 'Включение возможности получения браузерных push—уведомлений для всех пользователей. Для использования браузерных push—уведомлений необходимо задать или сгенерировать корректные ключи идентификации VAPID.', - 'WEBPUSH_GENERATE_VAPID_KEYS' => 'Сгенерировать ключи идентификации', + 'WEBPUSH_VAPID_CREDENTIALS' => 'Ключи VAPID', + 'WEBPUSH_VAPID_CREDENTIALS_EXPLAIN'=> 'Эти ключи идентифицируют конференцию в службах push—уведомлений. Сгенерируйте новую пару или введите существующую пару ключей ниже.', + 'WEBPUSH_VAPID_KEYS_CONFIGURED'=> 'Ключи настроены', + 'WEBPUSH_VAPID_KEYS_REQUIRED' => 'Требуется настройка', + 'WEBPUSH_VAPID_KEYS_PENDING' => 'Ключи не сохранены', + 'WEBPUSH_GENERATE_VAPID_KEYS' => 'Сгенерировать пару ключей', + 'WEBPUSH_REGENERATE_VAPID_KEYS'=> 'Сгенерировать новую пару', + 'WEBPUSH_ROTATE_VAPID_KEYS_TITLE'=> 'Сгенерировать новую пару ключей VAPID?', + 'WEBPUSH_ROTATE_VAPID_KEYS_CONFIRM'=> 'После сохранения новых ключей все существующие подписки на браузерные push—уведомления станут недействительными. Пользователям потребуется подписаться снова.', + 'WEBPUSH_VAPID_KEYS_GENERATED'=> 'Новая пара ключей сгенерирована. Отправьте эту форму, чтобы активировать её.', + 'WEBPUSH_VAPID_KEYS_GENERATION_FAILED'=> 'Не удалось сгенерировать пару ключей в этом браузере. Введите корректную пару вручную или используйте поддерживаемый браузер.', + 'WEBPUSH_VAPID_ROTATION_WARNING'=> 'Важно: изменение любого ключа делает недействительными все существующие подписки на браузерные push—уведомления.', 'WEBPUSH_VAPID_PUBLIC' => 'Публичный ключ идентификации сервера', - 'WEBPUSH_VAPID_PUBLIC_EXPLAIN' => 'Публичный ключ идентификации сервера VAPID (Voluntary Application Server Identification) необходим для аутентификации отправки push—уведомлений с вашей конференции.
Внимание: изменение публичного ключа VAPID приведёт к автоматической отмене всех действующих подписок на push—уведомления.', + 'WEBPUSH_VAPID_PUBLIC_EXPLAIN' => 'Передаётся службам push—уведомлений для аутентификации сообщений, отправляемых конференцией.', 'WEBPUSH_VAPID_PRIVATE' => 'Приватный ключ идентификации сервера', - 'WEBPUSH_VAPID_PRIVATE_EXPLAIN' => 'Приватный ключ идентификации сервера VAPID (Voluntary Application Server Identification) необходим для создания push—уведомлений, отправляемых с вашей конференции. Приватный ключ VAPID должен составлять корректную пару ключей вместе с публичным ключом VAPID.
Внимание: изменение приватного ключа VAPID приведёт к автоматической отмене всех действующих подписок на push—уведомления.', + 'WEBPUSH_VAPID_PRIVATE_EXPLAIN' => 'Используется для подписи исходящих push—уведомлений. Приватный и публичный ключи должны составлять одну криптографическую пару; приватный ключ необходимо хранить в тайне.', + 'WEBPUSH_VAPID_PRIVATE_KEEP' => 'Оставьте поле пустым, чтобы сохранить текущий приватный ключ', + 'WEBPUSH_VAPID_PRIVATE_ENTER' => 'Введите приватный ключ или сгенерируйте пару ключей', + 'WEBPUSH_SHOW_PRIVATE_KEY' => 'Показать приватный ключ', + 'WEBPUSH_HIDE_PRIVATE_KEY' => 'Скрыть приватный ключ', 'WEBPUSH_METHOD_ENABLED' => 'Включить все типы уведомлений по умолчанию', 'WEBPUSH_METHOD_ENABLED_EXPLAIN'=> 'Если включено, то пользователи, подписавшиеся на браузерные push—уведомления, будут автоматически получать все их типы. Если отключено, то пользователи не будут получать браузерные push—уведомления до тех пор, пока хотя бы один их тип не выбран.

Отключить нежелательные или выбрать нужные типы браузерных push—уведомлений можно в настройках уведомлений в Личном разделе.', 'WEBPUSH_DROPDOWN_SUBSCRIBE' => 'Показать кнопку «Подписаться» в выпадающем меню уведомлений', diff --git a/migrations/generate_vapid_keys.php b/migrations/generate_vapid_keys.php new file mode 100644 index 0000000..b047536 --- /dev/null +++ b/migrations/generate_vapid_keys.php @@ -0,0 +1,80 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\webpushnotifications\migrations; + +use Minishlink\WebPush\VAPID; +use phpbb\db\migration\migration; + +class generate_vapid_keys extends migration +{ + public function effectively_installed(): bool + { + return !empty($this->config['wpn_webpush_vapid_public']) + || !empty($this->config['wpn_webpush_vapid_private']); + } + + public static function depends_on() + { + return ['\phpbb\webpushnotifications\migrations\add_webpush']; + } + + public function update_data(): array + { + return [ + ['custom', [[$this, 'generate_keys']]], + ]; + } + + /** + * Generate and store a VAPID key pair when no key material exists. + */ + public function generate_keys(): void + { + if ($this->effectively_installed()) + { + return; + } + + try + { + $keys = $this->create_vapid_keys(); + } + catch (\Throwable $e) + { + // Keep installation usable. Keys can still be generated from the ACP. + return; + } + + if (empty($keys['publicKey']) || empty($keys['privateKey'])) + { + return; + } + + $this->config->set('wpn_webpush_vapid_public', $keys['publicKey']); + $this->config->set('wpn_webpush_vapid_private', $keys['privateKey']); + } + + /** + * Create a VAPID key pair. + * + * @return string[] + * @throws \ErrorException + */ + protected function create_vapid_keys(): array + { + if (!class_exists(VAPID::class)) + { + require_once __DIR__ . '/../vendor/autoload.php'; + } + + return VAPID::createVapidKeys(); + } +} diff --git a/tests/acp/acp_module_test.php b/tests/acp/acp_module_test.php index 83c5391..4abc8ad 100644 --- a/tests/acp/acp_module_test.php +++ b/tests/acp/acp_module_test.php @@ -353,9 +353,9 @@ public function webpush_save_data(): array ], true, ], - 'masked existing private key is preserved' => [ + 'blank existing private key is preserved' => [ array_merge($valid, [ - 'wpn_webpush_vapid_private' => wpn_acp_module::MASKED_PRIVATE_KEY, + 'wpn_webpush_vapid_private' => '', ]), [ 'S_ERROR' => false, @@ -395,7 +395,7 @@ public function webpush_save_data(): array [ 'wpn_webpush_enable' => 0, 'wpn_webpush_vapid_public' => '', - 'wpn_webpush_vapid_private' => '', + 'wpn_webpush_vapid_private' => 'existing-private-key', ], true, ], @@ -439,6 +439,35 @@ public function test_save_settings_validates_and_persists_webpush_config(array $ } } + public function test_save_settings_requires_private_key_when_none_is_stored(): void + { + $this->config['wpn_webpush_vapid_public'] = ''; + $this->config['wpn_webpush_vapid_private'] = ''; + + $this->request->method('variable') + ->with('config', ['' => ''], true) + ->willReturn([ + 'wpn_webpush_enable' => 1, + 'wpn_webpush_vapid_public' => str_repeat('a', 25), + 'wpn_webpush_vapid_private' => '', + 'wpn_webpush_dropdown_subscribe' => 1, + 'wpn_webpush_method_enabled' => 0, + 'wpn_webpush_popup_prompt' => 1, + ]); + + $this->template->expects($this->once()) + ->method('assign_vars') + ->with([ + 'S_ERROR' => true, + 'ERROR_MSG' => 'SETTING_TOO_SHORT', + ]); + $this->log->expects($this->never())->method('add'); + + $this->create_module()->save_settings(); + + self::assertSame('', $this->config['wpn_webpush_vapid_private']); + } + public function pwa_save_data(): array { $valid = [ @@ -814,6 +843,7 @@ protected function invoke_method($object, $method_name, array $parameters = []) protected function default_config(): array { return [ + 'sitename' => 'Board & 😀 Forum', 'wpn_webpush_enable' => 1, 'wpn_webpush_vapid_public' => 'existing-public-key', 'wpn_webpush_vapid_private' => 'existing-private-key', @@ -850,7 +880,8 @@ protected function webpush_template_vars($u_action): array return [ 'S_WEBPUSH_ENABLE' => 1, 'WEBPUSH_VAPID_PUBLIC' => 'existing-public-key', - 'WEBPUSH_VAPID_PRIVATE' => wpn_acp_module::MASKED_PRIVATE_KEY, + 'S_VAPID_KEYS_CONFIGURED' => true, + 'S_VAPID_PRIVATE_CONFIGURED' => true, 'S_WEBPUSH_DROPDOWN_SUBSCRIBE' => 1, 'S_WEBPUSH_METHOD_ENABLED' => 1, 'S_WEBPUSH_POPUP_PROMPT' => 1, @@ -863,6 +894,7 @@ protected function pwa_template_vars($u_action): array return [ 'S_PWA_SHOW_INSTALL_BANNER' => true, 'PWA_SHORT_NAME' => 'Old', + 'PWA_SHORT_NAME_PLACEHOLDER' => 'Board & 😀 Fo', 'PWA_ICON_SMALL' => 'old-small.png', 'PWA_ICON_LARGE' => 'old-large.png', 'STYLES' => $this->style_rows(), diff --git a/tests/functional/functional_test.php b/tests/functional/functional_test.php index b4bb6f2..9e3c177 100644 --- a/tests/functional/functional_test.php +++ b/tests/functional/functional_test.php @@ -42,7 +42,15 @@ public function test_acp_module() $this->assertContainsLang('ACP_WEBPUSH_EXT_SETTINGS', $crawler->filter('div.main > h1')->text()); $this->assertContainsLang('ACP_WEBPUSH_SETTINGS_EXPLAIN', $crawler->filter('div.main > p')->html()); - $this->assertContainsLang('WEBPUSH_GENERATE_VAPID_KEYS', $crawler->filter('input[type="button"]')->attr('value')); + $this->assertContainsLang('WEBPUSH_REGENERATE_VAPID_KEYS', $crawler->filter('#generate-vapid-keys')->text()); + $this->assertContainsLang('WEBPUSH_VAPID_KEYS_CONFIGURED', $crawler->filter('#vapid-status-badge')->text()); + $this->assertNotSame('', $crawler->filter('input[name="config[wpn_webpush_vapid_public]"]')->attr('value')); + $this->assertSame('', $crawler->filter('input[name="config[wpn_webpush_vapid_private]"]')->attr('value')); + $confirm_template = $crawler->filter('#vapid-rotation-confirm-template'); + $this->assertCount(1, $confirm_template); + $this->assertContainsLang('WEBPUSH_ROTATE_VAPID_KEYS_TITLE', $confirm_template->filter('h3')->text()); + $this->assertSame($this->lang('YES'), $confirm_template->filter('input[name="confirm"]')->attr('value')); + $this->assertSame($this->lang('NO'), $confirm_template->filter('input[name="cancel"]')->attr('value')); $form_data = [ 'config[wpn_webpush_enable]' => 1, @@ -55,11 +63,10 @@ public function test_acp_module() $crawler = self::request('GET', 'adm/index.php?i=-phpbb-webpushnotifications-acp-wpn_acp_module&mode=webpush&sid=' . $this->sid); - foreach ($form_data as $config_name => $config_value) - { - $config_value = ($config_name === 'config[wpn_webpush_vapid_private]') ? \phpbb\webpushnotifications\acp\wpn_acp_module::MASKED_PRIVATE_KEY : $config_value; - $this->assertEquals($config_value, $crawler->filter('input[name="' . $config_name . '"]')->attr('value')); - } + $this->assertEquals($form_data['config[wpn_webpush_enable]'], $crawler->filter('input[name="config[wpn_webpush_enable]"][checked]')->attr('value')); + $this->assertEquals($form_data['config[wpn_webpush_vapid_public]'], $crawler->filter('input[name="config[wpn_webpush_vapid_public]"]')->attr('value')); + $this->assertSame('', $crawler->filter('input[name="config[wpn_webpush_vapid_private]"]')->attr('value')); + $this->assertContainsLang('WEBPUSH_VAPID_KEYS_CONFIGURED', $crawler->filter('#vapid-status-badge')->text()); } public function test_ucp_module() @@ -130,6 +137,7 @@ public function test_manifest() $this->admin_login(); $crawler = self::request('GET', 'adm/index.php?i=-phpbb-webpushnotifications-acp-wpn_acp_module&mode=pwa&sid=' . $this->sid); + $this->assertSame(utf8_substr($expected['name'], 0, 12), $crawler->filter('#pwa_short_name')->attr('placeholder')); $form_data = [ 'config[pwa_short_name]' => $expected['short_name'], diff --git a/tests/migrations/generate_vapid_keys_test.php b/tests/migrations/generate_vapid_keys_test.php new file mode 100644 index 0000000..9e4793c --- /dev/null +++ b/tests/migrations/generate_vapid_keys_test.php @@ -0,0 +1,160 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbb\webpushnotifications\tests\migrations; + +use Minishlink\WebPush\VAPID; +use phpbb\config\config; +use phpbb\webpushnotifications\migrations\generate_vapid_keys; + +class generate_vapid_keys_test extends \phpbb_test_case +{ + public function test_migration_definition(): void + { + $migration = $this->create_migration([]); + + self::assertSame( + ['\phpbb\webpushnotifications\migrations\add_webpush'], + generate_vapid_keys::depends_on() + ); + self::assertSame( + [['custom', [[$migration, 'generate_keys']]]], + $migration->update_data() + ); + } + + public function test_empty_configuration_generates_valid_keys(): void + { + $config = new config([ + 'wpn_webpush_vapid_public' => '', + 'wpn_webpush_vapid_private' => '', + ]); + $migration = $this->create_migration([], $config); + + self::assertFalse($migration->effectively_installed()); + + $migration->generate_keys(); + + self::assertNotSame('', $config['wpn_webpush_vapid_public']); + self::assertNotSame('', $config['wpn_webpush_vapid_private']); + self::assertTrue($migration->effectively_installed()); + self::assertArrayHasKey('publicKey', VAPID::validate([ + 'subject' => 'mailto:admin@example.com', + 'publicKey' => $config['wpn_webpush_vapid_public'], + 'privateKey' => $config['wpn_webpush_vapid_private'], + ])); + } + + public function existing_key_data(): array + { + return [ + 'complete pair' => ['existing-public', 'existing-private'], + 'public key only' => ['existing-public', ''], + 'private key only' => ['', 'existing-private'], + ]; + } + + /** + * @dataProvider existing_key_data + */ + public function test_existing_key_material_is_preserved(string $public_key, string $private_key): void + { + $config = new config([ + 'wpn_webpush_vapid_public' => $public_key, + 'wpn_webpush_vapid_private' => $private_key, + ]); + $migration = $this->create_migration([ + 'publicKey' => 'generated-public', + 'privateKey' => 'generated-private', + ], $config); + + self::assertTrue($migration->effectively_installed()); + + $migration->generate_keys(); + + self::assertSame($public_key, $config['wpn_webpush_vapid_public']); + self::assertSame($private_key, $config['wpn_webpush_vapid_private']); + } + + public function invalid_generation_data(): array + { + return [ + 'missing public key' => [['privateKey' => 'generated-private'], false], + 'missing private key' => [['publicKey' => 'generated-public'], false], + 'generation failure' => [[], true], + ]; + } + + /** + * @dataProvider invalid_generation_data + */ + public function test_generation_failure_leaves_configuration_empty(array $keys, bool $throw): void + { + $config = new config([ + 'wpn_webpush_vapid_public' => '', + 'wpn_webpush_vapid_private' => '', + ]); + $migration = $this->create_migration($keys, $config, $throw); + + $migration->generate_keys(); + + self::assertSame('', $config['wpn_webpush_vapid_public']); + self::assertSame('', $config['wpn_webpush_vapid_private']); + } + + protected function create_migration(array $keys, config $config = null, bool $throw = false): testable_generate_vapid_keys + { + return new testable_generate_vapid_keys( + $config ?? new config([]), + $this->createMock(\phpbb\db\driver\driver_interface::class), + $this->createMock(\phpbb\db\tools\tools_interface::class), + '', + 'php', + 'phpbb_', + $keys, + $throw + ); + } +} + +class testable_generate_vapid_keys extends generate_vapid_keys +{ + /** @var array */ + private $keys; + + /** @var bool */ + private $throw; + + public function __construct( + config $config, + \phpbb\db\driver\driver_interface $db, + \phpbb\db\tools\tools_interface $db_tools, + string $phpbb_root_path, + string $php_ext, + string $table_prefix, + array $keys, + bool $throw + ) + { + parent::__construct($config, $db, $db_tools, $phpbb_root_path, $php_ext, $table_prefix); + $this->keys = $keys; + $this->throw = $throw; + } + + protected function create_vapid_keys(): array + { + if ($this->throw) + { + throw new \RuntimeException('VAPID generation failed'); + } + + return $this->keys ?: parent::create_vapid_keys(); + } +}