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> */ 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> */ 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 */ 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> $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 */ 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 */ 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 $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> */ 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> $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 : ''; } }