Merge pull request #6416 from marc1706/ticket/17010

[ticket/17010] Implement notification method for web push
This commit is contained in:
Marc Alexander 2024-04-03 20:05:23 +02:00 committed by GitHub
commit 70d61a4e91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 5512 additions and 253 deletions

1
.github/check-js.sh vendored
View file

@ -15,4 +15,5 @@ sudo npm install -g > /dev/null
npm ci > /dev/null
set -x
node_modules/eslint/bin/eslint.js "phpBB/**/*.js"
node_modules/eslint/bin/eslint.js "phpBB/**/*.js.twig"
node_modules/eslint/bin/eslint.js "gulpfile.js"

View file

@ -217,6 +217,13 @@ jobs:
run: |
.github/setup-ldap.sh
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup node dependencies
run: npm ci
- name: Setup SPHINX
run: |
.github/setup-sphinx.sh
@ -342,6 +349,13 @@ jobs:
run: |
.github/setup-database.sh $DB $MYISAM
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup node dependencies
run: npm ci
- name: Run unit tests
env:
DB: ${{steps.database-type.outputs.db}}
@ -447,6 +461,13 @@ jobs:
run: |
.github/setup-database.sh $DB $MYISAM
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup node dependencies
run: npm ci
- name: Run unit tests
env:
DB: ${{steps.database-type.outputs.db}}
@ -555,6 +576,14 @@ jobs:
psql -c 'create database phpbb_tests;' -U postgres
Set-MpPreference -ExclusionPath "${env:PGDATA}" # Exclude PGDATA directory from Windows Defender
Set-MpPreference -DisableRealtimeMonitoring $true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
- name: Setup node dependencies
run: npm ci
- name: Run unit tests
if: ${{ matrix.type == 'unit' }}
run: |

1610
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -111,6 +111,7 @@
"postcss": "^8.4.31",
"postcss-sorting": "^7.0.1",
"stylelint": "^14.7.0",
"stylelint-order": "^5.0.0"
"stylelint-order": "^5.0.0",
"web-push-testing": "^1.1.1"
}
}

View file

@ -157,6 +157,55 @@ phpbb.addAjaxCallback('row_delete', function(res) {
}
});
/**
* This callback generates the VAPID keys for the web push notification service.
*/
phpbb.addAjaxCallback('generate_vapid_keys', () => {
/**
* Generate VAPID keypair with public and private key string
*
* @returns {Promise<{privateKey: string, publicKey: string}|null>}
*/
async function generateVAPIDKeys() {
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
};
} catch (error) {
console.error('Error generating keys with SubtleCrypto:', error);
return null;
}
}
generateVAPIDKeys().then(keyPair => {
if (!keyPair) {
return;
}
const publicKeyInput = document.querySelector('#webpush_vapid_public');
const privateKeyInput = document.querySelector('#webpush_vapid_private');
publicKeyInput.value = keyPair.publicKey;
privateKeyInput.value = keyPair.privateKey;
})
})
/**
* Handler for submitting permissions form in chunks
* This call will submit permissions forms in chunks of 5 fieldsets.

View file

@ -1677,6 +1677,33 @@ phpbb.getFunctionByName = function (functionName) {
return context[func];
};
/**
* Convert raw key ArrayBuffer to base64 string.
*
* @param {ArrayBuffer} rawKey Raw key array buffer as exported by SubtleCrypto exportKey()
* @returns {string} Base64 encoded raw key string
*/
phpbb.rawKeyToBase64 = (rawKey) => {
const keyBuffer = new Uint8Array(rawKey);
let keyText = '';
const keyLength = keyBuffer.byteLength;
for (let i = 0; i < keyLength; i++) {
keyText += String.fromCharCode(keyBuffer[i]);
}
return window.btoa(keyText);
};
/**
* Base64URL encode base64 encoded string
*
* @param {string} base64String Base64 encoded string
* @returns {string} Base64URL encoded string
*/
phpbb.base64UrlEncode = (base64String) => {
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
/**
* Register page dropdowns.
*/

View file

@ -0,0 +1,300 @@
/* global phpbb */
'use strict';
function PhpbbWebpush() {
/** @type {string} URL to service worker */
let serviceWorkerUrl = '';
/** @type {string} URL to subscribe to push */
let subscribeUrl = '';
/** @type {string} URL to unsubscribe from push */
let unsubscribeUrl = '';
/** @type { {creationTime: number, formToken: string} } Form tokens */
this.formTokens = {
creationTime: 0,
formToken: '',
};
/** @type {{endpoint: string, expiration: string}[]} Subscriptions */
let subscriptions;
/** @type {string} Title of error message */
let ajaxErrorTitle = '';
/** @type {string} VAPID public key */
let vapidPublicKey = '';
/** @type {HTMLElement} Subscribe button */
let subscribeButton;
/** @type {HTMLElement} Unsubscribe button */
let unsubscribeButton;
/**
* Init function for phpBB Web Push
* @type {array} options
*/
this.init = function(options) {
serviceWorkerUrl = options.serviceWorkerUrl;
subscribeUrl = options.subscribeUrl;
unsubscribeUrl = options.unsubscribeUrl;
this.formTokens = options.formTokens;
subscriptions = options.subscriptions;
ajaxErrorTitle = options.ajaxErrorTitle;
vapidPublicKey = options.vapidPublicKey;
subscribeButton = document.querySelector('#subscribe_webpush');
unsubscribeButton = document.querySelector('#unsubscribe_webpush');
// Service workers are only supported in secure context
if (window.isSecureContext !== true) {
subscribeButton.disabled = true;
return;
}
if ('serviceWorker' in navigator && 'PushManager' in window) {
navigator.serviceWorker.register(serviceWorkerUrl)
.then(() => {
subscribeButton.addEventListener('click', subscribeButtonHandler);
unsubscribeButton.addEventListener('click', unsubscribeButtonHandler);
updateButtonState();
})
.catch(error => {
console.info(error);
// Service worker could not be registered
subscribeButton.disabled = true;
});
} else {
subscribeButton.disabled = true;
}
};
/**
* Update button state depending on notifications state
*
* @return void
*/
function updateButtonState() {
if (Notification.permission === 'granted') {
navigator.serviceWorker.getRegistration(serviceWorkerUrl)
.then(registration => {
if (typeof registration === 'undefined') {
return;
}
registration.pushManager.getSubscription()
.then(subscribed => {
if (isValidSubscription(subscribed)) {
setSubscriptionState(true);
}
});
});
}
}
/**
* Check whether subscription is valid
*
* @param {PushSubscription} subscription
* @returns {boolean}
*/
const isValidSubscription = subscription => {
if (!subscription) {
return false;
}
if (subscription.expirationTime && subscription.expirationTime <= Date.now()) {
return false;
}
for (const curSubscription of subscriptions) {
if (subscription.endpoint === curSubscription.endpoint) {
return true;
}
}
// Subscription is not in valid subscription list for user
return false;
};
/**
* Set subscription state for buttons
*
* @param {boolean} subscribed True if subscribed, false if not
*/
function setSubscriptionState(subscribed) {
if (subscribed) {
subscribeButton.classList.add('hidden');
unsubscribeButton.classList.remove('hidden');
} else {
subscribeButton.classList.remove('hidden');
unsubscribeButton.classList.add('hidden');
}
}
/**
* Handler for pushing subscribe button
*
* @param {Object} event Subscribe button push event
* @returns {Promise<void>}
*/
async function subscribeButtonHandler(event) {
event.preventDefault();
subscribeButton.addEventListener('click', subscribeButtonHandler);
// Prevent the user from clicking the subscribe button multiple times.
const result = await Notification.requestPermission();
if (result === 'denied') {
return;
}
const registration = await navigator.serviceWorker.getRegistration(serviceWorkerUrl);
// We might already have a subscription that is unknown to this instance of phpBB.
// Unsubscribe before trying to subscribe again.
if (typeof registration !== 'undefined') {
const subscribed = await registration.pushManager.getSubscription();
if (subscribed) {
await subscribed.unsubscribe();
}
}
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
});
const loadingIndicator = phpbb.loadingIndicator();
fetch(subscribeUrl, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
body: getFormData(newSubscription),
})
.then(response => {
loadingIndicator.fadeOut(phpbb.alertTime);
return response.json();
})
.then(handleSubscribe)
.catch(error => {
loadingIndicator.fadeOut(phpbb.alertTime);
phpbb.alert(ajaxErrorTitle, error);
});
}
/**
* Handler for pushing unsubscribe button
*
* @param {Object} event Unsubscribe button push event
* @returns {Promise<void>}
*/
async function unsubscribeButtonHandler(event) {
event.preventDefault();
const registration = await navigator.serviceWorker.getRegistration(serviceWorkerUrl);
if (typeof registration === 'undefined') {
return;
}
const subscription = await registration.pushManager.getSubscription();
const loadingIndicator = phpbb.loadingIndicator();
fetch(unsubscribeUrl, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
body: getFormData({ endpoint: subscription.endpoint }),
})
.then(() => {
loadingIndicator.fadeOut(phpbb.alertTime);
return subscription.unsubscribe();
})
.then(unsubscribed => {
if (unsubscribed) {
setSubscriptionState(false);
}
})
.catch(error => {
loadingIndicator.fadeOut(phpbb.alertTime);
phpbb.alert(ajaxErrorTitle, error);
});
}
/**
* Handle subscribe response
*
* @param {Object} response Response from subscription endpoint
*/
function handleSubscribe(response) {
if (response.success) {
setSubscriptionState(true);
if ('form_tokens' in response) {
updateFormTokens(response.form_tokens);
}
}
}
/**
* Get form data object including form tokens
*
* @param {Object} data Data to create form data from
* @returns {FormData} Form data
*/
function getFormData(data) {
const formData = new FormData();
formData.append('form_token', phpbb.webpush.formTokens.formToken);
formData.append('creation_time', phpbb.webpush.formTokens.creationTime.toString());
formData.append('data', JSON.stringify(data));
return formData;
}
/**
* Update form tokens with supplied ones
*
* @param {Object} formTokens
*/
function updateFormTokens(formTokens) {
phpbb.webpush.formTokens.creationTime = formTokens.creation_time;
phpbb.webpush.formTokens.formToken = formTokens.form_token;
}
/**
* Convert a base64 string to Uint8Array
*
* @param base64String
* @returns {Uint8Array}
*/
function urlB64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
}
function domReady(callBack) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', callBack);
} else {
callBack();
}
}
phpbb.webpush = new PhpbbWebpush();
domReady(() => {
/* global phpbbWebpushOptions */
phpbb.webpush.init(phpbbWebpushOptions);
});

View file

@ -40,6 +40,7 @@
"google/recaptcha": "~1.1",
"guzzlehttp/guzzle": "~6.3",
"marc1706/fast-image-size": "^1.1",
"minishlink/web-push": "^8.0",
"s9e/text-formatter": "^2.0",
"symfony/config": "^6.3",
"symfony/console": "^6.3",

1367
phpBB/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -126,6 +126,13 @@ services:
arguments:
- '%core.root_path%'
form_helper:
class: phpbb\form\form_helper
arguments:
- '@config'
- '@request'
- '@user'
group_helper:
class: phpbb\group\helper
arguments:

View file

@ -243,3 +243,19 @@ services:
- '%core.php_ext%'
tags:
- { name: notification.method }
notification.method.webpush:
class: phpbb\notification\method\webpush
shared: false
arguments:
- '@config'
- '@dbal.conn'
- '@log'
- '@user_loader'
- '@user'
- '%core.root_path%'
- '%core.php_ext%'
- '%tables.notification_push%'
- '%tables.push_subscriptions%'
tags:
- { name: notification.method }

