<?php
namespace XF\Service;
use Minishlink\WebPush\Encryption;
use Minishlink\WebPush\MessageSentReport;
use Minishlink\WebPush\Subscription;
use Minishlink\WebPush\WebPush;
use XF\Entity\User;
use XF\Language;
use function boolval, count, strlen, strval;
class PushNotification extends AbstractService
{
/**
* @var User
*/
protected $receiver;
/**
* @var Language
*/
protected $language;
/**
* @var array
*/
protected $subscriptions;
/**
* @var array
*/
protected $payloadData = [];
public function __construct(\XF\App $app, User $receiver)
{
parent::__construct($app);
$this->receiver = $receiver;
$this->language = $app->userLanguage($receiver);
}
public function setNotificationContent($title, $body, $url = null)
{
$this->payloadData['title'] = $title;
$this->payloadData['body'] = $body;
$this->payloadData['url'] = \XF::canonicalizeUrl($url);
}
public function setIconAndBadge($icon, $badge = null)
{
$this->payloadData['icon'] = \XF::canonicalizeUrl($icon);
if ($badge)
{
$this->payloadData['badge'] = \XF::canonicalizeUrl($badge);
}
}
public function setDirection($direction)
{
$this->payloadData['dir'] = $direction;
}
public function setNotificationTag($tag)
{
$this->payloadData['tag'] = $tag;
}
public function setCustomPayloadData($name, $value)
{
$this->payloadData[$name] = $value;
}
protected function applyPayloadDefaults()
{
$options = $this->app->options();
$language = $this->language;
$this->payloadData = array_replace([
'title' => $language->phrase('notification_from_x', ['boardTitle' => $options->boardTitle])->render(),
'body' => $language->phrase('you_have_new_notification_at_x', ['boardTitle' => $options->boardTitle])->render(),
'url' => $options->boardUrl,
'badge' => $this->getDefaultBadgeForVisitor(),
'icon' => $this->getDefaultIconForVisitor(),
'dir' => $language->isRtl() ? 'rtl' : 'ltr',
'tag' => '',
'tag_phrase' => $language->phrase('(plus_x_previous)')->render() // {count} is calculated on client
], $this->payloadData);
return $this->payloadData;
}
public function isPushAvailable()
{
$options = $this->app->options();
return (
$options->enablePush
&& $options->pushKeysVAPID['publicKey']
&& $options->pushKeysVAPID['privateKey']
&& $this->isReceiverSubscribed()
);
}
public function isReceiverSubscribed()
{
$subscriptions = $this->getReceiverSubscriptions();
return boolval(count($subscriptions));
}
protected function getReceiverSubscriptions()
{
if ($this->subscriptions === null)
{
$this->subscriptions = $this->getUserPushRepository()->getUserSubscriptions($this->receiver);
}
return $this->subscriptions;
}
public function sendNotifications()
{
if (!$this->isPushAvailable())
{
return;
}
$payload = $this->applyPayloadDefaults();
$webPush = $this->getWebPushObject();
$webPush->setDefaultOptions([
'TTL' => 86400, // if undelivered after 1 day, expire the notification
]);
$subscriptions = $this->getReceiverSubscriptions();
foreach ($subscriptions AS $subscription)
{
if (substr($subscription['endpoint'], 0, strlen(WebPush::GCM_URL)) === WebPush::GCM_URL)
{
// if we somehow get a GCM URL into this, it will fail later, so we need to skip it here
continue;
}
$authData = json_decode($subscription['data'], true);
try
{
$webPush->setAutomaticPadding($this->getEndpointPadding($subscription['endpoint']));
$subObj = Subscription::create([
'endpoint' => $subscription['endpoint'],
'publicKey' => $authData['key'],
'authToken' => $authData['token'],
'contentEncoding' => $authData['encoding']
]);
$webPush->sendNotification(
$subObj,
json_encode($payload)
);
}
catch (\Exception $e)
{
// generally indicates that the payload is too big which at ~3000 bytes shouldn't happen for a typical alert...
if (\XF::$debugMode)
{
\XF::logException($e);
}
continue;
}
}
$results = $webPush->flush();
$this->handleResults($results);
}
protected function getEndpointPadding($endpoint)
{
if (strpos($endpoint, 'mozilla') !== false)
{
// firefox, at least on Android, has an issue with automatic padding which
// is used to make encryption more secure, but makes encryption slower.
// see: https://github.com/web-push-libs/web-push-php/issues/108
// TODO: ideally won't need this forever if Mozilla ever fix this or if library works-around it
return 0;
}
if (strpos($endpoint, '.ucweb.com') !== false)
{
// See https://xenforo.com/community/threads/158252/
return 0;
}
return Encryption::MAX_COMPATIBILITY_PAYLOAD_LENGTH;
}
protected function handleResults($results)
{
$db = $this->db();
$pushRepo = $this->getUserPushRepository();
/** @var MessageSentReport $report */
foreach ($results AS $report)
{
$endpoint = strval($report->getRequest()->getUri());
$endpointHash = $pushRepo->getEndpointHash($endpoint);
if ($report->isSuccess())
{
$db->update('xf_user_push_subscription', [
'last_seen' => time()
], 'endpoint_hash = ?', $endpointHash);
}
else
{
$response = $report->getResponse();
$code = $response ? $response->getStatusCode() : null;
switch ($code)
{
// 401 and 403 generally represent when something in the request doesn't match, likely
// due to VAPID keys changing
case 401:
case 403:
case 404:
case 410:
$db->delete('xf_user_push_subscription', 'endpoint_hash = ?', $endpointHash);
break;
case 406:
// not a server error but rate limiting - future pushes should work
case 500:
case 502:
case 503:
case 504:
// these indicate server errors that are likely temporary - future pushes should work
break;
default:
\XF::logError("Push notification failure: " . $report->getReason());
break;
}
}
}
}
/**
* @return WebPush
*/
protected function getWebPushObject()
{
$options = $this->app->options();
$auth = [
'VAPID' => [
'subject' => $options->boardUrl
] + $options->pushKeysVAPID
];
$httpOptions = $this->app->http()->getDefaultClientOptions();
$config = $this->app->config();
if ($config['http']['proxy'])
{
$httpOptions['proxy'] = $config['http']['proxy'];
}
return new WebPush($auth, [], 10, $httpOptions);
}
protected function getDefaultBadgeForVisitor()
{
$style = $this->app->style($this->receiver->style_id);
$badge = $style->getProperty('publicPushBadgeUrl', null);
if ($badge)
{
$badge = \XF::canonicalizeUrl($badge);
}
return $badge;
}
protected function getDefaultIconForVisitor()
{
$style = $this->app->style($this->receiver->style_id);
$icon = $style->getProperty('publicMetadataLogoUrl', null);
if ($icon)
{
$icon = \XF::canonicalizeUrl($icon);
}
return $icon;
}
/**
* @return \XF\Mvc\Entity\Repository|\XF\Repository\UserPush
*/
protected function getUserPushRepository()
{
return $this->repository('XF:UserPush');
}
}