471 lines
16 KiB
PHP
471 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\CustomPwa\Service;
|
|
|
|
use OCA\CustomPwa\AppInfo\Application;
|
|
use OCP\Http\Client\IClientService;
|
|
use OCP\IConfig;
|
|
use OCP\IURLGenerator;
|
|
use OCP\IUserManager;
|
|
use OCP\L10N\IFactory;
|
|
use OCP\Notification\AlreadyProcessedException;
|
|
use OCP\Notification\IManager as INotificationManager;
|
|
use OCP\Notification\IncompleteParsedNotificationException;
|
|
use OCP\Notification\INotification;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
class PushNotificationService {
|
|
private const USER_DEVICES_KEY = 'device_registrations_json';
|
|
private const USER_PENDING_NOTIFICATIONS_KEY = 'pending_notifications_json';
|
|
private const APP_VAPID_SUBJECT_KEY = 'vapid_subject';
|
|
private const APP_VAPID_PUBLIC_KEY = 'vapid_public_key';
|
|
private const APP_VAPID_PRIVATE_KEY = 'vapid_private_key';
|
|
private const TALK_NOTIFICATION_APPS = ['spreed', 'talk', 'admin_notification_talk'];
|
|
|
|
public function __construct(
|
|
private IConfig $config,
|
|
private IClientService $clientService,
|
|
private IURLGenerator $urlGenerator,
|
|
private INotificationManager $notificationManager,
|
|
private IUserManager $userManager,
|
|
private IFactory $l10nFactory,
|
|
private LoggerInterface $logger,
|
|
) {
|
|
}
|
|
|
|
public function forwardNotification(INotification $notification): void {
|
|
if (!$this->shouldForward($notification)) {
|
|
return;
|
|
}
|
|
|
|
$userId = $this->normalizeToken($notification->getUser(), 128);
|
|
if ($userId === '') {
|
|
return;
|
|
}
|
|
|
|
$devices = $this->loadUserDevices($userId);
|
|
$pushCandidates = $this->filterPushCandidates($devices);
|
|
if ($pushCandidates === []) {
|
|
return;
|
|
}
|
|
|
|
$entry = $this->buildPendingEntry($notification);
|
|
$this->appendPendingNotification($userId, $entry);
|
|
$this->sendWakePushes($userId, $pushCandidates, $notification->getObjectType() === 'call');
|
|
}
|
|
|
|
/** @return array<int, array<string, mixed>> */
|
|
public function getPendingNotifications(string $userId): array {
|
|
$userId = $this->normalizeToken($userId, 128);
|
|
if ($userId === '') {
|
|
return [];
|
|
}
|
|
|
|
$now = time();
|
|
$pending = [];
|
|
foreach ($this->loadPendingNotifications($userId) as $entry) {
|
|
if (!is_array($entry)) {
|
|
continue;
|
|
}
|
|
$createdAt = (int)($entry['createdAtTs'] ?? 0);
|
|
if ($createdAt > 0 && ($now - $createdAt) > 600) {
|
|
continue;
|
|
}
|
|
$pending[] = [
|
|
'title' => $this->normalizeText((string)($entry['title'] ?? ''), 120),
|
|
'body' => $this->normalizeText((string)($entry['body'] ?? ''), 240),
|
|
'url' => $this->sanitizeUrl((string)($entry['url'] ?? '')),
|
|
'tag' => $this->normalizeTag((string)($entry['tag'] ?? '')),
|
|
'requireInteraction' => !empty($entry['requireInteraction']),
|
|
'createdAt' => $this->normalizeText((string)($entry['createdAt'] ?? ''), 64),
|
|
'app' => $this->normalizeToken((string)($entry['app'] ?? ''), 64),
|
|
'objectType' => $this->normalizeToken((string)($entry['objectType'] ?? ''), 64),
|
|
'objectId' => $this->normalizeText((string)($entry['objectId'] ?? ''), 128),
|
|
];
|
|
}
|
|
|
|
return array_slice(array_reverse($pending), 0, 5);
|
|
}
|
|
|
|
private function shouldForward(INotification $notification): bool {
|
|
return in_array($notification->getApp(), self::TALK_NOTIFICATION_APPS, true);
|
|
}
|
|
|
|
/** @return array<string, array<string, mixed>> */
|
|
private function filterPushCandidates(array $devices): array {
|
|
$candidates = [];
|
|
foreach ($devices as $deviceId => $entry) {
|
|
if (!is_string($deviceId) || !is_array($entry)) {
|
|
continue;
|
|
}
|
|
$endpoint = $this->normalizeText((string)($entry['pushEndpoint'] ?? ''), 1024);
|
|
$permission = $this->normalizeToken((string)($entry['notificationPermission'] ?? ''), 16);
|
|
if ($endpoint === '' || !str_starts_with(strtolower($endpoint), 'https://') || $permission !== 'granted') {
|
|
continue;
|
|
}
|
|
$candidates[$deviceId] = $entry;
|
|
}
|
|
return $candidates;
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function buildPendingEntry(INotification $notification): array {
|
|
$parsed = $this->prepareNotification($notification);
|
|
$title = $this->normalizeText($parsed?->getParsedSubject() ?? '', 120);
|
|
$body = $this->normalizeText($parsed?->getParsedMessage() ?? '', 240);
|
|
$url = $this->sanitizeUrl($parsed?->getLink() ?? $notification->getLink());
|
|
|
|
if ($title === '') {
|
|
$title = $notification->getObjectType() === 'call' ? 'Incoming Talk call' : 'Talk notification';
|
|
}
|
|
if ($body === '') {
|
|
$body = $notification->getObjectType() === 'call'
|
|
? 'Open Talk to answer.'
|
|
: 'Open Talk to view the latest activity.';
|
|
}
|
|
if ($url === '') {
|
|
$url = $this->urlGenerator->getAbsoluteURL('/apps/spreed/');
|
|
}
|
|
|
|
$createdAtTs = time();
|
|
return [
|
|
'id' => sha1($notification->getApp() . '|' . $notification->getObjectType() . '|' . $notification->getObjectId() . '|' . $notification->getDateTime()->getTimestamp()),
|
|
'app' => $notification->getApp(),
|
|
'objectType' => $notification->getObjectType(),
|
|
'objectId' => $this->normalizeText($notification->getObjectId(), 128),
|
|
'title' => $title,
|
|
'body' => $body,
|
|
'url' => $url,
|
|
'tag' => $this->normalizeTag('custom-pwa-' . $notification->getApp() . '-' . $notification->getObjectType() . '-' . $notification->getObjectId()),
|
|
'requireInteraction' => $notification->getObjectType() === 'call',
|
|
'createdAt' => gmdate('c', $createdAtTs),
|
|
'createdAtTs' => $createdAtTs,
|
|
];
|
|
}
|
|
|
|
private function prepareNotification(INotification $notification): ?INotification {
|
|
if ($notification->isValidParsed()) {
|
|
return $notification;
|
|
}
|
|
|
|
$user = $this->userManager->get($notification->getUser());
|
|
$language = $user !== null ? $this->l10nFactory->getUserLanguage($user) : 'en';
|
|
try {
|
|
$this->notificationManager->setPreparingPushNotification(true);
|
|
return $this->notificationManager->prepare(clone $notification, $language);
|
|
} catch (AlreadyProcessedException|IncompleteParsedNotificationException|\InvalidArgumentException) {
|
|
return null;
|
|
} finally {
|
|
$this->notificationManager->setPreparingPushNotification(false);
|
|
}
|
|
}
|
|
|
|
/** @param array<string, array<string, mixed>> $devices */
|
|
private function sendWakePushes(string $userId, array $devices, bool $highPriority): void {
|
|
$vapidConfig = $this->getVapidConfig();
|
|
if (!$vapidConfig['ok']) {
|
|
return;
|
|
}
|
|
|
|
$client = $this->clientService->newClient();
|
|
$changed = false;
|
|
foreach ($devices as $deviceId => $entry) {
|
|
$endpoint = $this->normalizeText((string)($entry['pushEndpoint'] ?? ''), 1024);
|
|
$outcome = $this->sendWebPushWake(
|
|
$client,
|
|
$endpoint,
|
|
$vapidConfig['subject'],
|
|
$vapidConfig['publicKey'],
|
|
$vapidConfig['privatePem'],
|
|
$highPriority
|
|
);
|
|
$entry['lastTalkPushAt'] = gmdate('c');
|
|
$entry['lastTalkPushHttpStatus'] = (int)($outcome['status'] ?? 0);
|
|
$entry['lastTalkPushOk'] = !empty($outcome['ok']);
|
|
$entry['lastTalkPushError'] = $this->normalizeText((string)($outcome['error'] ?? ''), 256);
|
|
if (($outcome['status'] ?? 0) === 404 || ($outcome['status'] ?? 0) === 410) {
|
|
$entry['pushEndpointStaleAt'] = gmdate('c');
|
|
}
|
|
$devices[$deviceId] = $entry;
|
|
$changed = true;
|
|
}
|
|
|
|
if ($changed) {
|
|
$allDevices = $this->loadUserDevices($userId);
|
|
foreach ($devices as $deviceId => $entry) {
|
|
$allDevices[$deviceId] = $entry;
|
|
}
|
|
$this->saveUserDevices($userId, $allDevices);
|
|
}
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function sendWebPushWake(
|
|
\OCP\Http\Client\IClient $client,
|
|
string $endpoint,
|
|
string $subject,
|
|
string $publicKey,
|
|
string $privatePem,
|
|
bool $highPriority
|
|
): array {
|
|
$audience = $this->buildPushAudience($endpoint);
|
|
if ($audience === '') {
|
|
return ['ok' => false, 'status' => 0, 'error' => 'invalid_endpoint'];
|
|
}
|
|
$jwt = $this->buildVapidJwt($audience, $subject, $privatePem);
|
|
if ($jwt === '') {
|
|
return ['ok' => false, 'status' => 0, 'error' => 'vapid_jwt_failed'];
|
|
}
|
|
|
|
try {
|
|
$response = $client->post($endpoint, [
|
|
'timeout' => 15,
|
|
'connect_timeout' => 5,
|
|
'http_errors' => false,
|
|
'verify' => true,
|
|
'headers' => [
|
|
'TTL' => '60',
|
|
'Urgency' => $highPriority ? 'high' : 'normal',
|
|
'Authorization' => 'vapid t=' . $jwt . ', k=' . $publicKey,
|
|
'Crypto-Key' => 'p256ecdsa=' . $publicKey,
|
|
'Content-Length' => '0',
|
|
],
|
|
'body' => '',
|
|
'nextcloud' => [
|
|
'allow_local_address' => false,
|
|
],
|
|
]);
|
|
$status = (int)$response->getStatusCode();
|
|
$ok = $status >= 200 && $status < 300;
|
|
return [
|
|
'ok' => $ok,
|
|
'status' => $status,
|
|
'error' => $ok ? '' : $this->normalizeText(trim((string)$response->getBody()), 256),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
$this->logger->warning('CustomPWA Talk push wake failed', [
|
|
'app' => Application::APP_ID,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
return ['ok' => false, 'status' => 0, 'error' => $this->normalizeText($e->getMessage(), 256)];
|
|
}
|
|
}
|
|
|
|
/** @return array<int, mixed> */
|
|
private function loadPendingNotifications(string $userId): array {
|
|
$raw = trim($this->config->getUserValue($userId, Application::APP_ID, self::USER_PENDING_NOTIFICATIONS_KEY, '[]'));
|
|
if ($raw === '') {
|
|
return [];
|
|
}
|
|
try {
|
|
$decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
|
return is_array($decoded) ? $decoded : [];
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** @param array<string, mixed> $entry */
|
|
private function appendPendingNotification(string $userId, array $entry): void {
|
|
$pending = $this->loadPendingNotifications($userId);
|
|
$pending[] = $entry;
|
|
$pending = array_slice($pending, -20);
|
|
$this->config->setUserValue(
|
|
$userId,
|
|
Application::APP_ID,
|
|
self::USER_PENDING_NOTIFICATIONS_KEY,
|
|
json_encode($pending, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
}
|
|
|
|
/** @return array<string, array<string, mixed>> */
|
|
private function loadUserDevices(string $userId): array {
|
|
$raw = trim($this->config->getUserValue($userId, Application::APP_ID, self::USER_DEVICES_KEY, '{}'));
|
|
if ($raw === '') {
|
|
return [];
|
|
}
|
|
try {
|
|
$decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
if (!is_array($decoded)) {
|
|
return [];
|
|
}
|
|
|
|
$devices = [];
|
|
foreach ($decoded as $id => $entry) {
|
|
if (!is_string($id) || !is_array($entry)) {
|
|
continue;
|
|
}
|
|
$normalizedId = $this->normalizeToken($id, 128);
|
|
if ($normalizedId === '') {
|
|
continue;
|
|
}
|
|
$devices[$normalizedId] = $entry;
|
|
}
|
|
return $devices;
|
|
}
|
|
|
|
/** @param array<string, array<string, mixed>> $devices */
|
|
private function saveUserDevices(string $userId, array $devices): void {
|
|
$this->config->setUserValue(
|
|
$userId,
|
|
Application::APP_ID,
|
|
self::USER_DEVICES_KEY,
|
|
json_encode($devices, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
|
);
|
|
}
|
|
|
|
/** @return array{ok:bool,subject:string,publicKey:string,privatePem:string} */
|
|
private function getVapidConfig(): array {
|
|
$subject = $this->normalizeText($this->config->getAppValue(Application::APP_ID, self::APP_VAPID_SUBJECT_KEY, ''), 256);
|
|
$publicKey = $this->normalizeText($this->config->getAppValue(Application::APP_ID, self::APP_VAPID_PUBLIC_KEY, ''), 512);
|
|
$privateRaw = trim($this->config->getAppValue(Application::APP_ID, self::APP_VAPID_PRIVATE_KEY, ''));
|
|
$privatePem = $this->normalizeVapidPrivateKeyPem($privateRaw);
|
|
return [
|
|
'ok' => $subject !== '' && $publicKey !== '' && $privatePem !== '',
|
|
'subject' => $subject,
|
|
'publicKey' => $publicKey,
|
|
'privatePem' => $privatePem,
|
|
];
|
|
}
|
|
|
|
private function normalizeVapidPrivateKeyPem(string $value): string {
|
|
$trimmed = trim($value);
|
|
if ($trimmed === '') {
|
|
return '';
|
|
}
|
|
if (str_contains($trimmed, 'BEGIN PRIVATE KEY') || str_contains($trimmed, 'BEGIN EC PRIVATE KEY')) {
|
|
return $trimmed;
|
|
}
|
|
$raw = $this->base64UrlDecode($trimmed);
|
|
if ($raw === '' || strlen($raw) !== 32) {
|
|
return '';
|
|
}
|
|
$version = "\x02\x01\x01";
|
|
$privateOctet = "\x04\x20" . $raw;
|
|
$prime256v1 = "\xA0\x0A\x06\x08\x2A\x86\x48\xCE\x3D\x03\x01\x07";
|
|
$sequence = $version . $privateOctet . $prime256v1;
|
|
$len = strlen($sequence);
|
|
if ($len > 127) {
|
|
return '';
|
|
}
|
|
$pemBody = chunk_split(base64_encode("\x30" . chr($len) . $sequence), 64, "\n");
|
|
return "-----BEGIN EC PRIVATE KEY-----\n" . $pemBody . "-----END EC PRIVATE KEY-----";
|
|
}
|
|
|
|
private function buildPushAudience(string $endpoint): string {
|
|
$parts = parse_url($endpoint);
|
|
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
|
return '';
|
|
}
|
|
$scheme = strtolower((string)$parts['scheme']);
|
|
if ($scheme !== 'https') {
|
|
return '';
|
|
}
|
|
$host = (string)$parts['host'];
|
|
$port = isset($parts['port']) ? (int)$parts['port'] : 443;
|
|
return $scheme . '://' . $host . ($port === 443 ? '' : ':' . $port);
|
|
}
|
|
|
|
private function buildVapidJwt(string $audience, string $subject, string $privatePem): string {
|
|
$header = $this->base64UrlEncode(json_encode(['typ' => 'JWT', 'alg' => 'ES256'], JSON_UNESCAPED_SLASHES));
|
|
$payload = $this->base64UrlEncode(json_encode([
|
|
'aud' => $audience,
|
|
'exp' => time() + 12 * 3600,
|
|
'sub' => $subject,
|
|
], JSON_UNESCAPED_SLASHES));
|
|
if ($header === '' || $payload === '') {
|
|
return '';
|
|
}
|
|
$input = $header . '.' . $payload;
|
|
$derSignature = '';
|
|
$privateKey = openssl_pkey_get_private($privatePem);
|
|
if ($privateKey === false || !openssl_sign($input, $derSignature, $privateKey, OPENSSL_ALGO_SHA256)) {
|
|
return '';
|
|
}
|
|
if (is_resource($privateKey)) {
|
|
openssl_free_key($privateKey);
|
|
}
|
|
$jose = $this->convertDerSignatureToJose($derSignature, 64);
|
|
return $jose !== '' ? $input . '.' . $this->base64UrlEncode($jose) : '';
|
|
}
|
|
|
|
private function convertDerSignatureToJose(string $der, int $partLength): string {
|
|
if ($der === '' || !isset($der[0]) || ord($der[0]) !== 0x30) {
|
|
return '';
|
|
}
|
|
$offset = 2;
|
|
if (!isset($der[$offset]) || ord($der[$offset]) !== 0x02) {
|
|
return '';
|
|
}
|
|
$offset += 1;
|
|
$rLen = ord($der[$offset] ?? "\x00");
|
|
$offset += 1;
|
|
$r = substr($der, $offset, $rLen);
|
|
$offset += $rLen;
|
|
if (!isset($der[$offset]) || ord($der[$offset]) !== 0x02) {
|
|
return '';
|
|
}
|
|
$offset += 1;
|
|
$sLen = ord($der[$offset] ?? "\x00");
|
|
$offset += 1;
|
|
$s = substr($der, $offset, $sLen);
|
|
$r = str_pad(ltrim($r, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT);
|
|
$s = str_pad(ltrim($s, "\x00"), $partLength / 2, "\x00", STR_PAD_LEFT);
|
|
return strlen($r) === ($partLength / 2) && strlen($s) === ($partLength / 2) ? $r . $s : '';
|
|
}
|
|
|
|
private function sanitizeUrl(string $url): string {
|
|
$trimmed = trim($url);
|
|
if ($trimmed === '') {
|
|
return '';
|
|
}
|
|
if (preg_match('#^https?://#i', $trimmed) === 1) {
|
|
return $trimmed;
|
|
}
|
|
if (str_starts_with($trimmed, '/')) {
|
|
return $this->urlGenerator->getAbsoluteURL($trimmed);
|
|
}
|
|
return '';
|
|
}
|
|
|
|
private function normalizeText(string $value, int $maxLen): string {
|
|
$trimmed = trim(strip_tags($value));
|
|
if ($trimmed === '') {
|
|
return '';
|
|
}
|
|
return strlen($trimmed) > $maxLen ? substr($trimmed, 0, $maxLen) : $trimmed;
|
|
}
|
|
|
|
private function normalizeTag(string $value): string {
|
|
$tag = preg_replace('/[^a-zA-Z0-9:_\\-\\.]/', '-', trim($value));
|
|
return is_string($tag) && $tag !== '' ? substr($tag, 0, 128) : 'custom-pwa-talk';
|
|
}
|
|
|
|
private function normalizeToken(string $value, int $maxLen): string {
|
|
$normalized = preg_replace('/[^a-zA-Z0-9:_\\-\\.]/', '', trim($value));
|
|
if (!is_string($normalized) || $normalized === '') {
|
|
return '';
|
|
}
|
|
return strlen($normalized) > $maxLen ? substr($normalized, 0, $maxLen) : $normalized;
|
|
}
|
|
|
|
private function base64UrlEncode(string $value): string {
|
|
return $value !== '' ? rtrim(strtr(base64_encode($value), '+/', '-_'), '=') : '';
|
|
}
|
|
|
|
private function base64UrlDecode(string $value): string {
|
|
$normalized = strtr($value, '-_', '+/');
|
|
$padding = strlen($normalized) % 4;
|
|
if ($padding > 0) {
|
|
$normalized .= str_repeat('=', 4 - $padding);
|
|
}
|
|
$decoded = base64_decode($normalized, true);
|
|
return is_string($decoded) ? $decoded : '';
|
|
}
|
|
}
|