View file

@ -15,3 +15,17 @@ services:
- '%tables.users%'
- '%core.root_path%'
- '%core.php_ext%'
phpbb.ucp.controller.webpush:
class: phpbb\ucp\controller\webpush
arguments:
- '@config'
- '@controller.helper'
- '@dbal.conn'
- '@form_helper'
- '@path_helper'
- '@request'
- '@user'
- '@template.twig.environment'
- '%tables.notification_push%'
- '%tables.push_subscriptions%'

View file

@ -38,6 +38,7 @@ parameters:
tables.modules: '%core.table_prefix%modules'
tables.notification_emails: '%core.table_prefix%notification_emails'
tables.notification_types: '%core.table_prefix%notification_types'
tables.notification_push: '%core.table_prefix%notification_push'
tables.notifications: '%core.table_prefix%notifications'
tables.poll_options: '%core.table_prefix%poll_options'
tables.poll_votes: '%core.table_prefix%poll_votes'
@ -50,6 +51,7 @@ parameters:
tables.profile_fields_data: '%core.table_prefix%profile_fields_data'
tables.profile_fields_options_language: '%core.table_prefix%profile_fields_lang'
tables.profile_fields_language: '%core.table_prefix%profile_lang'
tables.push_subscriptions: '%core.table_prefix%push_subscriptions'
tables.ranks: '%core.table_prefix%ranks'
tables.reports: '%core.table_prefix%reports'
tables.reports_reasons: '%core.table_prefix%reports_reasons'

View file

@ -5,3 +5,19 @@ phpbb_ucp_reset_password_controller:
phpbb_ucp_forgot_password_controller:
path: /forgot_password
defaults: { _controller: phpbb.ucp.controller.reset_password:request }
phpbb_ucp_push_get_notification_controller:
path: /push/notification
defaults: { _controller: phpbb.ucp.controller.webpush:notification }
phpbb_ucp_push_worker_controller:
path: /push/worker
defaults: { _controller: phpbb.ucp.controller.webpush:worker }
phpbb_ucp_push_subscribe_controller:
path: /push/subscribe
defaults: { _controller: phpbb.ucp.controller.webpush:subscribe }
phpbb_ucp_push_unsubscribe_controller:
path: /push/unsubscribe
defaults: { _controller: phpbb.ucp.controller.webpush:unsubscribe }

View file

@ -19,6 +19,7 @@
* @ignore
*/
use Minishlink\WebPush\VAPID;
use phpbb\config\config;
use phpbb\language\language;
use phpbb\user;
@ -485,6 +486,20 @@ class acp_board
);
break;
case 'webpush':
$display_vars = [
'title' => 'ACP_WEBPUSH_SETTINGS',
'vars' => [
'legend1' => 'GENERAL_SETTINGS',
'webpush_enable' => ['lang' => 'WEBPUSH_ENABLE', 'validate' => 'bool', 'type' => 'custom', 'method' => 'webpush_enable', 'explain' => true],
'webpush_vapid_public' => ['lang' => 'WEBPUSH_VAPID_PUBLIC', 'validate' => 'string', 'type' => 'text:25:255', 'explain' => true],
'webpush_vapid_private' => ['lang' => 'WEBPUSH_VAPID_PRIVATE', 'validate' => 'string', 'type' => 'password:25:255', 'explain' => true],
'legend3' => 'ACP_SUBMIT_CHANGES',
],
];
break;
default:
trigger_error('NO_MODE', E_USER_ERROR);
break;
@ -1347,4 +1362,49 @@ class acp_board
return '<input class="button2" type="submit" id="' . $key . '" name="' . $key . '" value="' . $user->lang('SEND_TEST_EMAIL') . '" />
<textarea id="' . $key . '_text" name="' . $key . '_text" placeholder="' . $user->lang('MESSAGE') . '"></textarea>';
}
/**
* Generate form data for web push enable
*
* @param string $value Webpush enable value
* @param string $key Webpush enable config key
*
* @return array[] Form data
*/
public function webpush_enable($value, $key): array
{
return [
[
'tag' => 'radio',
'buttons' => [
[
'name' => "config[$key]",
'label' => $this->language->lang('YES'),
'type' => 'radio',
'class' => 'radio',
'value' => 1,
'checked' => $value,
],
[
'name' => "config[$key]",
'label' => $this->language->lang('NO'),
'type' => 'radio',
'class' => 'radio',
'value' => 0,
'checked' => !$value,
],
],
],
[
'tag' => 'input',
'class' => 'button2',
'name' => "config[$key]",
'type' => 'button',
'value' => $this->language->lang('WEBPUSH_GENERATE_VAPID_KEYS'),
'data' => [
'ajax' => 'generate_vapid_keys',
]
],
];
}
}

View file

@ -30,6 +30,7 @@ class acp_board_info
'auth' => array('title' => 'ACP_AUTH_SETTINGS', 'auth' => 'acl_a_server', 'cat' => array('ACP_CLIENT_COMMUNICATION')),
'email' => array('title' => 'ACP_EMAIL_SETTINGS', 'auth' => 'acl_a_server', 'cat' => array('ACP_CLIENT_COMMUNICATION')),
'webpush' => array('title' => 'ACP_WEBPUSH_SETTINGS', 'auth' => 'acl_a_server', 'cat' => array('ACP_CLIENT_COMMUNICATION')),
'cookie' => array('title' => 'ACP_COOKIE_SETTINGS', 'auth' => 'acl_a_server', 'cat' => array('ACP_SERVER_CONFIGURATION')),
'server' => array('title' => 'ACP_SERVER_SETTINGS', 'auth' => 'acl_a_server', 'cat' => array('ACP_SERVER_CONFIGURATION')),

View file

