isCurrentUserAdmin()) { return new DataResponse([ 'ok' => false, 'error' => 'Admin privileges are required', ], 403); } $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 ); $privateKeyRaw = trim($this->config->getAppValue(Application::APP_ID, self::APP_VAPID_PRIVATE_KEY, '')); return new DataResponse([ 'ok' => true, 'data' => [ 'vapidSubject' => $subject, 'vapidPublicKey' => $publicKey, 'vapidPrivateKeyConfigured' => $privateKeyRaw !== '', 'emailFallbackEnabled' => $this->toBool( $this->config->getAppValue(Application::APP_ID, self::APP_EMAIL_FALLBACK_ENABLED_KEY, '1') ), 'smsCriticalFallbackEnabled' => $this->toBool( $this->config->getAppValue(Application::APP_ID, self::APP_SMS_CRITICAL_FALLBACK_ENABLED_KEY, '0') ), ], ], 200); } public function saveAdminSettings(): DataResponse { if (!$this->isCurrentUserAdmin()) { return new DataResponse([ 'ok' => false, 'error' => 'Admin privileges are required', ], 403); } $subject = $this->normalizeText((string)$this->request->getParam('vapidSubject', ''), 256); $publicKey = $this->normalizeText((string)$this->request->getParam('vapidPublicKey', ''), 512); $privateKey = trim((string)$this->request->getParam('vapidPrivateKey', '')); $clearPrivateKey = $this->toBool($this->request->getParam('clearVapidPrivateKey', false)); $emailFallbackEnabled = $this->toBool($this->request->getParam('emailFallbackEnabled', true)); $smsCriticalFallbackEnabled = $this->toBool($this->request->getParam('smsCriticalFallbackEnabled', false)); $this->config->setAppValue(Application::APP_ID, self::APP_VAPID_SUBJECT_KEY, $subject); $this->config->setAppValue(Application::APP_ID, self::APP_VAPID_PUBLIC_KEY, $publicKey); if ($clearPrivateKey) { $this->config->setAppValue(Application::APP_ID, self::APP_VAPID_PRIVATE_KEY, ''); } elseif ($privateKey !== '') { $this->config->setAppValue(Application::APP_ID, self::APP_VAPID_PRIVATE_KEY, $privateKey); } $this->config->setAppValue( Application::APP_ID, self::APP_EMAIL_FALLBACK_ENABLED_KEY, $emailFallbackEnabled ? '1' : '0' ); $this->config->setAppValue( Application::APP_ID, self::APP_SMS_CRITICAL_FALLBACK_ENABLED_KEY, $smsCriticalFallbackEnabled ? '1' : '0' ); return new DataResponse([ 'ok' => true, 'data' => [ 'vapidConfigured' => $this->isVapidConfigured(), 'vapidSubject' => $this->normalizeText( $this->config->getAppValue(Application::APP_ID, self::APP_VAPID_SUBJECT_KEY, ''), 256 ), 'vapidPrivateKeyConfigured' => trim( $this->config->getAppValue(Application::APP_ID, self::APP_VAPID_PRIVATE_KEY, '') ) !== '', 'emailFallbackEnabled' => $emailFallbackEnabled, 'smsCriticalFallbackEnabled' => $smsCriticalFallbackEnabled, ], ], 200); } #[NoAdminRequired] #[NoCSRFRequired] public function status(): DataResponse { $userId = $this->getUserIdOrEmpty(); if ($userId === '') { return new DataResponse([ 'ok' => false, 'error' => 'Authentication required', ], 401); } $installationId = $this->normalizeToken((string)$this->request->getParam('installationId', ''), 128); $devices = $this->loadUserDevices($userId); $device = ($installationId !== '' && isset($devices[$installationId]) && is_array($devices[$installationId])) ? $devices[$installationId] : null; return new DataResponse([ 'ok' => true, 'data' => [ 'device' => $device, 'deviceCount' => count($devices), 'vapidConfigured' => $this->isVapidConfigured(), 'vapidSubject' => $this->normalizeText($this->config->getAppValue(Application::APP_ID, self::APP_VAPID_SUBJECT_KEY, ''), 256), ], ]); } #[NoAdminRequired] #[NoCSRFRequired] public function registerDevice(): DataResponse { $userId = $this->getUserIdOrEmpty(); if ($userId === '') { return new DataResponse([ 'ok' => false, 'error' => 'Authentication required', ], 401); } $installationId = $this->normalizeToken((string)$this->request->getParam('installationId', ''), 128); if ($installationId === '') { return new DataResponse([ 'ok' => false, 'error' => 'installationId is required', ], 400); } $permission = $this->normalizeToken((string)$this->request->getParam('notificationPermission', ''), 16); $swVersion = $this->normalizeText((string)$this->request->getParam('serviceWorkerVersion', ''), 64); $standalone = $this->toBool($this->request->getParam('standalone', false)); $pushEndpoint = $this->normalizeText((string)$this->request->getParam('pushEndpoint', ''), 1024); $pushKey = $this->normalizeText((string)$this->request->getParam('pushKey', ''), 1024); $pushAuth = $this->normalizeText((string)$this->request->getParam('pushAuth', ''), 512); $userAgent = $this->normalizeText((string)$this->request->getParam('userAgent', ''), 512); $platform = $this->normalizeText((string)$this->request->getParam('platform', ''), 128); $appVersion = $this->normalizeText((string)$this->request->getParam('appVersion', ''), 64); $devices = $this->loadUserDevices($userId); $now = gmdate('c'); $existing = isset($devices[$installationId]) && is_array($devices[$installationId]) ? $devices[$installationId] : []; $createdAt = $this->normalizeText((string)($existing['createdAt'] ?? ''), 64); if ($createdAt === '') { $createdAt = $now; } $device = [ 'installationId' => $installationId, 'createdAt' => $createdAt, 'updatedAt' => $now, 'notificationPermission' => $permission, 'standalone' => $standalone, 'serviceWorkerVersion' => $swVersion, 'pushEndpoint' => $pushEndpoint, 'pushKey' => $pushKey, 'pushAuth' => $pushAuth, 'userAgent' => $userAgent, 'platform' => $platform, 'appVersion' => $appVersion, 'lastPushReceivedAt' => $this->normalizeText((string)($existing['lastPushReceivedAt'] ?? ''), 64), 'lastHealthCheckAt' => $now, ]; $devices[$installationId] = $device; $this->saveUserDevices($userId, $devices); return new DataResponse([ 'ok' => true, 'data' => [ 'device' => $device, 'deviceCount' => count($devices), 'vapidConfigured' => $this->isVapidConfigured(), 'vapidSubject' => $this->normalizeText($this->config->getAppValue(Application::APP_ID, self::APP_VAPID_SUBJECT_KEY, ''), 256), ], ]); } #[NoAdminRequired] #[NoCSRFRequired] public function markPushReceived(): DataResponse { $userId = $this->getUserIdOrEmpty(); if ($userId === '') { return new DataResponse([ 'ok' => false, 'error' => 'Authentication required', ], 401); } $installationId = $this->normalizeToken((string)$this->request->getParam('installationId', ''), 128); if ($installationId === '') { return new DataResponse([ 'ok' => false, 'error' => 'installationId is required', ], 400); } $devices = $this->loadUserDevices($userId); $existing = isset($devices[$installationId]) && is_array($devices[$installationId]) ? $devices[$installationId] : null; if ($existing === null) { return new DataResponse([ 'ok' => false, 'error' => 'device_not_registered', ], 404); } $receivedAtInput = $this->normalizeText((string)$this->request->getParam('receivedAt', ''), 64); $receivedAt = $receivedAtInput !== '' ? $receivedAtInput : gmdate('c'); $existing['lastPushReceivedAt'] = $receivedAt; $existing['updatedAt'] = gmdate('c'); $devices[$installationId] = $existing; $this->saveUserDevices($userId, $devices); return new DataResponse([ 'ok' => true, 'data' => [ 'lastPushReceivedAt' => $receivedAt, 'device' => $existing, ], ]); } #[NoAdminRequired] #[NoCSRFRequired] public function testNotification(): DataResponse { $userId = $this->getUserIdOrEmpty(); if ($userId === '') { return new DataResponse([ 'ok' => false, 'error' => 'Authentication required', ], 401); } $installationId = $this->normalizeToken((string)$this->request->getParam('installationId', ''), 128); $devices = $this->loadUserDevices($userId); $hasDevice = $installationId !== '' && isset($devices[$installationId]) && is_array($devices[$installationId]); $selectedDevices = []; if ($installationId !== '') { if ($hasDevice) { $selectedDevices[$installationId] = $devices[$installationId]; } } else { foreach ($devices as $deviceId => $entry) { if (!is_array($entry)) { continue; } $selectedDevices[$deviceId] = $entry; } } $vapidConfig = $this->getVapidConfig(); if (!$vapidConfig['ok']) { return new DataResponse([ 'ok' => true, 'data' => [ 'queued' => false, 'reason' => 'vapid_not_configured', 'deviceRegistered' => $hasDevice, 'vapidConfigured' => false, 'vapidSubject' => $vapidConfig['subject'], 'results' => [], 'summary' => [ 'total' => 0, 'success' => 0, 'failed' => 0, ], ], ], 202); } $pushCandidates = []; foreach ($selectedDevices as $deviceId => $entry) { $endpoint = $this->normalizeText((string)($entry['pushEndpoint'] ?? ''), 1024); if ($endpoint === '' || !str_starts_with(strtolower($endpoint), 'https://')) { continue; } $pushCandidates[$deviceId] = $entry; } if (count($pushCandidates) === 0) { return new DataResponse([ 'ok' => true, 'data' => [ 'queued' => false, 'reason' => 'no_push_endpoints', 'deviceRegistered' => $hasDevice, 'vapidConfigured' => true, 'vapidSubject' => $vapidConfig['subject'], 'results' => [], 'summary' => [ 'total' => 0, 'success' => 0, 'failed' => 0, ], ], ], 202); } $client = $this->clientService->newClient(); $results = []; $successCount = 0; $failureCount = 0; $now = gmdate('c'); foreach ($pushCandidates as $deviceId => $entry) { $endpoint = $this->normalizeText((string)($entry['pushEndpoint'] ?? ''), 1024); $outcome = $this->sendWebPushPing( $client, $endpoint, $vapidConfig['subject'], $vapidConfig['publicKey'], $vapidConfig['privatePem'] ); $entry['lastTestPushAt'] = $now; $entry['lastTestPushHttpStatus'] = (int)($outcome['status'] ?? 0); $entry['lastTestPushOk'] = !empty($outcome['ok']); $entry['lastTestPushError'] = $this->normalizeText((string)($outcome['error'] ?? ''), 512); $entry['updatedAt'] = $now; if (($outcome['status'] ?? 0) === 404 || ($outcome['status'] ?? 0) === 410) { $entry['pushEndpointStaleAt'] = $now; } $devices[$deviceId] = $entry; $results[] = [ 'installationId' => $deviceId, 'status' => (int)($outcome['status'] ?? 0), 'ok' => !empty($outcome['ok']), 'error' => $this->normalizeText((string)($outcome['error'] ?? ''), 512), ]; if (!empty($outcome['ok'])) { $successCount += 1; } else { $failureCount += 1; } } $this->saveUserDevices($userId, $devices); return new DataResponse([ 'ok' => true, 'data' => [ 'queued' => $successCount > 0, 'reason' => $successCount > 0 ? 'webpush_ping_sent' : 'webpush_ping_failed', 'deviceRegistered' => $hasDevice, 'vapidConfigured' => true, 'vapidSubject' => $vapidConfig['subject'], 'results' => $results, 'summary' => [ 'total' => count($results), 'success' => $successCount, 'failed' => $failureCount, ], ], ], 200); } #[NoAdminRequired] #[NoCSRFRequired] public function pendingNotifications(): DataResponse { $userId = $this->getUserIdOrEmpty(); if ($userId === '') { return new DataResponse([ 'ok' => false, 'error' => 'Authentication required', ], 401); } return new DataResponse([ 'ok' => true, 'data' => [ 'notifications' => $this->pushNotificationService->getPendingNotifications($userId), ], ]); } 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; } 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) ); } private function normalizeText(string $value, int $maxLen): string { $trimmed = trim($value); if ($trimmed === '') { return ''; } if (strlen($trimmed) > $maxLen) { return substr($trimmed, 0, $maxLen); } return $trimmed; } private function normalizeToken(string $value, int $maxLen): string { $normalized = preg_replace('/[^a-zA-Z0-9:_\\-\\.]/', '', trim($value)); if (!is_string($normalized)) { return ''; } if ($normalized === '') { return ''; } if (strlen($normalized) > $maxLen) { return substr($normalized, 0, $maxLen); } return $normalized; } private function toBool(mixed $value): bool { if (is_bool($value)) { return $value; } $text = strtolower(trim((string)$value)); return in_array($text, ['1', 'true', 'yes', 'on'], true); } private function getUserIdOrEmpty(): string { return $this->userSession->getUser()?->getUID() ?? ''; } private function isCurrentUserAdmin(): bool { $userId = $this->getUserIdOrEmpty(); if ($userId === '') { return false; } return $this->groupManager->isAdmin($userId); } private function isVapidConfigured(): bool { $config = $this->getVapidConfig(); return $config['ok']; } /** @return array{ok:bool,subject:string,publicKey:string,privatePem:string,error: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, '')); if ($subject === '' || $publicKey === '' || $privateRaw === '') { return [ 'ok' => false, 'subject' => $subject, 'publicKey' => $publicKey, 'privatePem' => '', 'error' => 'missing_vapid_values', ]; } $privatePem = $this->normalizeVapidPrivateKeyPem($privateRaw); if ($privatePem === '') { return [ 'ok' => false, 'subject' => $subject, 'publicKey' => $publicKey, 'privatePem' => '', 'error' => 'invalid_private_key', ]; } return [ 'ok' => true, 'subject' => $subject, 'publicKey' => $publicKey, 'privatePem' => $privatePem, 'error' => '', ]; } 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 ''; } $der = $this->buildEcPrivateKeyDer($raw); if ($der === '') { return ''; } $pemBody = chunk_split(base64_encode($der), 64, "\n"); return "-----BEGIN EC PRIVATE KEY-----\n" . $pemBody . "-----END EC PRIVATE KEY-----"; } private function buildEcPrivateKeyDer(string $rawPrivateKey): string { if (strlen($rawPrivateKey) !== 32) { return ''; } $version = "\x02\x01\x01"; $privateOctet = "\x04\x20" . $rawPrivateKey; $prime256v1 = "\xA0\x0A\x06\x08\x2A\x86\x48\xCE\x3D\x03\x01\x07"; $sequence = $version . $privateOctet . $prime256v1; $len = strlen($sequence); if ($len > 127) { return ''; } return "\x30" . chr($len) . $sequence; } /** @return array{ok:bool,status:int,error:string} */ private function sendWebPushPing( \OCP\Http\Client\IClient $client, string $endpoint, string $subject, string $publicKey, string $privatePem ): 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' => '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; $error = ''; if (!$ok) { $error = $this->normalizeText(trim((string)$response->getBody()), 512); if ($error === '') { $error = 'HTTP ' . $status; } } return ['ok' => $ok, 'status' => $status, 'error' => $error]; } catch (\Throwable $e) { return ['ok' => false, 'status' => 0, 'error' => $this->normalizeText($e->getMessage(), 512)]; } } 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; $defaultPort = $port === 443; return $scheme . '://' . $host . ($defaultPort ? '' : ':' . $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; $privateKey = openssl_pkey_get_private($privatePem); if ($privateKey === false) { return ''; } $derSignature = ''; $signed = openssl_sign($input, $derSignature, $privateKey, OPENSSL_ALGO_SHA256); if (is_resource($privateKey)) { openssl_free_key($privateKey); } if (!$signed || $derSignature === '') { return ''; } $jose = $this->convertDerSignatureToJose($derSignature, 64); if ($jose === '') { return ''; } return $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; if (!isset($der[$offset])) { return ''; } $rLen = ord($der[$offset]); $offset += 1; if (!isset($der[$offset + $rLen - 1])) { return ''; } $r = substr($der, $offset, $rLen); $offset += $rLen; if (!isset($der[$offset]) || ord($der[$offset]) !== 0x02) { return ''; } $offset += 1; if (!isset($der[$offset])) { return ''; } $sLen = ord($der[$offset]); $offset += 1; if (!isset($der[$offset + $sLen - 1])) { return ''; } $s = substr($der, $offset, $sLen); $r = ltrim($r, "\x00"); $s = ltrim($s, "\x00"); $r = str_pad($r, $partLength / 2, "\x00", STR_PAD_LEFT); $s = str_pad($s, $partLength / 2, "\x00", STR_PAD_LEFT); if (strlen($r) !== ($partLength / 2) || strlen($s) !== ($partLength / 2)) { return ''; } return $r . $s; } private function base64UrlEncode(string $value): string { if ($value === '') { return ''; } return 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 : ''; } }