diff --git a/.github/workflows/update-nextcloud-ocp.yml b/.github/workflows/update-nextcloud-ocp.yml index ff22862c2..766ecea94 100644 --- a/.github/workflows/update-nextcloud-ocp.yml +++ b/.github/workflows/update-nextcloud-ocp.yml @@ -66,7 +66,7 @@ jobs: - name: Composer update nextcloud/ocp # zizmor: ignore[template-injection] id: update_branch if: ${{ steps.checkout.outcome == 'success' && matrix.branches != 'main' }} - run: composer require --dev 'nextcloud/ocp:dev-${{ matrix.branches }}' + run: composer bin nextcloud-ocp require --dev 'nextcloud/ocp:dev-${{ matrix.branches }}' - name: Raise on issue on failure uses: dacbd/create-issue-action@cdb57ab6ff8862aa09fee2be6ba77a59581921c2 # v2.0.0 diff --git a/.gitignore b/.gitignore index 5ee260a29..0199af6a9 100644 --- a/.gitignore +++ b/.gitignore @@ -70,5 +70,6 @@ nbproject # Build related files /npm-debug.log /build +/lib/Vendor /vendor /vendor-bin/*/vendor diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index f51434dcf..ef485d06f 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -17,6 +17,7 @@ ->notPath('l10n') ->notPath('node_modules') ->notPath('src') + ->notPath('lib/Vendor') ->notPath('vendor') ->in(__DIR__); return $config; diff --git a/appinfo/info.xml b/appinfo/info.xml index 97c8d3f79..deb6fe645 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -20,7 +20,7 @@ 📣 Announcement center: An announcement was posted by an admin]]> - 7.0.0-dev.0 + 7.0.0-dev.1 agpl Joas Schilling diff --git a/composer.json b/composer.json index 819f4c09b..8617ddf88 100644 --- a/composer.json +++ b/composer.json @@ -8,8 +8,7 @@ "description": "notifications", "license": "AGPL", "require-dev": { - "bamarni/composer-bin-plugin": "^1.9", - "nextcloud/ocp": "dev-master" + "bamarni/composer-bin-plugin": "^1.9" }, "config": { "optimize-autoloader": true, @@ -24,7 +23,13 @@ }, "scripts": { "post-install-cmd": [ - "[ $COMPOSER_DEV_MODE -eq 0 ] || composer bin all install", + "@composer bin all install --ansi", + "\"vendor/bin/mozart\" compose", + "composer dump-autoload" + ], + "post-update-cmd": [ + "@composer bin all install --ansi", + "\"vendor/bin/mozart\" compose", "composer dump-autoload" ], "lint": "find . -name \\*.php -not -path './vendor/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l", @@ -38,5 +43,22 @@ "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": "NEXTCLOUDNOTIFICATIONS_", + "packages": [ + "minishlink/web-push" + ], + "excluded_packages": [ + "symfony/polyfill-php82" + ] + } } } diff --git a/composer.lock b/composer.lock index 7e2010dda..6d87030b0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,114 +4,459 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "842fa0a9610b190b54a15dcb3621c43d", - "packages": [], - "packages-dev": [ + "content-hash": "fb9b5b6e9b41539323bfed134fc1df02", + "packages": [ { - "name": "bamarni/composer-bin-plugin", - "version": "1.9.1", + "name": "brick/math", + "version": "0.14.8", "source": { "type": "git", - "url": "https://github.com/bamarni/composer-bin-plugin.git", - "reference": "641d0663f5ac270b1aeec4337b7856f76204df47" + "url": "https://github.com/brick/math.git", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/641d0663f5ac270b1aeec4337b7856f76204df47", - "reference": "641d0663f5ac270b1aeec4337b7856f76204df47", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { - "composer-plugin-api": "^2.0", - "php": "^7.2.5 || ^8.0" + "php": "^8.2" }, "require-dev": { - "composer/composer": "^2.2.26", + "php-coveralls/php-coveralls": "^2.2", + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.14.8" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-02-10T14:33:43+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { "ext-json": "*", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8 || ^2.0", - "phpstan/phpstan-phpunit": "^1.1 || ^2.0", - "phpunit/phpunit": "^8.5 || ^9.6 || ^10.0", - "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" + "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" }, - "type": "composer-plugin", + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", "extra": { - "class": "Bamarni\\Composer\\Bin\\BamarniBinPlugin" + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } }, "autoload": { + "files": [ + "src/functions_include.php" + ], "psr-4": { - "Bamarni\\Composer\\Bin\\": "src" + "GuzzleHttp\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "No conflicts for your bin dependencies", + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", "keywords": [ - "composer", - "conflict", - "dependency", - "executable", - "isolation", - "tool" + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" ], "support": { - "issues": "https://github.com/bamarni/composer-bin-plugin/issues", - "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.9.1" + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, - "time": "2026-02-04T10:18: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": "nextcloud/ocp", - "version": "dev-master", + "name": "guzzlehttp/promises", + "version": "2.3.0", "source": { "type": "git", - "url": "https://github.com/nextcloud-deps/ocp.git", - "reference": "c34a484ad6a6d6aa05b12f23862483269c7ef9e0" + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/c34a484ad6a6d6aa05b12f23862483269c7ef9e0", - "reference": "c34a484ad6a6d6aa05b12f23862483269c7ef9e0", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { - "php": "~8.1 || ~8.2 || ~8.3 || ~8.4 || ~8.5", - "psr/clock": "^1.0", - "psr/container": "^2.0.2", - "psr/event-dispatcher": "^1.0", - "psr/log": "^3.0.2" + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, - "default-branch": true, "type": "library", "extra": { - "branch-alias": { - "dev-master": "34.0.0-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "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": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" }, { - "name": "Joas Schilling", - "email": "coding@schilljs.com" + "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": "Composer package containing Nextcloud's public OCP API and the unstable NCU API", + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], "support": { - "issues": "https://github.com/nextcloud-deps/ocp/issues", - "source": "https://github.com/nextcloud-deps/ocp/tree/master" + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, - "time": "2026-02-09T19:49:58+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": "guzzlehttp/psr7", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "21dc724a0583619cd1652f673303492272778051" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.8.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-08-23T21:21:41+00:00" + }, + { + "name": "minishlink/web-push", + "version": "v9.0.4", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f979f40b0017d2f86d82b9f21edbc515d031cc23", + "reference": "f979f40b0017d2f86d82b9f21edbc515d031cc23", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.1", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php82": "^v1.31.0", + "web-token/jwt-library": "^3.3.0|^4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.91.3", + "phpstan/phpstan": "^2.1.2", + "phpunit/phpunit": "^10.5.44|^11.5.6", + "symfony/polyfill-iconv": "^1.33" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v9.0.4" + }, + "time": "2025-12-10T14:00:12+00:00" }, { "name": "psr/clock", @@ -162,31 +507,32 @@ "time": "2022-11-25T14:36:26+00:00" }, { - "name": "psr/container", - "version": "2.0.2", + "name": "psr/http-client", + "version": "1.0.3", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", - "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "Psr\\Http\\Client\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -199,37 +545,36 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "http", + "http-client", + "psr", + "psr-18" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/2.0.2" + "source": "https://github.com/php-fig/http-client" }, - "time": "2021-11-05T16:47:00+00:00" + "time": "2023-09-23T14:17:50+00:00" }, { - "name": "psr/event-dispatcher", - "version": "1.0.0", + "name": "psr/http-factory", + "version": "1.1.0", "source": { "type": "git", - "url": "https://github.com/php-fig/event-dispatcher.git", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", - "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { @@ -239,7 +584,7 @@ }, "autoload": { "psr-4": { - "Psr\\EventDispatcher\\": "src/" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -249,47 +594,51 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], - "description": "Standard interfaces for event handling.", + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", "keywords": [ - "events", + "factory", + "http", + "message", "psr", - "psr-14" + "psr-17", + "psr-7", + "request", + "response" ], "support": { - "issues": "https://github.com/php-fig/event-dispatcher/issues", - "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + "source": "https://github.com/php-fig/http-factory" }, - "time": "2019-01-08T18:20:26+00:00" + "time": "2024-04-15T12:06:14+00:00" }, { - "name": "psr/log", - "version": "3.0.2", + "name": "psr/http-message", + "version": "2.0", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { - "php": ">=8.0.0" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "src" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -302,24 +651,538 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", "keywords": [ - "log", + "http", + "http-message", "psr", - "psr-3" + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631", + "reference": "f0e9a548df4e3942886adc9b7830581a46334631", + "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.1" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-12-20T12:57:40+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/php-fig/log/tree/3.0.2" + "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/polyfill-php82", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php82.git", + "reference": "5d2ed36f7734637dacc025f179698031951b1692" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php82/zipball/5d2ed36f7734637dacc025f179698031951b1692", + "reference": "5d2ed36f7734637dacc025f179698031951b1692", + "shasum": "" + }, + "require": { + "php": ">=7.2" }, - "time": "2024-09-11T13:17:53+00:00" + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php82\\": "" + }, + "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.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php82/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": "web-token/jwt-library", + "version": "4.1.3", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "690d4dd47b78f423cb90457f858e4106e1deb728" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/690d4dd47b78f423cb90457f858e4106e1deb728", + "reference": "690d4dd47b78f423cb90457f858e4106e1deb728", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2025-12-18T14:27:35+00:00" + } + ], + "packages-dev": [ + { + "name": "bamarni/composer-bin-plugin", + "version": "1.9.1", + "source": { + "type": "git", + "url": "https://github.com/bamarni/composer-bin-plugin.git", + "reference": "641d0663f5ac270b1aeec4337b7856f76204df47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bamarni/composer-bin-plugin/zipball/641d0663f5ac270b1aeec4337b7856f76204df47", + "reference": "641d0663f5ac270b1aeec4337b7856f76204df47", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0", + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "composer/composer": "^2.2.26", + "ext-json": "*", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8 || ^2.0", + "phpstan/phpstan-phpunit": "^1.1 || ^2.0", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.0", + "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.9.1" + }, + "time": "2026-02-04T10:18:12+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": { - "nextcloud/ocp": 20 - }, + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": {}, diff --git a/composer/autoload.php b/composer/autoload.php new file mode 100644 index 000000000..b1c7bd340 --- /dev/null +++ b/composer/autoload.php @@ -0,0 +1,9 @@ + [ + 'webpush', 'devices', 'object-data', 'delete', diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php new file mode 100644 index 000000000..8a4e6bfb7 --- /dev/null +++ b/lib/Controller/WebPushController.php @@ -0,0 +1,334 @@ + + * + * 200: The VAPID key + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/webpush/vapid', requirements: ['apiVersion' => '(v2)'])] + public function getVapid(): DataResponse { + return new DataResponse(['vapid' => $this->getWPClient()->getVapidPublicKey()], Http::STATUS_OK); + } + + /** + * Register a subscription for push notifications + * + * @param string $endpoint Push Server URL, max 765 chars (RFC8030) + * @param string $uaPublicKey Public key of the device, uncompress base64url encoded (RFC8291) + * @param string $auth Authentication tag, base64url encoded (RFC8291) + * @param string $appTypes comma seperated list of types used to filter incoming notifications - appTypes are alphanum - use "all" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk') + * @return DataResponse, array{}>|DataResponse + * + * 200: A subscription was already registered and activated + * 201: New subscription registered successfully + * 400: Registering is not possible + * 401: Missing permissions to register + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/webpush', requirements: ['apiVersion' => '(v2)'])] + public function registerWP(string $endpoint, string $uaPublicKey, string $auth, string $appTypes): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + if (!WebPushClient::isValidP256dh($uaPublicKey)) { + return new DataResponse(['message' => 'INVALID_P256DH'], Http::STATUS_BAD_REQUEST); + } + + if (!WebPushClient::isValidAuth($auth)) { + return new DataResponse(['message' => 'INVALID_AUTH'], Http::STATUS_BAD_REQUEST); + } + + if ( + !filter_var($endpoint, FILTER_VALIDATE_URL) + || \strlen($endpoint) > 765 + || !str_starts_with($endpoint, 'https://') + ) { + return new DataResponse(['message' => 'INVALID_ENDPOINT'], Http::STATUS_BAD_REQUEST); + } + + if (strlen($appTypes) > 256) { + return new DataResponse(['message' => 'TOO_MANY_APP_TYPES'], Http::STATUS_BAD_REQUEST); + } + + $tokenId = $this->session->get('token-id'); + if (!\is_int($tokenId)) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + [$status, $activationToken] = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes); + + if ($status === NewSubStatus::CREATED) { + $wp = $this->getWPClient(); + $wp->notify($endpoint, $uaPublicKey, $auth, (string)json_encode(['activationToken' => $activationToken])); + } + + return match($status) { + NewSubStatus::UPDATED => new DataResponse([], Http::STATUS_OK), + NewSubStatus::CREATED => new DataResponse([], Http::STATUS_CREATED), + // This should not happen + default => new DataResponse(['message' => 'DB_ERROR'], Http::STATUS_BAD_REQUEST), + }; + } + + /** + * Activate subscription for push notifications + * + * @param string $activationToken Random token sent via a push notification during registration to enable the subscription + * @return DataResponse, array{}>|DataResponse + * + * 200: Subscription was already activated + * 202: Subscription activated successfully + * 400: Activating subscription is not possible, may be because of a wrong activation token + * 401: Missing permissions to activate subscription + * 404: No subscription found for the device + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/webpush/activate', requirements: ['apiVersion' => '(v2)'])] + public function activateWP(string $activationToken): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + $tokenId = (int)$this->session->get('token-id'); + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + $status = $this->activateSubscription($user, $token, $activationToken); + + return match($status) { + ActivationSubStatus::OK => new DataResponse([], Http::STATUS_OK), + ActivationSubStatus::CREATED => new DataResponse([], Http::STATUS_ACCEPTED), + ActivationSubStatus::NO_TOKEN => new DataResponse(['message' => 'INVALID_ACTIVATION_TOKEN'], Http::STATUS_BAD_REQUEST), + ActivationSubStatus::NO_SUB => new DataResponse(['message' => 'NO_PUSH_SUBSCRIPTION'], Http::STATUS_NOT_FOUND), + }; + } + + /** + * Remove a subscription from push notifications + * + * @return DataResponse, array{}>|DataResponse + * + * 200: No subscription for the device + * 202: Subscription removed successfully + * 400: Removing subscription is not possible + * 401: Missing permissions to remove subscription + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/webpush', requirements: ['apiVersion' => '(v2)'])] + public function removeWP(): DataResponse { + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + + $tokenId = (int)$this->session->get('token-id'); + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException) { + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } + + if ($this->deleteSubscription($user, $token)) { + return new DataResponse([], Http::STATUS_ACCEPTED); + } + + return new DataResponse([], Http::STATUS_OK); + } + + protected function getWPClient(): WebPushClient { + return new WebPushClient($this->appConfig); + } + + /** + * @param string $appTypes comma separated list of types + * @return array{0: NewSubStatus, 1: ?string} + * + * - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth + * - UPDATED if the subscription has been updated (use to change appTypes) + */ + protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): array { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('notifications_webpush') + ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))) + ->andWhere($query->expr()->eq('endpoint', $query->createNamedParameter($endpoint))) + ->andWhere($query->expr()->eq('ua_public', $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 endpoint, 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]; + } + return [NewSubStatus::ERROR, null]; + } + + if ($this->updateSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes)) { + return [NewSubStatus::UPDATED, null]; + } + 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; + } + 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), + 'ua_public' => $query->createNamedParameter($uaPublicKey), + 'auth' => $query->createNamedParameter($auth), + 'app_types' => $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('ua_public', $query->createNamedParameter($uaPublicKey)) + ->set('auth', $query->createNamedParameter($auth)) + ->set('app_types', $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..ca2e1b8c1 --- /dev/null +++ b/lib/Migration/Version6000Date20251112110000.php @@ -0,0 +1,83 @@ +hasTable('notifications_webpush')) { + $table = $schema->createTable('notifications_webpush'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 4, + ]); + // uid+token identifies a device + $table->addColumn('uid', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('token', Types::BIGINT, [ + 'default' => 0, + ]); + $table->addColumn('endpoint', Types::STRING, [ + 'notnull' => true, + 'length' => 767, + ]); + $table->addColumn('ua_public', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + $table->addColumn('auth', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + ]); + $table->addColumn('app_types', 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'], 'notifwebpush_uid_token'); + // If the push endpoint is removed, we will delete the row based on the endpoint + $table->addIndex(['endpoint'], 'notifwebpush_endpoint'); + return $schema; + } + return null; + } +} diff --git a/lib/Push.php b/lib/Push.php index 7673e9318..9f7e1a0aa 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -15,6 +15,7 @@ use OC\Security\IdentityProof\Key; use OC\Security\IdentityProof\Manager; use OCA\Notifications\AppInfo\Application; +use OCA\Notifications\Vendor\Minishlink\WebPush\MessageSentReport; use OCP\AppFramework\Http; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Authentication\Exceptions\InvalidTokenException; @@ -25,6 +26,7 @@ use OCP\ICacheFactory; use OCP\IConfig; use OCP\IDBConnection; +use OCP\IUser; use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Notification\AlreadyProcessedException; @@ -63,10 +65,14 @@ class Push { * @psalm-var array */ protected array $userStatuses = []; + /** + * @psalm-var array> + */ + protected array $userWebPushDevices = []; /** * @psalm-var array> */ - protected array $userDevices = []; + protected array $userProxyDevices = []; /** @var string[] */ protected array $loadDevicesForUsers = []; /** @var string[] */ @@ -77,6 +83,7 @@ public function __construct( protected IUserManager $userManager, protected INotificationManager $notificationManager, protected IConfig $config, + protected WebPushClient $wpClient, protected IProvider $tokenProvider, protected Manager $keyManager, protected IClientService $clientService, @@ -114,10 +121,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 = []; } @@ -148,23 +162,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->wpClient->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['app_types']); + return $device['activated'] && (\in_array($app, $appTypes) + || (\in_array('all', $appTypes) && !\in_array('-' . $app, $appTypes))); + }); + } + + /** * @param array $devices * @psalm-param $devices list @@ -172,7 +202,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'); @@ -231,14 +261,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; } @@ -259,6 +295,86 @@ 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']); + + switch ($this->validateToken($device['token'], $maxAge)) { + case TokenValidation::VALID: + break; + case TokenValidation::INVALID: + // Token does not exist anymore + $this->deleteWebPushToken($device['token']); + // no break + case TokenValidation::OLD: + continue 2; + } + + // 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->wpClient->enqueue( + $device['endpoint'], + $device['ua_public'], + $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->wpClient->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())); @@ -268,7 +384,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; @@ -283,9 +399,15 @@ public function pushToDevice(int $id, INotification $notification, ?OutputInterf $this->printInfo(''); $this->printInfo('Device token: ' . $device['token']); - if (!$this->validateToken($device['token'], $maxAge)) { - // Token does not exist anymore - continue; + switch ($this->validateToken($device['token'], $maxAge)) { + case TokenValidation::VALID: + break; + case TokenValidation::INVALID: + // Token does not exist anymore + $this->deleteProxyPushToken($device['token']); + // no break + case TokenValidation::OLD: + continue 2; } try { @@ -300,7 +422,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(''); @@ -332,7 +454,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] = []; @@ -353,17 +475,37 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri $user = $this->userManager->getExistingUser($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; } @@ -371,24 +513,35 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri // We don't push to devices that are older than 60 days $maxAge = time() - 60 * 24 * 60 * 60; - $userKey = $this->keyManager->getKey($user); foreach ($devices as $device) { $device['token'] = (int)$device['token']; - if (!$this->validateToken($device['token'], $maxAge)) { - // Token does not exist anymore + switch ($this->validateToken($device['token'], $maxAge)) { + case TokenValidation::VALID: + break; + case TokenValidation::INVALID: + // Token does not exist anymore + $this->deleteWebPushToken($device['token']); + // no break + case TokenValidation::OLD: + continue 2; + } + + // 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 { - $proxyServer = rtrim($device['proxyserver'], '/'); - if (!isset($this->payloadsToSend[$proxyServer])) { - $this->payloadsToSend[$proxyServer] = []; - } - if ($deleteAll) { - $data = $this->encryptAndSignDelete($userKey, $device, null); + $data = $this->encodeDeleteNotifs(null); try { - $this->payloadsToSend[$proxyServer][] = json_encode($data['payload'], JSON_THROW_ON_ERROR); + $payload = json_encode($data['data'], JSON_THROW_ON_ERROR); + $this->wpClient->enqueue($device['endpoint'], $device['ua_public'], $device['auth'], $payload); } catch (\JsonException $e) { $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); } @@ -396,10 +549,11 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri $temp = $notificationIds; while (!empty($temp)) { - $data = $this->encryptAndSignDelete($userKey, $device, $temp); + $data = $this->encodeDeleteNotifs($temp); $temp = $data['remaining']; try { - $this->payloadsToSend[$proxyServer][] = json_encode($data['payload'], JSON_THROW_ON_ERROR); + $payload = json_encode($data['data'], JSON_THROW_ON_ERROR); + $this->wpClient->enqueue($device['endpoint'], $device['ua_public'], $device['auth'], $payload); } catch (\JsonException $e) { $this->log->error('JSON error while encoding push notification: ' . $e->getMessage(), ['exception' => $e]); } @@ -407,7 +561,85 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri } } catch (\InvalidArgumentException) { // Failed to encrypt message for device: public key is invalid - $this->deletePushToken($device['token']); + $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; + } + + // We don't push to devices that are older than 60 days + $maxAge = time() - 60 * 24 * 60 * 60; + + $userKey = $this->keyManager->getKey($user); + foreach ($devices as $device) { + $device['token'] = (int)$device['token']; + switch ($this->validateToken($device['token'], $maxAge)) { + case TokenValidation::VALID: + break; + case TokenValidation::INVALID: + // Token does not exist anymore + $this->deleteProxyPushToken($device['token']); + // no break + case TokenValidation::OLD: + continue 2; + } + + try { + $proxyServer = rtrim($device['proxyserver'], '/'); + if (!isset($this->payloadsToSend[$proxyServer])) { + $this->payloadsToSend[$proxyServer] = []; + } + + if ($deleteAll) { + $data = $this->encryptAndSignDelete($userKey, $device, null); + 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 { + // 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->deleteProxyPushToken($device['token']); } } @@ -416,6 +648,18 @@ public function pushDeleteToDevice(string $userId, ?array $notificationIds, stri } } + /** + * Delete expired web push subscriptions + */ + protected function webPushCallback(MessageSentReport $report): void { + if ($report->isSubscriptionExpired()) { + $this->deleteWebPushTokenByEndpoint($report->getEndpoint()); + } elseif ($report->getResponse()?->getStatusCode() === 429) { + $retryAfter = (int)($report->getResponse()?->getHeader('Retry-After')[0] ?? '60'); + $this->cache->set('wp.' . $report->getEndpoint(), true, $retryAfter); + } + } + protected function sendNotificationsToProxies(): void { $pushNotifications = $this->payloadsToSend; $this->payloadsToSend = []; @@ -503,7 +747,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); } } @@ -535,7 +779,7 @@ protected function sendNotificationsToProxies(): void { } } - protected function validateToken(int $tokenId, int $maxAge): bool { + protected function validateToken(int $tokenId, int $maxAge): TokenValidation { $age = $this->cache->get('t' . $tokenId); if ($age === null) { @@ -546,9 +790,8 @@ 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; + return TokenValidation::INVALID; } $age = $token->getLastCheck(); @@ -560,19 +803,18 @@ 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; + return TokenValidation::INVALID; } } if ($age > $maxAge) { $this->printInfo('Device token is valid'); - return true; + return TokenValidation::VALID; } $this->printInfo('Device token "last checked" is older than 60 days: ' . $age . ''); - return false; + return TokenValidation::OLD; } /** @@ -595,17 +837,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(), @@ -616,22 +854,84 @@ protected function encryptAndSign(Key $userKey, array $device, int $id, INotific // Max length of encryption is ~240, so we need to make sure the subject is shorter. // Also, subtract two for encapsulating quotes will be added. - $maxDataLength = 200 - strlen(json_encode($data)) - 2; + $maxDataLength = $maxLength - strlen((string)json_encode($data)) - 2; $data['subject'] = Util::shortenMultibyteString($notification->getParsedSubject(), $maxDataLength); if ($notification->getParsedSubject() !== $data['subject']) { $data['subject'] .= '…'; } + return $data; + } - 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{data: array{'delete-all'?: true, 'delete-multiple'?: true, delete?: true, nid?: int, nids?: int[]}, remaining: int[]} + */ + protected function encodeDeleteNotifs(?array $ids): array { + $remainingIds = []; + if ($ids === null) { + $data = [ + 'delete-all' => true, + ]; + } elseif (count($ids) === 1) { + $data = [ + 'nid' => array_pop($ids), + 'delete' => true, + ]; } else { - $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)); @@ -671,23 +971,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']); @@ -716,7 +1002,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') @@ -731,10 +1017,9 @@ protected function getDevicesForUser(string $uid): array { /** * @param string[] $userIds - * @return array[] - * @psalm-return array> + * @return array> */ - protected function getDevicesForUsers(array $userIds): array { + protected function getProxyDevicesForUsers(array $userIds): array { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_pushhash') @@ -754,11 +1039,77 @@ protected function getDevicesForUsers(array $userIds): array { return $devices; } + + /** + * @param string $uid + * @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> + */ + 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))); @@ -770,7 +1121,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/TokenValidation.php b/lib/TokenValidation.php new file mode 100644 index 000000000..50271b302 --- /dev/null +++ b/lib/TokenValidation.php @@ -0,0 +1,16 @@ +vapid = $this->getVapid(); + } + + public static function isValidP256dh(string $key): bool { + if (!preg_match('/^[A-Za-z0-9_-]{87}=*$/', $key)) { + return false; + } + try { + Utils::unserializePublicKey(Base64Url::decode($key)); + } catch (\InvalidArgumentException) { + return false; + } + return true; + } + + public static function isValidAuth(string $auth): bool { + if (!preg_match('/^[A-Za-z0-9_-]{22}=*$/', $auth)) { + return false; + } + try { + $a = Base64Url::decode($auth); + } catch (\InvalidArgumentException) { + return false; + } + return strlen($a) === 16; + } + + private function getClient(): WebPush { + if (isset($this->client)) { + return $this->client; + } + $this->client = new WebPush(auth: ['VAPID' => $this->vapid]); + $this->client->setReuseVAPIDHeaders(true); + return $this->client; + } + + /** + * @return array + * @psalm-return array{publicKey: string, privateKey: string, subject: string} + */ + private function getVapid(): array { + // Do not use lazy for now + $publicKey = $this->appConfig->getValueString( + Application::APP_ID, + 'webpush_vapid_pubkey' + ); + $privateKey = $this->appConfig->getValueString( + Application::APP_ID, + 'webpush_vapid_privkey' + ); + if ($publicKey === '' || $privateKey === '') { + /** @var array{publicKey: string, privateKey: string} $vapid */ + $vapid = VAPID::createVapidKeys(); + $this->appConfig->setValueString( + Application::APP_ID, + 'webpush_vapid_pubkey', + $vapid['publicKey'] + ); + $this->appConfig->setValueString( + Application::APP_ID, + 'webpush_vapid_privkey', + $vapid['privateKey'], + sensitive: true + ); + } else { + $vapid = [ + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + ]; + } + $vapid['subject'] = 'https://nextcloud.com/contact/'; + return $vapid; + } + + /** + * @return string + */ + public function getVapidPublicKey(): string { + return $this->vapid['publicKey']; + } + + /** + * Send one notification - blocking (should be avoided most of the time) + */ + public function notify(string $endpoint, string $uaPublicKey, string $auth, string $body): void { + $c = $this->getClient(); + $c->queueNotification( + new Subscription($endpoint, $uaPublicKey, $auth, 'aes128gcm'), + $body + ); + // the callback could be defined by the caller + // For the moment, it is used during registration only - no need to catch 404 &co + // as the registration isn't activated + $callback = function ($r) { + }; + $c->flushPooled($callback); + } + + /** + * Queue one notification. [flush] needs to be called to actually send the notifications + * @throws \ErrorException + */ + public function enqueue(string $endpoint, string $uaPublicKey, string $auth, string $body, string $urgency = 'normal'): void { + $c = $this->getClient(); + $c->queueNotification( + new Subscription($endpoint, $uaPublicKey, $auth, 'aes128gcm'), + $body, + options: [ + 'urgency' => $urgency + ] + ); + } + + /** + * @param callable $callback + * @psalm-param $callback callable(MessageSentReport): void + */ + public function flush(callable $callback): void { + $c = $this->getClient(); + $c->flushPooled($callback); + } +} diff --git a/openapi-full.json b/openapi-full.json index 6e4379483..d68228a9c 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -2349,6 +2349,774 @@ } } } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/vapid": { + "get": { + "operationId": "web_push-get-vapid", + "summary": "Return the server VAPID public key", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "The VAPID key", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "vapid" + ], + "properties": { + "vapid": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush": { + "post": { + "operationId": "web_push-registerwp", + "summary": "Register a subscription for push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "endpoint", + "uaPublicKey", + "auth", + "appTypes" + ], + "properties": { + "endpoint": { + "type": "string", + "description": "Push Server URL, max 765 chars (RFC8030)" + }, + "uaPublicKey": { + "type": "string", + "description": "Public key of the device, uncompress base64url encoded (RFC8291)" + }, + "auth": { + "type": "string", + "description": "Authentication tag, base64url encoded (RFC8291)" + }, + "appTypes": { + "type": "string", + "description": "comma seperated list of types used to filter incoming notifications - appTypes are alphanum - use \"all\" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk')" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "A subscription was already registered and activated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "201": { + "description": "New subscription registered successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to register", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Registering is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "web_push-removewp", + "summary": "Remove a subscription from push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "No subscription for the device", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "Subscription removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to remove subscription", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Removing subscription is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/activate": { + "post": { + "operationId": "web_push-activatewp", + "summary": "Activate subscription for push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "activationToken" + ], + "properties": { + "activationToken": { + "type": "string", + "description": "Random token sent via a push notification during registration to enable the subscription" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Subscription was already activated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "Subscription activated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to activate subscription", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Activating subscription is not possible, may be because of a wrong activation token", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "No subscription found for the device", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } } }, "tags": [] diff --git a/openapi-webpush.json b/openapi-webpush.json new file mode 100644 index 000000000..c58e19154 --- /dev/null +++ b/openapi-webpush.json @@ -0,0 +1,856 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "notifications-webpush", + "version": "0.0.1", + "description": "This app provides a backend and frontend for the notification API available in Nextcloud.", + "license": { + "name": "agpl" + } + }, + "components": { + "securitySchemes": { + "basic_auth": { + "type": "http", + "scheme": "basic" + }, + "bearer_auth": { + "type": "http", + "scheme": "bearer" + } + }, + "schemas": { + "Capabilities": { + "type": "object", + "required": [ + "notifications" + ], + "properties": { + "notifications": { + "type": "object", + "required": [ + "ocs-endpoints", + "push", + "admin-notifications" + ], + "properties": { + "ocs-endpoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "push": { + "type": "array", + "items": { + "type": "string" + } + }, + "admin-notifications": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + }, + "OCSMeta": { + "type": "object", + "required": [ + "status", + "statuscode" + ], + "properties": { + "status": { + "type": "string" + }, + "statuscode": { + "type": "integer" + }, + "message": { + "type": "string" + }, + "totalitems": { + "type": "string" + }, + "itemsperpage": { + "type": "string" + } + } + } + } + }, + "paths": { + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/vapid": { + "get": { + "operationId": "web_push-get-vapid", + "summary": "Return the server VAPID public key", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "The VAPID key", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "vapid" + ], + "properties": { + "vapid": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush": { + "post": { + "operationId": "web_push-registerwp", + "summary": "Register a subscription for push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "endpoint", + "uaPublicKey", + "auth", + "appTypes" + ], + "properties": { + "endpoint": { + "type": "string", + "description": "Push Server URL, max 765 chars (RFC8030)" + }, + "uaPublicKey": { + "type": "string", + "description": "Public key of the device, uncompress base64url encoded (RFC8291)" + }, + "auth": { + "type": "string", + "description": "Authentication tag, base64url encoded (RFC8291)" + }, + "appTypes": { + "type": "string", + "description": "comma seperated list of types used to filter incoming notifications - appTypes are alphanum - use \"all\" to get all notifications, prefix with `-` to exclude (eg. 'all,-talk')" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "A subscription was already registered and activated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "201": { + "description": "New subscription registered successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to register", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Registering is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "web_push-removewp", + "summary": "Remove a subscription from push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "No subscription for the device", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "Subscription removed successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to remove subscription", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Removing subscription is not possible", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/activate": { + "post": { + "operationId": "web_push-activatewp", + "summary": "Activate subscription for push notifications", + "tags": [ + "web_push" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "activationToken" + ], + "properties": { + "activationToken": { + "type": "string", + "description": "Random token sent via a push notification during registration to enable the subscription" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Subscription was already activated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "Subscription activated successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Missing permissions to activate subscription", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Activating subscription is not possible, may be because of a wrong activation token", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "No subscription found for the device", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "tags": [] +} diff --git a/psalm.xml b/psalm.xml index 4ebff01b6..9b49c0c62 100644 --- a/psalm.xml +++ b/psalm.xml @@ -13,12 +13,15 @@ + - + + + diff --git a/tests/Integration/base-query-count.txt b/tests/Integration/base-query-count.txt index 78e0702fc..c0e5a555f 100644 --- a/tests/Integration/base-query-count.txt +++ b/tests/Integration/base-query-count.txt @@ -1 +1 @@ -7535 +7828 diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php index d2f67a1fb..f8ad1d852 100644 --- a/tests/Unit/AppInfo/ApplicationTest.php +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -14,6 +14,7 @@ use OCA\Notifications\Capabilities; use OCA\Notifications\Controller\EndpointController; use OCA\Notifications\Controller\PushController; +use OCA\Notifications\Controller\WebPushController; use OCA\Notifications\Handler; use OCA\Notifications\Push; use OCP\AppFramework\OCSController; @@ -47,6 +48,7 @@ public static function dataContainerQuery(): array { // Controller/ [EndpointController::class, OCSController::class], [PushController::class, OCSController::class], + [WebPushController::class, OCSController::class], ]; } diff --git a/tests/Unit/CapabilitiesTest.php b/tests/Unit/CapabilitiesTest.php index ca31155af..cebe2677b 100644 --- a/tests/Unit/CapabilitiesTest.php +++ b/tests/Unit/CapabilitiesTest.php @@ -31,6 +31,7 @@ public function testGetCapabilities(): void { 'test-push', ], 'push' => [ + 'webpush', 'devices', 'object-data', 'delete', diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php new file mode 100644 index 000000000..2a146f9c9 --- /dev/null +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -0,0 +1,485 @@ +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 app_types' => [ + '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 5c59925fd..f83b36faf 100644 --- a/tests/Unit/PushTest.php +++ b/tests/Unit/PushTest.php @@ -16,6 +16,8 @@ use OC\Security\IdentityProof\Key; use OC\Security\IdentityProof\Manager; use OCA\Notifications\Push; +use OCA\Notifications\TokenValidation; +use OCA\Notifications\WebPushClient; use OCP\AppFramework\Http; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Authentication\Token\IToken as OCPIToken; @@ -33,6 +35,7 @@ use OCP\Notification\INotification; use OCP\Security\ISecureRandom; use OCP\UserStatus\IManager as IUserStatusManager; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; @@ -49,6 +52,7 @@ class PushTest extends TestCase { protected IDBConnection $db; protected INotificationManager&MockObject $notificationManager; protected IConfig&MockObject $config; + protected WebPushClient&MockObject $wpClient; protected IProvider&MockObject $tokenProvider; protected Manager&MockObject $keyManager; protected IClientService&MockObject $clientService; @@ -61,12 +65,16 @@ class PushTest extends TestCase { protected LoggerInterface&MockObject $logger; protected IUserManager&MockObject $userManager; + public const EX_UA_PUBLIC = 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4'; + public const EX_AUTH = 'BTBZMqHH6r4Tts7J_aSIgg'; + protected function setUp(): void { parent::setUp(); $this->db = \OCP\Server::get(IDBConnection::class); $this->notificationManager = $this->createMock(INotificationManager::class); $this->config = $this->createMock(IConfig::class); + $this->wpClient = $this->createMock(WebPushClient::class); $this->tokenProvider = $this->createMock(IProvider::class); $this->keyManager = $this->createMock(Manager::class); $this->clientService = $this->createMock(IClientService::class); @@ -95,6 +103,7 @@ protected function getPush(array $methods = []): Push|MockObject { $this->userManager, $this->notificationManager, $this->config, + $this->wpClient, $this->tokenProvider, $this->keyManager, $this->clientService, @@ -114,6 +123,7 @@ protected function getPush(array $methods = []): Push|MockObject { $this->userManager, $this->notificationManager, $this->config, + $this->wpClient, $this->tokenProvider, $this->keyManager, $this->clientService, @@ -147,7 +157,7 @@ public function testPushToDeviceNoInternet(): void { } public function testPushToDeviceNoDevices(): void { - $push = $this->getPush(['getDevicesForUser']); + $push = $this->getPush(['getProxyDevicesForUser']); $this->keyManager->expects($this->never()) ->method('getKey'); $this->clientService->expects($this->never()) @@ -173,14 +183,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(['getDevicesForUser']); + $push = $this->getPush(['getProxyDevicesForUser']); $this->keyManager->expects($this->never()) ->method('getKey'); $this->clientService->expects($this->never()) @@ -206,7 +216,7 @@ public function testPushToDeviceNotPrepared(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 'token1', @@ -225,8 +235,8 @@ public function testPushToDeviceNotPrepared(): void { $push->pushToDevice(1337, $notification); } - public function testPushToDeviceInvalidToken(): void { - $push = $this->getPush(['getDevicesForUser', 'encryptAndSign', 'deletePushToken']); + public function testProxyPushToDeviceInvalidToken(): void { + $push = $this->getPush(['getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken']); $this->clientService->expects($this->never()) ->method('newClient'); @@ -250,7 +260,7 @@ public function testPushToDeviceInvalidToken(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 23, @@ -284,14 +294,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(['getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']); + public function testProxyPushToDeviceEncryptionError(): void { + $push = $this->getPush(['getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken', 'validateToken']); $this->clientService->expects($this->never()) ->method('newClient'); @@ -315,7 +325,7 @@ public function testPushToDeviceEncryptionError(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([[ 'proxyserver' => 'proxyserver1', 'token' => 23, @@ -342,20 +352,21 @@ public function testPushToDeviceEncryptionError(): void { $push->expects($this->once()) ->method('validateToken') - ->willReturn(true); + ->willReturn(TokenValidation::VALID); $push->expects($this->once()) ->method('encryptAndSign') ->willThrowException(new \InvalidArgumentException()); $push->expects($this->once()) - ->method('deletePushToken') + ->method('deleteProxyPushToken') ->with(23); $push->pushToDevice(1970, $notification); } - public function testPushToDeviceNoFairUse(): void { - $push = $this->getPush(['getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken', 'deletePushTokenByDeviceIdentifier']); + + public function testProxyPushToDeviceNoFairUse(): void { + $push = $this->getPush(['getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken', 'validateToken', 'deleteProxyPushTokenByDeviceIdentifier']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); @@ -372,7 +383,7 @@ public function testPushToDeviceNoFairUse(): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([ [ 'proxyserver' => 'proxyserver', @@ -406,14 +417,14 @@ public function testPushToDeviceNoFairUse(): void { $push->expects($this->exactly(1)) ->method('validateToken') - ->willReturn(true); + ->willReturn(TokenValidation::VALID); $push->expects($this->exactly(1)) ->method('encryptAndSign') ->willReturn(['Payload']); $push->expects($this->never()) - ->method('deletePushToken'); + ->method('deleteProxyPushToken'); $this->clientService->expects($this->never()) ->method('newClient'); @@ -426,24 +437,22 @@ 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], ]; } - /** - * @dataProvider dataPushToDeviceSending - */ + #[DataProvider(methodName: 'dataProxyPushToDeviceSending')] public function testPushToDeviceSending(bool $isDebug): void { - $push = $this->getPush(['getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken', 'deletePushTokenByDeviceIdentifier']); + $push = $this->getPush(['getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken', 'validateToken', 'deleteProxyPushTokenByDeviceIdentifier']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); @@ -460,7 +469,7 @@ public function testPushToDeviceSending(bool $isDebug): void { ->willReturn($user); $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn([ [ 'proxyserver' => 'proxyserver1', @@ -526,14 +535,14 @@ public function testPushToDeviceSending(bool $isDebug): void { $push->expects($this->exactly(6)) ->method('validateToken') - ->willReturn(true); + ->willReturn(TokenValidation::VALID); $push->expects($this->exactly(6)) ->method('encryptAndSign') ->willReturn(['Payload']); $push->expects($this->never()) - ->method('deletePushToken'); + ->method('deleteProxyPushToken'); /** @var IClient&MockObject $client */ $client = $this->createMock(IClient::class); @@ -649,13 +658,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], @@ -669,11 +678,11 @@ public static function dataPushToDeviceTalkNotification(): array { } /** - * @dataProvider dataPushToDeviceTalkNotification * @param string[] $deviceTypes */ - public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTalkNotification, ?int $pushedDevice): void { - $push = $this->getPush(['getDevicesForUser', 'encryptAndSign', 'deletePushToken', 'validateToken']); + #[DataProvider(methodName: 'dataProxyPushToDeviceTalkNotification')] + public function testProxyPushToDeviceTalkNotification(array $deviceTypes, bool $isTalkNotification, ?int $pushedDevice): void { + $push = $this->getPush(['getProxyDevicesForUser', 'encryptAndSign', 'deleteProxyPushToken', 'validateToken']); /** @var INotification&MockObject $notification */ $notification = $this->createMock(INotification::class); @@ -708,7 +717,7 @@ public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTal ]; } $push->expects($this->once()) - ->method('getDevicesForUser') + ->method('getProxyDevicesForUser') ->willReturn($devices); $this->l10nFactory @@ -741,7 +750,7 @@ public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTal } else { $push->expects($this->exactly(1)) ->method('validateToken') - ->willReturn(true); + ->willReturn(TokenValidation::VALID); $push->expects($this->exactly(1)) ->method('encryptAndSign') @@ -791,20 +800,477 @@ public function testPushToDeviceTalkNotification(array $deviceTypes, bool $isTal $push->pushToDevice(200718, $notification); } + public function testWebPushToDeviceNoDevices(): void { + $push = $this->getPush(['getWebPushDevicesForUser']); + + $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); + + $this->userManager->expects($this->once()) + ->method('getExistingUser') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([]); + + $push->pushToDevice(42, $notification); + } + + public function testWebPushToDeviceNotPrepared(): void { + $push = $this->getPush(['getWebPushDevicesForUser']); + + $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); + + + $this->userManager->expects($this->once()) + ->method('getExistingUser') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([[ + 'activated' => true, + 'endpoint' => 'endpoint1', + 'ua_public' => self::EX_UA_PUBLIC, + 'auth' => self::EX_AUTH, + '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(['getWebPushDevicesForUser', 'encodeNotif', 'deleteWebPushToken']); + + $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); + + $this->userManager->expects($this->once()) + ->method('getExistingUser') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([[ + 'activated' => true, + 'endpoint' => 'endpoint1', + 'ua_public' => self::EX_UA_PUBLIC, + 'auth' => self::EX_AUTH, + 'token' => 23, + 'app_types' => '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(['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); + + $this->userManager->expects($this->once()) + ->method('getExistingUser') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([[ + 'activated' => true, + 'endpoint' => 'endpoint1', + 'ua_public' => self::EX_UA_PUBLIC, + 'auth' => self::EX_AUTH, + 'token' => 23, + 'app_types' => '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(TokenValidation::VALID); + + $this->wpClient->method('enqueue') + ->willThrowException(new \InvalidArgumentException()); + + $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(['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); + + $this->userManager->expects($this->once()) + ->method('getExistingUser') + ->with('valid') + ->willReturn($user); + + $push->expects($this->once()) + ->method('getWebPushDevicesForUser') + ->willReturn([ + [ + 'activated' => true, + 'endpoint' => 'endpoint1', + 'ua_public' => self::EX_UA_PUBLIC, + 'auth' => self::EX_AUTH, + 'token' => 16, + 'app_types' => 'all', + ], + [ + 'activated' => true, + 'endpoint' => 'endpoint2', + 'ua_public' => self::EX_UA_PUBLIC, + 'auth' => self::EX_AUTH, + 'token' => 23, + 'app_types' => '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(TokenValidation::VALID); + + $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'); + + $this->wpClient->expects($this->exactly($isRateLimited ? 1 : 2)) + ->method('enqueue'); + + if ($isRateLimited) { + $this->cache + ->expects($this->exactly(2)) + ->method('get') + ->willReturn(true, false); + } + + $this->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, + 'app_types' => $deviceApptypes, + ]]; + if ($pass) { + $result = $devices; + } else { + $result = []; + } + $this->assertEquals($result, $push->filterWebPushDeviceList($devices, $app)); + } + /** + * @return array + * @psalm-return list> + * listgetPush(['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); + + $this->userManager->expects($this->once()) + ->method('getExistingUser') + ->with('valid') + ->willReturn($user); + + $devices = []; + foreach ($deviceTypes as $deviceType) { + $devices[] = [ + 'activated' => true, + 'endpoint' => 'endpoint', + 'ua_public' => self::EX_UA_PUBLIC, + 'auth' => self::EX_AUTH, + 'token' => strlen($deviceType), + 'app_types' => $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'); + } else { + $push->expects($this->exactly(1)) + ->method('validateToken') + ->willReturn(TokenValidation::VALID); + + $push->expects($this->exactly(1)) + ->method('encodeNotif') + ->willReturn([ + 'nid' => 1, + 'app' => $notificationApp, + 'subject' => 'test', + 'type' => 'someType', + 'id' => 'someId' + ]); + + $this->wpClient->expects($this->once()) + ->method('enqueue') + ->with( + 'endpoint', + self::EX_UA_PUBLIC, + self::EX_AUTH, + $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], - [1230000000, 1239999999, OCPIToken::WIPE_TOKEN, false], - [1230000000, 1239999999, OCPIToken::PERMANENT_TOKEN, true], - [1239999999, 1230000000, OCPIToken::PERMANENT_TOKEN, true], - [1230000000, 1230000000, OCPIToken::PERMANENT_TOKEN, false], + [1239999999, 1230000000, OCPIToken::WIPE_TOKEN, TokenValidation::INVALID], + [1230000000, 1239999999, OCPIToken::WIPE_TOKEN, TokenValidation::INVALID], + [1230000000, 1239999999, OCPIToken::PERMANENT_TOKEN, TokenValidation::VALID], + [1239999999, 1230000000, OCPIToken::PERMANENT_TOKEN, TokenValidation::VALID], + [1230000000, 1230000000, OCPIToken::PERMANENT_TOKEN, TokenValidation::OLD], ]; } /** * @dataProvider dataValidateToken */ - public function testValidateToken(int $lastCheck, int $lastActivity, int $type, bool $expected): void { + public function testValidateToken(int $lastCheck, int $lastActivity, int $type, TokenValidation $expected): void { $token = PublicKeyToken::fromParams([ 'lastCheck' => $lastCheck, 'lastActivity' => $lastActivity, diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index ae273cd7e..3ffa25ccb 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,5 +1,5 @@ - + identityProof]]> @@ -9,16 +9,38 @@ + + + tokenProvider]]> + tokenProvider]]> + tokenProvider]]> + + + + + + + + + + >]]> + >]]> ]]> ]]> + ]]> + ]]> @@ -34,4 +56,9 @@ + + + client)]]> + + diff --git a/vendor-bin/csfixer/composer.lock b/vendor-bin/csfixer/composer.lock index 64e8bbc83..b85102f3e 100644 --- a/vendor-bin/csfixer/composer.lock +++ b/vendor-bin/csfixer/composer.lock @@ -4,21 +4,21 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7a4ea163e545f99dfb090c16d51258d8", + "content-hash": "638dbf16d5723f237a35a0fcb7c540c5", "packages": [], "packages-dev": [ { "name": "kubawerlos/php-cs-fixer-custom-fixers", - "version": "v3.35.1", + "version": "v3.36.0", "source": { "type": "git", "url": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers.git", - "reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395" + "reference": "e1f97f6463f0b2a22e0dd320948a04132ff9c501" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/2a35f80ae24ca77443a7af1599c3a3db1b6bd395", - "reference": "2a35f80ae24ca77443a7af1599c3a3db1b6bd395", + "url": "https://api.github.com/repos/kubawerlos/php-cs-fixer-custom-fixers/zipball/e1f97f6463f0b2a22e0dd320948a04132ff9c501", + "reference": "e1f97f6463f0b2a22e0dd320948a04132ff9c501", "shasum": "" }, "require": { @@ -28,7 +28,7 @@ "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^9.6.24 || ^10.5.51 || ^11.5.32" + "phpunit/phpunit": "^9.6.24 || ^10.5.51 || ^11.5.44" }, "type": "library", "autoload": { @@ -49,7 +49,7 @@ "description": "A set of custom fixers for PHP CS Fixer", "support": { "issues": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/issues", - "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.35.1" + "source": "https://github.com/kubawerlos/php-cs-fixer-custom-fixers/tree/v3.36.0" }, "funding": [ { @@ -57,7 +57,7 @@ "type": "github" } ], - "time": "2025-09-28T18:43:35+00:00" + "time": "2026-01-31T07:02:11+00:00" }, { "name": "nextcloud/coding-standard", @@ -106,16 +106,16 @@ }, { "name": "php-cs-fixer/shim", - "version": "v3.89.2", + "version": "v3.93.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "8f1bf4fd7d8270020cd3c58756fcf3615ed14b68" + "reference": "3a9db22e8f01762fddd3a85b998053294c5a3629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/8f1bf4fd7d8270020cd3c58756fcf3615ed14b68", - "reference": "8f1bf4fd7d8270020cd3c58756fcf3615ed14b68", + "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/3a9db22e8f01762fddd3a85b998053294c5a3629", + "reference": "3a9db22e8f01762fddd3a85b998053294c5a3629", "shasum": "" }, "require": { @@ -152,9 +152,9 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/PHP-CS-Fixer/shim/issues", - "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.89.2" + "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.93.1" }, - "time": "2025-11-06T21:13:10+00:00" + "time": "2026-01-28T23:51:14+00:00" } ], "aliases": [], @@ -167,5 +167,5 @@ "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/vendor-bin/mozart/composer.json b/vendor-bin/mozart/composer.json new file mode 100644 index 000000000..6329de208 --- /dev/null +++ b/vendor-bin/mozart/composer.json @@ -0,0 +1,11 @@ +{ + "config": { + "platform": { + "php": "8.2" + }, + "sort-packages": true + }, + "require": { + "coenjacobs/mozart": "^1.0.6" + } +} diff --git a/vendor-bin/mozart/composer.lock b/vendor-bin/mozart/composer.lock new file mode 100644 index 000000000..ae277347c --- /dev/null +++ b/vendor-bin/mozart/composer.lock @@ -0,0 +1,1131 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "88fc50f843f26283d2df1a1035f1737c", + "packages": [ + { + "name": "coenjacobs/mozart", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/coenjacobs/mozart.git", + "reference": "dbda48b553086872881289d1d0edb1d3940a42a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/coenjacobs/mozart/zipball/dbda48b553086872881289d1d0edb1d3940a42a0", + "reference": "dbda48b553086872881289d1d0edb1d3940a42a0", + "shasum": "" + }, + "require": { + "league/flysystem": "^3.0", + "league/flysystem-local": "^3.0", + "netresearch/jsonmapper": "^4.4", + "php": "^8.1", + "symfony/console": "^6.4", + "symfony/finder": "^6.4" + }, + "conflict": { + "symfony/config": ">=7.0", + "symfony/dependency-injection": ">=7.0", + "symfony/filesystem": ">=7.0", + "symfony/string": ">=7.0", + "symfony/var-exporter": ">=8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "mheap/phpunit-github-actions-printer": "^1.5", + "niels-de-blaauw/php-doc-check": "^0.4.0", + "phpcompatibility/php-compatibility": "^10.1", + "phpmd/phpmd": "^2.15", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-deprecation-rules": "^1.2", + "phpunit/phpunit": "^10.0", + "squizlabs/php_codesniffer": "^4.0" + }, + "bin": [ + "bin/mozart" + ], + "type": "library", + "autoload": { + "psr-4": { + "CoenJacobs\\Mozart\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Coen Jacobs", + "email": "coenjacobs@gmail.com" + } + ], + "description": "Composes all dependencies as a package inside a WordPress plugin", + "support": { + "issues": "https://github.com/coenjacobs/mozart/issues", + "source": "https://github.com/coenjacobs/mozart/tree/1.0.6" + }, + "funding": [ + { + "url": "https://github.com/coenjacobs", + "type": "github" + } + ], + "time": "2026-02-11T20:18:02+00:00" + }, + { + "name": "league/flysystem", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "reference": "1717e0b3642b0df65ecb0cc89cdd99fa840672ff", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.31.0" + }, + "time": "2026-01-23T15:38:47+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.16.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2024-09-21T08:32:55+00:00" + }, + { + "name": "netresearch/jsonmapper", + "version": "v4.5.0", + "source": { + "type": "git", + "url": "https://github.com/cweiske/jsonmapper.git", + "reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8e76efb98ee8b6afc54687045e1b8dba55ac76e5", + "reference": "8e76efb98ee8b6afc54687045e1b8dba55ac76e5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "php": ">=7.1" + }, + "require-dev": { + "phpunit/phpunit": "~7.5 || ~8.0 || ~9.0 || ~10.0", + "squizlabs/php_codesniffer": "~3.5" + }, + "type": "library", + "autoload": { + "psr-0": { + "JsonMapper": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "OSL-3.0" + ], + "authors": [ + { + "name": "Christian Weiske", + "email": "cweiske@cweiske.de", + "homepage": "http://github.com/cweiske/jsonmapper/", + "role": "Developer" + } + ], + "description": "Map nested JSON structures onto PHP classes", + "support": { + "email": "cweiske@cweiske.de", + "issues": "https://github.com/cweiske/jsonmapper/issues", + "source": "https://github.com/cweiske/jsonmapper/tree/v4.5.0" + }, + "time": "2024-09-08T10:13:13+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "symfony/console", + "version": "v6.4.32", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "0bc2199c6c1f05276b05956f1ddc63f6d7eb5fc3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/0bc2199c6c1f05276b05956f1ddc63f6d7eb5fc3", + "reference": "0bc2199c6c1f05276b05956f1ddc63f6d7eb5fc3", + "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.32" + }, + "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": "2026-01-13T08:45:59+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/finder", + "version": "v6.4.33", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "24965ca011dac87431729640feef8bcf7b5523e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/24965ca011dac87431729640feef8bcf7b5523e0", + "reference": "24965ca011dac87431729640feef8bcf7b5523e0", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "symfony/filesystem": "^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v6.4.33" + }, + "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": "2026-01-26T13:03:48+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/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.30", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "50590a057841fa6bf69d12eceffce3465b9e32cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/50590a057841fa6bf69d12eceffce3465b9e32cb", + "reference": "50590a057841fa6bf69d12eceffce3465b9e32cb", + "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.30" + }, + "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-21T18:03:05+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.9.0" +} diff --git a/vendor-bin/nextcloud-ocp/composer.json b/vendor-bin/nextcloud-ocp/composer.json new file mode 100644 index 000000000..9f2be6a8b --- /dev/null +++ b/vendor-bin/nextcloud-ocp/composer.json @@ -0,0 +1,5 @@ +{ + "require-dev": { + "nextcloud/ocp": "dev-master" + } +} diff --git a/vendor-bin/nextcloud-ocp/composer.lock b/vendor-bin/nextcloud-ocp/composer.lock new file mode 100644 index 000000000..568aabf2b --- /dev/null +++ b/vendor-bin/nextcloud-ocp/composer.lock @@ -0,0 +1,271 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "5dbd6ae6465ad99765e7d232c3bba5b1", + "packages": [], + "packages-dev": [ + { + "name": "nextcloud/ocp", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/nextcloud-deps/ocp.git", + "reference": "26767040b00a471418ad1c691abe2e7ff1d9c04f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nextcloud-deps/ocp/zipball/26767040b00a471418ad1c691abe2e7ff1d9c04f", + "reference": "26767040b00a471418ad1c691abe2e7ff1d9c04f", + "shasum": "" + }, + "require": { + "php": "~8.1 || ~8.2 || ~8.3 || ~8.4 || ~8.5", + "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": "34.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": "2026-02-12T01:11:09+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/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" + }, + { + "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" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "nextcloud/ocp": 20 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +}