diff --git a/appinfo/routes.php b/appinfo/routes.php index 46e5a8c88..175ab6567 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -18,6 +18,7 @@ ['name' => 'API#generateNotification', 'url' => '/api/{apiVersion}/admin_notifications/{userId}', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v1|v2)']], ['name' => 'API#generateNotificationV3', 'url' => '/api/{apiVersion3}/admin_notifications/{userId}', 'verb' => 'POST', 'requirements' => ['apiVersion3' => '(v3)']], + ['name' => 'API#selfTestPush', 'url' => '/api/{apiVersion3}/test/self', 'verb' => 'POST', 'requirements' => ['apiVersion3' => '(v3)']], ['name' => 'Settings#personal', 'url' => '/api/{apiVersion}/settings', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], ['name' => 'Settings#admin', 'url' => '/api/{apiVersion}/settings/admin', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']], diff --git a/composer.json b/composer.json index 85943a6b1..06c3b9b47 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", "cs:check": "php-cs-fixer fix --dry-run --diff", "cs:fix": "php-cs-fixer fix", - "openapi": "generate-spec", + "openapi": "generate-spec --verbose", "psalm": "psalm --threads=1", "psalm:dev": "psalm --no-cache --threads=$(nproc)", "psalm:update-baseline": "psalm --threads=1 --update-baseline", diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 984e86240..611732537 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -42,6 +42,7 @@ public function getCapabilities(): array { 'action-web', 'user-status', 'exists', + 'test-push', ], 'push' => [ 'devices', diff --git a/lib/Controller/APIController.php b/lib/Controller/APIController.php index 54d0731df..83cfff45b 100644 --- a/lib/Controller/APIController.php +++ b/lib/Controller/APIController.php @@ -12,19 +12,23 @@ use OCA\Notifications\App; use OCA\Notifications\ResponseDefinitions; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IL10N; use OCP\IRequest; use OCP\IUser; use OCP\IUserManager; +use OCP\IUserSession; use OCP\Notification\IManager; use OCP\Notification\IncompleteNotificationException; use OCP\Notification\InvalidValueException; use OCP\RichObjectStrings\InvalidObjectExeption; use OCP\RichObjectStrings\IValidator; use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Output\BufferedOutput; /** * @psalm-import-type NotificationsRichObjectParameter from ResponseDefinitions @@ -36,9 +40,11 @@ public function __construct( IRequest $request, protected ITimeFactory $timeFactory, protected IUserManager $userManager, + protected IUserSession $userSession, protected IManager $notificationManager, protected App $notificationApp, protected IValidator $richValidator, + protected IL10N $l, protected LoggerInterface $logger, ) { parent::__construct($appName, $request); @@ -153,4 +159,74 @@ public function generateNotificationV3( return new DataResponse(['id' => (int)$this->notificationApp->getLastInsertedId()]); } + + /** + * Send a test notification to push registered mobile apps + * + * Required capability: `ocs-endpoints > test-push` + * + * @return DataResponse + * + * 200: Test notification generated successfully, but the device should still show the message to the user + * 400: Test notification could not be generated, show the message to the user + */ + #[NoAdminRequired] + #[OpenAPI(scope: 'push')] + public function selfTestPush(): DataResponse { + if (!$this->notificationManager->isFairUseOfFreePushService()) { + $message = $this->l->t('We want to keep offering our push notification service for free, but large users overload our infrastructure. For this reason we have to rate-limit the use of push notifications. If you need this feature, consider using Nextcloud Enterprise.'); + return new DataResponse( + ['message' => $message], + Http::STATUS_BAD_REQUEST, + ); + } + + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse( + ['message' => $this->l->t('User not found')], + Http::STATUS_BAD_REQUEST, + ); + } + + if (!$this->request->isUserAgent([ + IRequest::USER_AGENT_TALK_ANDROID, + IRequest::USER_AGENT_TALK_IOS, + IRequest::USER_AGENT_CLIENT_ANDROID, + IRequest::USER_AGENT_CLIENT_IOS, + ])) { + return new DataResponse( + ['message' => $this->l->t('The device does not seem to be supported')], + Http::STATUS_BAD_REQUEST, + ); + } + + $notification = $this->notificationManager->createNotification(); + $datetime = $this->timeFactory->getDateTime(); + $isTalkApp = $this->request->isUserAgent([ + IRequest::USER_AGENT_TALK_ANDROID, + IRequest::USER_AGENT_TALK_IOS, + ]); + $app = $isTalkApp ? 'admin_notification_talk' : 'admin_notifications'; + + $output = new BufferedOutput(); + try { + $notification->setApp($app) + ->setUser($user->getUID()) + ->setDateTime($datetime) + ->setObject('admin_notifications', dechex($datetime->getTimestamp())) + ->setSubject('self', ['Testing push notifications']); + + $this->notificationApp->setOutput($output); + $this->notificationManager->notify($notification); + } catch (\InvalidArgumentException $e) { + $this->logger->error('Self testing push notification failed: ' . $e->getMessage(), ['exception' => $e]); + return new DataResponse( + ['message' => $this->l->t('An unexpected error occurred, ask your administration to check the logs.')], + Http::STATUS_BAD_REQUEST, + ); + } + + return new DataResponse(['message' => $output->fetch()]); + } } diff --git a/lib/Notifier/AdminNotifications.php b/lib/Notifier/AdminNotifications.php index 84f0790e3..76e9f8b90 100644 --- a/lib/Notifier/AdminNotifications.php +++ b/lib/Notifier/AdminNotifications.php @@ -175,6 +175,7 @@ public function prepare(INotification $notification, string $languageCode): INot // Deal with known subjects case 'cli': case 'ocs': + case 'self': $subjectParams = $notification->getSubjectParameters(); if (isset($subjectParams['subject'])) { // Nextcloud 30+ diff --git a/lib/Push.php b/lib/Push.php index 620b1e68b..4adb1a106 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -249,8 +249,9 @@ public function pushToDevice(int $id, INotification $notification, ?OutputInterf try { $this->notificationManager->setPreparingPushNotification(true); $notification = $this->notificationManager->prepare($notification, $language); - } catch (AlreadyProcessedException|IncompleteParsedNotificationException|\InvalidArgumentException) { + } catch (AlreadyProcessedException|IncompleteParsedNotificationException|\InvalidArgumentException $e) { // FIXME remove \InvalidArgumentException in Nextcloud 39 + $this->printInfo('Error when preparing notification for push: ' . get_class($e)); return; } finally { $this->notificationManager->setPreparingPushNotification(false); diff --git a/openapi-full.json b/openapi-full.json index 7dbc4dfe3..82c3fbc88 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1453,6 +1453,123 @@ } } }, + "/ocs/v2.php/apps/notifications/api/{apiVersion3}/test/self": { + "post": { + "operationId": "api-self-test-push", + "summary": "Send a test notification to push registered mobile apps", + "description": "Required capability: `ocs-endpoints > test-push`", + "tags": [ + "api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion3", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^(v3)$" + } + }, + { + "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": "Test notification generated successfully, but the device should still show the message to the user", + "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" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Test notification could not be generated, show the message to the user", + "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}/push": { "post": { "operationId": "push-register-device", diff --git a/openapi-push.json b/openapi-push.json index 87bdc9b55..979e4838f 100644 --- a/openapi-push.json +++ b/openapi-push.json @@ -102,6 +102,123 @@ } }, "paths": { + "/ocs/v2.php/apps/notifications/api/{apiVersion3}/test/self": { + "post": { + "operationId": "api-self-test-push", + "summary": "Send a test notification to push registered mobile apps", + "description": "Required capability: `ocs-endpoints > test-push`", + "tags": [ + "api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion3", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^(v3)$" + } + }, + { + "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": "Test notification generated successfully, but the device should still show the message to the user", + "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" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Test notification could not be generated, show the message to the user", + "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}/push": { "post": { "operationId": "push-register-device", diff --git a/tests/Unit/AppInfo/RoutesTest.php b/tests/Unit/AppInfo/RoutesTest.php index dd1525040..726c979f1 100644 --- a/tests/Unit/AppInfo/RoutesTest.php +++ b/tests/Unit/AppInfo/RoutesTest.php @@ -24,6 +24,6 @@ public function testRoutes(): void { $this->assertCount(1, $routes); $this->assertArrayHasKey('ocs', $routes); $this->assertIsArray($routes['ocs']); - $this->assertCount(9, $routes['ocs']); + $this->assertCount(10, $routes['ocs']); } } diff --git a/tests/Unit/CapabilitiesTest.php b/tests/Unit/CapabilitiesTest.php index 62219de15..ca31155af 100644 --- a/tests/Unit/CapabilitiesTest.php +++ b/tests/Unit/CapabilitiesTest.php @@ -28,6 +28,7 @@ public function testGetCapabilities(): void { 'action-web', 'user-status', 'exists', + 'test-push', ], 'push' => [ 'devices', diff --git a/tests/Unit/Controller/APIControllerTest.php b/tests/Unit/Controller/APIControllerTest.php index 2a95c4204..20873af47 100644 --- a/tests/Unit/Controller/APIControllerTest.php +++ b/tests/Unit/Controller/APIControllerTest.php @@ -13,9 +13,11 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IL10N; use OCP\IRequest; use OCP\IUser; use OCP\IUserManager; +use OCP\IUserSession; use OCP\Notification\IManager; use OCP\Notification\IncompleteNotificationException; use OCP\Notification\INotification; @@ -33,9 +35,11 @@ class APIControllerTest extends TestCase { protected ITimeFactory&MockObject $timeFactory; protected IUserManager&MockObject $userManager; + protected IUserSession&MockObject $userSession; protected IManager&MockObject $notificationManager; protected App&MockObject $notificationApp; protected IValidator&MockObject $richValidator; + protected IL10N&MockObject $l; protected LoggerInterface&MockObject $logger; protected APIController $controller; @@ -47,9 +51,11 @@ protected function setUp(): void { $request = $this->createMock(IRequest::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->userManager = $this->createMock(IUserManager::class); + $this->userSession = $this->createMock(IUserSession::class); $this->notificationManager = $this->createMock(IManager::class); $this->notificationApp = $this->createMock(App::class); $this->richValidator = $this->createMock(IValidator::class); + $this->l = $this->createMock(IL10N::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new APIController( @@ -57,9 +63,11 @@ protected function setUp(): void { $request, $this->timeFactory, $this->userManager, + $this->userSession, $this->notificationManager, $this->notificationApp, $this->richValidator, + $this->l, $this->logger, ); }