diff --git a/composer.json b/composer.json index 292df6573..3447c039d 100644 --- a/composer.json +++ b/composer.json @@ -38,5 +38,19 @@ "rector:fix": "rector", "test:unit": "phpunit --color -c tests/Unit/phpunit.xml", "test:integration": "cd tests/Integration && ./run.sh" + }, + "require": { + "minishlink/web-push": "^9.0" + }, + "extra": { + "mozart": { + "dep_namespace": "OCA\\Notifications\\Vendor\\", + "dep_directory": "/lib/Vendor/", + "classmap_directory": "/lib/autoload/", + "classmap_prefix": "Notifications_", + "packages": [ + "minishlink/web-push" + ] + } } } diff --git a/composer.lock b/composer.lock index 1ba5a94c8..cf6d6f679 100644 --- a/composer.lock +++ b/composer.lock @@ -4,136 +4,225 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "294ba2f87b40c4d34c3f7cefe9fa098a", - "packages": [], - "packages-dev": [ + "content-hash": "5c216d1411a5662d7760acf57844d002", + "packages": [ { - "name": "bamarni/composer-bin-plugin", - "version": "1.8.2", + "name": "brick/math", + "version": "0.12.3", "source": { "type": "git", - "url": "https://github.com/bamarni/composer-bin-plugin.git", - "reference": "92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880" + "url": "https://github.com/brick/math.git", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880", - "reference": "92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880", + "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", "shasum": "" }, "require": { - "composer-plugin-api": "^2.0", - "php": "^7.2.5 || ^8.0" + "php": "^8.1" }, "require-dev": { - "composer/composer": "^2.0", - "ext-json": "*", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^8.5 || ^9.5", - "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", - "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", - "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0" - }, - "type": "composer-plugin", - "extra": { - "class": "Bamarni\\Composer\\Bin\\BamarniBinPlugin" + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" }, + "type": "library", "autoload": { "psr-4": { - "Bamarni\\Composer\\Bin\\": "src" + "Brick\\Math\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "No conflicts for your bin dependencies", + "description": "Arbitrary-precision arithmetic library", "keywords": [ - "composer", - "conflict", - "dependency", - "executable", - "isolation", - "tool" + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" ], "support": { - "issues": "https://github.com/bamarni/composer-bin-plugin/issues", - "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.8.2" + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.12.3" }, - "time": "2022-10-31T08:38:03+00:00" + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-02-28T13:11:00+00:00" }, { - "name": "nextcloud/ocp", - "version": "dev-master", + "name": "guzzlehttp/guzzle", + "version": "7.10.0", "source": { "type": "git", - "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "6096dced51d997d0e9bedefb6b428c09e6aeae09" + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/6096dced51d997d0e9bedefb6b428c09e6aeae09", - "reference": "6096dced51d997d0e9bedefb6b428c09e6aeae09", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { - "php": "~8.1 || ~8.2 || ~8.3 || ~8.4", - "psr/clock": "^1.0", - "psr/container": "^2.0.2", - "psr/event-dispatcher": "^1.0", - "psr/log": "^3.0.2" + "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" }, - "default-branch": true, "type": "library", "extra": { - "branch-alias": { - "dev-master": "33.0.0-dev" + "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": [ - "AGPL-3.0-or-later" + "MIT" ], "authors": [ { - "name": "Christoph Wurst", - "email": "christoph@winzerhof-wurst.at" + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" }, { - "name": "Joas Schilling", - "email": "coding@schilljs.com" + "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": "Composer package containing Nextcloud's public OCP API and the unstable NCU API", + "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/nextcloud-deps/ocp/issues", - "source": "https://github.com/nextcloud-deps/ocp/tree/master" + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, - "time": "2025-11-05T00:53:12+00:00" + "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": "psr/clock", - "version": "1.0.0", + "name": "guzzlehttp/promises", + "version": "2.3.0", "source": { "type": "git", - "url": "https://github.com/php-fig/clock.git", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", - "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0" + "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": { - "Psr\\Clock\\": "src/" + "GuzzleHttp\\Promise\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -142,51 +231,92 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "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": "Common interface for reading the clock.", - "homepage": "https://github.com/php-fig/clock", + "description": "Guzzle promises library", "keywords": [ - "clock", - "now", - "psr", - "psr-20", - "time" + "promise" ], "support": { - "issues": "https://github.com/php-fig/clock/issues", - "source": "https://github.com/php-fig/clock/tree/1.0.0" + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, - "time": "2022-11-25T14:36:26+00:00" + "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": "psr/container", - "version": "2.0.2", + "name": "guzzlehttp/psr7", + "version": "2.8.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { - "php": ">=7.4.0" + "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": { - "branch-alias": { - "dev-master": "2.0.x-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": false } }, "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "GuzzleHttp\\Psr7\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -195,51 +325,164 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "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": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "PSR-7 message implementation that also provides common utility methods", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, - "time": "2021-11-05T16:47:00+00:00" + "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": "psr/event-dispatcher", - "version": "1.0.0", + "name": "minishlink/web-push", + "version": "v9.0.3", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "5c185f78ee41f271e2ea7314c80760040465b713" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/5c185f78ee41f271e2ea7314c80760040465b713", + "reference": "5c185f78ee41f271e2ea7314c80760040465b713", "shasum": "" }, "require": { - "php": ">=7.2.0" + "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", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" + "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": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", "autoload": { "psr-4": { - "Psr\\EventDispatcher\\": "src/" + "ParagonIE\\ConstantTime\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -248,71 +491,1901 @@ ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" } ], - "description": "Standard interfaces for event handling.", + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", "keywords": [ - "events", - "psr", - "psr-14" + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" ], "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2025-09-24T15:06:41+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "paragonie/sodium_compat", + "version": "v2.4.0", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/paragonie/sodium_compat.git", + "reference": "547e2dc4d45107440e76c17ab5a46e4252460158" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/547e2dc4d45107440e76c17ab5a46e4252460158", + "reference": "547e2dc4d45107440e76c17ab5a46e4252460158", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": "^8.1", + "php-64bit": "*" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^7|^8|^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "suggest": { + "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { + "files": [ + "autoload.php" + ], "psr-4": { - "Psr\\Log\\": "src" + "ParagonIE\\Sodium\\": "namespaced/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "ISC" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com" + }, + { + "name": "Frank Denis", + "email": "jedisct1@pureftpd.org" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", "keywords": [ - "log", - "psr", - "psr-3" + "Authentication", + "BLAKE2b", + "ChaCha20", + "ChaCha20-Poly1305", + "Chapoly", + "Curve25519", + "Ed25519", + "EdDSA", + "Edwards-curve Digital Signature Algorithm", + "Elliptic Curve Diffie-Hellman", + "Poly1305", + "Pure-PHP cryptography", + "RFC 7748", + "RFC 8032", + "Salpoly", + "Salsa20", + "X25519", + "XChaCha20-Poly1305", + "XSalsa20-Poly1305", + "Xchacha20", + "Xsalsa20", + "aead", + "cryptography", + "ecdh", + "elliptic curve", + "elliptic curve cryptography", + "encryption", + "libsodium", + "php", + "public-key cryptography", + "secret-key cryptography", + "side-channel resistant" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" + "issues": "https://github.com/paragonie/sodium_compat/issues", + "source": "https://github.com/paragonie/sodium_compat/tree/v2.4.0" }, - "time": "2024-09-11T13:17:53+00:00" + "time": "2025-10-06T08:47:40+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+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", + "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": "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": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+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/console", + "version": "v6.4.27", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "13d3176cf8ad8ced24202844e9f95af11e2959fc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/13d3176cf8ad8ced24202844e9f95af11e2959fc", + "reference": "13d3176cf8ad8ced24202844e9f95af11e2959fc", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^5.4|^6.0|^7.0" + }, + "conflict": { + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "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/v6.4.27" + }, + "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-10-06T10:25:16+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/http-client", + "version": "v6.4.28", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "c9e69c185c4a845f9d46958cdb0dc7aa847f3981" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/c9e69c185c4a845f9d46958cdb0dc7aa847f3981", + "reference": "c9e69c185c4a845f9d46958cdb0dc7aa847f3981", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.3" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/amp": "^2.5", + "amphp/http-client": "^4.2.1", + "amphp/http-tunnel": "^1.0", + "amphp/socket": "^1.1", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/process": "^5.4|^6.0|^7.0", + "symfony/stopwatch": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "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 powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v6.4.28" + }, + "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-11-05T17:39:22+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "75d7043853a42837e68111812f4d964b01e5101c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c", + "reference": "75d7043853a42837e68111812f4d964b01e5101c", + "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": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "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 HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-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-29T11:18:49+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-php83", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "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\\Php83\\": "" + }, + "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 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/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-07-08T02:45:35+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "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.1" + }, + "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-07-15T11:30:57+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" + }, + { + "name": "web-token/jwt-library", + "version": "3.4.9", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "8fe1650bf3a73673a9c520feff8f9a0e9cbbcd8f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/8fe1650bf3a73673a9c520feff8f9a0e9cbbcd8f", + "reference": "8fe1650bf3a73673a9c520feff8f9a0e9cbbcd8f", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12", + "ext-json": "*", + "ext-mbstring": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "paragonie/sodium_compat": "^1.20|^2.0", + "php": ">=8.1", + "psr/cache": "^2.0|^3.0", + "psr/clock": "^1.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "spomky-labs/pki-framework": "^1.2.1", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/polyfill-mbstring": "^1.12" + }, + "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 (A128KW, A192KW, A256KW, A128GCMKW, A192GCMKW, A256GCMKW, PBES2-HS256+A128KW, PBES2-HS384+A192KW, PBES2-HS512+A256KW...)", + "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/3.4.9" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-11-17T20:20:37+00:00" + } + ], + "packages-dev": [ + { + "name": "bamarni/composer-bin-plugin", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/bamarni/composer-bin-plugin.git", + "reference": "92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880", + "reference": "92fd7b1e6e9cdae19b0d57369d8ad31a37b6a880", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "composer/composer": "^2.0", + "ext-json": "*", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.5", + "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", + "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", + "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Bamarni\\Composer\\Bin\\BamarniBinPlugin" + }, + "autoload": { + "psr-4": { + "Bamarni\\Composer\\Bin\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "No conflicts for your bin dependencies", + "keywords": [ + "composer", + "conflict", + "dependency", + "executable", + "isolation", + "tool" + ], + "support": { + "issues": "https://github.com/bamarni/composer-bin-plugin/issues", + "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.8.2" + }, + "time": "2022-10-31T08:38:03+00:00" + }, + { + "name": "nextcloud/ocp", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/nextcloud-deps/ocp.git", + "reference": "be29d44275c043c2ad861f5058bd33148a846f0e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/be29d44275c043c2ad861f5058bd33148a846f0e", + "reference": "be29d44275c043c2ad861f5058bd33148a846f0e", + "shasum": "" + }, + "require": { + "php": "~8.1 || ~8.2 || ~8.3 || ~8.4", + "psr/clock": "^1.0", + "psr/container": "^2.0.2", + "psr/event-dispatcher": "^1.0", + "psr/log": "^3.0.2" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "33.0.0-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "AGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Christoph Wurst", + "email": "christoph@winzerhof-wurst.at" + }, + { + "name": "Joas Schilling", + "email": "coding@schilljs.com" + } + ], + "description": "Composer package containing Nextcloud's public OCP API and the unstable NCU API", + "support": { + "issues": "https://github.com/nextcloud-deps/ocp/issues", + "source": "https://github.com/nextcloud-deps/ocp/tree/master" + }, + "time": "2025-11-19T00:52:11+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" } ], "aliases": [], 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/WebController.php b/lib/Controller/WebController.php new file mode 100644 index 000000000..6b1fda17d --- /dev/null +++ b/lib/Controller/WebController.php @@ -0,0 +1,48 @@ +> + */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/service-worker.js')] + public function serviceWorker(): StreamResponse { + $response = new StreamResponse(__DIR__ . '/../../service-worker.js'); + $response->setHeaders([ + 'Content-Type' => 'application/javascript', + 'Service-Worker-Allowed' => '/' + ]); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + $policy->addAllowedConnectDomain("'self'"); + $response->setContentSecurityPolicy($policy); + return $response; + } +} diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php new file mode 100644 index 000000000..31e33cabc --- /dev/null +++ b/lib/Controller/WebPushController.php @@ -0,0 +1,335 @@ + + * + * 200: The VAPID key + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/webpush/vapid', requirements: ['apiVersion' => '(v2)'])] + public function getVapid(string $apiVersion): DataResponse { + return new DataResponse(['vapid' => $this->getWPClient()->getVapidPublicKey()], Http::STATUS_OK); + } + + /** + * Register a subscription for push notifications + * + * @param string $endpoint Push Server URL (RFC8030) + * @param string $uaPublicKey Public key of the device, uncompress base64url encoded (RFC8291) + * @param string $auth Authentication tag, base64url encoded (RFC8291) + * @param string $apptypes comma seperated list of types used to filter incoming notifications - apptypes are alphanum - use "all" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk') + * @return DataResponse, array{}>|DataResponse + * + * 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) > 1000 + || !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, json_encode(['activationToken' => $activationToken])); + } + + return match($status) { + NewSubStatus::UPDATED => new DataResponse([], Http::STATUS_OK), + NewSubStatus::CREATED => new DataResponse([], Http::STATUS_CREATED), + // This should not happen + NewSubStatus::ERROR => new DataResponse(['message' => 'DB_ERROR'], Http::STATUS_BAD_REQUEST), + }; + } + + /** + * 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..f429051c1 --- /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' => 1024, + ]); + $table->addColumn('p256dh', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + $table->addColumn('auth', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('apptypes', Types::STRING, [ + 'notnull' => true, + 'length' => 256, + ]); + $table->addColumn('activated', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false + ]); + $table->addColumn('activation_token', Types::STRING, [ + 'notnull' => true, + 'length' => 36 + ]); + + $table->setPrimaryKey(['id']); + // Allow a single push subscription per device + $table->addUniqueIndex(['uid', 'token'], 'oc_npushwp_uid'); + // If the push endpoint is removed, we will delete the row based on the endpoint + $table->addIndex(['endpoint'], 'oc_npushwp_endpoint'); + } + return $schema; + } +} diff --git a/lib/Push.php b/lib/Push.php index 582c91a4f..d3c3c034a 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 { - $devices = $this->userDevices[$notification->getUser()]; + $webPushDevices = $this->userWebPushDevices[$notification->getUser()]; + } + if (!array_key_exists($notification->getUser(), $this->userProxyDevices)) { + $proxyDevices = $this->getProxyDevicesForUser($notification->getUser()); + $this->userProxyDevices[$notification->getUser()] = $proxyDevices; + } else { + $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 { - $devices = $this->userDevices[$userId]; + $webPushDevices = $this->userWebPushDevices[$userId]; + } + if (!array_key_exists($userId, $this->userProxyDevices)) { + $proxyDevices = $this->getProxyDevicesForUser($userId); + $this->userProxyDevices[$userId] = $proxyDevices; + } else { + $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 = $report->getResponse()?->getHeader('Retry-After'); + $this->cache->set('wp.' . $report->getEndpoint(), true, $retryAfter ?? 60); + } + } + 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(json_encode($data)) - 2; $data['subject'] = Util::shortenMultibyteString($notification->getParsedSubject(), $maxDataLength); if ($notification->getParsedSubject() !== $data['subject']) { $data['subject'] .= '…'; } + return $data; + } - if ($isTalkNotification) { - $priority = 'high'; - $type = $data['type'] === 'call' ? 'voip' : 'alert'; - } elseif ($data['app'] === 'twofactor_nextcloud_notification' || $data['app'] === 'phonetrack') { - $priority = 'high'; - $type = 'alert'; + /** + * @param ?int[] $ids + * @return array + * @psalm-return array{remaining: list, data: array{delete-all: bool, nid: int, delete: bool, nids: int[], delete-multiple: int}} + */ + protected function encodeDeleteNotifs(?array $ids): array { + $remainingIds = []; + if ($ids === null) { + $data = [ + 'delete-all' => true, + ]; + } elseif (count($ids) === 1) { + $data = [ + 'nid' => array_pop($ids), + 'delete' => true, + ]; } else { - $priority = 'normal'; - $type = 'alert'; + $remainingIds = array_splice($ids, 10); + $data = [ + 'nids' => $ids, + 'delete-multiple' => true, + ]; } + return [ + 'remaining' => $remainingIds, + 'data' => $data + ]; + } + + /** + * Get notification urgency (priority) and topic, the urgency is compatible with + * [RFC8030's Urgency](https://www.rfc-editor.org/rfc/rfc8030#section-5.3) + * + * + * @param string app + * @param string type + * @return array + * @psalm-return array{urgency: string, type: string} + */ + protected function getNotifTopicAndUrgency(string $app, string $type): array { + $res = []; + if (\in_array($app, ['spreed', 'talk', 'admin_notification_talk'], true)) { + $res['urgency'] = 'high'; + $res['type'] = $type === 'call' ? 'voip' : 'alert'; + } elseif ($app === 'twofactor_nextcloud_notification' || $app === 'phonetrack') { + $res['urgency'] = 'high'; + $res['type'] = 'alert'; + } else { + $res['urgency'] = 'normal'; + $res['type'] = 'alert'; + } + return $res; + } + + /** + * @param Key $userKey + * @param array $device + * @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..dc04ba20a --- /dev/null +++ b/lib/WebPushClient.php @@ -0,0 +1,152 @@ +client)) { + return $this->client; + } + $this->client = new WebPush(auth: $this->getVapid()); + $this->client->setReuseVAPIDHeaders(true); + return $this->client; + } + + /** + * @return array + * @psalm-return array{publicKey: string, privateKey: string} + */ + private function getVapid(): array { + if (isset($this->vapid) && array_key_exists('publicKey', $this->vapid) && array_key_exists('privateKey', $this->vapid)) { + return $this->vapid; + } + $publicKey = $this->appConfig->getValueString( + Application::APP_ID, + 'webpush_vapid_pubkey', + lazy: true + ); + $privateKey = $this->appConfig->getValueString( + Application::APP_ID, + 'webpush_vapid_privkey', + lazy: true + ); + if ($publicKey === '' || $privateKey === '') { + $this->vapid = VAPID::createVapidKeys(); + $this->appConfig->setValueString( + Application::APP_ID, + 'webpush_vapid_pubkey', + $this->vapid['publicKey'], + lazy: true, + sensitive: true + ); + $this->appConfig->setValueString( + Application::APP_ID, + 'webpush_vapid_privkey', + $this->vapid['privateKey'], + lazy: true, + sensitive: true + ); + } else { + $this->vapid = [ + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + ]; + } + return $this->vapid; + } + + /** + * @return string + */ + public function getVapidPublicKey(): string { + return $this->getVapid()['publicKey']; + } + + /** + * Send one notification - blocking (should be avoided most of the time) + */ + public function notify(string $endpoint, string $uaPublicKey, string $auth, string $body): void { + $c = $this->getClient(); + $c->queueNotification( + new Subscription($endpoint, $uaPublicKey, $auth, 'aes128gcm'), + $body + ); + // the callback could be defined by the caller + // For the moment, it is used during registration only - no need to catch 404 &co + // as the registration isn't activated + $callback = function ($r) {}; + $c->flushPooled($callback); + } + + /** + * Send one notification - blocking (should be avoided most of the time) + * @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/public/service-worker.js b/public/service-worker.js new file mode 100644 index 000000000..ffce312b4 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,72 @@ +'use strict'; + +function showBgNotification(title) { + registration.showNotification(title); +} + +self.addEventListener('push', function(event) { + console.info("Received push message"); + + if (event.data) { + const content = event.data.json(); + console.log("Got ", content); + // Send the event to the last focused page only, + // show a notification with the subject if we don't have any + // active tab + event.waitUntil( + self.clients.matchAll() + .then(clientList => { + const client = clientList[0]; + if (client !== undefined) { + console.debug("Sending to client ", client); + client.postMessage({'type':'push','content':content}); + } else if (content.subject) { + console.debug("No valid client to send notif - showing bg notif") + showBgNotification(content.subject); + } else { + console.warn("No valid client to send notif") + } + }) + .catch(err => { + console.error("Couldn't send message: ", err); + }) + ); + } +}); + +self.addEventListener('pushsubscriptionchange', function(event) { + console.log("Push Subscription Change", event); + event.waitUntil( + self.clients.matchAll() + .then(clientList => { + const client = clientList[0]; + if (client !== undefined) { + console.debug("Sending to client ", client); + client.postMessage({'type':'pushEndpoint'}); + } else { + console.warn("No valid client to send notif") + } + }) + .catch(err => { + console.error("Couldn't send message: ", err); + }) + ); +}); + +self.addEventListener('registration', function(event){ + console.log("Registered"); +}); + + +self.addEventListener('install', function(event){ + console.log("Installed"); + // Replace currenctly active serviceWorkers with this one + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', function(event){ + console.log("Activated") + // Ensure we control the clients + event.waitUntil(self.clients.claim()); +}); + diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 250d19022..d9ea4ce6c 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -87,7 +87,7 @@ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import { listen } from '@nextcloud/notify_push' -import { generateOcsUrl, imagePath } from '@nextcloud/router' +import { generateOcsUrl, imagePath, generateUrl } from '@nextcloud/router' import NcButton from '@nextcloud/vue/components/NcButton' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' @@ -232,8 +232,26 @@ export default { this.hasNotifyPush = true } - // Set up the background checker - this._setPollingInterval(this.pollIntervalBase) + // set the polling interval after checking web push status + // if web push may be configured + if (this.webNotificationsGranted === true) { + // We dont fetch on push if notify_push is enabled, to avoid concurrency fetch. + // We could do the other way: fallback to notify_push only if we don't have + // web push + window.addEventListener('load', () => { + this.setWebPush(!hasPush, (hasWebPush) => { + if (hasWebPush) { + console.debug('Has web push, slowing polling to 15 minutes') + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + } + this._setPollingInterval(this.pollIntervalBase) + }) + }) + } else { + // Set up the background checker + this._setPollingInterval(this.pollIntervalBase) + } this._watchTabVisibility() subscribe('networkOffline', this.handleNetworkOffline) @@ -250,14 +268,106 @@ export default { methods: { t, + loadServiceWorker() { + return navigator.serviceWorker.register( + generateUrl('/apps/notifications/service-worker.js', {}, { noRewrite: true }), + {scope: "/"} + ).then((registration) => { + console.info('ServiceWorker registered') + return registration + }) + }, + listenForPush(registration, syncOnPush) { + navigator.serviceWorker.addEventListener('message', (event) => { + console.debug("Received from serviceWorker: ", JSON.stringify(event.data)) + if (event.data.type == 'push') { + const activationToken = event.data.content.activationToken + if (activationToken) { + const form = new FormData() + form.append('activationToken', activationToken) + axios.post(generateOcsUrl('apps/notifications/api/v2/webpush/activate'), form) + .then((r) => { + if (r.status === 200 || r.status === 202) { + console.debug("Push notifications activated, slowing polling to 15 minutes") + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + this._setPollingInterval(this.pollIntervalBase) + } else { + console.warn("An error occured while activating push registration", r) + } + }) + } else { + if (syncOnPush) { + // force=true: we don't have to check if we're the last tab, + // the serviceworker send the event to a single tab + this._fetchAfterNotifyPush(true) + } + } + } else if (event.data.type == 'pushEndoint') { + registerPush(registration) + .catch(er => console.error(er)) + } + }) + }, + registerPush(registration) { + return registration.pushManager.subscribe().then((sub) => { + const form = new FormData() + form.append('endpoint', sub.endpoint) + form.append('uaPublicKey', this.b64UrlEncode(sub.getKey('p256dh'))) + form.append('auth', this.b64UrlEncode(sub.getKey('auth'))) + form.append('apptypes', ['all']) + return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) + }) + }, + /** + * syncOnPush: boolean, if we fetch for notifications on push. Param used to avoid concurrency + * fetch if another mechanism is in place + * callback(boolean) if the push notifications has been subscribed (statusCode == 200) + */ + setWebPush(syncOnPush, callback) { + if ('serviceWorker' in navigator) { + this.loadServiceWorker() + .then((r) => { + this.listenForPush(r, syncOnPush) + return this.registerPush(r) + }) + .then((r) => callback(r.status == 200)) + .catch(er => { + console.error(er) + callback(false) + }) + } + }, + userStatusUpdated(state) { if (getCurrentUser().uid === state.userId) { this.userStatus = state.status } }, + b64UrlEncode(inArr) { + return new Uint8Array(inArr) + .toBase64() + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', '') + }, async onOpen() { - this.requestWebNotificationPermissions() + if (this.webNotificationsGranted === null) { + this.requestWebNotificationPermissions() + .then((granted) => { + if (granted) { + this.setWebPush(!this.hasNotifyPush, (hasWebPush) => { + if (hasWebPush) { + console.debug('Has web push, slowing polling to 15 minutes') + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + } + this._setPollingInterval(this.pollIntervalBase) + }) + } + }) + } await setCurrentTabAsActive(this.tabId) await this._fetch() @@ -332,9 +442,9 @@ export default { /** * Performs the AJAX request to retrieve the notifications */ - _fetchAfterNotifyPush() { + _fetchAfterNotifyPush(force = false) { this.backgroundFetching = true - if (this.hasNotifyPush && this.tabId !== this.lastTabId) { + if (force || (this.hasNotifyPush && this.tabId !== this.lastTabId)) { console.debug('Deferring notification refresh from browser storage are notify_push event to give the last tab the chance to do it') setTimeout(() => { this._fetch() @@ -457,7 +567,7 @@ export default { return } - if (window.location.protocol === 'http:') { + if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost') { console.debug('Notifications require HTTPS') this.webNotificationsGranted = false return @@ -470,15 +580,15 @@ export default { /** * Check if we can do web notifications */ - async requestWebNotificationPermissions() { + requestWebNotificationPermissions() { if (this.webNotificationsGranted !== null) { - return + return new Promise((resolve, reject) => resolve(this.webNotificationsGranted)) } console.info('Requesting notifications permissions') - window.Notification.requestPermission() + return window.Notification.requestPermission() .then((permissions) => { - this.webNotificationsGranted = permissions === 'granted' + return this.webNotificationsGranted = permissions === 'granted' }) }, 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],