Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2efd214
feat(webpush): Add, update, activate and delete webpush subscription
p1gp1g Nov 18, 2025
8b87232
feat(webpush): Registration with new endpoint, p256dh or auth is 'cre…
p1gp1g Nov 19, 2025
a5b09aa
feat(webpush): Return error if too many appTypes are supplied
p1gp1g Nov 19, 2025
d9ffa95
feat(webpush): Send activation token during registration
p1gp1g Nov 19, 2025
ee564f7
feat(webpush): Prepare webpush requests
p1gp1g Nov 20, 2025
80c7390
feat(webpush): Send web push requests
p1gp1g Nov 20, 2025
094eccb
feat(webpush): Delete expired web push subscriptions
p1gp1g Nov 21, 2025
edb1d58
feat(webpush): Send web push delete notifs
p1gp1g Nov 21, 2025
fb3c960
feat(webpush): Fix tests after 'Delete expired web push subscriptions'
p1gp1g Nov 21, 2025
942c3d4
feat(webpush): Add Urgency to web push notifs
p1gp1g Nov 21, 2025
9481d4a
feat(webpush): Add support for 429 status code with web push
p1gp1g Nov 21, 2025
1423ea8
feat(webpush): Fix composer for webpush
p1gp1g Nov 21, 2025
20a9090
feat(webpush): Fix urgency
p1gp1g Nov 21, 2025
0621e1b
feat(webpush): Add support for VAPID
p1gp1g Nov 21, 2025
1b5cf32
feat(webpush): Fix missing $deleteAll
p1gp1g Nov 24, 2025
429b50f
feat(webpush): Add API endpoint to get the VAPID pubkey
p1gp1g Nov 24, 2025
4a69214
feat(webpush): Add webpush capability
p1gp1g Nov 24, 2025
676fb5c
feat(webpush): Fix apptypes
p1gp1g Nov 24, 2025
0c0f2b2
feat(webpush): Add tests for webpush
p1gp1g Nov 25, 2025
0aa109c
feat(webpush): Get apptypes as a string
p1gp1g Nov 26, 2025
e0c6bb0
feat(webpush): Fix tests for apptypes as string
p1gp1g Nov 27, 2025
aec0a1b
feat(webpush): Include WebPushController in ApplicationTest
p1gp1g Nov 27, 2025
424adbc
feat(webpush): Allow multiple delete with webpush
p1gp1g Nov 28, 2025
6363888
feat(webpush): Lint
p1gp1g Dec 2, 2025
1182172
feat(webpush): Add composer lock
p1gp1g Dec 2, 2025
0027d1b
feat(webpush): Fix OpenAPI
p1gp1g Dec 3, 2025
dc2fb55
feat(webpush): Small fixes
p1gp1g Dec 3, 2025
4ef6030
feat(webpush): Fix VAPID auth
p1gp1g Dec 4, 2025
c3df6e4
ci: Try to fix psalm for now
nickvergessen Dec 5, 2025
5fa1b47
ci(cs): Ignore lib/Vendor/ dir from coding standards
nickvergessen Dec 5, 2025
294025e
feat(webpush): Reduce max size for endpoint
p1gp1g Dec 5, 2025
e2c0181
feat(webpush): Lint
p1gp1g Dec 6, 2025
180cf92
feat(webpush): Update query count
p1gp1g Dec 8, 2025
de4a2bf
feat(webpush): Keep vendor dir
p1gp1g Dec 8, 2025
1c94e08
feat(webpush): Fix psalm CI
p1gp1g Dec 8, 2025
c6b6bc8
feat(webpush): Lint
p1gp1g Dec 9, 2025
c57b446
feat(webpush): Init VAPID in constructor
p1gp1g Dec 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(webpush): Prepare webpush requests
Signed-off-by: sim <[email protected]>
  • Loading branch information
