diff --git a/.gitignore b/.gitignore index d3d331b..9165828 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /tests/.phpunit.cache /coverage/ +/.vscode/ diff --git a/REUSE.toml b/REUSE.toml index 9f6d292..34d2636 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -41,7 +41,10 @@ SPDX-License-Identifier = "AGPL-3.0-or-later" path = [ "README.md", "CHANGELOG.md", - "CODE_OF_CONDUCT.md" + "CODE_OF_CONDUCT.md", + "docs/README.md", + "docs/events/post-setup.md", + "docs/events/user-stats.md" ] precedence = "aggregate" SPDX-FileCopyrightText = "2026 STRATO GmbH" diff --git a/composer.json b/composer.json index 07d4ed9..6c48070 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,34 @@ "homepage": "https://example.com" } ], + "repositories": [ + { + "type": "package", + "package": { + "name": "ionos-productivity/ionos-ncpss-addons-api-client", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/ionos-productivity/ionos-ncpss-addons-api-client.git", + "reference": "a5f09eacaa3139e50e43ced4a44137946a8bfe18" + }, + "dist": { + "type": "zip", + "url": "https://github.com/ionos-productivity/ionos-ncpss-addons-api-client/archive/refs/tags/2.0.0-20260429080405.zip", + "reference": "a5f09eacaa3139e50e43ced4a44137946a8bfe18" + }, + "require": { + "guzzlehttp/guzzle": "^7.3", + "guzzlehttp/psr7": "^1.7 || ^2.0" + }, + "autoload": { + "psr-4": { + "IONOS\\NextcloudPSS\\AddonsAPI\\Client\\": "lib/" + } + } + } + } + ], "autoload": { "psr-4": { "OCA\\NcwTools\\": "lib/" @@ -37,6 +65,7 @@ }, "require": { "bamarni/composer-bin-plugin": "^1.8", + "ionos-productivity/ionos-ncpss-addons-api-client": "2.0.0", "php": "^8.1" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 7ec3dc8..72f3dbd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c429e219370ff3b6c87f4d2c835f43b7", + "content-hash": "7ac7adafbdcb64d2d37d9c9e42108378", "packages": [ { "name": "bamarni/composer-bin-plugin", @@ -62,6 +62,631 @@ "source": "https://github.com/bamarni/composer-bin-plugin/tree/1.9.1" }, "time": "2026-02-04T10:18:12+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "481557b130ef3790cf82b713667b43030dc9c957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "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", + "jshttp/mime-db": "1.54.0.1", + "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.9.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": "2026-03-10T16:41:02+00:00" + }, + { + "name": "ionos-productivity/ionos-ncpss-addons-api-client", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/ionos-productivity/ionos-ncpss-addons-api-client.git", + "reference": "a5f09eacaa3139e50e43ced4a44137946a8bfe18" + }, + "dist": { + "type": "zip", + "url": "https://github.com/ionos-productivity/ionos-ncpss-addons-api-client/archive/refs/tags/2.0.0-20260429080405.zip", + "reference": "a5f09eacaa3139e50e43ced4a44137946a8bfe18" + }, + "require": { + "guzzlehttp/guzzle": "^7.3", + "guzzlehttp/psr7": "^1.7 || ^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "IONOS\\NextcloudPSS\\AddonsAPI\\Client\\": "lib/" + } + } + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-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.7.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": "2026-04-13T15:52:40+00:00" } ], "packages-dev": [ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6535d63 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,6 @@ +# Event Flows + +NCW Tools reacts to Nextcloud lifecycle events by registering listeners that enqueue background jobs. Each document below walks through one end-to-end flow — the triggering event, the listener's synchronous work, the job's asynchronous behaviour, and the configuration it relies on. + +- [Post-setup welcome mail](events/post-setup.md) — sends the initial welcome email to the admin user once the system is reachable after installation. +- [User stats reporting](events/user-stats.md) — reports the current total user count to the PSS Stats API after every user create or delete. diff --git a/docs/events/post-setup.md b/docs/events/post-setup.md new file mode 100644 index 0000000..ecc7db1 --- /dev/null +++ b/docs/events/post-setup.md @@ -0,0 +1,100 @@ +# Post-setup welcome mail + +After Nextcloud installation completes, this app sends an initial welcome email to the admin user. A listener seeds an app-config status flag and schedules a time-sensitive background job; the job retries on a configurable interval until the system URL is reachable and the admin user exists, then sends the welcome mail via Nextcloud's `NewUserMailHelper` (with a fresh password-reset token) and marks itself done. + +## Trigger event + +`OCP\Install\Events\InstallationCompletedEvent` + +## Configuration + +| Key | Where | Type | Default | Purpose | +| --- | --- | --- | --- | --- | +| `overwrite.cli.url` | `config/config.php` | string | _required_ | Base URL the job probes via `…/status.php` to confirm the instance is reachable. | +| `ncw_tools.post_setup_job.retry_interval` | `config/config.php` | int (seconds) | `2` | Interval between job retries while waiting for the system to become ready. | +| `post_install` | App config (`ncw_tools`) | string | _set by listener_ | Observable status: `INIT` (work pending), `DONE` (welcome mail sent), `UNKNOWN` (listener has not run). | + +## Flow + +```mermaid +sequenceDiagram + autonumber + + participant INS as Nextcloud Installer + participant ED as EventDispatcher + participant ICEL as InstallationCompletedEventListener + participant CFG as IAppConfig + participant LOG as LoggerInterface + participant JL as IJobList + participant CRON as Nextcloud Cron + participant PSJ as PostSetupJob + participant HTTP as IClientService + participant UM as IUserManager + participant MAIL as WelcomeMailHelper + + Note over INS,JL: Synchronous phase — runs during installation + + INS->>ED: dispatch(InstallationCompletedEvent) + ED->>ICEL: handle(event) + ICEL->>CFG: setValueString(post_install, "INIT") + + alt admin username missing + ICEL->>LOG: warning("No admin user provided") + else admin username present + ICEL->>LOG: info("Scheduling welcome email job") + ICEL->>JL: add(PostSetupJob::class, adminUserId) + end + + Note over CRON,MAIL: Asynchronous phase — TIME_SENSITIVE,
retries every ncw_tools.post_setup_job.retry_interval seconds (default 2) + + CRON->>PSJ: run(adminUserId) + PSJ->>CFG: getValueString(post_install) + + alt status == "DONE" + PSJ->>JL: remove(this) + PSJ-->>CRON: return (already completed) + else status == "UNKNOWN" + PSJ->>LOG: warning("Job status unknown, waiting") + PSJ-->>CRON: return (retry) + else status == "INIT" + PSJ->>HTTP: GET {overwrite.cli.url}/status.php + + alt URL empty + PSJ->>LOG: warning("System URL not configured") + PSJ-->>CRON: return (retry) + else HTTP not 2xx + PSJ->>LOG: info("System not ready, will retry") + PSJ-->>CRON: return (retry) + else HTTP 2xx + PSJ->>UM: userExists(adminUserId) + + alt user not found + PSJ->>LOG: warning("Admin user not found") + PSJ-->>CRON: return (retry) + else user exists + PSJ->>UM: get(adminUserId) + PSJ->>MAIL: sendWelcomeMail(user, generateResetToken=true) + + alt exception thrown + PSJ->>LOG: error("Failed to send welcome email, will retry") + PSJ-->>CRON: return (retry) + else success + PSJ->>CFG: setValueString(post_install, "DONE") + PSJ->>JL: remove(this) + PSJ->>LOG: info("Post-installation job completed") + end + end + end + end +``` + +## Failure modes + +The job is `TIME_SENSITIVE` and re-runs on every cron tick until it succeeds. Any of the following keep it in the queue: + +- `overwrite.cli.url` is unset. +- `…/status.php` returns a non-2xx response, or the request throws. +- The admin user does not exist (or cannot be retrieved). +- `WelcomeMailHelper::sendWelcomeMail` throws. + +On success, the job sets `post_install = "DONE"` and removes itself from the job list. A subsequent install event would re-seed the status to `INIT` and re-schedule the job. diff --git a/docs/events/user-stats.md b/docs/events/user-stats.md new file mode 100644 index 0000000..afcdee9 --- /dev/null +++ b/docs/events/user-stats.md @@ -0,0 +1,122 @@ +# User stats reporting + +On every user create or delete, this app reports the current total user count to the PSS Stats API. A listener logs the change and enqueues a single deduplicated background job; on the next cron tick the job reads the total user count and delegates to a `StatsReporter` (the PSS adapter validates configuration, builds the `StatsUpdateRequest`, and POSTs it to the PSS Stats API). The job is queued — it does not retry on failure; the next user event re-enqueues it. + +## Trigger events + +- `OCP\User\Events\UserCreatedEvent` +- `OCP\User\Events\UserDeletedEvent` + +## Configuration + +All keys live in `config/config.php`. The adapter aborts (with a logged error listing the missing keys) if any required value is missing or empty. + +### Required + +| Key | Type | Purpose | +| --- | --- | --- | +| `ncw_tools.pss.brand` | string | PSS brand identifier; first path segment of the stats endpoint. | +| `ncw_tools.pss.ext_ref` | string | External tenant reference; second path segment of the stats endpoint. | +| `ncw_tools.pss.base_url` | string | Base URL of the PSS API. | +| `ncw_tools.pss.username` | string | HTTP Basic auth username. | +| `ncw_tools.pss.password` | string | HTTP Basic auth password. | + +### Optional + +| Key | Type | Default | Purpose | +| --- | --- | --- | --- | +| `ncw_tools.pss.connect_timeout` | int (seconds) | `5` | Guzzle connect timeout. | +| `ncw_tools.pss.timeout` | int (seconds) | `10` | Guzzle overall timeout. | +| `ncw_tools.pss.allow_insecure` | bool | `false` | When `true`, disables TLS verification (`verify => false`). **Dev/debug only** — never enable in production. | + +### Security notes for `ncw_tools.pss.password` + +The password is stored in `config/config.php` in plaintext, same trust model as the DB password already in that file. Two operational considerations: + +- **Shell history / `ps` exposure.** `occ config:system:set` puts the value on the command line — visible in `ps` and shell history. Prefer a leading-space command (with `HISTCONTROL=ignorespace` set) or read the value from an env var so it does not appear in process listings. +- **File permissions.** `config/config.php` should be `0640 root:www-data`. Audit on deploy. + +Code-side, the `PssConfig` DTO redacts `password` via `__debugInfo()`, and the PSS error handler logs `exceptionClass + message` only (not the full trace), so accidental serialization or deep-vendor stack-traces will not leak the value. + +## Flow + +```mermaid +sequenceDiagram + autonumber + + participant OCC as occ user:add / user:delete + participant ED as EventDispatcher + participant UEL as UserEventListener + participant LOG as LoggerInterface + participant JL as IJobList + participant CRON as Nextcloud Cron + participant USJ as UserStatsJob + participant UM as IUserManager + participant SR as StatsReporter
(PssStatsReporter) + participant CR as PssConfigReader + participant AF as PssApiFactory + participant PSS as PSS Stats API + + Note over OCC,JL: Synchronous phase — runs during the OCC command + + OCC->>ED: dispatch(UserCreatedEvent | UserDeletedEvent) + ED->>UEL: handle(event) + + alt UserCreatedEvent + UEL->>LOG: info("User added", {uid}) + else UserDeletedEvent + UEL->>LOG: info("User deleted", {uid}) + end + + UEL->>JL: has(UserStatsJob::class, null) + + alt already queued + JL-->>UEL: true (skip) + else not queued + JL-->>UEL: false + UEL->>JL: add(UserStatsJob::class) + end + + Note over CRON,PSS: Asynchronous phase — next cron cycle + + CRON->>USJ: run() + USJ->>UM: countUsersTotal() + UM-->>USJ: int | false + + alt count === false + USJ->>LOG: warning("could not retrieve user count") + USJ-->>CRON: return + else count is int + USJ->>SR: reportUserCount(count, now) + SR->>CR: read() + + alt any required key missing + CR->>LOG: error("missing required PSS configuration", {keys}) + CR-->>SR: null + SR-->>USJ: return + else all values present + CR-->>SR: PssConfig + SR->>SR: build StatsUpdateRequest with UserStats(existingUsers) + SR->>AF: newStatsApi(baseUrl, username, password) + SR->>PSS: updateStats(brand, extRef, request) + + alt Throwable + PSS-->>SR: exception + SR->>LOG: error("failed to push stats to PSS", {exceptionClass, message}) + else success + PSS-->>SR: 2xx + SR->>LOG: info("pushed user stats", {existingUsers, timestamp}) + end + end + end +``` + +## Failure modes + +The job is a `QueuedJob` — there is no automatic retry. Each of the following ends the run without reporting: + +- `IUserManager::countUsersTotal()` returns `false` (logged at warning by the job). +- Any of the five required `ncw_tools.pss.*` values is missing or empty (logged at error by `PssConfigReader`, naming the missing keys). +- The PSS API call throws (logged at error by `PssStatsReporter` with `exceptionClass` + `message`). + +The next `UserCreatedEvent` or `UserDeletedEvent` re-enqueues the job, so the count converges once the underlying problem is resolved. diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index b0fc7ac..5a34c44 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -12,6 +12,8 @@ use OCA\NcwTools\Capabilities; use OCA\NcwTools\Listeners\InstallationCompletedEventListener; use OCA\NcwTools\Listeners\UserEventListener; +use OCA\NcwTools\Stats\PssStatsReporter; +use OCA\NcwTools\Stats\StatsReporter; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; @@ -20,6 +22,8 @@ use OCP\User\Events\UserCreatedEvent; use OCP\User\Events\UserDeletedEvent; +require_once __DIR__ . '/../../vendor/autoload.php'; + class Application extends App implements IBootstrap { public const APP_ID = 'ncw_tools'; @@ -32,6 +36,7 @@ public function register(IRegistrationContext $context): void { $context->registerEventListener(UserCreatedEvent::class, UserEventListener::class); $context->registerEventListener(UserDeletedEvent::class, UserEventListener::class); $context->registerCapability(Capabilities::class); + $context->registerServiceAlias(StatsReporter::class, PssStatsReporter::class); } public function boot(IBootContext $context): void { diff --git a/lib/BackgroundJob/UserStatsJob.php b/lib/BackgroundJob/UserStatsJob.php index a88d633..c68a617 100644 --- a/lib/BackgroundJob/UserStatsJob.php +++ b/lib/BackgroundJob/UserStatsJob.php @@ -9,6 +9,7 @@ namespace OCA\NcwTools\BackgroundJob; +use OCA\NcwTools\Stats\StatsReporter; use OCP\AppFramework\Utility\ITimeFactory; use OCP\BackgroundJob\QueuedJob; use OCP\IUserManager; @@ -18,24 +19,22 @@ class UserStatsJob extends QueuedJob { public function __construct( private LoggerInterface $logger, - private ITimeFactory $timeFactory, + ITimeFactory $timeFactory, private IUserManager $userManager, + private StatsReporter $reporter, ) { parent::__construct($timeFactory); } protected function run(mixed $argument): void { - $userTotalCount = $this->userManager->countUsersTotal(); - if ($userTotalCount === false) { + $count = $this->userManager->countUsersTotal(); + if ($count === false) { $this->logger->warning('UserStatsJob: could not retrieve user count'); return; } - - $payload = [ - 'timestamp' => $this->timeFactory->getDateTime('now', new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s.v\Z'), - 'users' => ['existingUsers' => $userTotalCount], - ]; - - $this->logger->info('User stats payload', ['payload' => $payload]); + $this->reporter->reportUserCount( + $count, + $this->time->getDateTime('now', new \DateTimeZone('UTC')), + ); } } diff --git a/lib/Stats/PssApiFactory.php b/lib/Stats/PssApiFactory.php new file mode 100644 index 0000000..dbeab02 --- /dev/null +++ b/lib/Stats/PssApiFactory.php @@ -0,0 +1,47 @@ +connectTimeout = $config->getSystemValueInt(self::KEY_CONNECT_TIMEOUT, self::DEFAULT_CONNECT_TIMEOUT_S); + $this->timeout = $config->getSystemValueInt(self::KEY_TIMEOUT, self::DEFAULT_TIMEOUT_S); + $this->allowInsecure = $config->getSystemValueBool(self::KEY_ALLOW_INSECURE, false); + } + + public function newStatsApi(string $baseUrl, string $username, string $password): StatsAPIApi { + $client = new Client([ + 'connect_timeout' => $this->connectTimeout, + 'timeout' => $this->timeout, + 'verify' => !$this->allowInsecure, + ]); + $config = new Configuration(); + $config->setHost($baseUrl); + $config->setUsername($username); + $config->setPassword($password); + return new StatsAPIApi($client, $config); + } +} diff --git a/lib/Stats/PssConfig.php b/lib/Stats/PssConfig.php new file mode 100644 index 0000000..3b2181a --- /dev/null +++ b/lib/Stats/PssConfig.php @@ -0,0 +1,35 @@ +password; + } + + public function __debugInfo(): array { + return [ + 'brand' => $this->brand, + 'extRef' => $this->extRef, + 'baseUrl' => $this->baseUrl, + 'username' => $this->username, + 'password' => '***', + ]; + } +} diff --git a/lib/Stats/PssConfigReader.php b/lib/Stats/PssConfigReader.php new file mode 100644 index 0000000..f1aa6fb --- /dev/null +++ b/lib/Stats/PssConfigReader.php @@ -0,0 +1,55 @@ + 'ncw_tools.pss.brand', + 'extRef' => 'ncw_tools.pss.ext_ref', + 'baseUrl' => 'ncw_tools.pss.base_url', + 'username' => 'ncw_tools.pss.username', + 'password' => 'ncw_tools.pss.password', + ]; + + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + ) { + } + + public function read(): ?PssConfig { + $values = []; + $missing = []; + foreach (self::KEYS as $field => $key) { + $value = $this->config->getSystemValueString($key); + if ($value === '') { + $missing[] = $key; + } + $values[$field] = $value; + } + if ($missing !== []) { + $this->logger->error( + 'PssConfigReader: missing required PSS configuration', + ['keys' => $missing], + ); + return null; + } + return new PssConfig( + $values['brand'], + $values['extRef'], + $values['baseUrl'], + $values['username'], + $values['password'], + ); + } +} diff --git a/lib/Stats/PssStatsReporter.php b/lib/Stats/PssStatsReporter.php new file mode 100644 index 0000000..ff428c4 --- /dev/null +++ b/lib/Stats/PssStatsReporter.php @@ -0,0 +1,62 @@ +configReader->read(); + if ($config === null) { + return; + } + + $userStats = new UserStats(); + $userStats->setExistingUsers($count); + + $request = new StatsUpdateRequest(); + $request->setTimestamp($at instanceof \DateTime ? $at : \DateTime::createFromInterface($at)); + $request->setUsers($userStats); + + $api = $this->apiFactory->newStatsApi( + $config->baseUrl, + $config->username, + $config->getPassword(), + ); + + // Narrow the catch to just updateStats() and log message-only (not the + // full exception). Deep vendor frames can carry credential strings in + // their stack-trace args; PHP 8.1 has no #[\SensitiveParameter] to + // scrub them. We trade trace richness for credential safety. + try { + $api->updateStats($config->brand, $config->extRef, $request); + } catch (\Throwable $e) { + $this->logger->error('PssStatsReporter: failed to push stats to PSS', [ + 'exceptionClass' => $e::class, + 'message' => $e->getMessage(), + ]); + return; + } + + $this->logger->info('PssStatsReporter: pushed user stats', [ + 'existingUsers' => $count, + 'timestamp' => $at->format('Y-m-d\TH:i:s.v\Z'), + ]); + } +} diff --git a/lib/Stats/StatsReporter.php b/lib/Stats/StatsReporter.php new file mode 100644 index 0000000..3f59f8b --- /dev/null +++ b/lib/Stats/StatsReporter.php @@ -0,0 +1,14 @@ + + + + diff --git a/tests/unit/BackgroundJob/UserStatsJobTest.php b/tests/unit/BackgroundJob/UserStatsJobTest.php index 839fc3b..f888767 100644 --- a/tests/unit/BackgroundJob/UserStatsJobTest.php +++ b/tests/unit/BackgroundJob/UserStatsJobTest.php @@ -11,6 +11,7 @@ use DateTime; use OCA\NcwTools\BackgroundJob\UserStatsJob; +use OCA\NcwTools\Stats\StatsReporter; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IUserManager; use PHPUnit\Framework\MockObject\MockObject; @@ -21,6 +22,7 @@ class UserStatsJobTest extends TestCase { private LoggerInterface&MockObject $logger; private ITimeFactory&MockObject $timeFactory; private IUserManager&MockObject $userManager; + private StatsReporter&MockObject $reporter; private UserStatsJob $job; protected function setUp(): void { @@ -28,42 +30,40 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->timeFactory = $this->createMock(ITimeFactory::class); - $this->timeFactory->method('getDateTime')->willReturn(new DateTime('2026-01-01T00:00:00.000 UTC')); $this->userManager = $this->createMock(IUserManager::class); + $this->reporter = $this->createMock(StatsReporter::class); $this->job = new UserStatsJob( $this->logger, $this->timeFactory, $this->userManager, + $this->reporter, ); } - public function testRunLogsPayloadOnSuccess(): void { + public function testRunDelegatesToReporterOnSuccess(): void { + $now = new DateTime('2026-01-01T00:00:00.000', new \DateTimeZone('UTC')); $this->userManager->method('countUsersTotal')->willReturn(42); + $this->timeFactory->method('getDateTime')->willReturn($now); - $this->logger->expects($this->once()) - ->method('info') - ->with( - 'User stats payload', - $this->callback(function (array $context): bool { - return preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', $context['payload']['timestamp']) === 1 - && $context['payload']['users']['existingUsers'] === 42; - }) - ); + $this->reporter->expects($this->once()) + ->method('reportUserCount') + ->with(42, $now); $this->logger->expects($this->never())->method('warning'); + $this->logger->expects($this->never())->method('error'); $this->invokePrivate($this->job, 'run', [null]); } - public function testRunLogsWarningWhenCountFalse(): void { + public function testRunLogsWarningAndSkipsReporterWhenCountFalse(): void { $this->userManager->method('countUsersTotal')->willReturn(false); $this->logger->expects($this->once()) ->method('warning') ->with('UserStatsJob: could not retrieve user count'); - $this->logger->expects($this->never())->method('info'); + $this->reporter->expects($this->never())->method('reportUserCount'); $this->invokePrivate($this->job, 'run', [null]); } diff --git a/tests/unit/Stats/PssConfigReaderTest.php b/tests/unit/Stats/PssConfigReaderTest.php new file mode 100644 index 0000000..19dee66 --- /dev/null +++ b/tests/unit/Stats/PssConfigReaderTest.php @@ -0,0 +1,107 @@ +config = $this->createMock(IConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->reader = new PssConfigReader($this->config, $this->logger); + } + + public function testReadReturnsConfigWhenAllKeysPresent(): void { + $this->stubValues([ + 'ncw_tools.pss.brand' => 'IONOS', + 'ncw_tools.pss.ext_ref' => 'tenant-1', + 'ncw_tools.pss.base_url' => 'https://pss.example.com', + 'ncw_tools.pss.username' => 'alice', + 'ncw_tools.pss.password' => 'secret', + ]); + + $this->logger->expects($this->never())->method('error'); + + $result = $this->reader->read(); + $this->assertNotNull($result); + $this->assertSame('IONOS', $result->brand); + $this->assertSame('tenant-1', $result->extRef); + $this->assertSame('https://pss.example.com', $result->baseUrl); + $this->assertSame('alice', $result->username); + $this->assertSame('secret', $result->getPassword()); + } + + public static function provideMissingKey(): array { + $full = [ + 'ncw_tools.pss.brand' => 'IONOS', + 'ncw_tools.pss.ext_ref' => 'tenant-1', + 'ncw_tools.pss.base_url' => 'https://pss.example.com', + 'ncw_tools.pss.username' => 'alice', + 'ncw_tools.pss.password' => 'secret', + ]; + $cases = []; + foreach (array_keys($full) as $missing) { + $values = $full; + $values[$missing] = ''; + $cases[$missing] = [$values, [$missing]]; + } + $cases['all missing'] = [ + array_fill_keys(array_keys($full), ''), + array_keys($full), + ]; + return $cases; + } + + #[DataProvider('provideMissingKey')] + public function testReadReturnsNullAndLogsMissingKeys(array $values, array $expectedMissing): void { + $this->stubValues($values); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + 'PssConfigReader: missing required PSS configuration', + $this->callback(fn (array $ctx): bool => $ctx['keys'] === $expectedMissing), + ); + + $this->assertNull($this->reader->read()); + } + + public function testPasswordRedactedFromDebugInfo(): void { + $this->stubValues([ + 'ncw_tools.pss.brand' => 'IONOS', + 'ncw_tools.pss.ext_ref' => 'tenant-1', + 'ncw_tools.pss.base_url' => 'https://pss.example.com', + 'ncw_tools.pss.username' => 'alice', + 'ncw_tools.pss.password' => 'super-secret-value', + ]); + $config = $this->reader->read(); + $this->assertNotNull($config); + + $dump = print_r($config, true); + $this->assertStringNotContainsString('super-secret-value', $dump); + $this->assertStringContainsString('***', $dump); + } + + private function stubValues(array $values): void { + $this->config + ->method('getSystemValueString') + ->willReturnCallback(fn (string $key, string $default = '') => $values[$key] ?? $default); + } +} diff --git a/tests/unit/Stats/PssStatsReporterTest.php b/tests/unit/Stats/PssStatsReporterTest.php new file mode 100644 index 0000000..4722311 --- /dev/null +++ b/tests/unit/Stats/PssStatsReporterTest.php @@ -0,0 +1,113 @@ +configReader = $this->createMock(PssConfigReader::class); + $this->apiFactory = $this->createMock(PssApiFactory::class); + $this->statsApi = $this->createMock(StatsAPIApi::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->apiFactory->method('newStatsApi')->willReturn($this->statsApi); + + $this->reporter = new PssStatsReporter( + $this->configReader, + $this->apiFactory, + $this->logger, + ); + } + + public function testReportSkipsWhenConfigNull(): void { + $this->configReader->method('read')->willReturn(null); + + $this->statsApi->expects($this->never())->method('updateStats'); + $this->logger->expects($this->never())->method('error'); + $this->logger->expects($this->never())->method('info'); + + $this->reporter->reportUserCount(42, new DateTime('2026-01-01T00:00:00.000', new \DateTimeZone('UTC'))); + } + + public function testReportPostsAndLogsOnSuccess(): void { + $at = new DateTime('2026-01-01T12:34:56.789', new \DateTimeZone('UTC')); + $this->configReader->method('read')->willReturn(new PssConfig( + 'IONOS', + 'tenant-1', + 'https://pss.example.com', + 'alice', + 'secret', + )); + + $this->apiFactory->expects($this->once()) + ->method('newStatsApi') + ->with('https://pss.example.com', 'alice', 'secret') + ->willReturn($this->statsApi); + + $this->statsApi->expects($this->once()) + ->method('updateStats') + ->with( + 'IONOS', + 'tenant-1', + $this->callback(function (StatsUpdateRequest $req) use ($at): bool { + return $req->getUsers()?->getExistingUsers() === 42 + && $req->getTimestamp() == $at; + }), + ); + + $this->logger->expects($this->once()) + ->method('info') + ->with('PssStatsReporter: pushed user stats', $this->callback(function (array $ctx): bool { + return $ctx['existingUsers'] === 42 + && $ctx['timestamp'] === '2026-01-01T12:34:56.789Z'; + })); + $this->logger->expects($this->never())->method('error'); + + $this->reporter->reportUserCount(42, $at); + } + + public function testReportLogsErrorOnThrowableWithoutLeakingException(): void { + $this->configReader->method('read')->willReturn(new PssConfig( + 'IONOS', + 'tenant-1', + 'https://pss.example.com', + 'alice', + 'secret', + )); + $this->statsApi->method('updateStats')->willThrowException(new \RuntimeException('connection refused')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('PssStatsReporter: failed to push stats to PSS', $this->callback(function (array $ctx): bool { + return $ctx['exceptionClass'] === \RuntimeException::class + && $ctx['message'] === 'connection refused' + && !array_key_exists('exception', $ctx); + })); + $this->logger->expects($this->never())->method('info'); + + $this->reporter->reportUserCount(42, new DateTime('2026-01-01T00:00:00.000', new \DateTimeZone('UTC'))); + } +}