@ -2009,16 +2009,14 @@ function check_link_hash($token, $link_name)
*/
function add_form_key($form_name, $template_variable_suffix = '')
{
global $config, $template, $user, $phpbb_dispatcher;
global $phpbb_container, $phpbb_dispatcher, $template;
$now = time();
$token_sid = ($user->data['user_id'] == ANONYMOUS && !empty($config['form_token_sid_guests'])) ? $user->session_id : '';
$token = sha1($now . $user->data['user_form_salt'] . $form_name . $token_sid);
/** @var \phpbb\form\form_helper $form_helper */
$form_helper = $phpbb_container->get('form_helper');
$s_fields = build_hidden_fields(array(
'creation_time' => $now,
'form_token' => $token,
));
$form_tokens = $form_helper->get_form_tokens($form_name, $now, $token_sid, $token);
$s_fields = build_hidden_fields($form_tokens);
/**
* Perform additional actions on creation of the form token
@ -2058,35 +2056,12 @@ function add_form_key($form_name, $template_variable_suffix = '')
*/
function check_form_key($form_name, $timespan = false)
{
global $config, $request, $user;
global $phpbb_container;
if ($timespan === false)
{
// we enforce a minimum value of half a minute here.
$timespan = ($config['form_token_lifetime'] == -1) ? -1 : max(30, $config['form_token_lifetime']);
}
/** @var \phpbb\form\form_helper $form_helper */
$form_helper = $phpbb_container->get('form_helper');
if ($request->is_set_post('creation_time') && $request->is_set_post('form_token'))
{
$creation_time = abs($request->variable('creation_time', 0));
$token = $request->variable('form_token', '');
$diff = time() - $creation_time;
// If creation_time and the time() now is zero we can assume it was not a human doing this (the check for if ($diff)...
if (defined('DEBUG_TEST') || $diff && ($diff <= $timespan || $timespan === -1))
{
$token_sid = ($user->data['user_id'] == ANONYMOUS && !empty($config['form_token_sid_guests'])) ? $user->session_id : '';
$key = sha1($creation_time . $user->data['user_form_salt'] . $form_name . $token_sid);
if ($key === $token)
{
return true;
}
}
}
return false;
return $form_helper->check_form_tokens($form_name, $timespan !== false ? $timespan : null);
}
// Message/Login boxes

View file

@ -14,6 +14,11 @@
/**
* @ignore
*/
use phpbb\controller\helper;
use phpbb\form\form_helper;
use phpbb\notification\method\extended_method_interface;
if (!defined('IN_PHPBB'))
{
exit;
@ -23,17 +28,28 @@ class ucp_notifications
{
public $u_action;
private const FORM_TOKEN_NAME = 'ucp_notification';
/** @var helper */
private helper $controller_helper;
/** @var form_helper */
private form_helper $form_helper;
public function main($id, $mode)
{
global $config, $template, $user, $request, $phpbb_container, $phpbb_dispatcher;
global $phpbb_root_path, $phpEx;
add_form_key('ucp_notification');
add_form_key(self::FORM_TOKEN_NAME);
$start = $request->variable('start', 0);
$form_time = $request->variable('form_time', 0);
$form_time = ($form_time <= 0 || $form_time > time()) ? time() : $form_time;
$this->controller_helper = $phpbb_container->get('controller.helper');
$this->form_helper = $phpbb_container->get('form_helper');
/* @var $phpbb_notifications \phpbb\notification\manager */
$phpbb_notifications = $phpbb_container->get('notification_manager');
@ -48,7 +64,7 @@ class ucp_notifications
// Add/remove subscriptions
if ($request->is_set_post('submit'))
{
if (!check_form_key('ucp_notification'))
if (!check_form_key(self::FORM_TOKEN_NAME))
{
trigger_error('FORM_INVALID');
}
@ -103,11 +119,15 @@ class ucp_notifications
trigger_error($message);
}
$this->output_notification_methods($phpbb_notifications, $template, $user, 'notification_methods');
$this->output_notification_methods($phpbb_notifications, $template, $user);
$this->output_notification_types($subscriptions, $phpbb_notifications, $template, $user, $phpbb_dispatcher, 'notification_types');
$this->tpl_name = 'ucp_notifications';
$template->assign_vars([
'FORM_TOKENS' => $this->form_helper->get_form_tokens(self::FORM_TOKEN_NAME),
]);
$this->tpl_name = 'ucp_notifications_options';
$this->page_title = 'UCP_NOTIFICATION_OPTIONS';
break;
@ -138,7 +158,7 @@ class ucp_notifications
// Mark specific notifications read
if ($request->is_set_post('submit'))
{
if (!check_form_key('ucp_notification'))
if (!check_form_key(self::FORM_TOKEN_NAME))
{
trigger_error('FORM_INVALID');
}
@ -266,11 +286,16 @@ class ucp_notifications
{
$notification_methods = $phpbb_notifications->get_subscription_methods();
foreach ($notification_methods as $method => $method_data)
foreach ($notification_methods as $method_data)
{
if ($method_data['method'] instanceof extended_method_interface)
{
$ucp_template_data = $method_data['method']->get_ucp_template_data($this->controller_helper, $this->form_helper);
$template->assign_vars($ucp_template_data);
}
$template->assign_block_vars($block, array(
'METHOD' => $method_data['id'],
'NAME' => $user->lang($method_data['lang']),
));
}

View file

@ -326,6 +326,9 @@ INSERT INTO phpbb_config (config_name, config_value) VALUES ('storage\avatar\pro
INSERT INTO phpbb_config (config_name, config_value) VALUES ('storage\avatar\config\path', 'images/avatars/upload');
INSERT INTO phpbb_config (config_name, config_value) VALUES ('storage\backup\provider', 'phpbb\storage\provider\local');
INSERT INTO phpbb_config (config_name, config_value) VALUES ('storage\backup\config\path', 'store');
INSERT INTO phpbb_config (config_name, config_value) VALUES ('webpush_enable', '0');
INSERT INTO phpbb_config (config_name, config_value) VALUES ('webpush_vapid_public', '');
INSERT INTO phpbb_config (config_name, config_value) VALUES ('webpush_vapid_private', '');
INSERT INTO phpbb_config (config_name, config_value, is_dynamic) VALUES ('cache_last_gc', '0', 1);
INSERT INTO phpbb_config (config_name, config_value, is_dynamic) VALUES ('cron_lock', '0', 1);

View file

@ -599,6 +599,17 @@ $lang = array_merge($lang, array(
'USE_SMTP_EXPLAIN' => 'Select “Yes” if you want or have to send email via a named server instead of the local mail function.',
));
$lang = array_merge($lang, [
'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.',
'WEBPUSH_ENABLE' => 'Enable Web Push',
'WEBPUSH_ENABLE_EXPLAIN' => 'Allow users to receive notifications in their browser or device via Web Push. To utilize Web Push, you must input or generate valid VAPID identification keys.',
'WEBPUSH_GENERATE_VAPID_KEYS' => 'Generate Identification keys',
'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.<br><em><strong>Caution:</strong> Modifying the VAPID public key will automatically render all Web Push subscriptions invalid.</em>',
'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 <strong>must</strong> form a valid public-private key pair alongside the VAPID public key.<br><em><strong>Caution:</strong> Modifying the VAPID private key will automatically render all Web Push subscriptions invalid.</em>',
]);
// Jabber settings
$lang = array_merge($lang, array(
'ACP_JABBER_SETTINGS_EXPLAIN' => 'Here you can enable and control the use of Jabber for instant messaging and board notifications. Jabber is an open source protocol and therefore available for use by anyone. Some Jabber servers include gateways or transports which allow you to contact users on other networks. Not all servers offer all transports and changes in protocols can prevent transports from operating. Please be sure to enter already registered account details - phpBB will use the details you enter here as is.',

View file

@ -219,6 +219,7 @@ $lang = array_merge($lang, array(
'ACP_VIEW_GLOBAL_MOD_PERMISSIONS' => 'View global moderation permissions',
'ACP_VIEW_USER_PERMISSIONS' => 'View user-based permissions',
'ACP_WEBPUSH_SETTINGS' => 'Web Push settings',
'ACP_WORDS' => 'Word censoring',
'ACTION' => 'Action',
@ -592,6 +593,7 @@ $lang = array_merge($lang, array(
'LOG_CONFIG_SETTINGS' => '<strong>Altered board settings</strong>',
'LOG_CONFIG_SIGNATURE' => '<strong>Altered signature settings</strong>',
'LOG_CONFIG_VISUAL' => '<strong>Altered anti-spambot settings</strong>',
'LOG_CONFIG_WEBPUSH' => '<strong>Altered Web Push settings</strong>',
'LOG_APPROVE_TOPIC' => '<strong>Approved topic</strong><br />» %s',
'LOG_BUMP_TOPIC' => '<strong>User bumped topic</strong><br />» %s',
@ -812,6 +814,9 @@ $lang = array_merge($lang, array(
),
'LOG_WARNINGS_DELETED_ALL' => '<strong>Deleted all user warnings</strong><br />» %s',
'LOG_WEBPUSH_MESSAGE_FAIL' => '<strong>Web Push message could not be sent:</strong> %s',
'LOG_WEBPUSH_SUBSCRIPTION_REMOVED' => '<strong>Removed Web Push subscription:</strong>» %s',
'LOG_WORD_ADD' => '<strong>Added word censor</strong><br />» %s',
'LOG_WORD_DELETE' => '<strong>Deleted word censor</strong><br />» %s',
'LOG_WORD_EDIT' => '<strong>Edited word censor</strong><br />» %s',

View file

@ -332,6 +332,7 @@ $lang = array_merge($lang, array(
'NOTIFICATION_METHOD_BOARD' => 'Notifications',
'NOTIFICATION_METHOD_EMAIL' => 'Email',
'NOTIFICATION_METHOD_JABBER' => 'Jabber',
'NOTIFICATION_METHOD_WEBPUSH' => 'Web Push',
'NOTIFICATION_TYPE' => 'Notification type',
'NOTIFICATION_TYPE_BOOKMARK' => 'Someone replies to a topic you have bookmarked',
'NOTIFICATION_TYPE_GROUP_REQUEST' => 'Someone requests to join a group you lead',
@ -355,6 +356,10 @@ $lang = array_merge($lang, array(
'NOTIFY_METHOD_EXPLAIN' => 'Method for sending messages sent via this board.',
'NOTIFY_METHOD_IM' => 'Jabber only',
'NOTIFY_ON_PM' => 'Notify me on new private messages',
'NOTIFY_WEBPUSH_ENABLE' => 'Enable receiving Web Push notifications',
'NOTIFY_WEBPUSH_ENABLE_EXPLAIN' => 'Enable receiving browser-based push notifications.<br>The notifications can be turned off at any time in your browser settings, by unsubscribing, or by disabling the push notifications below.',
'NOTIFY_WEBPUSH_SUBSCRIBE' => 'Subscribe',
'NOTIFY_WEBPUSH_UNSUBSCRIBE' => 'Unsubscribe',
'NOT_ADDED_FRIENDS_ANONYMOUS' => 'You cannot add the anonymous user to your friends list.',
'NOT_ADDED_FRIENDS_BOTS' => 'You cannot add bots to your friends list.',
'NOT_ADDED_FRIENDS_FOES' => 'You cannot add users to your friends list who are on your foes list.',

View file

@ -0,0 +1,101 @@
<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/
namespace phpbb\db\migration\data\v400;
use phpbb\db\migration\migration;
class add_webpush extends migration
{
public static function depends_on(): array
{
return [
'\phpbb\db\migration\data\v400\dev',
];
}
public function effectively_installed(): bool
{
return $this->db_tools->sql_table_exists($this->table_prefix . 'notification_push');
}
public function update_schema(): array
{
return [
'add_tables' => [
$this->table_prefix . 'notification_push' => [
'COLUMNS' => [
'notification_type_id' => ['USINT', 0],
'item_id' => ['ULINT', 0],
'item_parent_id' => ['ULINT', 0],
'user_id' => ['ULINT', 0],
'push_data' => ['MTEXT', ''],
'notification_time' => ['TIMESTAMP', 0]
],
'PRIMARY_KEY' => ['notification_type_id', 'item_id', 'item_parent_id', 'user_id'],
],
$this->table_prefix . 'push_subscriptions' => [
'COLUMNS' => [
'subscription_id' => ['ULINT', null, 'auto_increment'],
'user_id' => ['ULINT', 0],
'endpoint' => ['TEXT', ''],
'expiration_time' => ['TIMESTAMP', 0],
'p256dh' => ['VCHAR', ''],
'auth' => ['VCHAR', ''],
],
'PRIMARY_KEY' => ['subscription_id', 'user_id'],
]
],
];
}
public function revert_schema(): array
{
return [
'drop_tables' => [
$this->table_prefix . 'notification_push',
$this->table_prefix . 'push_subscriptions',
],
];
}
public function update_data(): array
{
return [
['config.add', ['webpush_enable', false]],
['config.add', ['webpush_vapid_public', '']],
['config.add', ['webpush_vapid_private', '']],
['module.add', [
'acp',
'ACP_BOARD_CONFIGURATION',
[
'module_basename' => 'acp_board',
'module_langname' => 'ACP_WEBPUSH_SETTINGS',
'module_mode' => 'webpush',
'module_auth' => 'acl_a_board',
'after' => ['settings', 'ACP_JABBER_SETTINGS'],
],
]],
];
}
public function revert_data(): array
{
return [
['config.remove', ['webpush_enable']],
['config.remove', ['webpush_vapid_public']],
['config.remove', ['webpush_vapid_private']],
['module.remove', ['acp', 'ACP_BOARD_CONFIGURATION', 'ACP_WEBPUSH_SETTINGS']]
];
}
}

View file

@ -0,0 +1,104 @@
<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/
namespace phpbb\form;
use phpbb\config\config;
use phpbb\request\request_interface;
use phpbb\user;
class form_helper
{
/** @var config */
protected $config;
/** @var request_interface */
protected $request;
/** @var user */
protected $user;
/**
* Constructor for form_helper
*
* @param config $config
* @param request_interface $request
* @param user $user
*/
public function __construct(config $config, request_interface $request, user $user)
{
$this->config = $config;
$this->request = $request;
$this->user = $user;
}
/**
* Get form tokens for form
*
* @param string $form_name Name of form
* @param int|null $now Token generation time
* @param string|null $token_sid SID used for form token
* @param string|null $token Generated token
*
* @return array Array containing form_token and creation_time of form token
*/
public function get_form_tokens(string $form_name, ?int &$now = 0, ?string &$token_sid = '', ?string &$token = ''): array
{
$now = time();
$token_sid = ($this->user->data['user_id'] == ANONYMOUS && !empty($this->config['form_token_sid_guests'])) ? $this->user->session_id : '';
$token = sha1($now . $this->user->data['user_form_salt'] . $form_name . $token_sid);
return [
'creation_time' => $now,
'form_token' => $token,
];
}
/**
* Check form token for form
*
* @param string $form_name Name of form
* @param int|null $timespan Lifetime of token or null if default value should be used
* @return bool True if form token is valid, false if not
*/
public function check_form_tokens(string $form_name, ?int $timespan = null): bool
{
if ($timespan === null)
{
// we enforce a minimum value of half a minute here.
$timespan = ($this->config['form_token_lifetime'] == -1) ? -1 : max(30, $this->config['form_token_lifetime']);
}
if ($this->request->is_set_post('creation_time') && $this->request->is_set_post('form_token'))
{
$creation_time = abs($this->request->variable('creation_time', 0));
$token = $this->request->variable('form_token', '');
$diff = time() - $creation_time;
// If creation_time and the time() now is zero we can assume it was not a human doing this (the check for if ($diff)...
if (defined('DEBUG_TEST') || $diff && ($diff <= $timespan || $timespan === -1))
{
$token_sid = ($this->user->data['user_id'] == ANONYMOUS && !empty($this->config['form_token_sid_guests'])) ? $this->user->session_id : '';
$key = sha1($creation_time . $this->user->data['user_form_salt'] . $form_name . $token_sid);
if (hash_equals($key, $token))
{
return true;
}
}
}
return false;
}
}

View file

@ -0,0 +1,29 @@
<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/
namespace phpbb\notification\method;
use phpbb\controller\helper;
use phpbb\form\form_helper;
interface extended_method_interface extends method_interface
{
/**
* Get UCP template data for type
*
* @param helper $controller_helper
* @param form_helper $form_helper
* @return array Template data
*/
public function get_ucp_template_data(helper $controller_helper, form_helper $form_helper): array;
}

View file

@ -0,0 +1,432 @@
<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/
namespace phpbb\notification\method;
use Minishlink\WebPush\Subscription;
use phpbb\config\config;
use phpbb\controller\helper;
use phpbb\db\driver\driver_interface;
use phpbb\form\form_helper;
use phpbb\log\log_interface;
use phpbb\notification\type\type_interface;
use phpbb\user;
use phpbb\user_loader;
/**
* Web Push notification method class
* This class handles sending push messages for notifications
*/
class webpush extends messenger_base implements extended_method_interface
{
/** @var config */
protected $config;
/** @var driver_interface */
protected $db;
/** @var log_interface */
protected $log;
/** @var user */
protected $user;
/** @var string Notification Web Push table */
protected $notification_webpush_table;
/** @var string Notification push subscriptions table */
protected $push_subscriptions_table;
/**
* Notification Method Web Push constructor
*
* @param config $config
* @param driver_interface $db
* @param log_interface $log
* @param user_loader $user_loader
* @param user $user
* @param string $phpbb_root_path
* @param string $php_ext
* @param string $notification_webpush_table
* @param string $push_subscriptions_table
*/
public function __construct(config $config, driver_interface $db, log_interface $log, user_loader $user_loader, user $user, string $phpbb_root_path,
string $php_ext, string $notification_webpush_table, string $push_subscriptions_table)
{
parent::__construct($user_loader, $phpbb_root_path, $php_ext);
$this->config = $config;
$this->db = $db;
$this->log = $log;
$this->user = $user;
$this->notification_webpush_table = $notification_webpush_table;
$this->push_subscriptions_table = $push_subscriptions_table;
}
/**
* {@inheritDoc}
*/
public function get_type(): string
{
return 'notification.method.webpush';
}
/**
* {@inheritDoc}
*/
public function is_available(type_interface $notification_type = null): bool
{
return parent::is_available($notification_type) && $this->config['webpush_enable']
&& !empty($this->config['webpush_vapid_public']) && !empty($this->config['webpush_vapid_private']);
}
/**
* {@inheritdoc}
*/
public function get_notified_users($notification_type_id, array $options): array
{
$notified_users = [];
$sql = 'SELECT user_id
FROM ' . $this->notification_webpush_table . '
WHERE notification_type_id = ' . (int) $notification_type_id .
(isset($options['item_id']) ? ' AND item_id = ' . (int) $options['item_id'] : '') .
(isset($options['item_parent_id']) ? ' AND item_parent_id = ' . (int) $options['item_parent_id'] : '') .
(isset($options['user_id']) ? ' AND user_id = ' . (int) $options['user_id'] : '');
$result = $this->db->sql_query($sql);
while ($row = $this->db->sql_fetchrow($result))
{
$notified_users[$row['user_id']] = $row;
}
$this->db->sql_freeresult($result);
return $notified_users;
}
/**
* Parse the queue and notify the users
*/
public function notify()
{
$insert_buffer = new \phpbb\db\sql_insert_buffer($this->db, $this->notification_webpush_table);
/** @var type_interface $notification */
foreach ($this->queue as $notification)
{
$data = $notification->get_insert_array();
$data += [
'push_data' => json_encode([
'heading' => $this->config['sitename'],
'title' => strip_tags($notification->get_title()),
'text' => strip_tags($notification->get_reference()),
'url' => htmlspecialchars_decode($notification->get_url()),
'avatar' => $notification->get_avatar(),
]),
'notification_time' => time(),
];
$data = self::clean_data($data);
$insert_buffer->insert($data);
}
$insert_buffer->flush();
$this->notify_using_webpush();
return false;
}
/**
* Notify using Web Push
*
* @return void
*/
protected function notify_using_webpush(): void
{
if (empty($this->queue))
{
return;
}
// Load all users we want to notify
$user_ids = [];
foreach ($this->queue as $notification)
{
$user_ids[] = $notification->user_id;
}
// Do not send push notifications to banned users
if (!function_exists('phpbb_get_banned_user_ids'))
{
include($this->phpbb_root_path . 'includes/functions_user.' . $this->php_ext);
}
$banned_users = phpbb_get_banned_user_ids($user_ids);
// Load all the users we need
$notify_users = array_diff($user_ids, $banned_users);
$this->user_loader->load_users($notify_users, array(USER_IGNORE));
// Get subscriptions for users
$user_subscription_map = $this->get_user_subscription_map($notify_users);
$auth = [
'VAPID' => [
'subject' => generate_board_url(false),
'publicKey' => $this->config['webpush_vapid_public'],
'privateKey' => $this->config['webpush_vapid_private'],
],
];
$web_push = new \Minishlink\WebPush\WebPush($auth);
$number_of_notifications = 0;
$remove_subscriptions = [];
// Time to go through the queue and send notifications
/** @var type_interface $notification */
foreach ($this->queue as $notification)
{
$user = $this->user_loader->get_user($notification->user_id);
$user_subscriptions = $user_subscription_map[$notification->user_id] ?? [];
if ($user['user_type'] == USER_INACTIVE && $user['user_inactive_reason'] == INACTIVE_MANUAL
|| empty($user_subscriptions))
{
continue;
}
// Add actual Web Push data
$data = [
'item_id' => $notification->item_id,
'type_id' => $notification->notification_type_id,
];
$json_data = json_encode($data);
foreach ($user_subscriptions as $subscription)
{
try
{
$push_subscription = Subscription::create([
'endpoint' => $subscription['endpoint'],
'keys' => [
'p256dh' => $subscription['p256dh'],
'auth' => $subscription['auth'],
],
]);
$web_push->queueNotification($push_subscription, $json_data);
$number_of_notifications++;
}
catch (\ErrorException $exception)
{
$remove_subscriptions[] = $subscription['subscription_id'];
$this->log->add('user', $user['user_id'], $user['user_ip'] ?? '', 'LOG_WEBPUSH_SUBSCRIPTION_REMOVED', false, [
'reportee_id' => $user['user_id'],
$user['username'],
]);
}
}
}
// Remove any subscriptions that couldn't be queued, i.e. that have invalid data
$this->remove_subscriptions($remove_subscriptions);
// List to fill with expired subscriptions based on return
$expired_endpoints = [];
try
{
foreach ($web_push->flush($number_of_notifications) as $report)
{
if (!$report->isSuccess())
{
// Fill array of endpoints to remove if subscription has expired
if ($report->isSubscriptionExpired())
{
$expired_endpoints[] = $report->getEndpoint();
}
else
{
$report_data = \phpbb\json\sanitizer::sanitize($report->jsonSerialize());
$this->log->add('admin', ANONYMOUS, '', 'LOG_WEBPUSH_MESSAGE_FAIL', false, [$report_data['reason']]);
}
}
}
}
catch (\ErrorException $exception)
{
$this->log->add('critical', ANONYMOUS, '', 'LOG_WEBPUSH_MESSAGE_FAIL', false, [$exception->getMessage()]);
}
$this->clean_expired_subscriptions($user_subscription_map, $expired_endpoints);
// We're done, empty the queue
$this->empty_queue();
}
/**
* {@inheritdoc}
*/
public function mark_notifications($notification_type_id, $item_id, $user_id, $time = false, $mark_read = true)
{
$sql = 'DELETE FROM ' . $this->notification_webpush_table . '
WHERE ' . ($notification_type_id !== false ? $this->db->sql_in_set('notification_type_id', is_array($notification_type_id) ? $notification_type_id : [$notification_type_id]) : '1=1') .
($user_id !== false ? ' AND ' . $this->db->sql_in_set('user_id', $user_id) : '') .
($item_id !== false ? ' AND ' . $this->db->sql_in_set('item_id', $item_id) : '');
$this->db->sql_query($sql);
}
/**
* {@inheritdoc}
*/
public function mark_notifications_by_parent($notification_type_id, $item_parent_id, $user_id, $time = false, $mark_read = true)
{
$sql = 'DELETE FROM ' . $this->notification_webpush_table . '
WHERE ' . ($notification_type_id !== false ? $this->db->sql_in_set('notification_type_id', is_array($notification_type_id) ? $notification_type_id : [$notification_type_id]) : '1=1') .
($user_id !== false ? ' AND ' . $this->db->sql_in_set('user_id', $user_id) : '') .
($item_parent_id !== false ? ' AND ' . $this->db->sql_in_set('item_parent_id', $item_parent_id, false, true) : '');
$this->db->sql_query($sql);
}
/**
* {@inheritDoc}
*/
public function prune_notifications($timestamp, $only_read = true): void
{
$sql = 'DELETE FROM ' . $this->notification_webpush_table . '
WHERE notification_time < ' . (int) $timestamp;
$this->db->sql_query($sql);
$this->config->set('read_notification_last_gc', (string) time(), false);
}
/**
* Clean data to contain only what we need for webpush notifications table
*
* @param array $data Notification data
* @return array Cleaned notification data
*/
public static function clean_data(array $data): array
{
$row = [
'notification_type_id' => null,
'item_id' => null,
'item_parent_id' => null,
'user_id' => null,
'push_data' => null,
'notification_time' => null,
];
return array_intersect_key($data, $row);
}
public function get_ucp_template_data(helper $controller_helper, form_helper $form_helper): array
{
$subscription_map = $this->get_user_subscription_map([$this->user->id()]);
$subscriptions = [];
if (isset($subscription_map[$this->user->id()]))
{
foreach ($subscription_map[$this->user->id()] as $subscription)
{
$subscriptions[] = [
'endpoint' => $subscription['endpoint'],
'expirationTime' => $subscription['expiration_time'],
];
}
}
return [
'NOTIFICATIONS_WEBPUSH_ENABLE' => true,
'U_WEBPUSH_SUBSCRIBE' => $controller_helper->route('phpbb_ucp_push_subscribe_controller'),
'U_WEBPUSH_UNSUBSCRIBE' => $controller_helper->route('phpbb_ucp_push_unsubscribe_controller'),
'VAPID_PUBLIC_KEY' => $this->config['webpush_vapid_public'],
'U_WEBPUSH_WORKER_URL' => $controller_helper->route('phpbb_ucp_push_worker_controller'),
'SUBSCRIPTIONS' => $subscriptions,
'WEBPUSH_FORM_TOKENS' => $form_helper->get_form_tokens(\phpbb\ucp\controller\webpush::FORM_TOKEN_UCP),
];
}
/**
* Get subscriptions for notify users
*
* @param array $notify_users Users to notify
*
* @return array Subscription map
*/
protected function get_user_subscription_map(array $notify_users): array
{
// Get subscriptions for users
$user_subscription_map = [];
$sql = 'SELECT subscription_id, user_id, endpoint, p256dh, auth, expiration_time
FROM ' . $this->push_subscriptions_table . '
WHERE ' . $this->db->sql_in_set('user_id', $notify_users);
$result = $this->db->sql_query($sql);
while ($row = $this->db->sql_fetchrow($result))
{
$user_subscription_map[$row['user_id']][] = $row;
}
$this->db->sql_freeresult($result);
return $user_subscription_map;
}
/**
* Remove subscriptions
*
* @param array $subscription_ids Subscription ids to remove
* @return void
*/
public function remove_subscriptions(array $subscription_ids): void
{
if (count($subscription_ids))
{
$sql = 'DELETE FROM ' . $this->push_subscriptions_table . '
WHERE ' . $this->db->sql_in_set('subscription_id', $subscription_ids);
$this->db->sql_query($sql);
}
}
/**
* Clean expired subscriptions from the database
*
* @param array $user_subscription_map User subscription map
* @param array $expired_endpoints Expired endpoints
* @return void
*/
protected function clean_expired_subscriptions(array $user_subscription_map, array $expired_endpoints): void
{
if (!count($expired_endpoints))
{
return;
}
$remove_subscriptions = [];
foreach ($expired_endpoints as $endpoint)
{
foreach ($user_subscription_map as $subscriptions)
{
foreach ($subscriptions as $subscription)
{
if (isset($subscription['endpoint']) && $subscription['endpoint'] == $endpoint)
{
$remove_subscriptions[] = $subscription['subscription_id'];
}
}
}
}
$this->remove_subscriptions($remove_subscriptions);
}
}

View file

@ -139,7 +139,7 @@ interface type_interface
/**
* Get the user's avatar (the user who caused the notification typically)
*
* @return string
* @return array
*/
public function get_avatar();

View file

@ -0,0 +1,241 @@
<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/
namespace phpbb\ucp\controller;
use phpbb\config\config;
use phpbb\controller\helper as controller_helper;
use phpbb\db\driver\driver_interface;
use phpbb\exception\http_exception;
use phpbb\form\form_helper;
use phpbb\json\sanitizer as json_sanitizer;
use phpbb\path_helper;
use phpbb\request\request_interface;
use phpbb\symfony_request;
use phpbb\user;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
class webpush
{
/** @var string UCP form token name */
public const FORM_TOKEN_UCP = 'ucp_webpush';
/** @var config */
protected $config;
/** @var controller_helper */
protected $controller_helper;
/** @var driver_interface */
protected $db;
/** @var form_helper */
protected $form_helper;
/** @var path_helper */
protected $path_helper;
/** @var request_interface */
protected $request;
/** @var user */
protected $user;
/** @var Environment */
protected $template;
/** @var string */
protected $notification_webpush_table;
/** @var string */
protected $push_subscriptions_table;
/**
* Constructor for webpush controller
*
* @param config $config
* @param controller_helper $controller_helper
* @param driver_interface $db
* @param form_helper $form_helper
* @param path_helper $path_helper
* @param request_interface $request
* @param user $user
* @param Environment $template
* @param string $notification_webpush_table
* @param string $push_subscriptions_table
*/
public function __construct(config $config, controller_helper $controller_helper, driver_interface $db, form_helper $form_helper, path_helper $path_helper,
request_interface $request, user $user, Environment $template, string $notification_webpush_table, string $push_subscriptions_table)
{
$this->config = $config;
$this->controller_helper = $controller_helper;
$this->db = $db;
$this->form_helper = $form_helper;
$this->path_helper = $path_helper;
$this->request = $request;
$this->user = $user;
$this->template = $template;
$this->notification_webpush_table = $notification_webpush_table;
$this->push_subscriptions_table = $push_subscriptions_table;
}
/**
* Handle request to retrieve notification data
*
* @return JsonResponse
*/
public function notification(): JsonResponse
{
// Subscribe should only be available for logged-in "normal" users
if (!$this->request->is_ajax() || $this->user->id() == ANONYMOUS || $this->user->data['is_bot']
|| $this->user->data['user_type'] == USER_IGNORE || $this->user->data['user_type'] == USER_INACTIVE)
{
throw new http_exception(Response::HTTP_FORBIDDEN, 'Forbidden');
}
$item_id = $this->request->variable('item_id', 0);
$type_id = $this->request->variable('type_id', 0);
$sql = 'SELECT push_data
FROM ' . $this->notification_webpush_table . '
WHERE user_id = ' . (int) $this->user->id() . '
AND notification_type_id = ' . (int) $type_id . '
AND item_id = ' . (int) $item_id;
$result = $this->db->sql_query($sql);
$notification_data = $this->db->sql_fetchfield('push_data');
$this->db->sql_freeresult($result);
$data = json_decode($notification_data, true);
$data['url'] = isset($data['url']) ? $this->path_helper->update_web_root_path($data['url']) : '';
return new JsonResponse($data);
}
/**
* Handle request to push worker javascript
*
* @return Response
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
public function worker(): Response
{
// @todo: only work for logged in users, no anonymous & bot
$content = $this->template->render('push_worker.js.twig', [
'U_WEBPUSH_GET_NOTIFICATION' => $this->controller_helper->route('phpbb_ucp_push_get_notification_controller'),
]);
$response = new Response($content);
$response->headers->set('Content-Type', 'text/javascript; charset=UTF-8');
if (!empty($this->user->data['is_bot']))
{
// Let reverse proxies know we detected a bot.
$response->headers->set('X-PHPBB-IS-BOT', 'yes');
}
return $response;
}
/**
* Get template variables for subscribe type pages
*
* @return array
*/
protected function get_subscribe_vars(): array
{
return [
'U_WEBPUSH_SUBSCRIBE' => $this->controller_helper->route('phpbb_ucp_push_subscribe_controller'),
'U_WEBPUSH_UNSUBSCRIBE' => $this->controller_helper->route('phpbb_ucp_push_unsubscribe_controller'),
'FORM_TOKENS' => $this->form_helper->get_form_tokens(self::FORM_TOKEN_UCP),
];
}
/**
* Check (un)subscribe form for valid link hash
*
* @throws http_exception If form is invalid or user should not request (un)subscription
* @return void
*/
protected function check_subscribe_requests(): void
{
if (!$this->form_helper->check_form_tokens(self::FORM_TOKEN_UCP))
{
throw new http_exception(Response::HTTP_BAD_REQUEST, 'FORM_INVALID');
}
// Subscribe should only be available for logged-in "normal" users
if (!$this->request->is_ajax() || $this->user->id() == ANONYMOUS || $this->user->data['is_bot']
|| $this->user->data['user_type'] == USER_IGNORE || $this->user->data['user_type'] == USER_INACTIVE)
{
throw new http_exception(Response::HTTP_FORBIDDEN, 'NO_AUTH_OPERATION');
}
}
/**
* Handle subscribe requests
*
* @param symfony_request $symfony_request
* @return JsonResponse
*/
public function subscribe(symfony_request $symfony_request): JsonResponse
{
$this->check_subscribe_requests();
$data = json_sanitizer::decode($symfony_request->get('data', ''));
$sql = 'INSERT INTO ' . $this->push_subscriptions_table . ' ' . $this->db->sql_build_array('INSERT', [
'user_id' => $this->user->id(),
'endpoint' => $data['endpoint'],
'expiration_time' => $data['expiration_time'] ?? 0,
'p256dh' => $data['keys']['p256dh'],
'auth' => $data['keys']['auth'],
]);
$this->db->sql_query($sql);
return new JsonResponse([
'success' => true,
'form_tokens' => $this->form_helper->get_form_tokens(self::FORM_TOKEN_UCP),
]);
}
/**
* Handle unsubscribe requests
*
* @param symfony_request $symfony_request
* @return JsonResponse
*/
public function unsubscribe(symfony_request $symfony_request): JsonResponse
{
$this->check_subscribe_requests();
$data = json_sanitizer::decode($symfony_request->get('data', ''));
$endpoint = $data['endpoint'];
$sql = 'DELETE FROM ' . $this->push_subscriptions_table . '
WHERE user_id = ' . (int) $this->user->id() . "
AND endpoint = '" . $this->db->sql_escape($endpoint) . "'";
$this->db->sql_query($sql);
return new JsonResponse([
'success' => true,
'form_tokens' => $this->form_helper->get_form_tokens(self::FORM_TOKEN_UCP),
]);
}
}

View file

@ -0,0 +1,53 @@
/**
* Event listener for push event
*/
self.addEventListener('push', event => {
if (typeof event.data === 'undefined') {
return;
}
let itemId = 0;
let typeId = 0;
try {
const notificationData = event.data.json();
itemId = notificationData.item_id;
typeId = notificationData.type_id;
} catch {
self.registration.showNotification(event.data.text());
return;
}
const getNotificationUrl = '{{ U_WEBPUSH_GET_NOTIFICATION }}';
const formData = new FormData();
formData.append('item_id', itemId.toString(10));
formData.append('type_id', typeId.toString(10));
fetch(getNotificationUrl, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
})
.then(response => response.json())
.then(response => {
const responseBody = response.title + '\n' + response.text;
const options = {
body: responseBody,
data: response,
icon: response.avatar.src,
};
self.registration.showNotification(response.heading, options);
});
});
/**
* Event listener for notification click
*/
self.addEventListener('notificationclick', event => {
event.notification.close();
if (typeof event.notification.data !== 'undefined') {
event.waitUntil(self.clients.openWindow(event.notification.data.url));
}
});

View file

@ -12,38 +12,6 @@
<div class="inner">
<p class="cp-desc">{TITLE_EXPLAIN}</p>
<!-- IF MODE == 'notification_options' -->
<table class="table1">
<thead>
<tr>
<th>{L_NOTIFICATION_TYPE}</th>
<!-- BEGIN notification_methods -->
<th class="mark">{notification_methods.NAME}</th>
<!-- END notification_methods -->
</tr>
</thead>
<tbody>
<!-- BEGIN notification_types -->
<!-- IF notification_types.GROUP_NAME -->
<tr class="bg3">
<td colspan="{NOTIFICATION_TYPES_COLS}">{notification_types.GROUP_NAME}</td>
</tr>
<!-- ELSE -->
<tr class="<!-- IF notification_types.S_ROW_COUNT is odd -->bg1<!-- ELSE -->bg2<!-- ENDIF -->">
<td>
{notification_types.NAME}
<!-- IF notification_types.EXPLAIN --><br />&nbsp; &nbsp;{notification_types.EXPLAIN}<!-- ENDIF -->
</td>
<!-- BEGIN notification_methods -->
<td class="mark"><input type="checkbox" name="{notification_types.TYPE}_{notification_types.notification_methods.METHOD}"<!-- IF notification_types.notification_methods.SUBSCRIBED --> checked="checked"<!-- ENDIF --><!-- IF not notification_types.notification_methods.AVAILABLE --> disabled="disabled"<!-- ENDIF --> /></td>
<!-- END notification_methods -->
</tr>
<!-- ENDIF -->
<!-- END notification_types -->
</tbody>
</table>
<!-- ELSE -->
<!-- IF .notification_list -->
<div class="action-bar bar-top">
<div class="pagination">
@ -105,8 +73,6 @@
<!-- ELSE -->
<p><strong>{L_NO_NOTIFICATIONS}</strong></p>
<!-- ENDIF -->
<!-- ENDIF -->
</div>
</div>

View file

@ -0,0 +1,76 @@
{% include('ucp_header.html') %}
{% if NOTIFICATIONS_WEBPUSH_ENABLE %}
{% include('ucp_notifications_webpush.html') %}
{% endif %}
<form id="ucp" method="post" action="{{ S_UCP_ACTION }}"{{ S_FORM_ENCTYPE }}>
<h2 class="cp-title">{{ TITLE }}</h2>
{% if NOTIFICATIONS_WEBPUSH_ENABLE %}
<div class="panel">
<div class="inner">
<fieldset>
<dl>
<dt><label for="subscribe_webpush">{{ lang('NOTIFY_WEBPUSH_ENABLE') ~ lang('COLON') }}</label><br><span>{{ lang('NOTIFY_WEBPUSH_ENABLE_EXPLAIN') }}</span></dt>
<dd>
<input id="subscribe_webpush" type="submit" name="subscribe_webpush" value="{{ lang('NOTIFY_WEBPUSH_SUBSCRIBE') }}" class="button1 button button-form">
<input id="unsubscribe_webpush" type="submit" name="unsubscribe_webpush" value="{{ lang('NOTIFY_WEBPUSH_UNSUBSCRIBE') }}" class="button1 button button-form hidden">
</dd>
</dl>
</fieldset>
</div>
</div>
{% endif %}
<div class="panel">
<div class="inner">
<p class="cp-desc">{{ TITLE_EXPLAIN }}</p>
<table class="table1">
<thead>
<tr>
<th>{{ lang('NOTIFICATION_TYPE') }}</th>
{% for method in notification_methods %}
<th class="mark">{{ method.NAME }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for notification_type in notification_types %}
{% if notification_type.GROUP_NAME %}
<tr class="bg3">
<td colspan="{{ NOTIFICATION_TYPES_COLS }}">{{ notification_type.GROUP_NAME }}</td>
</tr>
{% else %}
<tr class="{% if loop.index is even %}bg1{% else %}bg2{% endif %}">
<td>
{{ notification_type.NAME }}
{% if notification_type.EXPLAIN %}<br>&nbsp; &nbsp;{{ notification_type.EXPLAIN }}{% endif %}
</td>
{% for notification_method in notification_type.notification_methods %}
{% apply spaceless %}
<td class="mark">
<input type="checkbox" name="{{ notification_type.TYPE }}_{{ notification_method.METHOD }}"{% if notification_method.SUBSCRIBED %} checked="checked"{% endif %}{% if not notification_method.AVAILABLE %} disabled="disabled"{% endif %}/></td>
{% endapply %}
{% endfor %}
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if notification_types or notification_list %}
<fieldset class="display-actions">
<input type="hidden" name="form_time" value="{{ FORM_TIME }}" />
{{ S_HIDDEN_FIELDS }}
<input type="submit" name="submit" value="{% if MODE == 'notification_options' %}{{ lang('SUBMIT') }}{% else %}{{ lang('MARK_READ') }}{% endif %}" class="button1 button button-form" />
<div><a href="#" onclick="$('#ucp input:checkbox').prop('checked', true); return false;">{{ lang('MARK_ALL') }}</a> &bull; <a href="#" onclick="$('#ucp input:checkbox').prop('checked', false); return false;">{{ lang('UNMARK_ALL') }}</a></div>
{{ S_FORM_TOKEN }}
</fieldset>
{% endif %}
</form>
{% include('ucp_footer.html') %}

View file

@ -0,0 +1,21 @@
<script>
phpbbWebpushOptions = {
serviceWorkerUrl: '{{ U_WEBPUSH_WORKER_URL }}',
subscribeUrl: '{{ U_WEBPUSH_SUBSCRIBE }}',
unsubscribeUrl: '{{ U_WEBPUSH_UNSUBSCRIBE }}',
ajaxErrorTitle: '{{ lang('AJAX_ERROR_TITLE') }}',
vapidPublicKey: '{{ VAPID_PUBLIC_KEY }}',
formTokens: {
creationTime: '{{ WEBPUSH_FORM_TOKENS.creation_time }}',
formToken: '{{ WEBPUSH_FORM_TOKENS.form_token }}',
},
subscriptions: [
{% for sub in SUBSCRIPTIONS %}
{endpoint: '{{ sub.endpoint }}', expiration: '{{ sub.expiration }}' },
{% endfor %}
],
}
</script>
{% INCLUDEJS(T_ASSETS_PATH ~ '/javascript/webpush.js') %}

View file

@ -32,6 +32,20 @@
outline: none;
}
.button[disabled],
.button[disabled]:hover,
.button.disabled,
.button.disabled:hover {
background: #eeeeee;
border-color: #aaaaaa;
color: #aaaaaa;
cursor: default;
}
.button.hidden {
display: none;
}
.caret {
border-left: 1px solid;
position: relative;

View file

@ -142,7 +142,7 @@ class migrations_check_config_added_test extends phpbb_test_case
continue;
}
// Fill error entries for configuration options which were not added to shema_data.sql
// Fill error entries for configuration options which were not added to schema_data.sql
if (!isset($config_names[$config_name]))
{
$config_names[$config_name] = [$config_name, $class];
@ -160,7 +160,7 @@ class migrations_check_config_added_test extends phpbb_test_case
*/
public function test_config_option_exists_in_schema_data($config_name, $class)
{
$message = 'Migration: %1$s, config_name: %2$s; not added to shema_data.sql';
$message = 'Migration: %1$s, config_name: %2$s; not added to schema_data.sql';
$this->assertNotFalse(strpos($this->schema_data, $config_name),
sprintf($message, $class, $config_name)

View file

@ -106,6 +106,7 @@ abstract class phpbb_tests_notification_base extends phpbb_database_test_case
$phpbb_container->set('auth', $auth);
$phpbb_container->set('cache.driver', $cache_driver);
$phpbb_container->set('cache', $cache);
$phpbb_container->set('log', new \phpbb\log\dummy());
$phpbb_container->set('text_formatter.utils', new \phpbb\textformatter\s9e\utils());
$phpbb_container->set(
'text_formatter.s9e.mention_helper',
@ -124,6 +125,8 @@ abstract class phpbb_tests_notification_base extends phpbb_database_test_case
$phpbb_container->setParameter('tables.user_notifications', 'phpbb_user_notifications');
$phpbb_container->setParameter('tables.notification_types', 'phpbb_notification_types');
$phpbb_container->setParameter('tables.notification_emails', 'phpbb_notification_emails');
$phpbb_container->setParameter('tables.notification_push', 'phpbb_notification_push');
$phpbb_container->setParameter('tables.push_subscriptions', 'phpbb_push_subscriptions');
$this->notifications = new phpbb_notification_manager_helper(
array(),

View file

@ -0,0 +1,292 @@
<?xml version="1.0" encoding="UTF-8" ?>
<dataset>
<table name="phpbb_log">
</table>
<table name="phpbb_forums_watch">
<column>forum_id</column>
<column>user_id</column>
<column>notify_status</column>
<row>
<value>1</value>
<value>6</value>
<value>0</value>
</row>
<row>
<value>1</value>
<value>7</value>
<value>0</value>
</row>
<row>
<value>1</value>
<value>8</value>
<value>0</value>
</row>
</table>
<table name="phpbb_notifications">
<column>notification_id</column>
<column>notification_type_id</column>
<column>user_id</column>
<column>item_id</column>
<column>item_parent_id</column>
<column>notification_read</column>
<column>notification_data</column>
<row>
<value>1</value>
<value>1</value>
<value>5</value>
<value>1</value>
<value>1</value>
<value>0</value>
<value></value>
</row>
<row>
<value>2</value>
<value>1</value>
<value>8</value>
<value>1</value>
<value>1</value>
<value>0</value>
<value></value>
</row>
</table>
<table name="phpbb_notification_push">
</table>
<table name="phpbb_notification_types">
<column>notification_type_id</column>
<column>notification_type_name</column>
<column>notification_type_enabled</column>
<row>
<value>1</value>
<value>notification.type.post</value>
<value>1</value>
</row>
<row>
<value>2</value>
<value>notification.type.forum</value>
<value>1</value>
</row>
</table>
<table name="phpbb_posts">
<column>post_id</column>
<column>topic_id</column>
<column>forum_id</column>
<column>post_text</column>
<row>
<value>1</value>
<value>1</value>
<value>1</value>
<value></value>
</row>
</table>
<table name="phpbb_push_subscriptions">
<column>subscription_id</column>
<column>user_id</column>
<column>endpoint</column>
</table>
<table name="phpbb_topics">
<column>topic_id</column>
<column>forum_id</column>
<row>
<value>1</value>
<value>1</value>
</row>
<row>
<value>2</value>
<value>1</value>
</row>
</table>
<table name="phpbb_topics_watch">
<column>topic_id</column>
<column>user_id</column>
<column>notify_status</column>
<row>
<value>1</value>
<value>2</value>
<value>0</value>
</row>
<row>
<value>2</value>
<value>2</value>
<value>0</value>
</row>
<row>
<value>1</value>
<value>3</value>
<value>0</value>
</row>
<row>
<value>1</value>
<value>4</value>
<value>0</value>
</row>
<row>
<value>1</value>
<value>5</value>
<value>0</value>
</row>
<row>
<value>1</value>
<value>6</value>
<value>0</value>
</row>
</table>
<table name="phpbb_users">
<column>user_id</column>
<column>username_clean</column>
<column>user_permissions</column>
<column>user_sig</column>
<row>
<value>1</value>
<value>Anonymous</value>
<value></value>
<value></value>
</row>
<row>
<value>2</value>
<value>poster</value>
<value></value>
<value></value>
</row>
<row>
<value>3</value>
<value>test</value>
<value></value>
<value></value>
</row>
<row>
<value>4</value>
<value>unauthorized</value>
<value></value>
<value></value>
</row>
<row>
<value>5</value>
<value>notified</value>
<value></value>
<value></value>
</row>
<row>
<value>6</value>
<value>disabled</value>
<value></value>
<value></value>
</row>
<row>
<value>7</value>
<value>default</value>
<value></value>
<value></value>
</row>
<row>
<value>8</value>
<value>latest</value>
<value></value>
<value></value>
</row>
</table>
<table name="phpbb_user_notifications">
<column>item_type</column>
<column>item_id</column>
<column>user_id</column>
<column>method</column>
<column>notify</column>
<row>
<value>notification.type.post</value>
<value>0</value>
<value>2</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.post</value>
<value>0</value>
<value>3</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.post</value>
<value>0</value>
<value>4</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.post</value>
<value>0</value>
<value>5</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.post</value>
<value>0</value>
<value>6</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.post</value>
<value>0</value>
<value>7</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.post</value>
<value>0</value>
<value>8</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.forum</value>
<value>0</value>
<value>2</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.forum</value>
<value>0</value>
<value>3</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.forum</value>
<value>0</value>
<value>4</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.forum</value>
<value>0</value>
<value>5</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.forum</value>
<value>0</value>
<value>6</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.forum</value>
<value>0</value>
<value>7</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
<row>
<value>notification.type.forum</value>
<value>0</value>
<value>8</value>
<value>notification.method.webpush</value>
<value>1</value>
</row>
</table>
</dataset>

View file

@ -83,6 +83,7 @@ class notification_method_email_test extends phpbb_tests_notification_base
$phpbb_container->set('auth', $auth);
$phpbb_container->set('cache.driver', $cache_driver);
$phpbb_container->set('cache', $cache);
$phpbb_container->set('log', new \phpbb\log\dummy());
$phpbb_container->set('text_formatter.utils', new \phpbb\textformatter\s9e\utils());
$phpbb_container->set('event_dispatcher', $this->phpbb_dispatcher);
$phpbb_container->setParameter('core.root_path', $phpbb_root_path);
@ -91,6 +92,8 @@ class notification_method_email_test extends phpbb_tests_notification_base
$phpbb_container->setParameter('tables.user_notifications', 'phpbb_user_notifications');
$phpbb_container->setParameter('tables.notification_types', 'phpbb_notification_types');
$phpbb_container->setParameter('tables.notification_emails', 'phpbb_notification_emails');
$phpbb_container->setParameter('tables.notification_push', 'phpbb_notification_push');
$phpbb_container->setParameter('tables.push_subscriptions', 'phpbb_push_subscriptions');
$phpbb_container->set(
'text_formatter.s9e.mention_helper',
new \phpbb\textformatter\s9e\mention_helper(

View file

@ -0,0 +1,742 @@
<?php
/**
*
* This file is part of the phpBB Forum Software package.
*
* @copyright (c) phpBB Limited <https://www.phpbb.com>
* @license GNU General Public License, version 2 (GPL-2.0)
*
* For full copyright and license information, please see
* the docs/CREDITS.txt file.
*
*/
use phpbb\notification\method\webpush;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;
require_once __DIR__ . '/base.php';
/**
* @group slow
*/
class notification_method_webpush_test extends phpbb_tests_notification_base
{
/** @var string[] VAPID keys for testing purposes */
public const VAPID_KEYS = [
'publicKey' => 'BIcGkq1Ncj3a2-J0UW-1A0NETLjvxZzNLiYBiPVMKNjgwmwPi5jyK87VfS4FZn9n7S9pLMQzjV3LmFuOnRSOvmI',
'privateKey' => 'SrlbBEVgibWmKHYbDPu4Y2XvDWPjeGcc9fC16jq01xU',
];
/** @var webpush */
protected $notification_method_webpush;
/** @var \phpbb\language\language */
protected $language;
/** @var \phpbb\log\log_interface */
protected $log;
public function getDataSet()
{
return $this->createXMLDataSet(__DIR__ . '/fixtures/webpush_notification.type.post.xml');
}
protected function get_notification_methods()
{
return [
'notification.method.webpush',
];
}
public static function setUpBeforeClass(): void
{
self::start_webpush_testing();
}
public static function tearDownAfterClass(): void
{
self::stop_webpush_testing();
}
protected static function start_webpush_testing(): void
{
// Stop first to ensure port is available
self::stop_webpush_testing();
$process = new \Symfony\Component\Process\Process(['node_modules/.bin/web-push-testing', '--port', '9012', 'start']);
$process->run();
if (!$process->isSuccessful())
{
self::fail('Starting web push testing service failed: ' . $process->getErrorOutput());
}
}
protected static function stop_webpush_testing(): void
{
$process = new \Symfony\Component\Process\Process(['node_modules/.bin/web-push-testing', '--port', '9012', 'stop']);
$process->run();
}
protected function setUp(): void
{
phpbb_database_test_case::setUp();
global $phpbb_root_path, $phpEx;
include_once(__DIR__ . '/ext/test/notification/type/test.' . $phpEx);
global $db, $config, $user, $auth, $cache, $phpbb_container, $phpbb_dispatcher;
$avatar_helper = $this->getMockBuilder('\phpbb\avatar\helper')
->disableOriginalConstructor()
->getMock();
$db = $this->db = $this->new_dbal();
$config = $this->config = new \phpbb\config\config([
'allow_privmsg' => true,
'allow_bookmarks' => true,
'allow_topic_notify' => true,
'allow_forum_notify' => true,
'allow_board_notifications' => true,
'webpush_vapid_public' => self::VAPID_KEYS['publicKey'],
'webpush_vapid_private' => self::VAPID_KEYS['privateKey'],
]);
$lang_loader = new \phpbb\language\language_file_loader($phpbb_root_path, $phpEx);
$this->language = new \phpbb\language\language($lang_loader);
$this->language->add_lang('acp/common');
$user = new \phpbb\user($this->language, '\phpbb\datetime');
$this->user = $user;
$this->user->data['user_options'] = 230271;
$this->user_loader = new \phpbb\user_loader($avatar_helper, $this->db, $phpbb_root_path, $phpEx, 'phpbb_users');
$auth = $this->auth = new phpbb_mock_notifications_auth();
$this->phpbb_dispatcher = new phpbb_mock_event_dispatcher();
$phpbb_dispatcher = $this->phpbb_dispatcher;
$cache_driver = new \phpbb\cache\driver\dummy();
$cache = $this->cache = new \phpbb\cache\service(
$cache_driver,
$this->config,
$this->db,
$this->phpbb_dispatcher,
$phpbb_root_path,
$phpEx
);
$log_table = 'phpbb_log';
$this->log = new \phpbb\log\log($this->db, $user, $auth, $this->phpbb_dispatcher, $phpbb_root_path, 'adm/', $phpEx, $log_table);
$phpbb_container = $this->container = new ContainerBuilder();
$loader = new YamlFileLoader($phpbb_container, new FileLocator(__DIR__ . '/fixtures'));
$loader->load('services_notification.yml');
$phpbb_container->set('user_loader', $this->user_loader);
$phpbb_container->set('user', $user);
$phpbb_container->set('language', $this->language);
$phpbb_container->set('config', $this->config);
$phpbb_container->set('dbal.conn', $this->db);
$phpbb_container->set('auth', $auth);
$phpbb_container->set('cache.driver', $cache_driver);
$phpbb_container->set('cache', $cache);
$phpbb_container->set('log', $this->log);
$phpbb_container->set('text_formatter.utils', new \phpbb\textformatter\s9e\utils());
$phpbb_container->set('dispatcher', $this->phpbb_dispatcher);
$phpbb_container->setParameter('core.root_path', $phpbb_root_path);
$phpbb_container->setParameter('core.php_ext', $phpEx);
$phpbb_container->setParameter('tables.notifications', 'phpbb_notifications');
$phpbb_container->setParameter('tables.user_notifications', 'phpbb_user_notifications');
$phpbb_container->setParameter('tables.notification_types', 'phpbb_notification_types');
$phpbb_container->setParameter('tables.notification_emails', 'phpbb_notification_emails');
$phpbb_container->setParameter('tables.notification_push', 'phpbb_notification_push');
$phpbb_container->setParameter('tables.push_subscriptions', 'phpbb_push_subscriptions');
$phpbb_container->set(
'text_formatter.s9e.mention_helper',
new \phpbb\textformatter\s9e\mention_helper(
$this->db,
$auth,
$this->user,
$phpbb_root_path,
$phpEx
)
);
$ban_type_email = new \phpbb\ban\type\email($this->db, 'phpbb_bans', 'phpbb_users', 'phpbb_sessions', 'phpbb_sessions_keys');
$ban_type_user = new \phpbb\ban\type\user($this->db, 'phpbb_bans', 'phpbb_users', 'phpbb_sessions', 'phpbb_sessions_keys');
$ban_type_ip = new \phpbb\ban\type\ip($this->db, 'phpbb_bans', 'phpbb_users', 'phpbb_sessions', 'phpbb_sessions_keys');
$phpbb_container->set('ban.type.email', $ban_type_email);
$phpbb_container->set('ban.type.user', $ban_type_user);
$phpbb_container->set('ban.type.ip', $ban_type_ip);
$collection = new \phpbb\di\service_collection($phpbb_container);
$collection->add('ban.type.email');
$collection->add('ban.type.user');
$collection->add('ban.type.ip');
$ban_manager = new \phpbb\ban\manager($collection, new \phpbb\cache\driver\dummy(), $this->db, $this->language, $this->log, $user, 'phpbb_bans', 'phpbb_users');
$phpbb_container->set('ban.manager', $ban_manager);
$this->notification_method_webpush = new \phpbb\notification\method\webpush(
$phpbb_container->get('config'),
$phpbb_container->get('dbal.conn'),
$phpbb_container->get('log'),
$phpbb_container->get('user_loader'),
$phpbb_container->get('user'),
$phpbb_root_path,
$phpEx,
$phpbb_container->getParameter('tables.notification_push'),
$phpbb_container->getParameter('tables.push_subscriptions')
);
$phpbb_container->set('notification.method.webpush', $this->notification_method_webpush);
$this->notifications = new phpbb_notification_manager_helper(
array(),
array(),
$this->container,
$this->user_loader,
$this->phpbb_dispatcher,
$this->db,
$this->cache,
$this->language,
$this->user,
'phpbb_notification_types',
'phpbb_user_notifications'
);
$phpbb_container->set('notification_manager', $this->notifications);
$phpbb_container->addCompilerPass(new phpbb\di\pass\markpublic_pass());
$phpbb_container->compile();
$this->notifications->setDependencies($this->auth, $this->config);
$types = array();
foreach ($this->get_notification_types() as $type)
{
$class = $this->build_type($type);
$types[$type] = $class;
}
$this->notifications->set_var('notification_types', $types);
$methods = array();
foreach ($this->get_notification_methods() as $method)
{
$class = $this->container->get($method);
$methods[$method] = $class;
}
$this->notifications->set_var('notification_methods', $methods);
}
public function data_notification_webpush()
{
return [
/**
* Normal post
*
* User => State description
* 2 => Topic id=1 and id=2 subscribed, should receive a new topics post notification
* 3 => Topic id=1 subscribed, should receive a new topic post notification
* 4 => Topic id=1 subscribed, should receive a new topic post notification
* 5 => Topic id=1 subscribed, post id=1 already notified, should receive a new topic post notification
* 6 => Topic id=1 and forum id=1 subscribed, should receive a new topic/forum post notification
* 7 => Forum id=1 subscribed, should NOT receive a new topic post but a forum post notification
* 8 => Forum id=1 subscribed, post id=1 already notified, should NOT receive a new topic post but a forum post notification
*/
[
'notification.type.post',
[
'forum_id' => '1',
'post_id' => '2',
'topic_id' => '1',
],
[
2 => ['user_id' => '2'],
3 => ['user_id' => '3'],
4 => ['user_id' => '4'],
5 => ['user_id' => '5'],
6 => ['user_id' => '6'],
],
],
[
'notification.type.forum',
[
'forum_id' => '1',
'post_id' => '3',
'topic_id' => '1',
],
[
6 => ['user_id' => '6'],
7 => ['user_id' => '7'],
8 => ['user_id' => '8']
],
],
[
'notification.type.post',
[
'forum_id' => '1',
'post_id' => '4',
'topic_id' => '2',
],
[
2 => ['user_id' => '2'],
],
],
[
'notification.type.forum',
[
'forum_id' => '1',
'post_id' => '5',
'topic_id' => '2',
],
[
6 => ['user_id' => '6'],
7 => ['user_id' => '7'],
8 => ['user_id' => '8'],
],
],
[
'notification.type.post',
[
'forum_id' => '2',
'post_id' => '6',
'topic_id' => '3',
],
[
],
],
[
'notification.type.forum',
[
'forum_id' => '2',
'post_id' => '6',
'topic_id' => '3',
],
[
],
],
];
}
/**
* @dataProvider data_notification_webpush
*/
public function test_notification_webpush($notification_type, $post_data, $expected_users)
{
$post_data = array_merge([
'post_time' => 1349413322,
'poster_id' => 1,
'topic_title' => '',
'post_subject' => '',
'post_username' => '',
'forum_name' => '',
],
$post_data);
$notification_options = [
'item_id' => $post_data['post_id'],
'item_parent_id' => $post_data['topic_id'],
];
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals(0, count($notified_users), 'Assert no user has been notified yet');
$this->notifications->add_notifications($notification_type, $post_data);
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals($expected_users, $notified_users, 'Assert that expected users have been notified');
$post_data['post_id']++;
$notification_options['item_id'] = $post_data['post_id'];
$post_data['post_time'] = 1349413323;
$this->notifications->add_notifications($notification_type, $post_data);
$notified_users2 = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals($expected_users, $notified_users2, 'Assert that expected users stay the same after replying to same topic');
}
/**
* @dataProvider data_notification_webpush
*/
public function test_get_subscription($notification_type, $post_data, $expected_users): void
{
$subscription_info = [];
foreach ($expected_users as $user_id => $user_data)
{
$subscription_info[$user_id][] = $this->create_subscription_for_user($user_id);
}
// Create second subscription for first user ID passed
if (count($expected_users))
{
$first_user_id = array_key_first($expected_users);
$subscription_info[$first_user_id][] = $this->create_subscription_for_user($first_user_id);
}
$post_data = array_merge([
'post_time' => 1349413322,
'poster_id' => 1,
'topic_title' => '',
'post_subject' => '',
'post_username' => '',
'forum_name' => '',
],
$post_data);
$notification_options = [
'item_id' => $post_data['post_id'],
'item_parent_id' => $post_data['topic_id'],
];
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals(0, count($notified_users), 'Assert no user has been notified yet');
foreach ($expected_users as $user_id => $data)
{
$messages = $this->get_messages_for_subscription($subscription_info[$user_id][0]['clientHash']);
$this->assertEmpty($messages);
}
$this->notifications->add_notifications($notification_type, $post_data);
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals($expected_users, $notified_users, 'Assert that expected users have been notified');
foreach ($expected_users as $user_id => $data)
{
$messages = $this->get_messages_for_subscription($subscription_info[$user_id][0]['clientHash']);
$this->assertNotEmpty($messages, 'Failed asserting that user ' . $user_id . ' has received messages.');
}
}
/**
* @dataProvider data_notification_webpush
*/
public function test_notify_empty_queue($notification_type, $post_data, $expected_users): void
{
foreach ($expected_users as $user_id => $user_data)
{
$this->create_subscription_for_user($user_id);
}
$post_data = array_merge([
'post_time' => 1349413322,
'poster_id' => 1,
'topic_title' => '',
'post_subject' => '',
'post_username' => '',
'forum_name' => '',
],
$post_data);
$notification_options = [
'item_id' => $post_data['post_id'],
'item_parent_id' => $post_data['topic_id'],
];
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals(0, count($notified_users), 'Assert no user has been notified yet');
$this->notification_method_webpush->notify(); // should have no effect
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals(0, count($notified_users), 'Assert no user has been notified yet');
$post_data['post_id']++;
$notification_options['item_id'] = $post_data['post_id'];
$post_data['post_time'] = 1349413323;
$this->notifications->add_notifications($notification_type, $post_data);
$notified_users2 = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals($expected_users, $notified_users2, 'Assert that expected users stay the same after replying to same topic');
}
/**
* @dataProvider data_notification_webpush
*/
public function test_notify_invalid_endpoint($notification_type, $post_data, $expected_users): void
{
$subscription_info = [];
foreach ($expected_users as $user_id => $user_data)
{
$subscription_info[$user_id][] = $this->create_subscription_for_user($user_id);
}
// Create second subscription for first user ID passed
if (count($expected_users))
{
$first_user_id = array_key_first($expected_users);
$first_user_sub = $this->create_subscription_for_user($first_user_id, true);
$subscription_info[$first_user_id][] = $first_user_sub;
}
$post_data = array_merge([
'post_time' => 1349413322,
'poster_id' => 1,
'topic_title' => '',
'post_subject' => '',
'post_username' => '',
'forum_name' => '',
],
$post_data);
$notification_options = [
'item_id' => $post_data['post_id'],
'item_parent_id' => $post_data['topic_id'],
];
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals(0, count($notified_users), 'Assert no user has been notified yet');
foreach ($expected_users as $user_id => $data)
{
$messages = $this->get_messages_for_subscription($subscription_info[$user_id][0]['clientHash']);
$this->assertEmpty($messages);
}
$this->notifications->add_notifications($notification_type, $post_data);
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals($expected_users, $notified_users, 'Assert that expected users have been notified');
foreach ($expected_users as $user_id => $data)
{
$messages = $this->get_messages_for_subscription($subscription_info[$user_id][0]['clientHash']);
$this->assertNotEmpty($messages, 'Failed asserting that user ' . $user_id . ' has received messages.');
}
if (isset($first_user_sub))
{
$admin_logs = $this->log->get_logs('admin');
$this->db->sql_query('DELETE FROM phpbb_log'); // Clear logs
$this->assertCount(1, $admin_logs, 'Assert that an admin log was created for invalid endpoint');
$log_entry = $admin_logs[0];
$this->assertStringStartsWith('<strong>Web Push message could not be sent:</strong>', $log_entry['action']);
$this->assertStringContainsString('400', $log_entry['action']);
}
}
/**
* @dataProvider data_notification_webpush
*/
public function test_notify_expired($notification_type, $post_data, $expected_users)
{
$subscription_info = [];
foreach ($expected_users as $user_id => $user_data)
{
$subscription_info[$user_id][] = $this->create_subscription_for_user($user_id);
}
$expected_delivered_users = $expected_users;
// Expire subscriptions for first user
if (count($expected_users))
{
$first_user_id = array_key_first($expected_users);
$first_user_subs = $subscription_info[$first_user_id];
unset($expected_delivered_users[$first_user_id]);
$this->expire_subscription($first_user_subs[0]['clientHash']);
}
$post_data = array_merge([
'post_time' => 1349413322,
'poster_id' => 1,
'topic_title' => '',
'post_subject' => '',
'post_username' => '',
'forum_name' => '',
],
$post_data);
$notification_options = [
'item_id' => $post_data['post_id'],
'item_parent_id' => $post_data['topic_id'],
];
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals(0, count($notified_users), 'Assert no user has been notified yet');
foreach ($expected_delivered_users as $user_id => $data)
{
$messages = $this->get_messages_for_subscription($subscription_info[$user_id][0]['clientHash']);
$this->assertEmpty($messages);
}
$this->notifications->add_notifications($notification_type, $post_data);
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals($expected_users, $notified_users, 'Assert that expected users have been notified');
foreach ($expected_delivered_users as $user_id => $data)
{
$messages = $this->get_messages_for_subscription($subscription_info[$user_id][0]['clientHash']);
$this->assertNotEmpty($messages, 'Failed asserting that user ' . $user_id . ' has received messages.');
}
}
public function test_get_type(): void
{
$this->assertEquals('notification.method.webpush', $this->notification_method_webpush->get_type());
}
/**
* @dataProvider data_notification_webpush
*/
public function test_prune_notifications($notification_type, $post_data, $expected_users): void
{
$subscription_info = [];
foreach ($expected_users as $user_id => $user_data)
{
$subscription_info[$user_id][] = $this->create_subscription_for_user($user_id);
}
// Create second subscription for first user ID passed
if (count($expected_users))
{
$first_user_id = array_key_first($expected_users);
$subscription_info[$first_user_id][] = $this->create_subscription_for_user($first_user_id);
}
$post_data = array_merge([
'post_time' => 1349413322,
'poster_id' => 1,
'topic_title' => '',
'post_subject' => '',
'post_username' => '',
'forum_name' => '',
],
$post_data);
$notification_options = [
'item_id' => $post_data['post_id'],
'item_parent_id' => $post_data['topic_id'],
];
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals(0, count($notified_users), 'Assert no user has been notified yet');
foreach ($expected_users as $user_id => $data)
{
$messages = $this->get_messages_for_subscription($subscription_info[$user_id][0]['clientHash']);
$this->assertEmpty($messages);
}
$this->notifications->add_notifications($notification_type, $post_data);
$notified_users = $this->notification_method_webpush->get_notified_users($this->notifications->get_notification_type_id($notification_type), $notification_options);
$this->assertEquals($expected_users, $notified_users, 'Assert that expected users have been notified');
foreach ($expected_users as $user_id => $data)
{
$messages = $this->get_messages_for_subscription($subscription_info[$user_id][0]['clientHash']);
$this->assertNotEmpty($messages, 'Failed asserting that user ' . $user_id . ' has received messages.');
}
// Prune notifications with 0 time, shouldn't change anything
$prune_time = time();
$this->notification_method_webpush->prune_notifications(0);
$this->assertGreaterThanOrEqual($prune_time, $this->config->offsetGet('read_notification_last_gc'), 'Assert that prune time was set');
$cur_notifications = $this->get_notifications();
$this->assertSameSize($cur_notifications, $expected_users, 'Assert that no notifications have been pruned');
// Prune only read not supported, will prune all
$this->notification_method_webpush->prune_notifications($prune_time);
$this->assertGreaterThanOrEqual($prune_time, $this->config->offsetGet('read_notification_last_gc'), 'Assert that prune time was set');
$cur_notifications = $this->get_notifications();
$this->assertCount(0, $cur_notifications, 'Assert that no notifications have been pruned');
}
protected function create_subscription_for_user($user_id, bool $invalidate_endpoint = false): array
{
$client = new \GuzzleHttp\Client();
try
{
$response = $client->request('POST', 'http://localhost:9012/subscribe', ['form_params' => [
'applicationServerKey' => self::VAPID_KEYS['publicKey'],
]]);
}
catch (\GuzzleHttp\Exception\GuzzleException $exception)
{
$this->fail('Failed getting subscription from web-push-testing client: ' . $exception->getMessage());
}
$subscription_return = \phpbb\json\sanitizer::decode((string) $response->getBody());
$subscription_data = $subscription_return['data'];
$this->assertNotEmpty($subscription_data['endpoint']);
$this->assertStringStartsWith('http://localhost:9012/notify/', $subscription_data['endpoint']);
$this->assertIsArray($subscription_data['keys']);
if ($invalidate_endpoint)
{
$subscription_data['endpoint'] .= 'invalid';
}
$push_subscriptions_table = $this->container->getParameter('tables.push_subscriptions');
$sql = 'INSERT INTO ' . $push_subscriptions_table . ' ' . $this->db->sql_build_array('INSERT', [
'user_id' => $user_id,
'endpoint' => $subscription_data['endpoint'],
'p256dh' => $subscription_data['keys']['p256dh'],
'auth' => $subscription_data['keys']['auth'],
]);
$this->db->sql_query($sql);
return $subscription_data;
}
protected function expire_subscription(string $client_hash): void
{
$client = new \GuzzleHttp\Client();
try
{
$response = $client->request('POST', 'http://localhost:9012/expire-subscription/' . $client_hash);
}
catch (\GuzzleHttp\Exception\GuzzleException $exception)
{
$this->fail('Failed expiring subscription with web-push-testing client: ' . $exception->getMessage());
}
$subscription_return = \phpbb\json\sanitizer::decode((string) $response->getBody());
$this->assertEquals(200, $response->getStatusCode(), 'Expected response status to be 200');
}
protected function get_messages_for_subscription($client_hash): array
{
$client = new \GuzzleHttp\Client();
try
{
$response = $client->request('POST', 'http://localhost:9012/get-notifications', ['form_params' => [
'clientHash' => $client_hash,
]]);
}
catch (\GuzzleHttp\Exception\GuzzleException $exception)
{
$this->fail('Failed getting messages from web-push-testing client: ' . $exception->getMessage());
}
$response_data = json_decode($response->getBody()->getContents(), true);
$this->assertNotEmpty($response_data);
$this->assertArrayHasKey('data', $response_data);
$this->assertArrayHasKey('messages', $response_data['data']);
return $response_data['data']['messages'];
}
protected function get_notifications(): array
{
$webpush_table = $this->container->getParameter('tables.notification_push');
$sql = 'SELECT * FROM ' . $webpush_table;
$result = $this->db->sql_query($sql);
$sql_ary = $this->db->sql_fetchrowset($result);
$this->db->sql_freeresult($result);
return $sql_ary;
}
}

View file

@ -135,6 +135,7 @@ abstract class phpbb_notification_submit_post_base extends phpbb_database_test_c
$phpbb_container->set('auth', $auth);
$phpbb_container->set('cache.driver', $cache_driver);
$phpbb_container->set('cache', $cache);
$phpbb_container->set('log', new \phpbb\log\dummy());
$phpbb_container->set('text_formatter.utils', new \phpbb\textformatter\s9e\utils());
$phpbb_container->set(
'text_formatter.s9e.mention_helper',
@ -154,6 +155,8 @@ abstract class phpbb_notification_submit_post_base extends phpbb_database_test_c
$phpbb_container->setParameter('tables.user_notifications', 'phpbb_user_notifications');
$phpbb_container->setParameter('tables.notification_types', 'phpbb_notification_types');
$phpbb_container->setParameter('tables.notification_emails', 'phpbb_notification_emails');
$phpbb_container->setParameter('tables.notification_push', 'phpbb_notification_push');
$phpbb_container->setParameter('tables.push_subscriptions', 'phpbb_push_subscriptions');
$phpbb_container->set('content.visibility', new \phpbb\content_visibility($auth, $config, $phpbb_dispatcher, $db, $user, $phpbb_root_path, $phpEx, FORUMS_TABLE, POSTS_TABLE, TOPICS_TABLE, USERS_TABLE));
$phpbb_container->addCompilerPass(new phpbb\di\pass\markpublic_pass());
$phpbb_container->compile();