718 lines
22 KiB
PHP
718 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace OCA\CustomPwa\Controller;
|
|
|
|
use OCA\CustomPwa\AppInfo\Application;
|
|
use OCA\CustomPwa\Service\PushNotificationService;
|
|
use OCP\AppFramework\Controller;
|
|
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
|
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
|
|
use OCP\AppFramework\Http\DataResponse;
|
|
use OCP\Http\Client\IClientService;
|
|
use OCP\IConfig;
|
|
use OCP\IGroupManager;
|
|
use OCP\IRequest;
|
|
use OCP\IUserSession;
|
|
|
|
class ApiController extends Controller {
|
|
private const USER_DEVICES_KEY = 'device_registrations_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 APP_EMAIL_FALLBACK_ENABLED_KEY = 'email_fallback_enabled';
|
|
private const APP_SMS_CRITICAL_FALLBACK_ENABLED_KEY = 'sms_critical_fallback_enabled';
|
|
|
|
public function __construct(
|
|
string $appName,
|
|
IRequest $request,
|
|
private IUserSession $userSession,
|
|
private IConfig $config,
|
|
private IClientService $clientService,
|
|
private IGroupManager $groupManager,
|
|
private PushNotificationService $pushNotificationService,
|
|
) {
|
|
parent::__construct($appName, $request);
|
|
}
|
|
|
|
public function getAdminSettings(): DataResponse {
|
|
if (!$this->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 : '';
|
|
}
|
|
}
|