From 2efd214ed6d9c69bda07f5714b00fe279253b6e8 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 18 Nov 2025 16:34:38 +0100 Subject: [PATCH 01/37] feat(webpush): Add, update, activate and delete webpush subscription Signed-off-by: sim --- lib/Controller/WebPushController.php | 306 ++++++++++++ .../Version6000Date20251112110000.php | 90 ++++ .../Unit/Controller/WebPushControllerTest.php | 448 ++++++++++++++++++ 3 files changed, 844 insertions(+) create mode 100644 lib/Controller/WebPushController.php create mode 100644 lib/Migration/Version6000Date20251112110000.php create mode 100644 tests/Unit/Controller/WebPushControllerTest.php diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php new file mode 100644 index 000000000..5e2b251f9 --- /dev/null +++ b/lib/Controller/WebPushController.php @@ -0,0 +1,306 @@ + $appTypes used to filter incoming notifications - appTypes are alphanum - use "all" to get all notifications, prefix with `-` to exclude (eg. ['all', '-talk']) + * @return DataResponse, array{}>|DataResponse + * + * 200: A subscription was already registered and activated + * 201: New subscription registered successfully + * 400: Registering is not possible + * 401: Missing permissions to register + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/webpush', requirements: ['apiVersion' => '(v2)'])] + public function registerWP(string $endpoint, string $uaPublicKey, string $auth, array $appTypes): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + if (!preg_match('/^[A-Za-z0-9_-]{87}=*$/', $uaPublicKey)) { + return new DataResponse(['message' => 'INVALID_P256DH'], Http::STATUS_BAD_REQUEST); + } + + if (!preg_match('/^[A-Za-z0-9_-]{22}=*$/', $auth)) { + return new DataResponse(['message' => 'INVALID_AUTH'], Http::STATUS_BAD_REQUEST); + } + + if ( + !filter_var($endpoint, FILTER_VALIDATE_URL) + || \strlen($endpoint) > 1000 + || !preg_match('/^https\:\/\//', $endpoint) + ) { + return new DataResponse(['message' => 'INVALID_ENDPOINT'], Http::STATUS_BAD_REQUEST); + } + + $tokenId = $this->session->get('token-id'); + if (!\is_int($tokenId)) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + $status = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes); + + //TODO: send activation token to test if the pubkey, auth and the endpoints are valid + + return match($status) { + NewSubStatus::UPDATED => new DataResponse([], Http::STATUS_OK), + NewSubStatus::CREATED => new DataResponse([], Http::STATUS_CREATED), + // This should not happen + NewSubStatus::ERROR => new DataResponse(['message' => 'DB_ERROR'], Http::STATUS_BAD_REQUEST), + }; + } + + /** + * Activate subscription for push notifications + * + * @param string $activation_token Random token sent via a push notification during registration to enable the subscription + * @return DataResponse, array{}>|DataResponse + * + * 200: Subscription was already activated + * 202: Subscription activated successfully + * 400: Activating subscription is not possible, may be because of a wrong activation token + * 401: Missing permissions to activate subscription + * 404: No subscription found for the device + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/webpush/activate', requirements: ['apiVersion' => '(v2)'])] + public function activateWP(string $activation_token): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + $tokenId = (int)$this->session->get('token-id'); + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + $status = $this->activateSubscription($user, $token, $activation_token); + + return match($status) { + ActivationSubStatus::OK => new DataResponse([], Http::STATUS_OK), + ActivationSubStatus::CREATED => new DataResponse([], Http::STATUS_ACCEPTED), + ActivationSubStatus::NO_TOKEN => new DataResponse(['message' => 'INVALID_ACTIVATION_TOKEN'], Http::STATUS_BAD_REQUEST), + ActivationSubStatus::NO_SUB => new DataResponse(['message' => 'NO_PUSH_SUBSCRIPTION'], Http::STATUS_NOT_FOUND), + }; + } + + /** + * Remove a subscription from push notifications + * + * @return DataResponse, array{}>|DataResponse + * + * 200: No subscription for the device + * 202: Subscription removed successfully + * 400: Removing subscription is not possible + * 401: Missing permissions to remove subscription + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/webpush', requirements: ['apiVersion' => '(v2)'])] + public function removeWP(): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + $tokenId = (int)$this->session->get('token-id'); + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + if ($this->deleteSubscription($user, $token)) { + return new DataResponse([], Http::STATUS_ACCEPTED); + } + + return new DataResponse([], Http::STATUS_OK); + } + + /** + * @param list $appTypes + * @return NewSubStatus: + * - CREATED if the user didn't have an activated subscription + * - UPDATED if the subscription has been updated + */ + protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): NewSubStatus { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('notifications_webpush') + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))) + ->andWhere($query->expr()->eq('activated', $query->createNamedParameter(true))); + $result = $query->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if (!$row) { + // In case the user has already an inactive subscription + $this->deleteSubscription($user, $token); + if ($this->insertSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes)) { + return NewSubStatus::CREATED; + } else { + return NewSubStatus::ERROR; + } + } + + if ($this->updateSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes)) { + return NewSubStatus::UPDATED; + } else { + return NewSubStatus::ERROR; + } + } + + /** + * @return ActivationSubStatus: + * - OK if it was already activated + * - CREATED If the entry was updated + * - NO_TOKEN if we don't have this token + * - NO_SUB if we don't have this subscription + */ + protected function activateSubscription(IUser $user, IToken $token, string $activation_token): ActivationSubStatus { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('notifications_webpush') + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))); + $result = $query->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if (!$row) { + return ActivationSubStatus::NO_SUB; + } + if ($row['activated']) { + return ActivationSubStatus::OK; + } + $query->update('notifications_webpush') + ->set('activated', $query->createNamedParameter(true)) + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('activation_token', $query->createNamedParameter($activation_token))); + + if ($query->executeStatement() !== 0) { + return ActivationSubStatus::CREATED; + } else { + return ActivationSubStatus::NO_TOKEN; + } + } + + /** + * @param list $appTypes + * @return bool If the entry was created + */ + protected function insertSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): bool { + $activation_token = Uuid::v4()->toRfc4122(); + + $query = $this->db->getQueryBuilder(); + $query->insert('notifications_webpush') + ->values([ + 'uid' => $query->createNamedParameter($user->getUID()), + 'token' => $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT), + 'endpoint' => $query->createNamedParameter($endpoint), + 'p256dh' => $query->createNamedParameter($uaPublicKey), + 'auth' => $query->createNamedParameter($auth), + 'apptypes' => $query->createNamedParameter(join(',', $appTypes)), + 'activation_token' => $query->createNamedParameter($activation_token), + ]); + return $query->executeStatement() > 0; + } + + /** + * @param list $appTypes + * @return bool If the entry was updated + */ + protected function updateSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): bool { + $query = $this->db->getQueryBuilder(); + $query->update('notifications_webpush') + ->set('endpoint', $query->createNamedParameter($endpoint)) + ->set('p256dh', $query->createNamedParameter($uaPublicKey)) + ->set('auth', $query->createNamedParameter($auth)) + ->set('apptypes', $query->createNamedParameter(join(',', $appTypes))) + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); + + return $query->executeStatement() !== 0; + } + + /** + * @return bool If the entry was deleted + */ + protected function deleteSubscription(IUser $user, IToken $token): bool { + $query = $this->db->getQueryBuilder(); + $query->delete('notifications_webpush') + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); + + return $query->executeStatement() !== 0; + } +} diff --git a/lib/Migration/Version6000Date20251112110000.php b/lib/Migration/Version6000Date20251112110000.php new file mode 100644 index 000000000..02a88e3fa --- /dev/null +++ b/lib/Migration/Version6000Date20251112110000.php @@ -0,0 +1,90 @@ +hasTable('notifications_webpush')) { + $table = $schema->createTable('notifications_webpush'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + // uid+token identifies a device + $table->addColumn('uid', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('token', Types::INTEGER, [ + 'notnull' => true, + 'length' => 4, + 'default' => 0, + ]); + $table->addColumn('endpoint', Types::STRING, [ + 'notnull' => true, + 'length' => 1024, + ]); + $table->addColumn('p256dh', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + $table->addColumn('auth', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('apptypes', Types::STRING, [ + 'notnull' => true, + 'length' => 256, + ]); + $table->addColumn('activated', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false + ]); + $table->addColumn('activation_token', Types::STRING, [ + 'notnull' => true, + 'length' => 36 + ]); + + $table->setPrimaryKey(['id']); + // Allow a single push subscription per device + $table->addUniqueIndex(['uid', 'token'], 'oc_npushwp_uid'); + // If the push endpoint is removed, we will delete the row based on the endpoint + $table->addIndex(['endpoint'], 'oc_npushwp_endpoint'); + } + return $schema; + } +} diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php new file mode 100644 index 000000000..54296f051 --- /dev/null +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -0,0 +1,448 @@ +request = $this->createMock(IRequest::class); + $this->db = $this->createMock(IDBConnection::class); + $this->session = $this->createMock(ISession::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->tokenProvider = $this->createMock(IProvider::class); + $this->identityProof = $this->createMock(Manager::class); + } + + protected function getController(array $methods = []): WebPushController|MockObject { + if (empty($methods)) { + return new WebPushController( + 'notifications', + $this->request, + $this->db, + $this->session, + $this->userSession, + $this->tokenProvider, + $this->identityProof + ); + } + + return $this->getMockBuilder(WebPushController::class) + ->setConstructorArgs([ + 'notifications', + $this->request, + $this->db, + $this->session, + $this->userSession, + $this->tokenProvider, + $this->identityProof, + ]) + ->onlyMethods($methods) + ->getMock(); + } + + public static function dataRegisterWP(): array { + return [ + 'not authenticated' => [ + 'https://localhost/', + '', + '', + false, + 0, + false, + 0, + [], + Http::STATUS_UNAUTHORIZED + ], + 'too short uaPubKey' => [ + 'https://localhost/', + 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV', + self::$auth, + true, + 0, + false, + 0, + ['message' => 'INVALID_P256DH'], + Http::STATUS_BAD_REQUEST, + ], + 'too long uaPubKey' => [ + 'https://localhost/', + 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4bb', + self::$auth, + true, + 0, + false, + 0, + ['message' => 'INVALID_P256DH'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid char in uaPubKey' => [ + 'https://localhost/', + 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV- JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw', + self::$auth, + true, + 0, + false, + 0, + ['message' => 'INVALID_P256DH'], + Http::STATUS_BAD_REQUEST, + ], + 'too short auth' => [ + 'https://localhost/', + self::$uaPublicKey, + 'BTBZMqHH6r4Tts7J_aSI', + true, + 0, + false, + 0, + ['message' => 'INVALID_AUTH'], + Http::STATUS_BAD_REQUEST, + ], + 'too long auth' => [ + 'https://localhost/', + self::$uaPublicKey, + 'BTBZMqHH6r4Tts7J_aSIggxx', + true, + 0, + false, + 0, + ['message' => 'INVALID_AUTH'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid char in auth' => [ + 'https://localhost/', + self::$uaPublicKey, + 'BTBZM HH6r4Tts7J_aSIgg', + true, + 0, + false, + 0, + ['message' => 'INVALID_AUTH'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid endpoint' => [ + 'http://localhost/', + self::$uaPublicKey, + self::$auth, + true, + 0, + false, + 0, + ['message' => 'INVALID_ENDPOINT'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid session' => [ + 'https://localhost/', + self::$uaPublicKey, + self::$auth, + true, + 23, + false, + 0, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'created' => [ + 'https://localhost/', + self::$uaPublicKey, + self::$auth, + true, + 23, + true, + 0, + [], + Http::STATUS_CREATED, + ], + 'updated' => [ + 'https://localhost/', + self::$uaPublicKey, + self::$auth, + true, + 23, + true, + 1, + [], + Http::STATUS_OK, + ], + ]; + } + + /** + * @dataProvider dataRegisterWP + */ + public function testRegisterWP(string $endpoint, string $uaPublicKey, string $auth, bool $userIsValid, int $tokenId, bool $tokenIsValid, int $subStatus, array $payload, int $status): void { + $controller = $this->getController([ + 'saveSubscription', + ]); + + $user = $this->createMock(IUser::class); + if ($userIsValid) { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn($user); + } else { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn(null); + } + + $this->session->expects($tokenId > 0 ? $this->once() : $this->never()) + ->method('get') + ->with('token-id') + ->willReturn($tokenId); + + if ($tokenIsValid) { + $token = $this->createMock(IToken::class); + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willReturn($token); + + $controller->expects($this->once()) + ->method('saveSubscription') + ->with($user, $token, $endpoint, $uaPublicKey, $auth, $this->anything()) + ->willReturn(NewSubStatus::from($subStatus)); + } else { + $controller->expects($this->never()) + ->method('saveSubscription'); + + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willThrowException(new InvalidTokenException()); + } + + $response = $controller->registerWP($endpoint, $uaPublicKey, $auth, ["all"]); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertSame($status, $response->getStatus()); + $this->assertSame($payload, $response->getData()); + } + + public static function dataActivateWP(): array { + return [ + 'not authenticated' => [ + false, + 0, + false, + 0, + [], + Http::STATUS_UNAUTHORIZED + ], + 'invalid session token' => [ + true, + 23, + false, + 0, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'created' => [ + true, + 23, + true, + 0, + [], + Http::STATUS_ACCEPTED, + ], + 'updated' => [ + true, + 42, + true, + 1, + [], + Http::STATUS_OK, + ], + 'invalid activation token' => [ + true, + 42, + true, + 2, + ['message' => 'INVALID_ACTIVATION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'no subscription' => [ + true, + 42, + true, + 3, + ['message' => 'NO_PUSH_SUBSCRIPTION'], + Http::STATUS_NOT_FOUND, + ], + ]; + } + + /** + * @dataProvider dataActivateWP + */ + public function testActivateWP(bool $userIsValid, int $tokenId, bool $tokenIsValid, int $subStatus, array $payload, int $status): void { + $controller = $this->getController([ + 'activateSubscription', + ]); + + $user = $this->createMock(IUser::class); + if ($userIsValid) { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn($user); + } else { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn(null); + } + + $this->session->expects($tokenId > 0 ? $this->once() : $this->never()) + ->method('get') + ->with('token-id') + ->willReturn($tokenId); + + if ($tokenIsValid) { + $token = $this->createMock(IToken::class); + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willReturn($token); + + $controller->expects($this->once()) + ->method('activateSubscription') + ->with($user, $token, 'dummyToken') + ->willReturn(ActivationSubStatus::from($subStatus)); + } else { + $controller->expects($this->never()) + ->method('activateSubscription'); + + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willThrowException(new InvalidTokenException()); + } + + $response = $controller->activateWP('dummyToken'); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertSame($status, $response->getStatus()); + $this->assertSame($payload, $response->getData()); + } + + public static function dataRemoveSubscription(): array { + return [ + 'not authenticated' => [ + false, + 0, + false, + null, + [], + Http::STATUS_UNAUTHORIZED + ], + 'invalid session token' => [ + true, + 23, + false, + null, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'subscription deleted' => [ + true, + 23, + true, + true, + [], + Http::STATUS_ACCEPTED, + ], + 'subscription non existent' => [ + true, + 42, + true, + false, + [], + Http::STATUS_OK, + ], + ]; + } + + /** + * @dataProvider dataRemoveSubscription + */ + public function testRemoveSubscription(bool $userIsValid, int $tokenId, bool $tokenIsValid, ?bool $subDeleted, array $payload, int $status): void { + $controller = $this->getController([ + 'deleteSubscription', + ]); + + $user = $this->createMock(IUser::class); + if ($userIsValid) { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn($user); + } else { + $this->userSession->expects($this->any()) + ->method('getUser') + ->willReturn(null); + } + + $this->session->expects($tokenId > 0 ? $this->once() : $this->never()) + ->method('get') + ->with('token-id') + ->willReturn($tokenId); + + if ($tokenIsValid) { + $token = $this->createMock(IToken::class); + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willReturn($token); + + $controller->expects($this->once()) + ->method('deleteSubscription') + ->with($user, $token) + ->willReturn($subDeleted); + } else { + $controller->expects($this->never()) + ->method('deleteSubscription'); + + $this->tokenProvider->expects($this->any()) + ->method('getTokenById') + ->with($tokenId) + ->willThrowException(new InvalidTokenException()); + } + + $response = $controller->removeWP(); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertSame($status, $response->getStatus()); + $this->assertSame($payload, $response->getData()); + } +} From 8b872326e07f9ff4cf4acfcac0089d5496e1d1d7 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 19 Nov 2025 13:33:18 +0100 Subject: [PATCH 02/37] feat(webpush): Registration with new endpoint, p256dh or auth is 'created' Signed-off-by: sim --- lib/Controller/WebPushController.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 5e2b251f9..82311707c 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -187,8 +187,8 @@ public function removeWP(): DataResponse { /** * @param list $appTypes * @return NewSubStatus: - * - CREATED if the user didn't have an activated subscription - * - UPDATED if the subscription has been updated + * - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth + * - UPDATED if the subscription has been updated (use to change appTypes) */ protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): NewSubStatus { $query = $this->db->getQueryBuilder(); @@ -196,13 +196,16 @@ protected function saveSubscription(IUser $user, IToken $token, string $endpoint ->from('notifications_webpush') ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))) + ->andWhere($query->expr()->eq('endpoint', $query->createNamedParameter($endpoint))) + ->andWhere($query->expr()->eq('p256dh', $query->createNamedParameter($uaPublicKey))) + ->andWhere($query->expr()->eq('auth', $query->createNamedParameter($auth))) ->andWhere($query->expr()->eq('activated', $query->createNamedParameter(true))); $result = $query->executeQuery(); $row = $result->fetch(); $result->closeCursor(); if (!$row) { - // In case the user has already an inactive subscription + // In case the user has already a subscription, but inactive or with a different enpoint, pubkey or auth secret $this->deleteSubscription($user, $token); if ($this->insertSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes)) { return NewSubStatus::CREATED; From a5b09aa406e7c8882c4c860742826fe7a57816a1 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 19 Nov 2025 13:34:15 +0100 Subject: [PATCH 03/37] feat(webpush): Return error if too many appTypes are supplied Signed-off-by: sim --- lib/Controller/WebPushController.php | 5 ++++ .../Unit/Controller/WebPushControllerTest.php | 30 +++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 82311707c..cedf44551 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -93,6 +93,11 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, return new DataResponse(['message' => 'INVALID_ENDPOINT'], Http::STATUS_BAD_REQUEST); } + $appTypesStr = join(',', $appTypes); + if (strlen($appTypesStr) > 256) { + return new DataResponse(['message' => 'TOO_MANY_APP_TYPES'], Http::STATUS_BAD_REQUEST); + } + $tokenId = $this->session->get('token-id'); if (!\is_int($tokenId)) { return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php index 54296f051..f1e950a5f 100644 --- a/tests/Unit/Controller/WebPushControllerTest.php +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -83,6 +83,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', '', '', + ['all'], false, 0, false, @@ -94,6 +95,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV', self::$auth, + ['all'], true, 0, false, @@ -105,6 +107,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4bb', self::$auth, + ['all'], true, 0, false, @@ -116,6 +119,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV- JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw', self::$auth, + ['all'], true, 0, false, @@ -127,6 +131,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, 'BTBZMqHH6r4Tts7J_aSI', + ['all'], true, 0, false, @@ -138,6 +143,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, 'BTBZMqHH6r4Tts7J_aSIggxx', + ['all'], true, 0, false, @@ -149,6 +155,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, 'BTBZM HH6r4Tts7J_aSIgg', + ['all'], true, 0, false, @@ -160,6 +167,7 @@ public static function dataRegisterWP(): array { 'http://localhost/', self::$uaPublicKey, self::$auth, + ['all'], true, 0, false, @@ -167,10 +175,26 @@ public static function dataRegisterWP(): array { ['message' => 'INVALID_ENDPOINT'], Http::STATUS_BAD_REQUEST, ], + 'too many appTypes' => [ + 'https://localhost/', + self::$uaPublicKey, + self::$auth, + [ + 'all', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ], + true, + 0, + false, + 0, + ['message' => 'TOO_MANY_APP_TYPES'], + Http::STATUS_BAD_REQUEST, + ], 'invalid session' => [ 'https://localhost/', self::$uaPublicKey, self::$auth, + ['all'], true, 23, false, @@ -182,6 +206,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, self::$auth, + ['all'], true, 23, true, @@ -193,6 +218,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, self::$auth, + ['all'], true, 23, true, @@ -206,7 +232,7 @@ public static function dataRegisterWP(): array { /** * @dataProvider dataRegisterWP */ - public function testRegisterWP(string $endpoint, string $uaPublicKey, string $auth, bool $userIsValid, int $tokenId, bool $tokenIsValid, int $subStatus, array $payload, int $status): void { + public function testRegisterWP(string $endpoint, string $uaPublicKey, string $auth, array $appTypes, bool $userIsValid, int $tokenId, bool $tokenIsValid, int $subStatus, array $payload, int $status): void { $controller = $this->getController([ 'saveSubscription', ]); @@ -248,7 +274,7 @@ public function testRegisterWP(string $endpoint, string $uaPublicKey, string $au ->willThrowException(new InvalidTokenException()); } - $response = $controller->registerWP($endpoint, $uaPublicKey, $auth, ["all"]); + $response = $controller->registerWP($endpoint, $uaPublicKey, $auth, $appTypes); $this->assertInstanceOf(DataResponse::class, $response); $this->assertSame($status, $response->getStatus()); $this->assertSame($payload, $response->getData()); From d9ffa9595cc1fbdec35fe410987ffe6055837204 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 19 Nov 2025 16:50:40 +0100 Subject: [PATCH 04/37] feat(webpush): Send activation token during registration Signed-off-by: sim --- lib/Controller/WebPushController.php | 48 ++++++++------ lib/WebPushClient.php | 66 +++++++++++++++++++ .../Unit/Controller/WebPushControllerTest.php | 11 +++- 3 files changed, 103 insertions(+), 22 deletions(-) create mode 100644 lib/WebPushClient.php diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index cedf44551..8fb013379 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -9,9 +9,9 @@ namespace OCA\Notifications\Controller; +use OCA\Notifications\WebPushClient; use OC\Authentication\Token\IProvider; use OC\Security\IdentityProof\Manager; -use OCA\Notifications\ResponseDefinitions; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -77,11 +77,11 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, return new DataResponse([], Http::STATUS_UNAUTHORIZED); } - if (!preg_match('/^[A-Za-z0-9_-]{87}=*$/', $uaPublicKey)) { + if (!WebPushClient::isValidP256dh($uaPublicKey)) { return new DataResponse(['message' => 'INVALID_P256DH'], Http::STATUS_BAD_REQUEST); } - if (!preg_match('/^[A-Za-z0-9_-]{22}=*$/', $auth)) { + if (!WebPushClient::isValidAuth($auth)) { return new DataResponse(['message' => 'INVALID_AUTH'], Http::STATUS_BAD_REQUEST); } @@ -108,9 +108,12 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); } - $status = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes); + [$status, $activationToken] = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes); - //TODO: send activation token to test if the pubkey, auth and the endpoints are valid + if ($status === NewSubStatus::CREATED) { + $wp = $this->getWPClient(); + $wp->notify($endpoint, $uaPublicKey, $auth, json_encode(['activationToken' => $activationToken])); + } return match($status) { NewSubStatus::UPDATED => new DataResponse([], Http::STATUS_OK), @@ -123,7 +126,7 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, /** * Activate subscription for push notifications * - * @param string $activation_token Random token sent via a push notification during registration to enable the subscription + * @param string $activationToken Random token sent via a push notification during registration to enable the subscription * @return DataResponse, array{}>|DataResponse * * 200: Subscription was already activated @@ -134,7 +137,7 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, */ #[NoAdminRequired] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/webpush/activate', requirements: ['apiVersion' => '(v2)'])] - public function activateWP(string $activation_token): DataResponse { + public function activateWP(string $activationToken): DataResponse { $user = $this->userSession->getUser(); if (!$user instanceof IUser) { return new DataResponse([], Http::STATUS_UNAUTHORIZED); @@ -147,7 +150,7 @@ public function activateWP(string $activation_token): DataResponse { return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); } - $status = $this->activateSubscription($user, $token, $activation_token); + $status = $this->activateSubscription($user, $token, $activationToken); return match($status) { ActivationSubStatus::OK => new DataResponse([], Http::STATUS_OK), @@ -189,13 +192,17 @@ public function removeWP(): DataResponse { return new DataResponse([], Http::STATUS_OK); } + protected function getWPClient(): WebPushClient { + return new WebPushClient(); + } + /** * @param list $appTypes - * @return NewSubStatus: + * @return array{0: NewSubStatus, 1: ?string}: * - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth * - UPDATED if the subscription has been updated (use to change appTypes) */ - protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): NewSubStatus { + protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): array { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_webpush') @@ -212,17 +219,18 @@ protected function saveSubscription(IUser $user, IToken $token, string $endpoint if (!$row) { // In case the user has already a subscription, but inactive or with a different enpoint, pubkey or auth secret $this->deleteSubscription($user, $token); - if ($this->insertSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes)) { - return NewSubStatus::CREATED; + $activationToken = Uuid::v4()->toRfc4122(); + if ($this->insertSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $activationToken, $appTypes)) { + return [NewSubStatus::CREATED, $activationToken]; } else { - return NewSubStatus::ERROR; + return [NewSubStatus::ERROR, null]; } } if ($this->updateSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes)) { - return NewSubStatus::UPDATED; + return [NewSubStatus::UPDATED, null]; } else { - return NewSubStatus::ERROR; + return [NewSubStatus::ERROR, null]; } } @@ -233,7 +241,7 @@ protected function saveSubscription(IUser $user, IToken $token, string $endpoint * - NO_TOKEN if we don't have this token * - NO_SUB if we don't have this subscription */ - protected function activateSubscription(IUser $user, IToken $token, string $activation_token): ActivationSubStatus { + protected function activateSubscription(IUser $user, IToken $token, string $activationToken): ActivationSubStatus { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_webpush') @@ -253,7 +261,7 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti ->set('activated', $query->createNamedParameter(true)) ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))) - ->andWhere($query->expr()->eq('activation_token', $query->createNamedParameter($activation_token))); + ->andWhere($query->expr()->eq('activation_token', $query->createNamedParameter($activationToken))); if ($query->executeStatement() !== 0) { return ActivationSubStatus::CREATED; @@ -266,9 +274,7 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti * @param list $appTypes * @return bool If the entry was created */ - protected function insertSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): bool { - $activation_token = Uuid::v4()->toRfc4122(); - + protected function insertSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, array $appTypes): bool { $query = $this->db->getQueryBuilder(); $query->insert('notifications_webpush') ->values([ @@ -278,7 +284,7 @@ protected function insertSubscription(IUser $user, IToken $token, string $endpoi 'p256dh' => $query->createNamedParameter($uaPublicKey), 'auth' => $query->createNamedParameter($auth), 'apptypes' => $query->createNamedParameter(join(',', $appTypes)), - 'activation_token' => $query->createNamedParameter($activation_token), + 'activation_token' => $query->createNamedParameter($activationToken), ]); return $query->executeStatement() > 0; } diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php new file mode 100644 index 000000000..9b389229f --- /dev/null +++ b/lib/WebPushClient.php @@ -0,0 +1,66 @@ +client; + if (isset($c)) { + return $c; + } + $c = new WebPush(); + $this->client = $c; + return $c; + } + + /** + * Send one notification - blocking (should be avoided most of the time) + */ + public function notify(string $endpoint, string $uaPublicKey, string $auth, string $body): void { + $c = $this->getClient(); + $c->queueNotification( + new Subscription($endpoint, $uaPublicKey, $auth, "aes128gcm"), + $body + ); + $callback = function($r) {}; + $c->flushPooled($callback); + } +} diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php index f1e950a5f..25fdc1dd5 100644 --- a/tests/Unit/Controller/WebPushControllerTest.php +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -8,6 +8,7 @@ namespace OCA\Notifications\Tests\Unit\Controller; +use OCA\Notifications\WebPushClient; use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; @@ -235,6 +236,7 @@ public static function dataRegisterWP(): array { public function testRegisterWP(string $endpoint, string $uaPublicKey, string $auth, array $appTypes, bool $userIsValid, int $tokenId, bool $tokenIsValid, int $subStatus, array $payload, int $status): void { $controller = $this->getController([ 'saveSubscription', + 'getWPClient' ]); $user = $this->createMock(IUser::class); @@ -263,7 +265,14 @@ public function testRegisterWP(string $endpoint, string $uaPublicKey, string $au $controller->expects($this->once()) ->method('saveSubscription') ->with($user, $token, $endpoint, $uaPublicKey, $auth, $this->anything()) - ->willReturn(NewSubStatus::from($subStatus)); + ->willReturn([NewSubStatus::from($subStatus), 'tok']); + + if ($subStatus === 0) { + $wpClient = $this->createMock(WebPushClient::class); + $controller->expects($this->once()) + ->method('getWPClient') + ->willReturn($wpClient); + } } else { $controller->expects($this->never()) ->method('saveSubscription'); From ee564f7ee566b82e32bc4e0e7d2f1c17a5789a1b Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 20 Nov 2025 16:28:27 +0100 Subject: [PATCH 05/37] feat(webpush): Prepare webpush requests Signed-off-by: sim --- lib/Push.php | 141 +++++++++++++++++++++++++++++++++++----- tests/Unit/PushTest.php | 50 +++++++------- 2 files changed, 149 insertions(+), 42 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index 582c91a4f..1e1906dba 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -63,10 +63,14 @@ class Push { * @psalm-var array */ protected array $userStatuses = []; + /** + * @psalm-var array> + */ + protected array $userWebPushDevices = []; /** * @psalm-var array> */ - protected array $userDevices = []; + protected array $userProxyDevices = []; /** @var string[] */ protected array $loadDevicesForUsers = []; /** @var string[] */ @@ -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 = []; } @@ -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('No devices found for user'); return; } @@ -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('No web push devices found for user'); + return; + } + } + + public function proxyPushToDevice(int $id, IUser $user, array $devices, INotification $notification, ?OutputInterface $output = null): void { + if (empty($devices)) { + $this->printInfo('No proxy devices found for user'); + return; + } + $userKey = $this->keyManager->getKey($user); $this->printInfo('Private user key size: ' . strlen($userKey->getPrivate())); @@ -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; } @@ -715,7 +778,7 @@ protected function encryptAndSignDelete(Key $userKey, array $device, ?array $ids * @return array[] * @psalm-return list */ - protected function getDevicesForUser(string $uid): array { + protected function getProxyDevicesForUser(string $uid): array { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_pushhash') @@ -733,7 +796,7 @@ protected function getDevicesForUser(string $uid): array { * @return array[] * @psalm-return array> */ - protected function getDevicesForUsers(array $userIds): array { + protected function getProxyDevicesForUsers(array $userIds): array { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_pushhash') @@ -753,6 +816,50 @@ protected function getDevicesForUsers(array $userIds): array { return $devices; } + + /** + * @param string $uid + * @return array[] + * @psalm-return list + */ + 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> + */ + 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 diff --git a/tests/Unit/PushTest.php b/tests/Unit/PushTest.php index 1f7758b0f..6339162a4 100644 --- a/tests/Unit/PushTest.php +++ b/tests/Unit/PushTest.php @@ -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()) @@ -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()) @@ -201,7 +201,7 @@ public function testPushToDeviceNotPrepared(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 'token1', @@ -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'); @@ -245,7 +245,7 @@ public function testPushToDeviceInvalidToken(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 23, @@ -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'); @@ -310,7 +310,7 @@ public function testPushToDeviceEncryptionError(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 23, @@ -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); @@ -367,7 +367,7 @@ public function testPushToDeviceNoFairUse(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([ [ 'proxyserver' => 'proxyserver', @@ -427,7 +427,7 @@ public function testPushToDeviceNoFairUse(): void { $push->pushToDevice(207787, $notification); } - public static function dataPushToDeviceSending(): array { + public static function dataProxyPushToDeviceSending(): array { return [ [true], [false], @@ -435,10 +435,10 @@ public static function dataPushToDeviceSending(): array { } /** - * @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); @@ -455,7 +455,7 @@ public function testPushToDeviceSending(bool $isDebug): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([ [ 'proxyserver' => 'proxyserver1', @@ -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], @@ -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); @@ -703,7 +703,7 @@ public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTal ]; } $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn($devices); $this->l10nFactory From 80c7390c7d6afe3f89897bd44f7eab12e7a38c82 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 20 Nov 2025 19:45:39 +0100 Subject: [PATCH 06/37] feat(webpush): Send web push requests Signed-off-by: sim --- lib/Push.php | 97 ++++++++++++++++++++++++++++++++++++++----- lib/WebPushClient.php | 38 +++++++++++++---- 2 files changed, 117 insertions(+), 18 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index 1e1906dba..ae3f3c6d5 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -75,6 +75,8 @@ class Push { protected array $loadDevicesForUsers = []; /** @var string[] */ protected array $loadStatusForUsers = []; + /** @var WebPushClient */ + protected WebPushClient $wpClient; public function __construct( protected IDBConnection $db, @@ -91,6 +93,7 @@ public function __construct( protected LoggerInterface $log, ) { $this->cache = $cacheFactory->createDistributed('pushtokens'); + $this->wpClient = new WebPushClient($log); } public function setOutput(OutputInterface $output): void { @@ -172,9 +175,31 @@ public function flushPayloads(): void { } $this->deferPayloads = false; + $this->wpClient->flush(); $this->sendNotificationsToProxies(); } + /** + * @param array $devices + * @psalm-param $devices list + * @param string $app + * @return array + * @psalm-return list + */ + public function filterWebPushDeviceList(array $devices, string $app): array { + // Consider all 3 options as 'talk' + if (\in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true)) { + $app = "talk"; + } + + return array_filter($devices, function($device) { + $appTypes = explode(',', $device['apptypes']); + return $device['activated'] && (\in_array($app, $appTypes) || + (\in_array("all", $appTypes) && !\in_array('-'.$app, $appTypes))); + }); + } + + /** * @param array $devices * @psalm-param $devices list @@ -182,7 +207,7 @@ public function flushPayloads(): void { * @return array * @psalm-return list */ - public function filterDeviceList(array $devices, string $app): array { + public function filterProxyDeviceList(array $devices, string $app): array { $isTalkNotification = \in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true); $talkDevices = array_filter($devices, static fn ($device) => $device['apptype'] === 'talk'); @@ -284,6 +309,44 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat $this->printInfo('No web push devices found for user'); return; } + + $this->printInfo(''); + $this->printInfo('Found ' . count($devices) . ' devices registered for push notifications'); + $devices = $this->filterWebPushDeviceList($devices, $notification->getApp()); + if (empty($devices)) { + $this->printInfo('No devices left after filtering'); + return; + } + $this->printInfo('Trying to push to ' . count($devices) . ' devices'); + + // We don't push to devices that are older than 60 days + $maxAge = time() - 60 * 24 * 60 * 60; + + foreach ($devices as $device) { + $device['token'] = (int)$device['token']; + $this->printInfo(''); + $this->printInfo('Device token: ' . $device['token']); + + if (!$this->validateToken($device['token'], $maxAge)) { + // Token does not exist anymore + continue; + } + + try { + $payload = json_encode($this->encodeNotif($id, $notification, 3000), JSON_THROW_ON_ERROR); + $this->wpClient->enqueue($device['endpoint'], $device['p256dh'], $device['auth'], $payload); + } catch (\JsonException $e) { + $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); + } catch (\InvalidArgumentException) { + // Failed to encrypt message for device: public key is invalid + //TODO $this->deletePushToken($device['token']); + } + } + $this->printInfo(''); + + if (!$this->deferPayloads) { + $this->wpClient->flush(); + } } public function proxyPushToDevice(int $id, IUser $user, array $devices, INotification $notification, ?OutputInterface $output = null): void { @@ -301,7 +364,7 @@ public function proxyPushToDevice(int $id, IUser $user, array $devices, INotific $this->printInfo(''); $this->printInfo('Found ' . count($devices) . ' devices registered for push notifications'); $isTalkNotification = \in_array($notification->getApp(), ['spreed', 'talk', 'admin_notification_talk'], true); - $devices = $this->filterDeviceList($devices, $notification->getApp()); + $devices = $this->filterProxyDeviceList($devices, $notification->getApp()); if (empty($devices)) { $this->printInfo('No devices left after filtering'); return; @@ -401,7 +464,7 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri if (!$deleteAll) { // Only filter when it's not delete-all - $proxyDevices = $this->filterDeviceList($proxyDevices, $app); + $proxyDevices = $this->filterProxyDeviceList($proxyDevices, $app); //TODO filter webpush devices } @@ -657,17 +720,13 @@ protected function callSafelyForToken(IToken $token, string $method): ?int { } /** - * @param Key $userKey - * @param array $device * @param int $id * @param INotification $notification - * @param bool $isTalkNotification + * @param int $maxLength max length of the push notification (shorter than 240 for proxy push, 3993 for webpush) * @return array - * @psalm-return array{deviceIdentifier: string, pushTokenHash: string, subject: string, signature: string, priority: string, type: string} - * @throws InvalidTokenException - * @throws \InvalidArgumentException + * @psalm-return array{nid: int, app: string, subject: string, type: string, id: string} */ - protected function encryptAndSign(Key $userKey, array $device, int $id, INotification $notification, bool $isTalkNotification): array { + protected function encodeNotif(int $id, INotification $notification, int $maxLength): array { $data = [ 'nid' => $id, 'app' => $notification->getApp(), @@ -678,11 +737,27 @@ protected function encryptAndSign(Key $userKey, array $device, int $id, INotific // Max length of encryption is ~240, so we need to make sure the subject is shorter. // Also, subtract two for encapsulating quotes will be added. - $maxDataLength = 200 - strlen(json_encode($data)) - 2; + $maxDataLength = $maxLength - strlen(json_encode($data)) - 2; $data['subject'] = Util::shortenMultibyteString($notification->getParsedSubject(), $maxDataLength); if ($notification->getParsedSubject() !== $data['subject']) { $data['subject'] .= '…'; } + return $data; + } + + /** + * @param Key $userKey + * @param array $device + * @param int $id + * @param INotification $notification + * @param bool $isTalkNotification + * @return array + * @psalm-return array{deviceIdentifier: string, pushTokenHash: string, subject: string, signature: string, priority: string, type: string} + * @throws InvalidTokenException + * @throws \InvalidArgumentException + */ + protected function encryptAndSign(Key $userKey, array $device, int $id, INotification $notification, bool $isTalkNotification): array { + $data = $this->encodeNotif($id, $notification, 200); if ($isTalkNotification) { $priority = 'high'; diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 9b389229f..af919217f 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -13,9 +13,14 @@ use OCA\Notifications\Vendor\Minishlink\WebPush\Utils; use OCA\Notifications\Vendor\Minishlink\WebPush\WebPush; use OCA\Notifications\Vendor\Minishlink\WebPush\Subscription; +use Psr\Log\LoggerInterface; class WebPushClient { - static private WebPush $client; + private WebPush $client; + + public function __construct( + protected LoggerInterface $log, + ) {} static public function isValidP256dh(string $key): bool { if (!preg_match('/^[A-Za-z0-9_-]{87}=*$/', $key)) { @@ -42,13 +47,11 @@ static public function isValidAuth(string $auth): bool { } private function getClient(): WebPush { - $c = $this->client; - if (isset($c)) { - return $c; + if (isset($this->client)) { + return $this->client; } - $c = new WebPush(); - $this->client = $c; - return $c; + $this->client = new WebPush(); + return $this->client; } /** @@ -60,6 +63,27 @@ public function notify(string $endpoint, string $uaPublicKey, string $auth, stri new Subscription($endpoint, $uaPublicKey, $auth, "aes128gcm"), $body ); + // the callback could be defined by the caller + // For the moment, it is used during registration only - no need to catch 404 &co + // as the registration isn't activated + $callback = function($r) {}; + $c->flushPooled($callback); + } + + /** + * Send one notification - blocking (should be avoided most of the time) + */ + public function enqueue(string $endpoint, string $uaPublicKey, string $auth, string $body): void { + $c = $this->getClient(); + $c->queueNotification( + new Subscription($endpoint, $uaPublicKey, $auth, "aes128gcm"), + $body + ); + } + + // TODO remove 404 and others + public function flush(): void { + $c = $this->getClient(); $callback = function($r) {}; $c->flushPooled($callback); } From 094eccb8f57be5ace48e200e737be0d186f01cd1 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 21 Nov 2025 07:49:42 +0100 Subject: [PATCH 07/37] feat(webpush): Delete expired web push subscriptions Signed-off-by: sim --- lib/Push.php | 55 +++++++++++++++++++++++++++++++++++-------- lib/WebPushClient.php | 11 +++++---- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index ae3f3c6d5..cf1ed45f8 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -15,6 +15,7 @@ use OC\Security\IdentityProof\Key; use OC\Security\IdentityProof\Manager; use OCA\Notifications\AppInfo\Application; +use OCA\Notifications\Vendor\Minishlink\WebPush\MessageSentReport; use OCP\AppFramework\Http; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Authentication\Exceptions\InvalidTokenException; @@ -175,7 +176,7 @@ public function flushPayloads(): void { } $this->deferPayloads = false; - $this->wpClient->flush(); + $this->wpClient->flush(fn ($r) => $this->webPushCallback($r)); $this->sendNotificationsToProxies(); } @@ -329,6 +330,7 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat if (!$this->validateToken($device['token'], $maxAge)) { // Token does not exist anymore + $this->deleteWebPushToken($device['token']); continue; } @@ -339,13 +341,13 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); } catch (\InvalidArgumentException) { // Failed to encrypt message for device: public key is invalid - //TODO $this->deletePushToken($device['token']); + $this->deleteWebPushToken($device['token']); } } $this->printInfo(''); if (!$this->deferPayloads) { - $this->wpClient->flush(); + $this->wpClient->flush(fn ($r) => $this->webPushCallback($r)); } } @@ -381,6 +383,7 @@ public function proxyPushToDevice(int $id, IUser $user, array $devices, INotific if (!$this->validateToken($device['token'], $maxAge)) { // Token does not exist anymore + $this->deleteProxyPushToken($device['token']); continue; } @@ -396,7 +399,7 @@ public function proxyPushToDevice(int $id, IUser $user, array $devices, INotific $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); } catch (\InvalidArgumentException) { // Failed to encrypt message for device: public key is invalid - $this->deletePushToken($device['token']); + $this->deleteProxyPushToken($device['token']); } } $this->printInfo(''); @@ -501,6 +504,7 @@ public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devi $device['token'] = (int)$device['token']; if (!$this->validateToken($device['token'], $maxAge)) { // Token does not exist anymore + $this->deleteProxyPushToken($device['token']); continue; } @@ -532,7 +536,7 @@ public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devi } } catch (\InvalidArgumentException) { // Failed to encrypt message for device: public key is invalid - $this->deletePushToken($device['token']); + $this->deleteProxyPushToken($device['token']); } } @@ -541,6 +545,15 @@ public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devi } } + /** + * Delete expired web push subscriptions + */ + protected function webPushCallback (MessageSentReport $report): void { + if ($report->isSubscriptionExpired()) { + $this->deleteWebPushTokenByEndpoint($report->getEndpoint()); + } + } + protected function sendNotificationsToProxies(): void { $pushNotifications = $this->payloadsToSend; $this->payloadsToSend = []; @@ -628,7 +641,7 @@ protected function sendNotificationsToProxies(): void { // Proxy returns null when the array is empty foreach ($bodyData['unknown'] as $unknownDevice) { $this->printInfo('Deleting device because it is unknown by the push server: ' . $unknownDevice . ''); - $this->deletePushTokenByDeviceIdentifier($unknownDevice); + $this->deleteProxyPushTokenByDeviceIdentifier($unknownDevice); } } @@ -671,7 +684,6 @@ protected function validateToken(int $tokenId, int $maxAge): bool { if ($type === IToken::WIPE_TOKEN) { // Token does not exist any more, should drop the push device entry $this->printInfo('Device token is marked for remote wipe'); - $this->deletePushToken($tokenId); $this->cache->set('t' . $tokenId, 0, 600); return false; } @@ -685,7 +697,6 @@ protected function validateToken(int $tokenId, int $maxAge): bool { } catch (InvalidTokenException) { // Token does not exist any more, should drop the push device entry $this->printInfo('InvalidTokenException is thrown'); - $this->deletePushToken($tokenId); $this->cache->set('t' . $tokenId, 0, 600); return false; } @@ -939,7 +950,31 @@ protected function getWebPushDevicesForUsers(array $userIds): array { * @param int $tokenId * @return bool */ - protected function deletePushToken(int $tokenId): bool { + protected function deleteWebPushToken(int $tokenId): bool { + $query = $this->db->getQueryBuilder(); + $query->delete('notifications_webpush') + ->where($query->expr()->eq('token', $query->createNamedParameter($tokenId, IQueryBuilder::PARAM_INT))); + + return $query->executeStatement() !== 0; + } + + /** + * @param string $endpoint + * @return bool + */ + protected function deleteWebPushTokenByEndpoint(string $endpoint): bool { + $query = $this->db->getQueryBuilder(); + $query->delete('notifications_webpush') + ->where($query->expr()->eq('endpoint', $query->createNamedParameter($endpoint))); + + return $query->executeStatement() !== 0; + } + + /** + * @param int $tokenId + * @return bool + */ + protected function deleteProxyPushToken(int $tokenId): bool { $query = $this->db->getQueryBuilder(); $query->delete('notifications_pushhash') ->where($query->expr()->eq('token', $query->createNamedParameter($tokenId, IQueryBuilder::PARAM_INT))); @@ -951,7 +986,7 @@ protected function deletePushToken(int $tokenId): bool { * @param string $deviceIdentifier * @return bool */ - protected function deletePushTokenByDeviceIdentifier(string $deviceIdentifier): bool { + protected function deleteProxyPushTokenByDeviceIdentifier(string $deviceIdentifier): bool { $query = $this->db->getQueryBuilder(); $query->delete('notifications_pushhash') ->where($query->expr()->eq('deviceidentifier', $query->createNamedParameter($deviceIdentifier))); diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index af919217f..5acccb310 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -10,9 +10,10 @@ namespace OCA\Notifications; use OCA\Notifications\Vendor\Base64Url\Base64Url; +use OCA\Notifications\Vendor\Minishlink\WebPush\MessageSentReport; +use OCA\Notifications\Vendor\Minishlink\WebPush\Subscription; use OCA\Notifications\Vendor\Minishlink\WebPush\Utils; use OCA\Notifications\Vendor\Minishlink\WebPush\WebPush; -use OCA\Notifications\Vendor\Minishlink\WebPush\Subscription; use Psr\Log\LoggerInterface; class WebPushClient { @@ -81,10 +82,12 @@ public function enqueue(string $endpoint, string $uaPublicKey, string $auth, str ); } - // TODO remove 404 and others - public function flush(): void { + /** + * @param callable $callback + * @psalm-param $callback callable(MessageSentReport): void + */ + public function flush(callable $callback): void { $c = $this->getClient(); - $callback = function($r) {}; $c->flushPooled($callback); } } From edb1d5881d3a8df2da08c03741d18f0ee530748b Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 21 Nov 2025 08:21:07 +0100 Subject: [PATCH 08/37] feat(webpush): Send web push delete notifs Signed-off-by: sim --- lib/Push.php | 94 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 17 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index cf1ed45f8..cb679b645 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -484,6 +484,51 @@ public function webPushDeleteToDevice(string $userId, IUser $user, array $device if (empty($devices)) { return; } + + // We don't push to devices that are older than 60 days + $maxAge = time() - 60 * 24 * 60 * 60; + + $userKey = $this->keyManager->getKey($user); + foreach ($devices as $device) { + $device['token'] = (int)$device['token']; + if (!$this->validateToken($device['token'], $maxAge)) { + // Token does not exist anymore + $this->deleteWebPushToken($device['token']); + continue; + } + + try { + if ($deleteAll) { + $data = $this->encodeDeleteNotifs(null); + try { + $payload = json_encode($data['data'], JSON_THROW_ON_ERROR); + $this->wpClient->enqueue($device['endpoint'], $device['p256dh'], $device['auth'], $payload); + } catch (\JsonException $e) { + $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); + } + } else { + $temp = $notificationIds; + + while (!empty($temp)) { + $data = $this->encodeDeleteNotifs($temp); + $temp = $data['remaining']; + try { + $payload = json_encode($data['data'], JSON_THROW_ON_ERROR); + $this->wpClient->enqueue($device['endpoint'], $device['p256dh'], $device['auth'], $payload); + } catch (\JsonException $e) { + $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); + } + } + } + } catch (\InvalidArgumentException) { + // Failed to encrypt message for device: public key is invalid + $this->deleteWebPushToken($device['token']); + } + } + + if (!$this->deferPayloads) { + $this->sendNotificationsToProxies(); + } } /** @@ -756,6 +801,35 @@ protected function encodeNotif(int $id, INotification $notification, int $maxLen return $data; } + /** + * @param ?int[] $ids + * @return array + * @psalm-return array{remaining: list, data: array{delete-all: bool, nid: int, delete: bool, nids: int[], delete-multiple: int}} + */ + protected function encodeDeleteNotifs(?array $ids): array { + $remainingIds = []; + if ($ids === null) { + $data = [ + 'delete-all' => true, + ]; + } elseif (count($ids) === 1) { + $data = [ + 'nid' => array_pop($ids), + 'delete' => true, + ]; + } else { + $remainingIds = array_splice($ids, 10); + $data = [ + 'nids' => $ids, + 'delete-multiple' => true, + ]; + } + return [ + 'remaining' => $remainingIds, + 'data' => $data + ]; + } + /** * @param Key $userKey * @param array $device @@ -819,23 +893,9 @@ protected function encryptAndSign(Key $userKey, array $device, int $id, INotific * @throws \InvalidArgumentException */ protected function encryptAndSignDelete(Key $userKey, array $device, ?array $ids): array { - $remainingIds = []; - if ($ids === null) { - $data = [ - 'delete-all' => true, - ]; - } elseif (count($ids) === 1) { - $data = [ - 'nid' => array_pop($ids), - 'delete' => true, - ]; - } else { - $remainingIds = array_splice($ids, 10); - $data = [ - 'nids' => $ids, - 'delete-multiple' => true, - ]; - } + $ret = $this->encodeDeleteNotifs($ids); + $remainingIds = $ret['remaining']; + $data = $ret['data']; if (!openssl_public_encrypt(json_encode($data), $encryptedSubject, $device['devicepublickey'], OPENSSL_PKCS1_PADDING)) { $this->log->error(openssl_error_string(), ['app' => 'notifications']); From fb3c960d1c87c7eb879d3f58058fd30026cfa13b Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 21 Nov 2025 08:34:08 +0100 Subject: [PATCH 09/37] feat(webpush): Fix tests after 'Delete expired web push subscriptions' Signed-off-by: sim --- tests/Unit/PushTest.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/Unit/PushTest.php b/tests/Unit/PushTest.php index 6339162a4..c82046bda 100644 --- a/tests/Unit/PushTest.php +++ b/tests/Unit/PushTest.php @@ -221,7 +221,7 @@ public function testProxyPushToDeviceNotPrepared(): void { } public function testProxyPushToDeviceInvalidToken(): void { - $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken']); + $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken']); $this->clientService->expects($this->never()) ->method('newClient'); @@ -279,14 +279,14 @@ public function testProxyPushToDeviceInvalidToken(): void { ->method('encryptAndSign'); $push->expects($this->once()) - ->method('deletePushToken') + ->method('deleteProxyPushToken') ->with(23); $push->pushToDevice(2018, $notification); } public function testProxyPushToDeviceEncryptionError(): void { - $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']); + $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken', 'validateToken']); $this->clientService->expects($this->never()) ->method('newClient'); @@ -344,13 +344,13 @@ public function testProxyPushToDeviceEncryptionError(): void { ->willThrowException(new \InvalidArgumentException()); $push->expects($this->once()) - ->method('deletePushToken') + ->method('deleteProxyPushToken') ->with(23); $push->pushToDevice(1970, $notification); } public function testProxyPushToDeviceNoFairUse(): void { - $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken', 'deletePushTokenByDeviceIdentifier']); + $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken', 'validateToken', 'deleteProxyPushTokenByDeviceIdentifier']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); @@ -408,7 +408,7 @@ public function testProxyPushToDeviceNoFairUse(): void { ->willReturn(['Payload']); $push->expects($this->never()) - ->method('deletePushToken'); + ->method('deleteProxyPushToken'); $this->clientService->expects($this->never()) ->method('newClient'); @@ -421,7 +421,7 @@ public function testProxyPushToDeviceNoFairUse(): void { $this->notificationManager->method('isFairUseOfFreePushService') ->willReturn(false); - $push->method('deletePushTokenByDeviceIdentifier') + $push->method('deleteProxyPushTokenByDeviceIdentifier') ->with('123456'); $push->pushToDevice(207787, $notification); @@ -438,7 +438,7 @@ public static function dataProxyPushToDeviceSending(): array { * @dataProvider dataProxyPushToDeviceSending */ public function testProxyPushToDeviceSending(bool $isDebug): void { - $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken', 'deletePushTokenByDeviceIdentifier']); + $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken', 'validateToken', 'deleteProxyPushTokenByDeviceIdentifier']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); @@ -528,7 +528,7 @@ public function testProxyPushToDeviceSending(bool $isDebug): void { ->willReturn(['Payload']); $push->expects($this->never()) - ->method('deletePushToken'); + ->method('deleteProxyPushToken'); /** @var IClient&MockObject $client */ $client = $this->createMock(IClient::class); @@ -644,7 +644,7 @@ public function testProxyPushToDeviceSending(bool $isDebug): void { $this->notificationManager->method('isFairUseOfFreePushService') ->willReturn(true); - $push->method('deletePushTokenByDeviceIdentifier') + $push->method('deleteProxyPushTokenByDeviceIdentifier') ->with('123456'); $push->pushToDevice(207787, $notification); @@ -668,7 +668,7 @@ public static function dataProxyPushToDeviceTalkNotification(): array { * @param string[] $deviceTypes */ public function testProxyPushToDeviceTalkNotification(array $deviceTypes, bool $isTalkNotification, ?int $pushedDevice): void { - $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']); + $push = $this->getPush(['createFakeUserObject', 'getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken', 'validateToken']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); From 942c3d451d0616d4839b9d7e123f021ee3c40cf3 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 21 Nov 2025 15:14:11 +0100 Subject: [PATCH 10/37] feat(webpush): Add Urgency to web push notifs Signed-off-by: sim --- lib/Push.php | 50 ++++++++++++++++++++++++++++++++----------- lib/WebPushClient.php | 7 ++++-- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index cb679b645..d1118c248 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -335,8 +335,15 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat } try { - $payload = json_encode($this->encodeNotif($id, $notification, 3000), JSON_THROW_ON_ERROR); - $this->wpClient->enqueue($device['endpoint'], $device['p256dh'], $device['auth'], $payload); + $data = $this->encodeNotif($id, $notification, 3000); + $urgency = $this->getNotifTopicAndUrgency($data['app'], $data['type'])['priority']; + $this->wpClient->enqueue( + $device['endpoint'], + $device['p256dh'], + $device['auth'], + json_encode($data, JSON_THROW_ON_ERROR), + urgency: $urgency + ); } catch (\JsonException $e) { $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); } catch (\InvalidArgumentException) { @@ -830,6 +837,31 @@ protected function encodeDeleteNotifs(?array $ids): array { ]; } + /** + * Get notification urgency (priority) and topic, the urgency is compatible with + * [RFC8030's Urgency](https://www.rfc-editor.org/rfc/rfc8030#section-5.3) + * + * + * @param string app + * @param string type + * @return array + * @psalm-return array{urgency: string, type: string} + */ + protected function getNotifTopicAndUrgency(string $app, string $type): array { + $res = []; + if (\in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true)) { + $res['urgency'] = 'high'; + $res['type'] = $type === 'call' ? 'voip' : 'alert'; + } elseif ($app === 'twofactor_nextcloud_notification' || $app === 'phonetrack') { + $res['urgency'] = 'high'; + $res['type'] = 'alert'; + } else { + $res['urgency'] = 'normal'; + $res['type'] = 'alert'; + } + return $res; + } + /** * @param Key $userKey * @param array $device @@ -843,17 +875,9 @@ protected function encodeDeleteNotifs(?array $ids): array { */ protected function encryptAndSign(Key $userKey, array $device, int $id, INotification $notification, bool $isTalkNotification): array { $data = $this->encodeNotif($id, $notification, 200); - - if ($isTalkNotification) { - $priority = 'high'; - $type = $data['type'] === 'call' ? 'voip' : 'alert'; - } elseif ($data['app'] === 'twofactor_nextcloud_notification' || $data['app'] === 'phonetrack') { - $priority = 'high'; - $type = 'alert'; - } else { - $priority = 'normal'; - $type = 'alert'; - } + $ret = $this->getNotifTopicAndUrgency($data['app'], $data['type']); + $priority = $ret['urgency']; + $type = $ret['type']; $this->printInfo('Device public key size: ' . strlen($device['devicepublickey'])); $this->printInfo('Data to encrypt is: ' . json_encode($data)); diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 5acccb310..a1601de90 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -74,11 +74,14 @@ public function notify(string $endpoint, string $uaPublicKey, string $auth, stri /** * Send one notification - blocking (should be avoided most of the time) */ - public function enqueue(string $endpoint, string $uaPublicKey, string $auth, string $body): void { + public function enqueue(string $endpoint, string $uaPublicKey, string $auth, string $body, string $urgency = 'normal'): void { $c = $this->getClient(); $c->queueNotification( new Subscription($endpoint, $uaPublicKey, $auth, "aes128gcm"), - $body + $body, + options: [ + 'urgency' => $urgency + ] ); } From 9481d4aec97d2a8348c00dd02ba2725b668ebce1 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 21 Nov 2025 16:21:17 +0100 Subject: [PATCH 11/37] feat(webpush): Add support for 429 status code with web push Signed-off-by: sim --- lib/Push.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/lib/Push.php b/lib/Push.php index d1118c248..76488afcd 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -37,6 +37,7 @@ use OCP\UserStatus\IUserStatus; use OCP\Util; use Psr\Log\LoggerInterface; +use Psr\Http\Message\ResponseInterface; use Symfony\Component\Console\Output\OutputInterface; class Push { @@ -334,6 +335,16 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat continue; } + // If the endpoint got a 429 TOO_MANY_REQUESTS, + // we wait for the time sent by the server + if ($this->cache->get('wp.' . $device['endpoint'])) { + // It would be better to cache the notification to send it later + // in this case, but + // 429 is rare, and ~ an emergency response: dropping the notification + // is a solution good enough to not overload the push server + continue; + } + try { $data = $this->encodeNotif($id, $notification, 3000); $urgency = $this->getNotifTopicAndUrgency($data['app'], $data['type'])['priority']; @@ -504,6 +515,16 @@ public function webPushDeleteToDevice(string $userId, IUser $user, array $device continue; } + // If the endpoint got a 429 TOO_MANY_REQUESTS, + // we wait for the time sent by the server + if ($this->cache->get('wp.' . $device['endpoint'])) { + // It would be better to cache the notification to send it later + // in this case, but + // 429 is rare, and ~ an emergency response: dropping the notification + // is a solution good enough to not overload the push server + continue; + } + try { if ($deleteAll) { $data = $this->encodeDeleteNotifs(null); @@ -603,6 +624,9 @@ public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devi protected function webPushCallback (MessageSentReport $report): void { if ($report->isSubscriptionExpired()) { $this->deleteWebPushTokenByEndpoint($report->getEndpoint()); + } else if ($report->getResponse()?->getStatusCode() === 429) { + $retryAfter = $report->getResponse()?->getHeader('Retry-After'); + $this->cache->set('wp.' . $report->getEndpoint(), true, $retryAfter ?? 60); } } From 1423ea87e7390bcb59f71978e34e19bf62f05388 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 21 Nov 2025 16:38:56 +0100 Subject: [PATCH 12/37] feat(webpush): Fix composer for webpush Signed-off-by: sim --- composer.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/composer.json b/composer.json index 3ea5f5839..ebae4a5c6 100644 --- a/composer.json +++ b/composer.json @@ -38,5 +38,19 @@ "rector:fix": "rector", "test:unit": "phpunit --color -c tests/Unit/phpunit.xml", "test:integration": "cd tests/Integration && ./run.sh" + }, + "require": { + "minishlink/web-push": "^9.0" + }, + "extra": { + "mozart": { + "dep_namespace": "OCA\\Notifications\\Vendor\\", + "dep_directory": "/lib/Vendor/", + "classmap_directory": "/lib/autoload/", + "classmap_prefix": "Notifications_", + "packages": [ + "minishlink/web-push" + ] + } } } From 20a9090d1cadec98b8d9178760ff14166d1f0483 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 21 Nov 2025 17:51:53 +0100 Subject: [PATCH 13/37] feat(webpush): Fix urgency Signed-off-by: sim --- lib/Push.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Push.php b/lib/Push.php index 76488afcd..6e8d62fdf 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -347,7 +347,7 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat try { $data = $this->encodeNotif($id, $notification, 3000); - $urgency = $this->getNotifTopicAndUrgency($data['app'], $data['type'])['priority']; + $urgency = $this->getNotifTopicAndUrgency($data['app'], $data['type'])['urgency']; $this->wpClient->enqueue( $device['endpoint'], $device['p256dh'], From 0621e1b9951e80b314e06869bf1e39f48459e1f0 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 21 Nov 2025 17:53:49 +0100 Subject: [PATCH 14/37] feat(webpush): Add support for VAPID Signed-off-by: sim --- lib/Controller/WebPushController.php | 4 +- lib/Push.php | 4 +- lib/WebPushClient.php | 62 ++++++++++++++++++- .../Unit/Controller/WebPushControllerTest.php | 7 ++- tests/Unit/PushTest.php | 5 ++ 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 8fb013379..16602ac44 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -21,6 +21,7 @@ use OCP\Authentication\Exceptions\InvalidTokenException; use OCP\Authentication\Token\IToken; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IAppConfig; use OCP\IDBConnection; use OCP\IRequest; use OCP\ISession; @@ -46,6 +47,7 @@ class WebPushController extends OCSController { public function __construct( string $appName, IRequest $request, + protected IAppConfig $appConfig, protected IDBConnection $db, protected ISession $session, protected IUserSession $userSession, @@ -193,7 +195,7 @@ public function removeWP(): DataResponse { } protected function getWPClient(): WebPushClient { - return new WebPushClient(); + return new WebPushClient($this->appConfig); } /** diff --git a/lib/Push.php b/lib/Push.php index 6e8d62fdf..3aef70d45 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -22,6 +22,7 @@ use OCP\Authentication\Token\IToken; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Http\Client\IClientService; +use OCP\IAppConfig; use OCP\ICache; use OCP\ICacheFactory; use OCP\IConfig; @@ -84,6 +85,7 @@ public function __construct( protected IDBConnection $db, protected INotificationManager $notificationManager, protected IConfig $config, + protected IAppConfig $appConfig, protected IProvider $tokenProvider, protected Manager $keyManager, protected IClientService $clientService, @@ -95,7 +97,7 @@ public function __construct( protected LoggerInterface $log, ) { $this->cache = $cacheFactory->createDistributed('pushtokens'); - $this->wpClient = new WebPushClient($log); + $this->wpClient = new WebPushClient($appConfig); } public function setOutput(OutputInterface $output): void { diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index a1601de90..7ef6edabb 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -9,18 +9,22 @@ namespace OCA\Notifications; +use OCA\Notifications\AppInfo\Application; use OCA\Notifications\Vendor\Base64Url\Base64Url; use OCA\Notifications\Vendor\Minishlink\WebPush\MessageSentReport; use OCA\Notifications\Vendor\Minishlink\WebPush\Subscription; use OCA\Notifications\Vendor\Minishlink\WebPush\Utils; +use OCA\Notifications\Vendor\Minishlink\WebPush\VAPID; use OCA\Notifications\Vendor\Minishlink\WebPush\WebPush; -use Psr\Log\LoggerInterface; +use OCP\IAppConfig; class WebPushClient { private WebPush $client; + /** @psalm-var array{publicKey: string, privateKey: string} */ + private array $vapid; public function __construct( - protected LoggerInterface $log, + protected IAppConfig $appConfig, ) {} static public function isValidP256dh(string $key): bool { @@ -51,10 +55,62 @@ private function getClient(): WebPush { if (isset($this->client)) { return $this->client; } - $this->client = new WebPush(); + $this->client = new WebPush(auth: $this->getVapid()); + $this->client->setReuseVAPIDHeaders(true); return $this->client; } + /** + * @return array + * @psalm-return array{publicKey: string, privateKey: string} + */ + private function getVapid(): array { + if (isset($this->vapid)) { + return $this->vapid; + } + $publicKey = $this->appConfig->getValueString( + Application::APP_ID, + 'webpush_vapid_pubkey', + lazy: true + ); + $privateKey = $this->appConfig->getValueString( + Application::APP_ID, + 'webpush_vapid_privkey', + lazy: true + ); + if ($publicKey === '' || $privateKey === '') { + $vapid = VAPID::createVapidKeys(); + $this->vapid = $vapid; + $this->appConfig->setValueString( + Application::APP_ID, + 'webpush_vapid_pubkey', + $vapid['publicKey'], + lazy: true, + sensitive: true + ); + $this->appConfig->setValueString( + Application::APP_ID, + 'webpush_vapid_privkey', + $vapid['privateKey'], + lazy: true, + sensitive: true + ); + } else { + $this->vapid = [ + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + ]; + } + return $this->vapid; + } + + /** + * @return string + */ + public function getVapidPublicKey(): string { + $this->getVapid()['publicKey']; + } + /** * Send one notification - blocking (should be avoided most of the time) */ diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php index 25fdc1dd5..33139f0ee 100644 --- a/tests/Unit/Controller/WebPushControllerTest.php +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -19,6 +19,7 @@ use OCA\Notifications\Controller\NewSubStatus; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\IAppConfig; use OCP\IDBConnection; use OCP\IRequest; use OCP\ISession; @@ -29,13 +30,14 @@ class WebPushControllerTest extends TestCase { protected IRequest&MockObject $request; + protected IAppConfig&MockObject $appConfig; protected IDBConnection&MockObject $db; protected ISession&MockObject $session; protected IUserSession&MockObject $userSession; protected IProvider&MockObject $tokenProvider; protected Manager&MockObject $identityProof; protected IUser&MockObject $user; - protected PushController $controller; + protected WebPushController $controller; protected static string $uaPublicKey = 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'; protected static string $auth = 'BTBZMqHH6r4Tts7J_aSIgg'; @@ -44,6 +46,7 @@ protected function setUp(): void { parent::setUp(); $this->request = $this->createMock(IRequest::class); + $this->appConfig = $this->createMock(IAppConfig::class); $this->db = $this->createMock(IDBConnection::class); $this->session = $this->createMock(ISession::class); $this->userSession = $this->createMock(IUserSession::class); @@ -56,6 +59,7 @@ protected function getController(array $methods = []): WebPushController|MockObj return new WebPushController( 'notifications', $this->request, + $this->appConfig, $this->db, $this->session, $this->userSession, @@ -68,6 +72,7 @@ protected function getController(array $methods = []): WebPushController|MockObj ->setConstructorArgs([ 'notifications', $this->request, + $this->appConfig, $this->db, $this->session, $this->userSession, diff --git a/tests/Unit/PushTest.php b/tests/Unit/PushTest.php index c82046bda..1d61adee6 100644 --- a/tests/Unit/PushTest.php +++ b/tests/Unit/PushTest.php @@ -24,6 +24,7 @@ use OCP\Http\Client\IResponse; use OCP\ICache; use OCP\ICacheFactory; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IDBConnection; use OCP\IUser; @@ -48,6 +49,7 @@ class PushTest extends TestCase { protected IDBConnection $db; protected INotificationManager&MockObject $notificationManager; protected IConfig&MockObject $config; + protected IAppConfig&MockObject $appConfig; protected IProvider&MockObject $tokenProvider; protected Manager&MockObject $keyManager; protected IClientService&MockObject $clientService; @@ -65,6 +67,7 @@ protected function setUp(): void { $this->db = \OCP\Server::get(IDBConnection::class); $this->notificationManager = $this->createMock(INotificationManager::class); $this->config = $this->createMock(IConfig::class); + $this->appConfig = $this->createMock(IAppConfig::class); $this->tokenProvider = $this->createMock(IProvider::class); $this->keyManager = $this->createMock(Manager::class); $this->clientService = $this->createMock(IClientService::class); @@ -91,6 +94,7 @@ protected function getPush(array $methods = []): Push|MockObject { $this->db, $this->notificationManager, $this->config, + $this->appConfig, $this->tokenProvider, $this->keyManager, $this->clientService, @@ -109,6 +113,7 @@ protected function getPush(array $methods = []): Push|MockObject { $this->db, $this->notificationManager, $this->config, + $this->appConfig, $this->tokenProvider, $this->keyManager, $this->clientService, From 1b5cf327358d1ae04a9d2048c87af9f3ed38f236 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 24 Nov 2025 11:29:37 +0100 Subject: [PATCH 15/37] feat(webpush): Fix missing $deleteAll Signed-off-by: sim --- lib/Push.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index 3aef70d45..7bb35d85f 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -491,16 +491,18 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri //TODO filter webpush devices } - $this->webPushDeleteToDevice($userId, $user, $webPushDevices, $notificationIds, $app); - $this->proxyPushDeleteToDevice($userId, $user, $proxyDevices, $notificationIds, $app); + $this->webPushDeleteToDevice($userId, $user, $webPushDevices, $deleteAll, $notificationIds, $app); + $this->proxyPushDeleteToDevice($userId, $user, $proxyDevices, $deleteAll, $notificationIds, $app); } /** * @param string $userId + * @param IUser $user + * @param bool $deleteAll * @param ?int[] $notificationIds * @param string $app */ - public function webPushDeleteToDevice(string $userId, IUser $user, array $devices, ?array $notificationIds, string $app = ''): void { + public function webPushDeleteToDevice(string $userId, IUser $user, array $devices, bool $deleteAll, ?array $notificationIds, string $app = ''): void { if (empty($devices)) { return; } @@ -563,10 +565,12 @@ public function webPushDeleteToDevice(string $userId, IUser $user, array $device /** * @param string $userId + * @param IUser $user + * @param bool $deleteAll * @param ?int[] $notificationIds * @param string $app */ - public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devices, ?array $notificationIds, string $app = ''): void { + public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devices, bool $deleteAll, ?array $notificationIds, string $app = ''): void { if (empty($devices)) { return; } From 429b50f3a1018970806a31c9a48d421fe36a5463 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 24 Nov 2025 14:43:18 +0100 Subject: [PATCH 16/37] feat(webpush): Add API endpoint to get the VAPID pubkey Signed-off-by: sim --- lib/Controller/WebPushController.php | 14 ++++++++++++++ lib/WebPushClient.php | 12 +++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 16602ac44..9c7255bcc 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -57,6 +57,20 @@ public function __construct( parent::__construct($appName, $request); } + + /** + * Return the server VAPID public key + * + * @return DataResponse + * + * 200: The VAPID key + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/webpush/vapid', requirements: ['apiVersion' => '(v2)'])] + public function getVapid(string $apiVersion): DataResponse { + return new DataResponse(['vapid' => $this->getWPClient()->getVapidPublicKey()], Http::STATUS_OK); + } + /** * Register a subscription for push notifications * diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 7ef6edabb..a160dd69b 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -11,7 +11,6 @@ use OCA\Notifications\AppInfo\Application; use OCA\Notifications\Vendor\Base64Url\Base64Url; -use OCA\Notifications\Vendor\Minishlink\WebPush\MessageSentReport; use OCA\Notifications\Vendor\Minishlink\WebPush\Subscription; use OCA\Notifications\Vendor\Minishlink\WebPush\Utils; use OCA\Notifications\Vendor\Minishlink\WebPush\VAPID; @@ -65,7 +64,7 @@ private function getClient(): WebPush { * @psalm-return array{publicKey: string, privateKey: string} */ private function getVapid(): array { - if (isset($this->vapid)) { + if (isset($this->vapid) && array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid)) { return $this->vapid; } $publicKey = $this->appConfig->getValueString( @@ -79,19 +78,18 @@ private function getVapid(): array { lazy: true ); if ($publicKey === '' || $privateKey === '') { - $vapid = VAPID::createVapidKeys(); - $this->vapid = $vapid; + $this->vapid = VAPID::createVapidKeys(); $this->appConfig->setValueString( Application::APP_ID, 'webpush_vapid_pubkey', - $vapid['publicKey'], + $this->vapid['publicKey'], lazy: true, sensitive: true ); $this->appConfig->setValueString( Application::APP_ID, 'webpush_vapid_privkey', - $vapid['privateKey'], + $this->vapid['privateKey'], lazy: true, sensitive: true ); @@ -108,7 +106,7 @@ private function getVapid(): array { * @return string */ public function getVapidPublicKey(): string { - $this->getVapid()['publicKey']; + return $this->getVapid()['publicKey']; } /** From 4a692140c76abe1dff7a271af58e04840bafeb16 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 24 Nov 2025 15:24:47 +0100 Subject: [PATCH 17/37] feat(webpush): Add webpush capability Signed-off-by: sim --- lib/Capabilities.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 2c440ff13..35a8d3ce8 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -46,6 +46,7 @@ public function getCapabilities(): array { 'test-push', ], 'push' => [ + 'webpush', 'devices', 'object-data', 'delete', From 676fb5cec4466b0c0c1c2c9c85fdfae488459f73 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 24 Nov 2025 17:18:17 +0100 Subject: [PATCH 18/37] feat(webpush): Fix apptypes Signed-off-by: sim --- lib/Push.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index 7bb35d85f..7bd483d93 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -67,7 +67,7 @@ class Push { */ protected array $userStatuses = []; /** - * @psalm-var array> + * @psalm-var array> */ protected array $userWebPushDevices = []; /** @@ -185,10 +185,10 @@ public function flushPayloads(): void { /** * @param array $devices - * @psalm-param $devices list + * @psalm-param $devices list * @param string $app * @return array - * @psalm-return list + * @psalm-return list */ public function filterWebPushDeviceList(array $devices, string $app): array { // Consider all 3 options as 'talk' @@ -197,9 +197,9 @@ public function filterWebPushDeviceList(array $devices, string $app): array { } return array_filter($devices, function($device) { - $appTypes = explode(',', $device['apptypes']); - return $device['activated'] && (\in_array($app, $appTypes) || - (\in_array("all", $appTypes) && !\in_array('-'.$app, $appTypes))); + $apptypes = explode(',', $device['apptypes']); + return $device['activated'] && (\in_array($app, $apptypes) || + (\in_array("all", $apptypes) && !\in_array('-'.$app, $apptypes))); }); } @@ -1020,7 +1020,7 @@ protected function getProxyDevicesForUsers(array $userIds): array { /** * @param string $uid * @return array[] - * @psalm-return list + * @psalm-return list */ protected function getWebPushDevicesForUser(string $uid): array { $query = $this->db->getQueryBuilder(); @@ -1038,7 +1038,7 @@ protected function getWebPushDevicesForUser(string $uid): array { /** * @param string[] $userIds * @return array[] - * @psalm-return array> + * @psalm-return array> */ protected function getWebPushDevicesForUsers(array $userIds): array { $query = $this->db->getQueryBuilder(); From 0c0f2b2b0575086c7ddc69aaf6ed2b3e3e54a21c Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 25 Nov 2025 15:59:00 +0100 Subject: [PATCH 19/37] feat(webpush): Add tests for webpush Signed-off-by: sim --- lib/Push.php | 19 +- lib/WebPushClient.php | 1 + tests/Unit/CapabilitiesTest.php | 1 + tests/Unit/PushTest.php | 486 ++++++++++++++++++++++++++++++++ 4 files changed, 500 insertions(+), 7 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index 7bd483d93..5d766e255 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -100,6 +100,10 @@ public function __construct( $this->wpClient = new WebPushClient($appConfig); } + protected function getWpClient(): WebPushClient { + return $this->wpClient; + } + public function setOutput(OutputInterface $output): void { $this->output = $output; } @@ -179,7 +183,7 @@ public function flushPayloads(): void { } $this->deferPayloads = false; - $this->wpClient->flush(fn ($r) => $this->webPushCallback($r)); + $this->getWpClient()->flush(fn ($r) => $this->webPushCallback($r)); $this->sendNotificationsToProxies(); } @@ -196,7 +200,7 @@ public function filterWebPushDeviceList(array $devices, string $app): array { $app = "talk"; } - return array_filter($devices, function($device) { + return array_filter($devices, function($device) use ($app) { $apptypes = explode(',', $device['apptypes']); return $device['activated'] && (\in_array($app, $apptypes) || (\in_array("all", $apptypes) && !\in_array('-'.$app, $apptypes))); @@ -350,7 +354,7 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat try { $data = $this->encodeNotif($id, $notification, 3000); $urgency = $this->getNotifTopicAndUrgency($data['app'], $data['type'])['urgency']; - $this->wpClient->enqueue( + $this->getWpClient()->enqueue( $device['endpoint'], $device['p256dh'], $device['auth'], @@ -359,6 +363,8 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat ); } catch (\JsonException $e) { $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); + } catch (\ErrorException $e) { + $this->log->error('Error while sending push notification: ' . $e->getMessage(), ['exception' => $e]); } catch (\InvalidArgumentException) { // Failed to encrypt message for device: public key is invalid $this->deleteWebPushToken($device['token']); @@ -367,7 +373,7 @@ public function webPushToDevice(int $id, IUser $user, array $devices, INotificat $this->printInfo(''); if (!$this->deferPayloads) { - $this->wpClient->flush(fn ($r) => $this->webPushCallback($r)); + $this->getWpClient()->flush(fn ($r) => $this->webPushCallback($r)); } } @@ -510,7 +516,6 @@ public function webPushDeleteToDevice(string $userId, IUser $user, array $device // We don't push to devices that are older than 60 days $maxAge = time() - 60 * 24 * 60 * 60; - $userKey = $this->keyManager->getKey($user); foreach ($devices as $device) { $device['token'] = (int)$device['token']; if (!$this->validateToken($device['token'], $maxAge)) { @@ -534,7 +539,7 @@ public function webPushDeleteToDevice(string $userId, IUser $user, array $device $data = $this->encodeDeleteNotifs(null); try { $payload = json_encode($data['data'], JSON_THROW_ON_ERROR); - $this->wpClient->enqueue($device['endpoint'], $device['p256dh'], $device['auth'], $payload); + $this->getWpClient()->enqueue($device['endpoint'], $device['p256dh'], $device['auth'], $payload); } catch (\JsonException $e) { $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); } @@ -546,7 +551,7 @@ public function webPushDeleteToDevice(string $userId, IUser $user, array $device $temp = $data['remaining']; try { $payload = json_encode($data['data'], JSON_THROW_ON_ERROR); - $this->wpClient->enqueue($device['endpoint'], $device['p256dh'], $device['auth'], $payload); + $this->getWpClient()->enqueue($device['endpoint'], $device['p256dh'], $device['auth'], $payload); } catch (\JsonException $e) { $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); } diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index a160dd69b..5179bea29 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -127,6 +127,7 @@ public function notify(string $endpoint, string $uaPublicKey, string $auth, stri /** * Send one notification - blocking (should be avoided most of the time) + * @throws ErrorException */ public function enqueue(string $endpoint, string $uaPublicKey, string $auth, string $body, string $urgency = 'normal'): void { $c = $this->getClient(); diff --git a/tests/Unit/CapabilitiesTest.php b/tests/Unit/CapabilitiesTest.php index ca31155af..cebe2677b 100644 --- a/tests/Unit/CapabilitiesTest.php +++ b/tests/Unit/CapabilitiesTest.php @@ -31,6 +31,7 @@ public function testGetCapabilities(): void { 'test-push', ], 'push' => [ + 'webpush', 'devices', 'object-data', 'delete', diff --git a/tests/Unit/PushTest.php b/tests/Unit/PushTest.php index 1d61adee6..67e903110 100644 --- a/tests/Unit/PushTest.php +++ b/tests/Unit/PushTest.php @@ -10,6 +10,7 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ServerException; +use OCA\Notifications\WebPushClient; use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\PublicKeyToken; @@ -791,6 +792,491 @@ public function testProxyPushToDeviceTalkNotification(array $deviceTypes, bool $ $push->pushToDevice(200718, $notification); } + public function testWebPushToDeviceNoDevices(): void { + $push = $this->getPush(['createFakeUserObject', 'getWpClient', 'getWebPushDevicesForUser']); + $push->expects($this->never()) + ->method('getWpClient'); + + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('has_internet_connection', true) + ->willReturn(true); + + /** @var INotification&MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification + ->method('getUser') + ->willReturn('valid'); + $notification + ->expects($this->any()) + ->method('getApp') + ->willReturn('someApp'); + + /** @var IUser&MockObject $user */ + $user = $this->createMock(IUser::class); + + $push->expects($this->once()) + ->method('createFakeUserObject') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([]); + + $push->pushToDevice(42, $notification); + } + + public function testWebPushToDeviceNotPrepared(): void { + $push = $this->getPush(['createFakeUserObject', 'getWpClient', 'getWebPushDevicesForUser']); + $push->expects($this->never()) + ->method('getWpClient'); + + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('has_internet_connection', true) + ->willReturn(true); + + /** @var INotification&MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification + ->method('getUser') + ->willReturn('valid'); + $notification + ->expects($this->any()) + ->method('getApp') + ->willReturn('someApp'); + + /** @var IUser&MockObject $user */ + $user = $this->createMock(IUser::class); + + $push->expects($this->once()) + ->method('createFakeUserObject') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([[ + 'activated' => true, + 'endpoint' => 'endpoint1', + 'p256dh' => 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4', + 'auth' => 'BTBZMqHH6r4Tts7J_aSIgg', + 'token' => 'token1', + ]]); + + $this->l10nFactory + ->method('getUserLanguage') + ->with($user) + ->willReturn('de'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'de') + ->willThrowException(new \InvalidArgumentException()); + + $push->pushToDevice(1337, $notification); + } + + public function testWebPushToDeviceInvalidToken(): void { + $push = $this->getPush(['createFakeUserObject', 'getWpClient', 'getWebPushDevicesForUser', 'encodeNotif', 'deleteWebPushToken']); + // Called once to flush + $push->expects($this->once()) + ->method('getWpClient'); + + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('has_internet_connection', true) + ->willReturn(true); + + /** @var INotification&MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification + ->method('getUser') + ->willReturn('valid'); + $notification + ->expects($this->any()) + ->method('getApp') + ->willReturn('someApp'); + + /** @var IUser&MockObject $user */ + $user = $this->createMock(IUser::class); + + $push->expects($this->once()) + ->method('createFakeUserObject') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([[ + 'activated' => true, + 'endpoint' => 'endpoint1', + 'p256dh' => 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4', + 'auth' => 'BTBZMqHH6r4Tts7J_aSIgg', + 'token' => 23, + 'apptypes' => 'all', + ]]); + + $this->l10nFactory + ->method('getUserLanguage') + ->with($user) + ->willReturn('ru'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'ru') + ->willReturnArgument(0); + + $this->tokenProvider->expects($this->once()) + ->method('getTokenById') + ->willThrowException(new InvalidTokenException()); + + $push->expects($this->never()) + ->method('encodeNotif'); + + $push->expects($this->once()) + ->method('deleteWebPushToken') + ->with(23); + + $push->pushToDevice(2018, $notification); + } + + public function testWebPushToDeviceEncryptionError(): void { + $push = $this->getPush(['createFakeUserObject', 'getWpClient', 'getWebPushDevicesForUser', 'deleteWebPushToken', 'validateToken']); + + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('has_internet_connection', true) + ->willReturn(true); + + /** @var INotification&MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification + ->method('getUser') + ->willReturn('valid'); + $notification + ->expects($this->any()) + ->method('getApp') + ->willReturn('someApp'); + + /** @var IUser&MockObject $user */ + $user = $this->createMock(IUser::class); + + $push->expects($this->once()) + ->method('createFakeUserObject') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([[ + 'activated' => true, + 'endpoint' => 'endpoint1', + 'p256dh' => 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4', + 'auth' => 'BTBZMqHH6r4Tts7J_aSIgg', + 'token' => 23, + 'apptypes' => 'all', + ]]); + + $this->l10nFactory + ->method('getUserLanguage') + ->with($user) + ->willReturn('ru'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'ru') + ->willReturnArgument(0); + + $push->expects($this->once()) + ->method('validateToken') + ->willReturn(true); + + $wpClient = $this->createMock(WebPushClient::class); + $wpClient->method('enqueue') + ->willThrowException(new \InvalidArgumentException()); + + $push->expects($this->exactly(2)) + ->method('getWpClient') + ->willReturn($wpClient); + + $push->expects($this->once()) + ->method('deleteWebPushToken') + ->with(23); + + $push->pushToDevice(1970, $notification); + } + + public static function dataWebPushToDeviceSending(): array { + return [ + [true], + [false], + ]; + } + + /** + * @dataProvider dataWebPushToDeviceSending + */ + public function testWebPushToDeviceSending(bool $isRateLimited): void { + $push = $this->getPush(['createFakeUserObject', 'getWpClient', 'getWebPushDevicesForUser', 'encodeNotif', 'deleteWebPushToken', 'validateToken']); + + /** @var INotification&MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification + ->method('getUser') + ->willReturn('valid'); + $notification + ->expects($this->any()) + ->method('getApp') + ->willReturn('someApp'); + + /** @var IUser&MockObject $user */ + $user = $this->createMock(IUser::class); + + $push->expects($this->once()) + ->method('createFakeUserObject') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([ + [ + 'activated' => true, + 'endpoint' => 'endpoint1', + 'p256dh' => 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4', + 'auth' => 'BTBZMqHH6r4Tts7J_aSIgg', + 'token' => 16, + 'apptypes' => 'all', + ], + [ + 'activated' => true, + 'endpoint' => 'endpoint2', + 'p256dh' => 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4', + 'auth' => 'BTBZMqHH6r4Tts7J_aSIgg', + 'token' => 23, + 'apptypes' => 'all', + ] + ]); + + $this->l10nFactory + ->expects($this->once()) + ->method('getUserLanguage') + ->with($user) + ->willReturn('ru'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'ru') + ->willReturnArgument(0); + + $push->expects($this->exactly(2)) + ->method('validateToken') + ->willReturn(true); + + $push->expects($this->exactly($isRateLimited ? 1 : 2)) + ->method('encodeNotif') + ->willReturn([ + 'nid' => 1, + 'app' => 'someApp', + 'subject' => 'test', + 'type' => 'someType', + 'id' => 'someId' + ]); + + $push->expects($this->never()) + ->method('deleteWebPushToken'); + + /** @var WebPushClient&MockObject $client */ + $wpClient = $this->createMock(WebPushClient::class); + + $push->expects($this->exactly($isRateLimited ? 2 : 3)) + ->method('getWpClient') + ->willReturn($wpClient); + + $wpClient->expects($this->exactly($isRateLimited ? 1 : 2)) + ->method('enqueue'); + + if ($isRateLimited) { + $this->cache + ->expects($this->exactly(2)) + ->method('get') + ->willReturn(true, false); + } + + $wpClient->expects($this->once()) + ->method('flush'); + + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('has_internet_connection', true) + ->willReturn(true); + + $push->pushToDevice(207787, $notification); + } + + public static function dataFilterWebPushDeviceList(): array { + return [ + [false, 'all', 'myApp', false], + [true, 'all', 'myApp', true], + [true, 'all,-myApp', 'myApp', false], + [true, '-myApp,all', 'myApp', false], + [true, 'all,-other', 'myApp', true], + [true, 'all,-talk', 'spreed', false], + [true, 'all,-talk', 'talk', false], + [true, 'talk', 'spreed', true], + [true, 'talk', 'admin_notification_talk', true], + ]; + } + + /** + * @dataProvider dataFilterWebPushDeviceList + * @param string[] $deviceTypes + */ + public function testFilterWebPushDeviceList(bool $activated, string $deviceApptypes, string $app, bool $pass): void { + $push = $this->getPush([]); + $devices = [[ + 'activated' => $activated, + 'apptypes' => $deviceApptypes, + ]]; + if ($pass) { + $result = $devices; + } else { + $result = []; + } + $this->assertEquals($result, $push->filterWebPushDeviceList($devices, $app)); + } + /** + * @return array + * @psalm-return list> + * listgetPush(['createFakeUserObject', 'getWpClient', 'getWebPushDevicesForUser', 'encodeNotif', 'deleteWebPushToken', 'validateToken']); + + /** @var INotification&MockObject $notification */ + $notification = $this->createMock(INotification::class); + $notification + ->method('getUser') + ->willReturn('valid'); + $notification + ->method('getApp') + ->willReturn($notificationApp); + + /** @var IUser&MockObject $user */ + $user = $this->createMock(IUser::class); + + $push->expects($this->once()) + ->method('createFakeUserObject') + ->with('valid') + ->willReturn($user); + + $devices = []; + foreach ($deviceTypes as $deviceType) { + $devices[] = [ + 'activated' => true, + 'endpoint' => 'endpoint', + 'p256dh' => 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4', + 'auth' => 'BTBZMqHH6r4Tts7J_aSIgg', + 'token' => strlen($deviceType), + 'apptypes' => $deviceType, + ]; + } + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn($devices); + + $this->l10nFactory + ->method('getUserLanguage') + ->with($user) + ->willReturn('ru'); + + $this->notificationManager->expects($this->once()) + ->method('prepare') + ->with($notification, 'ru') + ->willReturnArgument(0); + + if ($pushedDevice === null) { + $push->expects($this->never()) + ->method('validateToken'); + + $push->expects($this->never()) + ->method('encodeNotif'); + + $push->expects($this->never()) + ->method('getWpClient'); + } else { + $push->expects($this->exactly(1)) + ->method('validateToken') + ->willReturn(true); + + $push->expects($this->exactly(1)) + ->method('encodeNotif') + ->willReturn([ + 'nid' => 1, + 'app' => $notificationApp, + 'subject' => 'test', + 'type' => 'someType', + 'id' => 'someId' + ]); + + /** @var WebPushClient&MockObject $client */ + $wpClient = $this->createMock(WebPushClient::class); + + $push->expects($this->exactly(2)) + ->method('getWpClient') + ->willReturn($wpClient); + + $wpClient->expects($this->once()) + ->method('enqueue') + ->with( + 'endpoint', + 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4', + 'BTBZMqHH6r4Tts7J_aSIgg', + $this->anything(), + $this->anything() + ); + } + + $this->config->expects($this->once()) + ->method('getSystemValueBool') + ->with('has_internet_connection', true) + ->willReturn(true); + + $push->pushToDevice(200718, $notification); + } + public static function dataValidateToken(): array { return [ [1239999999, 1230000000, OCPIToken::WIPE_TOKEN, false], From 0aa109cede4377e993cceb75dae2b934b8e3867d Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 26 Nov 2025 17:55:18 +0100 Subject: [PATCH 20/37] feat(webpush): Get apptypes as a string Signed-off-by: sim --- lib/Controller/WebPushController.php | 31 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 9c7255bcc..fd9e567a0 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -77,7 +77,7 @@ public function getVapid(string $apiVersion): DataResponse { * @param string $endpoint Push Server URL (RFC8030) * @param string $uaPublicKey Public key of the device, uncompress base64url encoded (RFC8291) * @param string $auth Authentication tag, base64url encoded (RFC8291) - * @param list $appTypes used to filter incoming notifications - appTypes are alphanum - use "all" to get all notifications, prefix with `-` to exclude (eg. ['all', '-talk']) + * @param string $apptypes comma seperated list of types used to filter incoming notifications - apptypes are alphanum - use "all" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk') * @return DataResponse, array{}>|DataResponse * * 200: A subscription was already registered and activated @@ -87,7 +87,7 @@ public function getVapid(string $apiVersion): DataResponse { */ #[NoAdminRequired] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/webpush', requirements: ['apiVersion' => '(v2)'])] - public function registerWP(string $endpoint, string $uaPublicKey, string $auth, array $appTypes): DataResponse { + public function registerWP(string $endpoint, string $uaPublicKey, string $auth, string $apptypes): DataResponse { $user = $this->userSession->getUser(); if (!$user instanceof IUser) { return new DataResponse([], Http::STATUS_UNAUTHORIZED); @@ -109,8 +109,7 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, return new DataResponse(['message' => 'INVALID_ENDPOINT'], Http::STATUS_BAD_REQUEST); } - $appTypesStr = join(',', $appTypes); - if (strlen($appTypesStr) > 256) { + if (strlen($apptypes) > 256) { return new DataResponse(['message' => 'TOO_MANY_APP_TYPES'], Http::STATUS_BAD_REQUEST); } @@ -124,7 +123,7 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); } - [$status, $activationToken] = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes); + [$status, $activationToken] = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $apptypes); if ($status === NewSubStatus::CREATED) { $wp = $this->getWPClient(); @@ -213,12 +212,12 @@ protected function getWPClient(): WebPushClient { } /** - * @param list $appTypes + * @param string $apptypes comma separated list of types * @return array{0: NewSubStatus, 1: ?string}: * - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth - * - UPDATED if the subscription has been updated (use to change appTypes) + * - UPDATED if the subscription has been updated (use to change apptypes) */ - protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): array { + protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $apptypes): array { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_webpush') @@ -236,14 +235,14 @@ protected function saveSubscription(IUser $user, IToken $token, string $endpoint // In case the user has already a subscription, but inactive or with a different enpoint, pubkey or auth secret $this->deleteSubscription($user, $token); $activationToken = Uuid::v4()->toRfc4122(); - if ($this->insertSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $activationToken, $appTypes)) { + if ($this->insertSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $activationToken, $apptypes)) { return [NewSubStatus::CREATED, $activationToken]; } else { return [NewSubStatus::ERROR, null]; } } - if ($this->updateSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes)) { + if ($this->updateSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $apptypes)) { return [NewSubStatus::UPDATED, null]; } else { return [NewSubStatus::ERROR, null]; @@ -287,10 +286,10 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti } /** - * @param list $appTypes + * @param string $apptypes comma separated list of types * @return bool If the entry was created */ - protected function insertSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, array $appTypes): bool { + protected function insertSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, string $apptypes): bool { $query = $this->db->getQueryBuilder(); $query->insert('notifications_webpush') ->values([ @@ -299,23 +298,23 @@ protected function insertSubscription(IUser $user, IToken $token, string $endpoi 'endpoint' => $query->createNamedParameter($endpoint), 'p256dh' => $query->createNamedParameter($uaPublicKey), 'auth' => $query->createNamedParameter($auth), - 'apptypes' => $query->createNamedParameter(join(',', $appTypes)), + 'apptypes' => $query->createNamedParameter($apptypes), 'activation_token' => $query->createNamedParameter($activationToken), ]); return $query->executeStatement() > 0; } /** - * @param list $appTypes + * @param string $apptypes comma separated list of types * @return bool If the entry was updated */ - protected function updateSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, array $appTypes): bool { + protected function updateSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $apptypes): bool { $query = $this->db->getQueryBuilder(); $query->update('notifications_webpush') ->set('endpoint', $query->createNamedParameter($endpoint)) ->set('p256dh', $query->createNamedParameter($uaPublicKey)) ->set('auth', $query->createNamedParameter($auth)) - ->set('apptypes', $query->createNamedParameter(join(',', $appTypes))) + ->set('apptypes', $query->createNamedParameter($apptypes)) ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); From e0c6bb09433e3337dfb01845f62ec59daa977e66 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 27 Nov 2025 21:54:55 +0100 Subject: [PATCH 21/37] feat(webpush): Fix tests for apptypes as string Signed-off-by: sim --- .../Unit/Controller/WebPushControllerTest.php | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php index 33139f0ee..a7af1c15b 100644 --- a/tests/Unit/Controller/WebPushControllerTest.php +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -89,7 +89,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', '', '', - ['all'], + 'all', false, 0, false, @@ -101,7 +101,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV', self::$auth, - ['all'], + 'all', true, 0, false, @@ -113,7 +113,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4bb', self::$auth, - ['all'], + 'all', true, 0, false, @@ -125,7 +125,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV- JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw', self::$auth, - ['all'], + 'all', true, 0, false, @@ -137,7 +137,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, 'BTBZMqHH6r4Tts7J_aSI', - ['all'], + 'all', true, 0, false, @@ -149,7 +149,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, 'BTBZMqHH6r4Tts7J_aSIggxx', - ['all'], + 'all', true, 0, false, @@ -161,7 +161,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, 'BTBZM HH6r4Tts7J_aSIgg', - ['all'], + 'all', true, 0, false, @@ -173,7 +173,7 @@ public static function dataRegisterWP(): array { 'http://localhost/', self::$uaPublicKey, self::$auth, - ['all'], + 'all', true, 0, false, @@ -181,14 +181,11 @@ public static function dataRegisterWP(): array { ['message' => 'INVALID_ENDPOINT'], Http::STATUS_BAD_REQUEST, ], - 'too many appTypes' => [ + 'too many apptypes' => [ 'https://localhost/', self::$uaPublicKey, self::$auth, - [ - 'all', - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - ], + 'all,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', true, 0, false, @@ -200,7 +197,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, self::$auth, - ['all'], + 'all', true, 23, false, @@ -212,7 +209,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, self::$auth, - ['all'], + 'all', true, 23, true, @@ -224,7 +221,7 @@ public static function dataRegisterWP(): array { 'https://localhost/', self::$uaPublicKey, self::$auth, - ['all'], + 'all', true, 23, true, @@ -238,7 +235,7 @@ public static function dataRegisterWP(): array { /** * @dataProvider dataRegisterWP */ - public function testRegisterWP(string $endpoint, string $uaPublicKey, string $auth, array $appTypes, bool $userIsValid, int $tokenId, bool $tokenIsValid, int $subStatus, array $payload, int $status): void { + public function testRegisterWP(string $endpoint, string $uaPublicKey, string $auth, string $apptypes, bool $userIsValid, int $tokenId, bool $tokenIsValid, int $subStatus, array $payload, int $status): void { $controller = $this->getController([ 'saveSubscription', 'getWPClient' @@ -288,7 +285,7 @@ public function testRegisterWP(string $endpoint, string $uaPublicKey, string $au ->willThrowException(new InvalidTokenException()); } - $response = $controller->registerWP($endpoint, $uaPublicKey, $auth, $appTypes); + $response = $controller->registerWP($endpoint, $uaPublicKey, $auth, $apptypes); $this->assertInstanceOf(DataResponse::class, $response); $this->assertSame($status, $response->getStatus()); $this->assertSame($payload, $response->getData()); From aec0a1b62f888b469836f908167094d57afb2551 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 27 Nov 2025 21:59:05 +0100 Subject: [PATCH 22/37] feat(webpush): Include WebPushController in ApplicationTest Signed-off-by: sim --- tests/Unit/AppInfo/ApplicationTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php index d2f67a1fb..f8ad1d852 100644 --- a/tests/Unit/AppInfo/ApplicationTest.php +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -14,6 +14,7 @@ use OCA\Notifications\Capabilities; use OCA\Notifications\Controller\EndpointController; use OCA\Notifications\Controller\PushController; +use OCA\Notifications\Controller\WebPushController; use OCA\Notifications\Handler; use OCA\Notifications\Push; use OCP\AppFramework\OCSController; @@ -47,6 +48,7 @@ public static function dataContainerQuery(): array { // Controller/ [EndpointController::class, OCSController::class], [PushController::class, OCSController::class], + [WebPushController::class, OCSController::class], ]; } From 424adbcecd0462bcafdff9692389895ea6b16f18 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 28 Nov 2025 08:47:20 +0100 Subject: [PATCH 23/37] feat(webpush): Allow multiple delete with webpush Signed-off-by: sim --- lib/Push.php | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index 5d766e255..f44212a63 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -169,14 +169,8 @@ public function flushPayloads(): void { if (!empty($this->deletesToPush)) { foreach ($this->deletesToPush as $userId => $data) { - foreach ($data as $client => $notificationIds) { - if ($client === 'talk') { - $this->pushDeleteToDevice((string)$userId, $notificationIds, $client); - } else { - foreach ($notificationIds as $notificationId) { - $this->pushDeleteToDevice((string)$userId, [$notificationId], $client); - } - } + foreach ($data as $app => $notificationIds) { + $this->pushDeleteToDevice((string)$userId, $notificationIds, $app); } } $this->deletesToPush = []; @@ -457,7 +451,7 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri } $isTalkNotification = \in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true); - $clientGroup = $isTalkNotification ? 'talk' : 'files'; + $clientGroup = $isTalkNotification ? 'talk' : $app; if (!isset($this->deletesToPush[$userId])) { $this->deletesToPush[$userId] = []; @@ -606,15 +600,27 @@ public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devi $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); } } else { - $temp = $notificationIds; - - while (!empty($temp)) { - $data = $this->encryptAndSignDelete($userKey, $device, $temp); - $temp = $data['remaining']; - try { - $this->payloadsToSend[$proxyServer][] = json_encode($data['payload'], JSON_THROW_ON_ERROR); - } catch (\JsonException $e) { - $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); + // The nextcloud application, requested with the proxy push, + // use to not support `delete-multiple` + if (!\in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true)) { + foreach ($notificationIds as $notificationId) { + $data = $this->encryptAndSignDelete($userKey, $device, [$notificationId]); + try { + $this->payloadsToSend[$proxyServer][] = json_encode($data['payload'], JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); + } + } + } else { + $temp = $notificationIds; + while (!empty($temp)) { + $data = $this->encryptAndSignDelete($userKey, $device, $temp); + $temp = $data['remaining']; + try { + $this->payloadsToSend[$proxyServer][] = json_encode($data['payload'], JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); + } } } } From 6363888c46c96e73ff6d576ad39f64756f4764c9 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 2 Dec 2025 09:07:23 +0100 Subject: [PATCH 24/37] feat(webpush): Lint Signed-off-by: sim --- lib/Controller/WebPushController.php | 10 +- .../Version6000Date20251112110000.php | 1 - lib/Push.php | 15 +- lib/WebPushClient.php | 239 +++++++++--------- .../Unit/Controller/WebPushControllerTest.php | 9 +- tests/Unit/PushTest.php | 4 +- 6 files changed, 138 insertions(+), 140 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index fd9e567a0..31e33cabc 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -9,9 +9,9 @@ namespace OCA\Notifications\Controller; -use OCA\Notifications\WebPushClient; use OC\Authentication\Token\IProvider; use OC\Security\IdentityProof\Manager; +use OCA\Notifications\WebPushClient; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoAdminRequired; @@ -74,9 +74,9 @@ public function getVapid(string $apiVersion): DataResponse { /** * Register a subscription for push notifications * - * @param string $endpoint Push Server URL (RFC8030) - * @param string $uaPublicKey Public key of the device, uncompress base64url encoded (RFC8291) - * @param string $auth Authentication tag, base64url encoded (RFC8291) + * @param string $endpoint Push Server URL (RFC8030) + * @param string $uaPublicKey Public key of the device, uncompress base64url encoded (RFC8291) + * @param string $auth Authentication tag, base64url encoded (RFC8291) * @param string $apptypes comma seperated list of types used to filter incoming notifications - apptypes are alphanum - use "all" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk') * @return DataResponse, array{}>|DataResponse * @@ -141,7 +141,7 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, /** * Activate subscription for push notifications * - * @param string $activationToken Random token sent via a push notification during registration to enable the subscription + * @param string $activationToken Random token sent via a push notification during registration to enable the subscription * @return DataResponse, array{}>|DataResponse * * 200: Subscription was already activated diff --git a/lib/Migration/Version6000Date20251112110000.php b/lib/Migration/Version6000Date20251112110000.php index 02a88e3fa..f429051c1 100644 --- a/lib/Migration/Version6000Date20251112110000.php +++ b/lib/Migration/Version6000Date20251112110000.php @@ -11,7 +11,6 @@ use Closure; use OCP\DB\ISchemaWrapper; -use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\DB\Types; use OCP\IDBConnection; use OCP\Migration\IOutput; diff --git a/lib/Push.php b/lib/Push.php index f44212a63..d3c3c034a 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -38,7 +38,6 @@ use OCP\UserStatus\IUserStatus; use OCP\Util; use Psr\Log\LoggerInterface; -use Psr\Http\Message\ResponseInterface; use Symfony\Component\Console\Output\OutputInterface; class Push { @@ -191,13 +190,13 @@ public function flushPayloads(): void { public function filterWebPushDeviceList(array $devices, string $app): array { // Consider all 3 options as 'talk' if (\in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true)) { - $app = "talk"; + $app = 'talk'; } - return array_filter($devices, function($device) use ($app) { + return array_filter($devices, function ($device) use ($app) { $apptypes = explode(',', $device['apptypes']); - return $device['activated'] && (\in_array($app, $apptypes) || - (\in_array("all", $apptypes) && !\in_array('-'.$app, $apptypes))); + return $device['activated'] && (\in_array($app, $apptypes) + || (\in_array('all', $apptypes) && !\in_array('-' . $app, $apptypes))); }); } @@ -638,10 +637,10 @@ public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devi /** * Delete expired web push subscriptions */ - protected function webPushCallback (MessageSentReport $report): void { + protected function webPushCallback(MessageSentReport $report): void { if ($report->isSubscriptionExpired()) { $this->deleteWebPushTokenByEndpoint($report->getEndpoint()); - } else if ($report->getResponse()?->getStatusCode() === 429) { + } elseif ($report->getResponse()?->getStatusCode() === 429) { $retryAfter = $report->getResponse()?->getHeader('Retry-After'); $this->cache->set('wp.' . $report->getEndpoint(), true, $retryAfter ?? 60); } @@ -847,7 +846,7 @@ protected function encodeNotif(int $id, INotification $notification, int $maxLen $data['subject'] .= '…'; } return $data; - } + } /** * @param ?int[] $ids diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 5179bea29..dc04ba20a 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -18,134 +18,135 @@ use OCP\IAppConfig; class WebPushClient { - private WebPush $client; - /** @psalm-var array{publicKey: string, privateKey: string} */ - private array $vapid; + private WebPush $client; + /** @psalm-var array{publicKey: string, privateKey: string} */ + private array $vapid; - public function __construct( - protected IAppConfig $appConfig, - ) {} + public function __construct( + protected IAppConfig $appConfig, + ) { + } - static public function isValidP256dh(string $key): bool { + public static function isValidP256dh(string $key): bool { if (!preg_match('/^[A-Za-z0-9_-]{87}=*$/', $key)) { - return false; - } - try { - Utils::unserializePublicKey(Base64Url::decode($key)); - } catch (\InvalidArgumentException $e) { - return false; - } - return true; - } + return false; + } + try { + Utils::unserializePublicKey(Base64Url::decode($key)); + } catch (\InvalidArgumentException $e) { + return false; + } + return true; + } - static public function isValidAuth(string $auth): bool { + public static function isValidAuth(string $auth): bool { if (!preg_match('/^[A-Za-z0-9_-]{22}=*$/', $auth)) { - return false; - } - try { - $a = Base64Url::decode($auth); - } catch (\InvalidArgumentException $e) { - return false; - } - return strlen($a) === 16; - } + return false; + } + try { + $a = Base64Url::decode($auth); + } catch (\InvalidArgumentException $e) { + return false; + } + return strlen($a) === 16; + } - private function getClient(): WebPush { - if (isset($this->client)) { - return $this->client; - } - $this->client = new WebPush(auth: $this->getVapid()); - $this->client->setReuseVAPIDHeaders(true); - return $this->client; - } + private function getClient(): WebPush { + if (isset($this->client)) { + return $this->client; + } + $this->client = new WebPush(auth: $this->getVapid()); + $this->client->setReuseVAPIDHeaders(true); + return $this->client; + } - /** - * @return array - * @psalm-return array{publicKey: string, privateKey: string} - */ - private function getVapid(): array { - if (isset($this->vapid) && array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid)) { - return $this->vapid; - } - $publicKey = $this->appConfig->getValueString( - Application::APP_ID, - 'webpush_vapid_pubkey', - lazy: true - ); - $privateKey = $this->appConfig->getValueString( - Application::APP_ID, - 'webpush_vapid_privkey', - lazy: true - ); - if ($publicKey === '' || $privateKey === '') { - $this->vapid = VAPID::createVapidKeys(); - $this->appConfig->setValueString( - Application::APP_ID, - 'webpush_vapid_pubkey', - $this->vapid['publicKey'], - lazy: true, - sensitive: true - ); - $this->appConfig->setValueString( - Application::APP_ID, - 'webpush_vapid_privkey', - $this->vapid['privateKey'], - lazy: true, - sensitive: true - ); - } else { - $this->vapid = [ - 'publicKey' => $publicKey, - 'privateKey' => $privateKey, - ]; - } - return $this->vapid; - } + /** + * @return array + * @psalm-return array{publicKey: string, privateKey: string} + */ + private function getVapid(): array { + if (isset($this->vapid) && array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid)) { + return $this->vapid; + } + $publicKey = $this->appConfig->getValueString( + Application::APP_ID, + 'webpush_vapid_pubkey', + lazy: true + ); + $privateKey = $this->appConfig->getValueString( + Application::APP_ID, + 'webpush_vapid_privkey', + lazy: true + ); + if ($publicKey === '' || $privateKey === '') { + $this->vapid = VAPID::createVapidKeys(); + $this->appConfig->setValueString( + Application::APP_ID, + 'webpush_vapid_pubkey', + $this->vapid['publicKey'], + lazy: true, + sensitive: true + ); + $this->appConfig->setValueString( + Application::APP_ID, + 'webpush_vapid_privkey', + $this->vapid['privateKey'], + lazy: true, + sensitive: true + ); + } else { + $this->vapid = [ + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + ]; + } + return $this->vapid; + } - /** - * @return string - */ - public function getVapidPublicKey(): string { - return $this->getVapid()['publicKey']; - } + /** + * @return string + */ + public function getVapidPublicKey(): string { + return $this->getVapid()['publicKey']; + } - /** - * Send one notification - blocking (should be avoided most of the time) - */ - public function notify(string $endpoint, string $uaPublicKey, string $auth, string $body): void { - $c = $this->getClient(); - $c->queueNotification( - new Subscription($endpoint, $uaPublicKey, $auth, "aes128gcm"), - $body - ); - // the callback could be defined by the caller - // For the moment, it is used during registration only - no need to catch 404 &co - // as the registration isn't activated - $callback = function($r) {}; - $c->flushPooled($callback); - } + /** + * Send one notification - blocking (should be avoided most of the time) + */ + public function notify(string $endpoint, string $uaPublicKey, string $auth, string $body): void { + $c = $this->getClient(); + $c->queueNotification( + new Subscription($endpoint, $uaPublicKey, $auth, 'aes128gcm'), + $body + ); + // the callback could be defined by the caller + // For the moment, it is used during registration only - no need to catch 404 &co + // as the registration isn't activated + $callback = function ($r) {}; + $c->flushPooled($callback); + } - /** - * Send one notification - blocking (should be avoided most of the time) - * @throws ErrorException - */ - public function enqueue(string $endpoint, string $uaPublicKey, string $auth, string $body, string $urgency = 'normal'): void { - $c = $this->getClient(); - $c->queueNotification( - new Subscription($endpoint, $uaPublicKey, $auth, "aes128gcm"), - $body, - options: [ - 'urgency' => $urgency - ] - ); - } + /** + * Send one notification - blocking (should be avoided most of the time) + * @throws ErrorException + */ + public function enqueue(string $endpoint, string $uaPublicKey, string $auth, string $body, string $urgency = 'normal'): void { + $c = $this->getClient(); + $c->queueNotification( + new Subscription($endpoint, $uaPublicKey, $auth, 'aes128gcm'), + $body, + options: [ + 'urgency' => $urgency + ] + ); + } - /** - * @param callable $callback - * @psalm-param $callback callable(MessageSentReport): void - */ - public function flush(callable $callback): void { - $c = $this->getClient(); - $c->flushPooled($callback); - } + /** + * @param callable $callback + * @psalm-param $callback callable(MessageSentReport): void + */ + public function flush(callable $callback): void { + $c = $this->getClient(); + $c->flushPooled($callback); + } } diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php index a7af1c15b..46a7de895 100644 --- a/tests/Unit/Controller/WebPushControllerTest.php +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -8,15 +8,14 @@ namespace OCA\Notifications\Tests\Unit\Controller; -use OCA\Notifications\WebPushClient; use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\IToken; -use OC\Security\IdentityProof\Key; use OC\Security\IdentityProof\Manager; -use OCA\Notifications\Controller\WebPushController; use OCA\Notifications\Controller\ActivationSubStatus; use OCA\Notifications\Controller\NewSubStatus; +use OCA\Notifications\Controller\WebPushController; +use OCA\Notifications\WebPushClient; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\IAppConfig; @@ -272,8 +271,8 @@ public function testRegisterWP(string $endpoint, string $uaPublicKey, string $au if ($subStatus === 0) { $wpClient = $this->createMock(WebPushClient::class); $controller->expects($this->once()) - ->method('getWPClient') - ->willReturn($wpClient); + ->method('getWPClient') + ->willReturn($wpClient); } } else { $controller->expects($this->never()) diff --git a/tests/Unit/PushTest.php b/tests/Unit/PushTest.php index 67e903110..9fcdf64b1 100644 --- a/tests/Unit/PushTest.php +++ b/tests/Unit/PushTest.php @@ -10,22 +10,22 @@ use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ServerException; -use OCA\Notifications\WebPushClient; use OC\Authentication\Exceptions\InvalidTokenException; use OC\Authentication\Token\IProvider; use OC\Authentication\Token\PublicKeyToken; use OC\Security\IdentityProof\Key; use OC\Security\IdentityProof\Manager; use OCA\Notifications\Push; +use OCA\Notifications\WebPushClient; use OCP\AppFramework\Http; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Authentication\Token\IToken as OCPIToken; use OCP\Http\Client\IClient; use OCP\Http\Client\IClientService; use OCP\Http\Client\IResponse; +use OCP\IAppConfig; use OCP\ICache; use OCP\ICacheFactory; -use OCP\IAppConfig; use OCP\IConfig; use OCP\IDBConnection; use OCP\IUser; From 1182172f5b84dca83e6bddf75750863a76b17253 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 2 Dec 2025 11:11:38 +0100 Subject: [PATCH 25/37] feat(webpush): Add composer lock Signed-off-by: sim --- composer.lock | 1087 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 1036 insertions(+), 51 deletions(-) diff --git a/composer.lock b/composer.lock index b738bb24e..a9e93366c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,1041 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "91c87f0002114e43633da311f32ad333", - "packages": [], + "content-hash": "1a1247194c75469d2716cd7c151bab74", + "packages": [ + { + "name": "brick/math", + "version": "0.14.1", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.1" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-11-24T14:40:29+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "minishlink/web-push", + "version": "v9.0.3", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "5c185f78ee41f271e2ea7314c80760040465b713" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/5c185f78ee41f271e2ea7314c80760040465b713", + "reference": "5c185f78ee41f271e2ea7314c80760040465b713", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.4.5", + "php": ">=8.1", + "spomky-labs/base64url": "^2.0.4", + "web-token/jwt-library": "^3.3.0|^4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.68.5", + "phpstan/phpstan": "^2.1.2", + "phpunit/phpunit": "^10.5.44|^11.5.6" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.3" + }, + "time": "2025-11-13T17:14:30+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "reference": "bf6f55a9d9eb25b7781640221cb54f5c727850d7", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-10-22T08:24:34+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.1.2", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "621ff3ec618c6a34f63d47e467cefe8788871d6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/621ff3ec618c6a34f63d47e467cefe8788871d6f", + "reference": "621ff3ec618c6a34f63d47e467cefe8788871d6f", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.2" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-11-17T21:14:49+00:00" + } + ], "packages-dev": [ { "name": "bamarni/composer-bin-plugin", @@ -113,54 +1146,6 @@ }, "time": "2025-12-09T00:53:48+00:00" }, - { - "name": "psr/clock", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Clock\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", - "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" - ], - "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" - }, - "time": "2022-11-25T14:36:26+00:00" - }, { "name": "psr/container", "version": "2.0.2", @@ -327,5 +1312,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } From 0027d1bebe1addd0691cb4ac347a55a246af785e Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 3 Dec 2025 14:02:54 +0100 Subject: [PATCH 26/37] feat(webpush): Fix OpenAPI Signed-off-by: sim --- lib/Controller/WebPushController.php | 4 +- openapi-full.json | 768 +++++++++++++++++++++++++++ openapi-push.json | 768 +++++++++++++++++++++++++++ 3 files changed, 1538 insertions(+), 2 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 31e33cabc..456159e44 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -67,7 +67,7 @@ public function __construct( */ #[NoAdminRequired] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/webpush/vapid', requirements: ['apiVersion' => '(v2)'])] - public function getVapid(string $apiVersion): DataResponse { + public function getVapid(): DataResponse { return new DataResponse(['vapid' => $this->getWPClient()->getVapidPublicKey()], Http::STATUS_OK); } @@ -142,7 +142,7 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, * Activate subscription for push notifications * * @param string $activationToken Random token sent via a push notification during registration to enable the subscription - * @return DataResponse, array{}>|DataResponse + * @return DataResponse, array{}>|DataResponse * * 200: Subscription was already activated * 202: Subscription activated successfully diff --git a/openapi-full.json b/openapi-full.json index 6e4379483..42ad3f1b5 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1495,6 +1495,774 @@ } } }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/vapid": { + "get": { + "operationId": "web_push-get-vapid", + "summary": "Return the server VAPID public key", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "The VAPID key", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "vapid" + ], + "properties": { + "vapid": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush": { + "post": { + "operationId": "web_push-registerwp", + "summary": "Register a subscription for push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "endpoint", + "uaPublicKey", + "auth", + "apptypes" + ], + "properties": { + "endpoint": { + "type": "string", + "description": "Push Server URL (RFC8030)" + }, + "uaPublicKey": { + "type": "string", + "description": "Public key of the device, uncompress base64url encoded (RFC8291)" + }, + "auth": { + "type": "string", + "description": "Authentication tag, base64url encoded (RFC8291)" + }, + "apptypes": { + "type": "string", + "description": "comma seperated list of types used to filter incoming notifications - apptypes are alphanum - use \"all\" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk')" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "A subscription was already registered and activated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "201": { + "description": "New subscription registered successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to register", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Registering is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "web_push-removewp", + "summary": "Remove a subscription from push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "No subscription for the device", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "Subscription removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to remove subscription", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Removing subscription is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/activate": { + "post": { + "operationId": "web_push-activatewp", + "summary": "Activate subscription for push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "activationToken" + ], + "properties": { + "activationToken": { + "type": "string", + "description": "Random token sent via a push notification during registration to enable the subscription" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Subscription was already activated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "Subscription activated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to activate subscription", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Activating subscription is not possible, may be because of a wrong activation token", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "No subscription found for the device", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/notifications/api/{apiVersion}/notifications": { "get": { "operationId": "endpoint-list-notifications", diff --git a/openapi-push.json b/openapi-push.json index 9381f46ff..d313f38ec 100644 --- a/openapi-push.json +++ b/openapi-push.json @@ -662,6 +662,774 @@ } } } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/vapid": { + "get": { + "operationId": "web_push-get-vapid", + "summary": "Return the server VAPID public key", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "The VAPID key", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "vapid" + ], + "properties": { + "vapid": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush": { + "post": { + "operationId": "web_push-registerwp", + "summary": "Register a subscription for push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "endpoint", + "uaPublicKey", + "auth", + "apptypes" + ], + "properties": { + "endpoint": { + "type": "string", + "description": "Push Server URL (RFC8030)" + }, + "uaPublicKey": { + "type": "string", + "description": "Public key of the device, uncompress base64url encoded (RFC8291)" + }, + "auth": { + "type": "string", + "description": "Authentication tag, base64url encoded (RFC8291)" + }, + "apptypes": { + "type": "string", + "description": "comma seperated list of types used to filter incoming notifications - apptypes are alphanum - use \"all\" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk')" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "A subscription was already registered and activated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "201": { + "description": "New subscription registered successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to register", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Registering is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "web_push-removewp", + "summary": "Remove a subscription from push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "No subscription for the device", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "Subscription removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to remove subscription", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Removing subscription is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/activate": { + "post": { + "operationId": "web_push-activatewp", + "summary": "Activate subscription for push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "activationToken" + ], + "properties": { + "activationToken": { + "type": "string", + "description": "Random token sent via a push notification during registration to enable the subscription" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Subscription was already activated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "Subscription activated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to activate subscription", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Activating subscription is not possible, may be because of a wrong activation token", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "No subscription found for the device", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } } }, "tags": [] From dc2fb55b6e7c59717dd059b57cb5702fc7f2c117 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 3 Dec 2025 15:32:44 +0100 Subject: [PATCH 27/37] feat(webpush): Small fixes Signed-off-by: sim --- lib/Controller/WebPushController.php | 22 ++++++++++++---------- lib/Push.php | 4 ++-- lib/WebPushClient.php | 4 ++-- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 456159e44..d2ccf97e3 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -127,14 +127,14 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, if ($status === NewSubStatus::CREATED) { $wp = $this->getWPClient(); - $wp->notify($endpoint, $uaPublicKey, $auth, json_encode(['activationToken' => $activationToken])); + $wp->notify($endpoint, $uaPublicKey, $auth, (string)json_encode(['activationToken' => $activationToken])); } return match($status) { NewSubStatus::UPDATED => new DataResponse([], Http::STATUS_OK), NewSubStatus::CREATED => new DataResponse([], Http::STATUS_CREATED), // This should not happen - NewSubStatus::ERROR => new DataResponse(['message' => 'DB_ERROR'], Http::STATUS_BAD_REQUEST), + default => new DataResponse(['message' => 'DB_ERROR'], Http::STATUS_BAD_REQUEST), }; } @@ -213,9 +213,10 @@ protected function getWPClient(): WebPushClient { /** * @param string $apptypes comma separated list of types - * @return array{0: NewSubStatus, 1: ?string}: - * - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth - * - UPDATED if the subscription has been updated (use to change apptypes) + * @return array{0: NewSubStatus, 1: ?string} + * + * - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth + * - UPDATED if the subscription has been updated (use to change apptypes) */ protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $apptypes): array { $query = $this->db->getQueryBuilder(); @@ -250,11 +251,12 @@ protected function saveSubscription(IUser $user, IToken $token, string $endpoint } /** - * @return ActivationSubStatus: - * - OK if it was already activated - * - CREATED If the entry was updated - * - NO_TOKEN if we don't have this token - * - NO_SUB if we don't have this subscription + * @return ActivationSubStatus + * + * - OK if it was already activated + * - CREATED If the entry was updated + * - NO_TOKEN if we don't have this token + * - NO_SUB if we don't have this subscription */ protected function activateSubscription(IUser $user, IToken $token, string $activationToken): ActivationSubStatus { $query = $this->db->getQueryBuilder(); diff --git a/lib/Push.php b/lib/Push.php index d3c3c034a..94e8bdbed 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -602,7 +602,7 @@ public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devi // The nextcloud application, requested with the proxy push, // use to not support `delete-multiple` if (!\in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true)) { - foreach ($notificationIds as $notificationId) { + foreach ($notificationIds ?? [] as $notificationId) { $data = $this->encryptAndSignDelete($userKey, $device, [$notificationId]); try { $this->payloadsToSend[$proxyServer][] = json_encode($data['payload'], JSON_THROW_ON_ERROR); @@ -840,7 +840,7 @@ protected function encodeNotif(int $id, INotification $notification, int $maxLen // Max length of encryption is ~240, so we need to make sure the subject is shorter. // Also, subtract two for encapsulating quotes will be added. - $maxDataLength = $maxLength - strlen(json_encode($data)) - 2; + $maxDataLength = $maxLength - strlen((string)json_encode($data)) - 2; $data['subject'] = Util::shortenMultibyteString($notification->getParsedSubject(), $maxDataLength); if ($notification->getParsedSubject() !== $data['subject']) { $data['subject'] .= '…'; diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index dc04ba20a..8340aa253 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -65,7 +65,7 @@ private function getClient(): WebPush { * @psalm-return array{publicKey: string, privateKey: string} */ private function getVapid(): array { - if (isset($this->vapid) && array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid)) { + if (!empty($this->vapid) && array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid)) { return $this->vapid; } $publicKey = $this->appConfig->getValueString( @@ -128,7 +128,7 @@ public function notify(string $endpoint, string $uaPublicKey, string $auth, stri /** * Send one notification - blocking (should be avoided most of the time) - * @throws ErrorException + * @throws \ErrorException */ public function enqueue(string $endpoint, string $uaPublicKey, string $auth, string $body, string $urgency = 'normal'): void { $c = $this->getClient(); From 4ef6030600e0a1d9be645ef7fc7344b75e1ef526 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 4 Dec 2025 08:11:01 +0100 Subject: [PATCH 28/37] feat(webpush): Fix VAPID auth Signed-off-by: sim --- lib/WebPushClient.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 8340aa253..5d2db2277 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -55,7 +55,7 @@ private function getClient(): WebPush { if (isset($this->client)) { return $this->client; } - $this->client = new WebPush(auth: $this->getVapid()); + $this->client = new WebPush(auth: ["VAPID" => $this->getVapid()]); $this->client->setReuseVAPIDHeaders(true); return $this->client; } @@ -100,6 +100,7 @@ private function getVapid(): array { 'privateKey' => $privateKey, ]; } + $this->vapid["subject"] = "https://github.com/nextcloud/notifications"; return $this->vapid; } From c3df6e42c240045d68d0fe48a146b18ee6960f4e Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 5 Dec 2025 10:54:03 +0100 Subject: [PATCH 29/37] ci: Try to fix psalm for now Signed-off-by: Joas Schilling --- composer.json | 28 +- psalm.xml | 3 +- vendor-bin/mozart/composer.json | 11 + vendor-bin/mozart/composer.lock | 1183 +++++++++++++++++++++++++++++++ 4 files changed, 1213 insertions(+), 12 deletions(-) create mode 100644 vendor-bin/mozart/composer.json create mode 100644 vendor-bin/mozart/composer.lock diff --git a/composer.json b/composer.json index ebae4a5c6..ef8d2dff1 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,13 @@ }, "scripts": { "post-install-cmd": [ - "[ $COMPOSER_DEV_MODE -eq 0 ] || composer bin all install", + "@composer bin all install --ansi", + "\"vendor/bin/mozart\" compose", + "composer dump-autoload" + ], + "post-update-cmd": [ + "@composer bin all install --ansi", + "\"vendor/bin/mozart\" compose", "composer dump-autoload" ], "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", @@ -42,15 +48,15 @@ "require": { "minishlink/web-push": "^9.0" }, - "extra": { - "mozart": { - "dep_namespace": "OCA\\Notifications\\Vendor\\", - "dep_directory": "/lib/Vendor/", - "classmap_directory": "/lib/autoload/", - "classmap_prefix": "Notifications_", - "packages": [ - "minishlink/web-push" - ] - } + "extra": { + "mozart": { + "dep_namespace": "OCA\\Notifications\\Vendor\\", + "dep_directory": "/lib/Vendor/", + "classmap_directory": "/lib/autoload/", + "classmap_prefix": "Notifications_", + "packages": [ + "minishlink/web-push" + ] + } } } diff --git a/psalm.xml b/psalm.xml index 4ebff01b6..b8fd4ca3d 100644 --- a/psalm.xml +++ b/psalm.xml @@ -13,11 +13,12 @@ + - + diff --git a/vendor-bin/mozart/composer.json b/vendor-bin/mozart/composer.json new file mode 100644 index 000000000..3cb285b3c --- /dev/null +++ b/vendor-bin/mozart/composer.json @@ -0,0 +1,11 @@ +{ + "config": { + "platform": { + "php": "8.2" + }, + "sort-packages": true + }, + "require": { + "coenjacobs/mozart": "^0.7.1" + } +} diff --git a/vendor-bin/mozart/composer.lock b/vendor-bin/mozart/composer.lock new file mode 100644 index 000000000..0c8b8f521 --- /dev/null +++ b/vendor-bin/mozart/composer.lock @@ -0,0 +1,1183 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "8e0cb8215624dc8098418fa04adc546b", + "packages": [ + { + "name": "coenjacobs/mozart", + "version": "0.7.1", + "source": { + "type": "git", + "url": "https://github.com/coenjacobs/mozart.git", + "reference": "dbcdeb992d20d9c8914eef090f9a0d684bb1102c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coenjacobs/mozart/zipball/dbcdeb992d20d9c8914eef090f9a0d684bb1102c", + "reference": "dbcdeb992d20d9c8914eef090f9a0d684bb1102c", + "shasum": "" + }, + "require": { + "league/flysystem": "^1.0", + "php": "^7.3|^8.0", + "symfony/console": "^4|^5", + "symfony/finder": "^4|^5" + }, + "require-dev": { + "mheap/phpunit-github-actions-printer": "^1.4", + "phpunit/phpunit": "^8.5", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.4" + }, + "bin": [ + "bin/mozart" + ], + "type": "library", + "autoload": { + "psr-4": { + "CoenJacobs\\Mozart\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Coen Jacobs", + "email": "coenjacobs@gmail.com" + } + ], + "description": "Composes all dependencies as a package inside a WordPress plugin", + "support": { + "issues": "https://github.com/coenjacobs/mozart/issues", + "source": "https://github.com/coenjacobs/mozart/tree/0.7.1" + }, + "funding": [ + { + "url": "https://github.com/coenjacobs", + "type": "github" + } + ], + "time": "2021-02-02T21:37:03+00:00" + }, + { + "name": "league/flysystem", + "version": "1.1.10", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "3239285c825c152bcc315fe0e87d6b55f5972ed1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/3239285c825c152bcc315fe0e87d6b55f5972ed1", + "reference": "3239285c825c152bcc315fe0e87d6b55f5972ed1", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/mime-type-detection": "^1.3", + "php": "^7.2.5 || ^8.0" + }, + "conflict": { + "league/flysystem-sftp": "<1.0.6" + }, + "require-dev": { + "phpspec/prophecy": "^1.11.1", + "phpunit/phpunit": "^8.5.8" + }, + "suggest": { + "ext-ftp": "Allows you to use FTP server storage", + "ext-openssl": "Allows you to use FTPS server storage", + "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", + "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", + "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", + "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", + "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", + "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", + "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", + "league/flysystem-webdav": "Allows you to use WebDAV storage", + "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter", + "spatie/flysystem-dropbox": "Allows you to use Dropbox storage", + "srmklive/flysystem-dropbox-v2": "Allows you to use Dropbox storage for PHP 5 applications" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Filesystem abstraction: Many filesystems, one API.", + "keywords": [ + "Cloud Files", + "WebDAV", + "abstraction", + "aws", + "cloud", + "copy.com", + "dropbox", + "file systems", + "files", + "filesystem", + "filesystems", + "ftp", + "rackspace", + "remote", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/1.1.10" + }, + "funding": [ + { + "url": "https://offset.earth/frankdejonge", + "type": "other" + } + ], + "time": "2022-10-04T09:16:37+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "symfony/console", + "version": "v5.4.47", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed", + "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php73": "^1.9", + "symfony/polyfill-php80": "^1.16", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/string": "^5.1|^6.0" + }, + "conflict": { + "psr/log": ">=3", + "symfony/dependency-injection": "<4.4", + "symfony/dotenv": "<5.1", + "symfony/event-dispatcher": "<4.4", + "symfony/lock": "<4.4", + "symfony/process": "<4.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0" + }, + "require-dev": { + "psr/log": "^1|^2", + "symfony/config": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/event-dispatcher": "^4.4|^5.0|^6.0", + "symfony/lock": "^4.4|^5.0|^6.0", + "symfony/process": "^4.4|^5.0|^6.0", + "symfony/var-dumper": "^4.4|^5.0|^6.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v5.4.47" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-06T11:30:55+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v5.4.45", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "63741784cd7b9967975eec610b256eed3ede022b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", + "reference": "63741784cd7b9967975eec610b256eed3ede022b", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php80": "^1.16" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v5.4.45" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-28T13:32:08+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-04-25T09:37:31+00:00" + }, + { + "name": "symfony/string", + "version": "v6.4.26", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "5621f039a71a11c87c106c1c598bdcd04a19aeea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/5621f039a71a11c87c106c1c598bdcd04a19aeea", + "reference": "5621f039a71a11c87c106c1c598bdcd04a19aeea", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/intl": "^6.2|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v6.4.26" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-09-11T14:32:46+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.6.0" +} From 5fa1b479c4cde3deaa6017e433461e7ddf8f1e39 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Fri, 5 Dec 2025 11:36:36 +0100 Subject: [PATCH 30/37] ci(cs): Ignore lib/Vendor/ dir from coding standards Signed-off-by: Joas Schilling --- .php-cs-fixer.dist.php | 1 + 1 file changed, 1 insertion(+) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index f51434dcf..ef485d06f 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -17,6 +17,7 @@ ->notPath('l10n') ->notPath('node_modules') ->notPath('src') + ->notPath('lib/Vendor') ->notPath('vendor') ->in(__DIR__); return $config; From 294025e5fb3639ab2e68bbc33be6339c6af17f16 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 5 Dec 2025 14:24:31 +0100 Subject: [PATCH 31/37] feat(webpush): Reduce max size for endpoint Fix index too long for the DB Signed-off-by: sim --- lib/Controller/WebPushController.php | 4 ++-- lib/Migration/Version6000Date20251112110000.php | 2 +- openapi-full.json | 2 +- openapi-push.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index d2ccf97e3..c2875d384 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -74,7 +74,7 @@ public function getVapid(): DataResponse { /** * Register a subscription for push notifications * - * @param string $endpoint Push Server URL (RFC8030) + * @param string $endpoint Push Server URL, max 765 chars (RFC8030) * @param string $uaPublicKey Public key of the device, uncompress base64url encoded (RFC8291) * @param string $auth Authentication tag, base64url encoded (RFC8291) * @param string $apptypes comma seperated list of types used to filter incoming notifications - apptypes are alphanum - use "all" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk') @@ -103,7 +103,7 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, if ( !filter_var($endpoint, FILTER_VALIDATE_URL) - || \strlen($endpoint) > 1000 + || \strlen($endpoint) > 765 || !preg_match('/^https\:\/\//', $endpoint) ) { return new DataResponse(['message' => 'INVALID_ENDPOINT'], Http::STATUS_BAD_REQUEST); diff --git a/lib/Migration/Version6000Date20251112110000.php b/lib/Migration/Version6000Date20251112110000.php index f429051c1..c6389a024 100644 --- a/lib/Migration/Version6000Date20251112110000.php +++ b/lib/Migration/Version6000Date20251112110000.php @@ -55,7 +55,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt ]); $table->addColumn('endpoint', Types::STRING, [ 'notnull' => true, - 'length' => 1024, + 'length' => 767, ]); $table->addColumn('p256dh', Types::STRING, [ 'notnull' => true, diff --git a/openapi-full.json b/openapi-full.json index 42ad3f1b5..862f601a4 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1634,7 +1634,7 @@ "properties": { "endpoint": { "type": "string", - "description": "Push Server URL (RFC8030)" + "description": "Push Server URL, max 765 chars (RFC8030)" }, "uaPublicKey": { "type": "string", diff --git a/openapi-push.json b/openapi-push.json index d313f38ec..6658ee3a3 100644 --- a/openapi-push.json +++ b/openapi-push.json @@ -802,7 +802,7 @@ "properties": { "endpoint": { "type": "string", - "description": "Push Server URL (RFC8030)" + "description": "Push Server URL, max 765 chars (RFC8030)" }, "uaPublicKey": { "type": "string", From e2c0181f1e4c23c4946ca990951feb9b14ff4cac Mon Sep 17 00:00:00 2001 From: sim Date: Sat, 6 Dec 2025 15:28:17 +0100 Subject: [PATCH 32/37] feat(webpush): Lint Signed-off-by: sim --- lib/WebPushClient.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 5d2db2277..0ec024e20 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -55,7 +55,7 @@ private function getClient(): WebPush { if (isset($this->client)) { return $this->client; } - $this->client = new WebPush(auth: ["VAPID" => $this->getVapid()]); + $this->client = new WebPush(auth: ['VAPID' => $this->getVapid()]); $this->client->setReuseVAPIDHeaders(true); return $this->client; } @@ -100,7 +100,7 @@ private function getVapid(): array { 'privateKey' => $privateKey, ]; } - $this->vapid["subject"] = "https://github.com/nextcloud/notifications"; + $this->vapid['subject'] = 'https://github.com/nextcloud/notifications'; return $this->vapid; } From 180cf92fccc1c8dd5ba5eab471a96cf0c0c8ad0d Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 8 Dec 2025 09:59:36 +0100 Subject: [PATCH 33/37] feat(webpush): Update query count Signed-off-by: sim --- tests/Integration/base-query-count.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/base-query-count.txt b/tests/Integration/base-query-count.txt index 78e0702fc..c0e5a555f 100644 --- a/tests/Integration/base-query-count.txt +++ b/tests/Integration/base-query-count.txt @@ -1 +1 @@ -7535 +7828 From de4a2bf8de41c621387f3a08d442358aeeb1b8ae Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 8 Dec 2025 17:37:54 +0100 Subject: [PATCH 34/37] feat(webpush): Keep vendor dir Signed-off-by: sim --- composer.json | 1 + composer.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ef8d2dff1..a79f73dd3 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,7 @@ }, "extra": { "mozart": { + "delete_vendor_directories": false, "dep_namespace": "OCA\\Notifications\\Vendor\\", "dep_directory": "/lib/Vendor/", "classmap_directory": "/lib/autoload/", diff --git a/composer.lock b/composer.lock index a9e93366c..37fdb7d63 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "1a1247194c75469d2716cd7c151bab74", + "content-hash": "7cb34b976d8d44a6e618b5796d7dd7f1", "packages": [ { "name": "brick/math", From 1c94e084b32b080af636d21c0047edbc423d30b7 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 8 Dec 2025 18:19:31 +0100 Subject: [PATCH 35/37] feat(webpush): Fix psalm CI Signed-off-by: sim --- lib/Push.php | 6 +++--- lib/WebPushClient.php | 15 ++++++++------- tests/psalm-baseline.xml | 29 ++++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index 94e8bdbed..df0e2fcb7 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -641,8 +641,8 @@ protected function webPushCallback(MessageSentReport $report): void { if ($report->isSubscriptionExpired()) { $this->deleteWebPushTokenByEndpoint($report->getEndpoint()); } elseif ($report->getResponse()?->getStatusCode() === 429) { - $retryAfter = $report->getResponse()?->getHeader('Retry-After'); - $this->cache->set('wp.' . $report->getEndpoint(), true, $retryAfter ?? 60); + $retryAfter = (int)($report->getResponse()?->getHeader('Retry-After')[0] ?? "60"); + $this->cache->set('wp.' . $report->getEndpoint(), true, $retryAfter); } } @@ -851,7 +851,7 @@ protected function encodeNotif(int $id, INotification $notification, int $maxLen /** * @param ?int[] $ids * @return array - * @psalm-return array{remaining: list, data: array{delete-all: bool, nid: int, delete: bool, nids: int[], delete-multiple: int}} + * @psalm-return array{data: array{'delete-all'?: true, 'delete-multiple'?: true, delete?: true, nid?: int, nids?: int[]}, remaining: int[]} */ protected function encodeDeleteNotifs(?array $ids): array { $remainingIds = []; diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 0ec024e20..1ad23382d 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -19,7 +19,7 @@ class WebPushClient { private WebPush $client; - /** @psalm-var array{publicKey: string, privateKey: string} */ + /** @psalm-var array{publicKey: string, privateKey: string, subject: string} */ private array $vapid; public function __construct( @@ -62,10 +62,10 @@ private function getClient(): WebPush { /** * @return array - * @psalm-return array{publicKey: string, privateKey: string} + * @psalm-return array{publicKey: string, privateKey: string, subject: string} */ private function getVapid(): array { - if (!empty($this->vapid) && array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid)) { + if (array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid) && array_key_exists('subject', $this->vapid) ) { return $this->vapid; } $publicKey = $this->appConfig->getValueString( @@ -79,7 +79,8 @@ private function getVapid(): array { lazy: true ); if ($publicKey === '' || $privateKey === '') { - $this->vapid = VAPID::createVapidKeys(); + /** @var array{publicKey: string, privateKey: string} $vapid */ + $vapid = VAPID::createVapidKeys(); $this->appConfig->setValueString( Application::APP_ID, 'webpush_vapid_pubkey', @@ -95,13 +96,13 @@ private function getVapid(): array { sensitive: true ); } else { - $this->vapid = [ + $vapid = [ 'publicKey' => $publicKey, 'privateKey' => $privateKey, ]; } - $this->vapid['subject'] = 'https://github.com/nextcloud/notifications'; - return $this->vapid; + $vapid['subject'] = 'https://github.com/nextcloud/notifications'; + return $this->vapid = $vapid; } /** diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index ae273cd7e..a312de02e 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,5 +1,5 @@ - + identityProof]]> @@ -9,29 +9,48 @@ + + + tokenProvider]]> + tokenProvider]]> + tokenProvider]]> + + + + + + + ]]> ]]> + ]]> + ]]> - - keyManager]]> keyManager]]> tokenProvider]]> - - + + + client)]]> + + From c6b6bc8804a7c9adfb347756b46cd4ec128603d2 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 9 Dec 2025 08:16:07 +0100 Subject: [PATCH 36/37] feat(webpush): Lint Signed-off-by: sim --- lib/Push.php | 2 +- lib/WebPushClient.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Push.php b/lib/Push.php index df0e2fcb7..acef45b70 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -641,7 +641,7 @@ protected function webPushCallback(MessageSentReport $report): void { if ($report->isSubscriptionExpired()) { $this->deleteWebPushTokenByEndpoint($report->getEndpoint()); } elseif ($report->getResponse()?->getStatusCode() === 429) { - $retryAfter = (int)($report->getResponse()?->getHeader('Retry-After')[0] ?? "60"); + $retryAfter = (int)($report->getResponse()?->getHeader('Retry-After')[0] ?? '60'); $this->cache->set('wp.' . $report->getEndpoint(), true, $retryAfter); } } diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 1ad23382d..8550d1cf5 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -65,7 +65,7 @@ private function getClient(): WebPush { * @psalm-return array{publicKey: string, privateKey: string, subject: string} */ private function getVapid(): array { - if (array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid) && array_key_exists('subject', $this->vapid) ) { + if (array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid) && array_key_exists('subject', $this->vapid)) { return $this->vapid; } $publicKey = $this->appConfig->getValueString( From c57b44653f1561c77898650cac6b98060571fa38 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 9 Dec 2025 08:24:48 +0100 Subject: [PATCH 37/37] feat(webpush): Init VAPID in constructor Signed-off-by: sim --- lib/WebPushClient.php | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/WebPushClient.php b/lib/WebPushClient.php index 8550d1cf5..d7acb4855 100644 --- a/lib/WebPushClient.php +++ b/lib/WebPushClient.php @@ -25,6 +25,7 @@ class WebPushClient { public function __construct( protected IAppConfig $appConfig, ) { + $this->vapid = $this->getVapid(); } public static function isValidP256dh(string $key): bool { @@ -55,7 +56,7 @@ private function getClient(): WebPush { if (isset($this->client)) { return $this->client; } - $this->client = new WebPush(auth: ['VAPID' => $this->getVapid()]); + $this->client = new WebPush(auth: ['VAPID' => $this->vapid]); $this->client->setReuseVAPIDHeaders(true); return $this->client; } @@ -65,9 +66,6 @@ private function getClient(): WebPush { * @psalm-return array{publicKey: string, privateKey: string, subject: string} */ private function getVapid(): array { - if (array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid) && array_key_exists('subject', $this->vapid)) { - return $this->vapid; - } $publicKey = $this->appConfig->getValueString( Application::APP_ID, 'webpush_vapid_pubkey', @@ -84,14 +82,14 @@ private function getVapid(): array { $this->appConfig->setValueString( Application::APP_ID, 'webpush_vapid_pubkey', - $this->vapid['publicKey'], + $vapid['publicKey'], lazy: true, sensitive: true ); $this->appConfig->setValueString( Application::APP_ID, 'webpush_vapid_privkey', - $this->vapid['privateKey'], + $vapid['privateKey'], lazy: true, sensitive: true ); @@ -102,14 +100,14 @@ private function getVapid(): array { ]; } $vapid['subject'] = 'https://github.com/nextcloud/notifications'; - return $this->vapid = $vapid; + return $vapid; } /** * @return string */ public function getVapidPublicKey(): string { - return $this->getVapid()['publicKey']; + return $this->vapid['publicKey']; } /**