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; diff --git a/composer.json b/composer.json index 3ea5f5839..a79f73dd3 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", @@ -38,5 +44,20 @@ "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": { + "delete_vendor_directories": false, + "dep_namespace": "OCA\\Notifications\\Vendor\\", + "dep_directory": "/lib/Vendor/", + "classmap_directory": "/lib/autoload/", + "classmap_prefix": "Notifications_", + "packages": [ + "minishlink/web-push" + ] + } } } diff --git a/composer.lock b/composer.lock index b738bb24e..37fdb7d63 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": "7cb34b976d8d44a6e618b5796d7dd7f1", + "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" } 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', diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php new file mode 100644 index 000000000..c2875d384 --- /dev/null +++ b/lib/Controller/WebPushController.php @@ -0,0 +1,337 @@ + + * + * 200: The VAPID key + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/webpush/vapid', requirements: ['apiVersion' => '(v2)'])] + public function getVapid(): DataResponse { + return new DataResponse(['vapid' => $this->getWPClient()->getVapidPublicKey()], Http::STATUS_OK); + } + + /** + * Register a subscription for push notifications + * + * @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') + * @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, string $apptypes): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + if (!WebPushClient::isValidP256dh($uaPublicKey)) { + return new DataResponse(['message' => 'INVALID_P256DH'], Http::STATUS_BAD_REQUEST); + } + + if (!WebPushClient::isValidAuth($auth)) { + return new DataResponse(['message' => 'INVALID_AUTH'], Http::STATUS_BAD_REQUEST); + } + + if ( + !filter_var($endpoint, FILTER_VALIDATE_URL) + || \strlen($endpoint) > 765 + || !preg_match('/^https\:\/\//', $endpoint) + ) { + return new DataResponse(['message' => 'INVALID_ENDPOINT'], Http::STATUS_BAD_REQUEST); + } + + if (strlen($apptypes) > 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); + } + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + [$status, $activationToken] = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $apptypes); + + if ($status === NewSubStatus::CREATED) { + $wp = $this->getWPClient(); + $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 + default => new DataResponse(['message' => 'DB_ERROR'], Http::STATUS_BAD_REQUEST), + }; + } + + /** + * 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 + * + * 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 $activationToken): 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, $activationToken); + + 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); + } + + protected function getWPClient(): WebPushClient { + return new WebPushClient($this->appConfig); + } + + /** + * @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) + */ + 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') + ->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 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)) { + return [NewSubStatus::CREATED, $activationToken]; + } else { + return [NewSubStatus::ERROR, null]; + } + } + + if ($this->updateSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $apptypes)) { + return [NewSubStatus::UPDATED, null]; + } else { + return [NewSubStatus::ERROR, null]; + } + } + + /** + * @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(); + $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($activationToken))); + + if ($query->executeStatement() !== 0) { + return ActivationSubStatus::CREATED; + } else { + return ActivationSubStatus::NO_TOKEN; + } + } + + /** + * @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, string $apptypes): bool { + $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($apptypes), + 'activation_token' => $query->createNamedParameter($activationToken), + ]); + return $query->executeStatement() > 0; + } + + /** + * @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, 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($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..c6389a024 --- /dev/null +++ b/lib/Migration/Version6000Date20251112110000.php @@ -0,0 +1,89 @@ +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' => 767, + ]); + $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/lib/Push.php b/lib/Push.php index 582c91a4f..acef45b70 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -15,12 +15,14 @@ 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; 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; @@ -63,19 +65,26 @@ 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[] */ protected array $loadStatusForUsers = []; + /** @var WebPushClient */ + protected WebPushClient $wpClient; public function __construct( protected IDBConnection $db, protected INotificationManager $notificationManager, protected IConfig $config, + protected IAppConfig $appConfig, protected IProvider $tokenProvider, protected Manager $keyManager, protected IClientService $clientService, @@ -87,6 +96,11 @@ public function __construct( protected LoggerInterface $log, ) { $this->cache = $cacheFactory->createDistributed('pushtokens'); + $this->wpClient = new WebPushClient($appConfig); + } + + protected function getWpClient(): WebPushClient { + return $this->wpClient; } public function setOutput(OutputInterface $output): void { @@ -113,10 +127,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 = []; } @@ -147,23 +168,39 @@ 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 = []; } $this->deferPayloads = false; + $this->getWpClient()->flush(fn ($r) => $this->webPushCallback($r)); $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) use ($app) { + $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 @@ -171,7 +208,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'); @@ -230,14 +267,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 +301,81 @@ 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; + } + + $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 + $this->deleteWebPushToken($device['token']); + 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'])['urgency']; + $this->getWpClient()->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 (\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']); + } + } + $this->printInfo(''); + + if (!$this->deferPayloads) { + $this->getWpClient()->flush(fn ($r) => $this->webPushCallback($r)); + } + } + + 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())); @@ -267,7 +385,7 @@ public function pushToDevice(int $id, INotification $notification, ?OutputInterf $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; @@ -284,6 +402,7 @@ public function pushToDevice(int $id, INotification $notification, ?OutputInterf if (!$this->validateToken($device['token'], $maxAge)) { // Token does not exist anymore + $this->deleteProxyPushToken($device['token']); continue; } @@ -299,7 +418,7 @@ public function pushToDevice(int $id, INotification $notification, ?OutputInterf $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(''); @@ -331,7 +450,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] = []; @@ -352,17 +471,104 @@ 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->filterProxyDeviceList($proxyDevices, $app); + //TODO filter webpush devices + } + + $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, bool $deleteAll, ?array $notificationIds, string $app = ''): void { + if (empty($devices)) { + return; + } + + // 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']; + if (!$this->validateToken($device['token'], $maxAge)) { + // Token does not exist anymore + $this->deleteWebPushToken($device['token']); + 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); + try { + $payload = json_encode($data['data'], JSON_THROW_ON_ERROR); + $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]); + } + } else { + $temp = $notificationIds; + + while (!empty($temp)) { + $data = $this->encodeDeleteNotifs($temp); + $temp = $data['remaining']; + try { + $payload = json_encode($data['data'], JSON_THROW_ON_ERROR); + $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]); + } + } + } + } catch (\InvalidArgumentException) { + // Failed to encrypt message for device: public key is invalid + $this->deleteWebPushToken($device['token']); + } } + + if (!$this->deferPayloads) { + $this->sendNotificationsToProxies(); + } + } + + /** + * @param string $userId + * @param IUser $user + * @param bool $deleteAll + * @param ?int[] $notificationIds + * @param string $app + */ + public function proxyPushDeleteToDevice(string $userId, IUser $user, array $devices, bool $deleteAll, ?array $notificationIds, string $app = ''): void { if (empty($devices)) { return; } @@ -375,6 +581,7 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri $device['token'] = (int)$device['token']; if (!$this->validateToken($device['token'], $maxAge)) { // Token does not exist anymore + $this->deleteProxyPushToken($device['token']); continue; } @@ -392,21 +599,33 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri $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]); + } } } } } catch (\InvalidArgumentException) { // Failed to encrypt message for device: public key is invalid - $this->deletePushToken($device['token']); + $this->deleteProxyPushToken($device['token']); } } @@ -415,6 +634,18 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri } } + /** + * Delete expired web push subscriptions + */ + 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'); + $this->cache->set('wp.' . $report->getEndpoint(), true, $retryAfter); + } + } + protected function sendNotificationsToProxies(): void { $pushNotifications = $this->payloadsToSend; $this->payloadsToSend = []; @@ -502,7 +733,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); } } @@ -545,7 +776,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; } @@ -559,7 +789,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; } @@ -594,17 +823,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(), @@ -615,22 +840,84 @@ 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((string)json_encode($data)) - 2; $data['subject'] = Util::shortenMultibyteString($notification->getParsedSubject(), $maxDataLength); if ($notification->getParsedSubject() !== $data['subject']) { $data['subject'] .= '…'; } + return $data; + } + + /** + * @param ?int[] $ids + * @return array + * @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 = []; + 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 + ]; + } - if ($isTalkNotification) { - $priority = 'high'; - $type = $data['type'] === 'call' ? 'voip' : 'alert'; - } elseif ($data['app'] === 'twofactor_nextcloud_notification' || $data['app'] === 'phonetrack') { - $priority = 'high'; - $type = 'alert'; + /** + * 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 { - $priority = 'normal'; - $type = 'alert'; + $res['urgency'] = 'normal'; + $res['type'] = 'alert'; } + return $res; + } + + /** + * @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); + $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)); @@ -670,23 +957,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']); @@ -715,7 +988,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 +1006,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,11 +1026,79 @@ 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 + */ + 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 deletePushToken(int $tokenId): 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))); @@ -769,7 +1110,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 new file mode 100644 index 000000000..d7acb4855 --- /dev/null +++ b/lib/WebPushClient.php @@ -0,0 +1,152 @@ +vapid = $this->getVapid(); + } + + 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; + } + + 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; + } + + private function getClient(): WebPush { + if (isset($this->client)) { + return $this->client; + } + $this->client = new WebPush(auth: ['VAPID' => $this->vapid]); + $this->client->setReuseVAPIDHeaders(true); + return $this->client; + } + + /** + * @return array + * @psalm-return array{publicKey: string, privateKey: string, subject: string} + */ + private function getVapid(): array { + $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 === '') { + /** @var array{publicKey: string, privateKey: string} $vapid */ + $vapid = VAPID::createVapidKeys(); + $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 { + $vapid = [ + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + ]; + } + $vapid['subject'] = 'https://github.com/nextcloud/notifications'; + return $vapid; + } + + /** + * @return string + */ + public function getVapidPublicKey(): string { + return $this->vapid['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) + * @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); + } +} diff --git a/openapi-full.json b/openapi-full.json index 6e4379483..862f601a4 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, max 765 chars (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..6658ee3a3 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, max 765 chars (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": [] 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/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 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], ]; } 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/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php new file mode 100644 index 000000000..46a7de895 --- /dev/null +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -0,0 +1,484 @@ +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); + $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->appConfig, + $this->db, + $this->session, + $this->userSession, + $this->tokenProvider, + $this->identityProof + ); + } + + return $this->getMockBuilder(WebPushController::class) + ->setConstructorArgs([ + 'notifications', + $this->request, + $this->appConfig, + $this->db, + $this->session, + $this->userSession, + $this->tokenProvider, + $this->identityProof, + ]) + ->onlyMethods($methods) + ->getMock(); + } + + public static function dataRegisterWP(): array { + return [ + 'not authenticated' => [ + 'https://localhost/', + '', + '', + 'all', + false, + 0, + false, + 0, + [], + Http::STATUS_UNAUTHORIZED + ], + 'too short uaPubKey' => [ + 'https://localhost/', + 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV', + self::$auth, + 'all', + true, + 0, + false, + 0, + ['message' => 'INVALID_P256DH'], + Http::STATUS_BAD_REQUEST, + ], + 'too long uaPubKey' => [ + 'https://localhost/', + 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4bb', + self::$auth, + 'all', + true, + 0, + false, + 0, + ['message' => 'INVALID_P256DH'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid char in uaPubKey' => [ + 'https://localhost/', + 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV- JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw', + self::$auth, + 'all', + true, + 0, + false, + 0, + ['message' => 'INVALID_P256DH'], + Http::STATUS_BAD_REQUEST, + ], + 'too short auth' => [ + 'https://localhost/', + self::$uaPublicKey, + 'BTBZMqHH6r4Tts7J_aSI', + 'all', + true, + 0, + false, + 0, + ['message' => 'INVALID_AUTH'], + Http::STATUS_BAD_REQUEST, + ], + 'too long auth' => [ + 'https://localhost/', + self::$uaPublicKey, + 'BTBZMqHH6r4Tts7J_aSIggxx', + 'all', + true, + 0, + false, + 0, + ['message' => 'INVALID_AUTH'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid char in auth' => [ + 'https://localhost/', + self::$uaPublicKey, + 'BTBZM HH6r4Tts7J_aSIgg', + 'all', + true, + 0, + false, + 0, + ['message' => 'INVALID_AUTH'], + Http::STATUS_BAD_REQUEST, + ], + 'invalid endpoint' => [ + 'http://localhost/', + self::$uaPublicKey, + self::$auth, + 'all', + true, + 0, + false, + 0, + ['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, + 0, + ['message' => 'INVALID_SESSION_TOKEN'], + Http::STATUS_BAD_REQUEST, + ], + 'created' => [ + 'https://localhost/', + self::$uaPublicKey, + self::$auth, + 'all', + true, + 23, + true, + 0, + [], + Http::STATUS_CREATED, + ], + 'updated' => [ + 'https://localhost/', + self::$uaPublicKey, + self::$auth, + 'all', + true, + 23, + true, + 1, + [], + Http::STATUS_OK, + ], + ]; + } + + /** + * @dataProvider dataRegisterWP + */ + 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' + ]); + + $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), 'tok']); + + if ($subStatus === 0) { + $wpClient = $this->createMock(WebPushClient::class); + $controller->expects($this->once()) + ->method('getWPClient') + ->willReturn($wpClient); + } + } 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, $apptypes); + $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()); + } +} diff --git a/tests/Unit/PushTest.php b/tests/Unit/PushTest.php index 1f7758b0f..9fcdf64b1 100644 --- a/tests/Unit/PushTest.php +++ b/tests/Unit/PushTest.php @@ -16,12 +16,14 @@ 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\IConfig; @@ -48,6 +50,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 +68,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 +95,7 @@ protected function getPush(array $methods = []): Push|MockObject { $this->db, $this->notificationManager, $this->config, + $this->appConfig, $this->tokenProvider, $this->keyManager, $this->clientService, @@ -109,6 +114,7 @@ protected function getPush(array $methods = []): Push|MockObject { $this->db, $this->notificationManager, $this->config, + $this->appConfig, $this->tokenProvider, $this->keyManager, $this->clientService, @@ -141,8 +147,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 +174,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 +207,7 @@ public function testPushToDeviceNotPrepared(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 'token1', @@ -220,8 +226,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', 'deleteProxyPushToken']); $this->clientService->expects($this->never()) ->method('newClient'); @@ -245,7 +251,7 @@ public function testPushToDeviceInvalidToken(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 23, @@ -279,14 +285,14 @@ public function testPushToDeviceInvalidToken(): void { ->method('encryptAndSign'); $push->expects($this->once()) - ->method('deletePushToken') + ->method('deleteProxyPushToken') ->with(23); $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', 'deleteProxyPushToken', 'validateToken']); $this->clientService->expects($this->never()) ->method('newClient'); @@ -310,7 +316,7 @@ public function testPushToDeviceEncryptionError(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 23, @@ -344,13 +350,13 @@ public function testPushToDeviceEncryptionError(): void { ->willThrowException(new \InvalidArgumentException()); $push->expects($this->once()) - ->method('deletePushToken') + ->method('deleteProxyPushToken') ->with(23); $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', 'deleteProxyPushToken', 'validateToken', 'deleteProxyPushTokenByDeviceIdentifier']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); @@ -367,7 +373,7 @@ public function testPushToDeviceNoFairUse(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([ [ 'proxyserver' => 'proxyserver', @@ -408,7 +414,7 @@ public function testPushToDeviceNoFairUse(): void { ->willReturn(['Payload']); $push->expects($this->never()) - ->method('deletePushToken'); + ->method('deleteProxyPushToken'); $this->clientService->expects($this->never()) ->method('newClient'); @@ -421,13 +427,13 @@ public function testPushToDeviceNoFairUse(): void { $this->notificationManager->method('isFairUseOfFreePushService') ->willReturn(false); - $push->method('deletePushTokenByDeviceIdentifier') + $push->method('deleteProxyPushTokenByDeviceIdentifier') ->with('123456'); $push->pushToDevice(207787, $notification); } - public static function dataPushToDeviceSending(): array { + public static function dataProxyPushToDeviceSending(): array { return [ [true], [false], @@ -435,10 +441,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', 'deleteProxyPushToken', 'validateToken', 'deleteProxyPushTokenByDeviceIdentifier']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); @@ -455,7 +461,7 @@ public function testPushToDeviceSending(bool $isDebug): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([ [ 'proxyserver' => 'proxyserver1', @@ -528,7 +534,7 @@ public function testPushToDeviceSending(bool $isDebug): void { ->willReturn(['Payload']); $push->expects($this->never()) - ->method('deletePushToken'); + ->method('deleteProxyPushToken'); /** @var IClient&MockObject $client */ $client = $this->createMock(IClient::class); @@ -644,13 +650,13 @@ public function testPushToDeviceSending(bool $isDebug): void { $this->notificationManager->method('isFairUseOfFreePushService') ->willReturn(true); - $push->method('deletePushTokenByDeviceIdentifier') + $push->method('deleteProxyPushTokenByDeviceIdentifier') ->with('123456'); $push->pushToDevice(207787, $notification); } - public static function dataPushToDeviceTalkNotification(): array { + public static function dataProxyPushToDeviceTalkNotification(): array { return [ [['nextcloud'], false, 0], [['nextcloud'], true, 0], @@ -664,11 +670,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', 'deleteProxyPushToken', 'validateToken']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); @@ -703,7 +709,7 @@ public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTal ]; } $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn($devices); $this->l10nFactory @@ -786,6 +792,491 @@ public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTal $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], 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)]]> + + 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" +}