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"
+}