p1gp1g committed Dec 9, 2025
commit ee564f7ee566b82e32bc4e0e7d2f1c17a5789a1b
141 changes: 124 additions & 17 deletions lib/Push.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,14 @@ class Push {
* @psalm-var array<string, ?IUserStatus>
*/
protected array $userStatuses = [];
/**
* @psalm-var array<string, list<array{id: int, uid: string, token: int, endpoint: string, p256dh: string, auth: string, appTypes: string, activated: bool, activation_token: string}>>
*/
protected array $userWebPushDevices = [];
/**
* @psalm-var array<string, list<array{id: int, uid: string, token: int, deviceidentifier: string, devicepublickey: string, devicepublickeyhash: string, pushtokenhash: string, proxyserver: string, apptype: string}>>
*/
protected array $userDevices = [];
protected array $userProxyDevices = [];
/** @var string[] */
protected array $loadDevicesForUsers = [];
/** @var string[] */
Expand Down Expand Up @@ -113,10 +117,17 @@ public function flushPayloads(): void {

if (!empty($this->loadDevicesForUsers)) {
$this->loadDevicesForUsers = array_unique($this->loadDevicesForUsers);
$missingDevicesFor = array_diff($this->loadDevicesForUsers, array_keys($this->userDevices));
$newUserDevices = $this->getDevicesForUsers($missingDevicesFor);
foreach ($missingDevicesFor as $userId) {
$this->userDevices[$userId] = $newUserDevices[$userId] ?? [];
// Add missing web push devices
$missingWebPushDevicesFor = array_diff($this->loadDevicesForUsers, array_keys($this->userWebPushDevices));
$newUserWebPushDevices = $this->getWebPushDevicesForUsers($missingWebPushDevicesFor);
foreach ($missingWebPushDevicesFor as $userId) {
$this->userWebPushDevices[$userId] = $newUserWebPushDevices[$userId] ?? [];
}
// Add missing proxy devices
$missingProxyDevicesFor = array_diff($this->loadDevicesForUsers, array_keys($this->userProxyDevices));
$newUserProxyDevices = $this->getProxyDevicesForUsers($missingProxyDevicesFor);
foreach ($missingProxyDevicesFor as $userId) {
$this->userProxyDevices[$userId] = $newUserProxyDevices[$userId] ?? [];
}
$this->loadDevicesForUsers = [];
}
Expand Down Expand Up @@ -230,14 +241,20 @@ public function pushToDevice(int $id, INotification $notification, ?OutputInterf
}
}

if (!array_key_exists($notification->getUser(), $this->userDevices)) {
$devices = $this->getDevicesForUser($notification->getUser());
$this->userDevices[$notification->getUser()] = $devices;
if (!array_key_exists($notification->getUser(), $this->userWebPushDevices)) {
$webPushDevices = $this->getWebPushDevicesForUser($notification->getUser());
$this->userWebPushDevices[$notification->getUser()] = $webPushDevices;
} else {
$webPushDevices = $this->userWebPushDevices[$notification->getUser()];
}
if (!array_key_exists($notification->getUser(), $this->userProxyDevices)) {
$proxyDevices = $this->getProxyDevicesForUser($notification->getUser());
$this->userProxyDevices[$notification->getUser()] = $proxyDevices;
} else {
$devices = $this->userDevices[$notification->getUser()];
$proxyDevices = $this->userProxyDevices[$notification->getUser()];
}

if (empty($devices)) {
if (empty($proxyDevices) && empty($webPushDevices)) {
$this->printInfo('<comment>No devices found for user</comment>');
return;
}
Expand All @@ -258,6 +275,23 @@ public function pushToDevice(int $id, INotification $notification, ?OutputInterf
}
}

$this->webPushToDevice($id, $user, $webPushDevices, $notification, $output);
$this->proxyPushToDevice($id, $user, $proxyDevices, $notification, $output);
}

public function webPushToDevice(int $id, IUser $user, array $devices, INotification $notification, ?OutputInterface $output = null): void {
if (empty($devices)) {
$this->printInfo('<comment>No web push devices found for user</comment>');
return;
}
}

public function proxyPushToDevice(int $id, IUser $user, array $devices, INotification $notification, ?OutputInterface $output = null): void {
if (empty($devices)) {
$this->printInfo('<comment>No proxy devices found for user</comment>');
return;
}

$userKey = $this->keyManager->getKey($user);

$this->printInfo('Private user key size: ' . strlen($userKey->getPrivate()));
Expand Down Expand Up @@ -352,17 +386,46 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri

$user = $this->createFakeUserObject($userId);

if (!array_key_exists($userId, $this->userDevices)) {
$devices = $this->getDevicesForUser($userId);
$this->userDevices[$userId] = $devices;
if (!array_key_exists($userId, $this->userWebPushDevices)) {
$webPushDevices = $this->getWebPushDevicesForUser($userId);
$this->userWebPushDevices[$userId] = $webPushDevices;
} else {
$webPushDevices = $this->userWebPushDevices[$userId];
}
if (!array_key_exists($userId, $this->userProxyDevices)) {
$proxyDevices = $this->getProxyDevicesForUser($userId);
$this->userProxyDevices[$userId] = $proxyDevices;
} else {
$devices = $this->userDevices[$userId];
$proxyDevices = $this->userProxyDevices[$userId];
}

if (!$deleteAll) {
// Only filter when it's not delete-all
$devices = $this->filterDeviceList($devices, $app);
$proxyDevices = $this->filterDeviceList($proxyDevices, $app);
//TODO filter webpush devices
}

$this->webPushDeleteToDevice($userId, $user, $webPushDevices, $notificationIds, $app);
$this->proxyPushDeleteToDevice($userId, $user, $proxyDevices, $notificationIds, $app);
}

/**
* @param string $userId
* @param ?int[] $notificationIds
* @param string $app
*/
public function webPushDeleteToDevice(string $userId, IUser $user, array $devices, ?array $notificationIds, string $app = ''): void {
if (empty($devices)) {
return;
}
}

/**
* @param string $userId
* @param ?int[] $notificationIds
* @param string $app
*/
public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devices, ?array $notificationIds, string $app = ''): void {
if (empty($devices)) {
return;
}
Expand Down Expand Up @@ -715,7 +778,7 @@ protected function encryptAndSignDelete(Key $userKey, array $device, ?array $ids
* @return array[]
* @psalm-return list<array{id: int, uid: string, token: int, deviceidentifier: string, devicepublickey: string, devicepublickeyhash: string, pushtokenhash: string, proxyserver: string, apptype: string}>
*/
protected function getDevicesForUser(string $uid): array {
protected function getProxyDevicesForUser(string $uid): array {
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('notifications_pushhash')
Expand All @@ -733,7 +796,7 @@ protected function getDevicesForUser(string $uid): array {
* @return array[]
* @psalm-return array<string, list<array{id: int, uid: string, token: int, deviceidentifier: string, devicepublickey: string, devicepublickeyhash: string, pushtokenhash: string, proxyserver: string, apptype: string}>>
*/
protected function getDevicesForUsers(array $userIds): array {
protected function getProxyDevicesForUsers(array $userIds): array {
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('notifications_pushhash')
Expand All @@ -753,6 +816,50 @@ protected function getDevicesForUsers(array $userIds): array {
return $devices;
}


/**
* @param string $uid
* @return array[]
* @psalm-return list<array{id: int, uid: string, token: int, endpoint: string, p256dh: string, auth: string, appTypes: string, activated: bool, activation_token: string}>
*/
protected function getWebPushDevicesForUser(string $uid): array {
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('notifications_webpush')
->where($query->expr()->eq('uid', $query->createNamedParameter($uid)));

$result = $query->executeQuery();
$devices = $result->fetchAll();
$result->closeCursor();

return $devices;
}

/**
* @param string[] $userIds
* @return array[]
* @psalm-return array<string, list<array{id: int, uid: string, token: int, endpoint: string, p256dh: string, auth: string, appTypes: string, activated: bool, activation_token: string}>>
*/
protected function getWebPushDevicesForUsers(array $userIds): array {
$query = $this->db->getQueryBuilder();
$query->select('*')
->from('notifications_webpush')
->where($query->expr()->in('uid', $query->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY)));

$devices = [];
$result = $query->executeQuery();
while ($row = $result->fetch()) {
if (!isset($devices[$row['uid']])) {
$devices[$row['uid']] = [];
}
$devices[$row['uid']][] = $row;
}

$result->closeCursor();

return $devices;
}

/**
* @param int $tokenId
* @return bool
Expand Down
50 changes: 25 additions & 25 deletions tests/Unit/PushTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ public function testPushToDeviceNoInternet(): void {
$push->pushToDevice(23, $notification);
}

public function testPushToDeviceNoDevices(): void {
$push = $this->getPush(['createFakeUserObject', 'getDevicesForUser']);
public function testProxyPushToDeviceNoDevices(): void {
$push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser']);
$this->keyManager->expects($this->never())
->method('getKey');
$this->clientService->expects($this->never())
Expand All @@ -168,14 +168,14 @@ public function testPushToDeviceNoDevices(): void {
->willReturn($user);

$push->expects($this->once())
->method('getDevicesForUser')
->method('getProxyDevicesForUser')
->willReturn([]);

$push->pushToDevice(42, $notification);
}

public function testPushToDeviceNotPrepared(): void {
$push = $this->getPush(['createFakeUserObject', 'getDevicesForUser']);
public function testProxyPushToDeviceNotPrepared(): void {
$push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser']);
$this->keyManager->expects($this->never())
->method('getKey');
$this->clientService->expects($this->never())
Expand All @@ -201,7 +201,7 @@ public function testPushToDeviceNotPrepared(): void {
->willReturn($user);

$push->expects($this->once())
->method('getDevicesForUser')
->method('getProxyDevicesForUser')
->willReturn([[
'proxyserver' => 'proxyserver1',
'token' => 'token1',
Expand All @@ -220,8 +220,8 @@ public function testPushToDeviceNotPrepared(): void {
$push->pushToDevice(1337, $notification);
}

public function testPushToDeviceInvalidToken(): void {
$push = $this->getPush(['createFakeUserObject', 'getDevicesForUser', 'encryptAndSign', 'deletePushToken']);
public function testProxyPushToDeviceInvalidToken(): void {
$push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken']);
$this->clientService->expects($this->never())
->method('newClient');

Expand All @@ -245,7 +245,7 @@ public function testPushToDeviceInvalidToken(): void {
->willReturn($user);

$push->expects($this->once())
->method('getDevicesForUser')
->method('getProxyDevicesForUser')
->willReturn([[
'proxyserver' => 'proxyserver1',
'token' => 23,
Expand Down Expand Up @@ -285,8 +285,8 @@ public function testPushToDeviceInvalidToken(): void {
$push->pushToDevice(2018, $notification);
}

public function testPushToDeviceEncryptionError(): void {
$push = $this->getPush(['createFakeUserObject', 'getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']);
public function testProxyPushToDeviceEncryptionError(): void {
$push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']);
$this->clientService->expects($this->never())
->method('newClient');

Expand All @@ -310,7 +310,7 @@ public function testPushToDeviceEncryptionError(): void {
->willReturn($user);

$push->expects($this->once())
->method('getDevicesForUser')
->method('getProxyDevicesForUser')
->willReturn([[
'proxyserver' => 'proxyserver1',
'token' => 23,
Expand Down Expand Up @@ -349,8 +349,8 @@ public function testPushToDeviceEncryptionError(): void {

$push->pushToDevice(1970, $notification);
}
public function testPushToDeviceNoFairUse(): void {
$push = $this->getPush(['createFakeUserObject', 'getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken', 'deletePushTokenByDeviceIdentifier']);
public function testProxyPushToDeviceNoFairUse(): void {
$push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken', 'deletePushTokenByDeviceIdentifier']);

/** @var INotification&MockObject $notification */
$notification = $this->createMock(INotification::class);
Expand All @@ -367,7 +367,7 @@ public function testPushToDeviceNoFairUse(): void {
->willReturn($user);

$push->expects($this->once())
->method('getDevicesForUser')
->method('getProxyDevicesForUser')
->willReturn([
[
'proxyserver' => 'proxyserver',
Expand Down Expand Up @@ -427,18 +427,18 @@ public function testPushToDeviceNoFairUse(): void {
$push->pushToDevice(207787, $notification);
}

public static function dataPushToDeviceSending(): array {
public static function dataProxyPushToDeviceSending(): array {
return [
[true],
[false],
];
}

/**
* @dataProvider dataPushToDeviceSending
* @dataProvider dataProxyPushToDeviceSending
*/
public function testPushToDeviceSending(bool $isDebug): void {
$push = $this->getPush(['createFakeUserObject', 'getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken', 'deletePushTokenByDeviceIdentifier']);
public function testProxyPushToDeviceSending(bool $isDebug): void {
$push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken', 'deletePushTokenByDeviceIdentifier']);

/** @var INotification&MockObject $notification */
$notification = $this->createMock(INotification::class);
Expand All @@ -455,7 +455,7 @@ public function testPushToDeviceSending(bool $isDebug): void {
->willReturn($user);

$push->expects($this->once())
->method('getDevicesForUser')
->method('getProxyDevicesForUser')
->willReturn([
[
'proxyserver' => 'proxyserver1',
Expand Down Expand Up @@ -650,7 +650,7 @@ public function testPushToDeviceSending(bool $isDebug): void {
$push->pushToDevice(207787, $notification);
}

public static function dataPushToDeviceTalkNotification(): array {
public static function dataProxyPushToDeviceTalkNotification(): array {
return [
[['nextcloud'], false, 0],
[['nextcloud'], true, 0],
Expand All @@ -664,11 +664,11 @@ public static function dataPushToDeviceTalkNotification(): array {
}

/**
* @dataProvider dataPushToDeviceTalkNotification
* @dataProvider dataProxyPushToDeviceTalkNotification
* @param string[] $deviceTypes
*/
public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTalkNotification, ?int $pushedDevice): void {
$push = $this->getPush(['createFakeUserObject', 'getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']);
public function testProxyPushToDeviceTalkNotification(array $deviceTypes, bool $isTalkNotification, ?int $pushedDevice): void {
$push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']);

/** @var INotification&MockObject $notification */
$notification = $this->createMock(INotification::class);
Expand Down Expand Up @@ -703,7 +703,7 @@ public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTal
];
}
$push->expects($this->once())
->method('getDevicesForUser')
->method('getProxyDevicesForUser')
->willReturn($devices);

$this->l10nFactory
Expand Down