From 9f7a7667083f1cd771c886afce1d3b5d168b7d4a Mon Sep 17 00:00:00 2001 From: Msprg Date: Tue, 10 Feb 2026 00:49:16 +0000 Subject: [PATCH 01/85] build: add QA tooling and CI quality gates --- .github/workflows/docker.yml | 38 + .gitignore | 3 + .php-cs-fixer.dist.php | 17 + Makefile | 45 + composer.json | 16 + composer.lock | 2786 +++++++++++++++++++++++++++++++++- phpstan-baseline.neon | 46 + phpstan.neon.dist | 23 + 8 files changed, 2972 insertions(+), 2 deletions(-) create mode 100644 .php-cs-fixer.dist.php create mode 100644 Makefile create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 08c6835..4794fcd 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -25,7 +25,45 @@ on: types: [ published ] jobs: + quality-gates: + runs-on: ubuntu-22.04 + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: ldap, mbstring, mysqli, pcntl, ssh2 + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Composer validate + run: composer validate --strict + + - name: Composer audit + run: composer audit + + - name: Composer platform requirements + run: composer check-platform-reqs + + - name: Docker compose config validation + run: docker compose config -q + + - name: PHP lint + run: composer run lint + + - name: PHP static analysis + run: composer run stan + + - name: Smoke harness dry-run + run: composer run smoke:dry-run + build-matrix: + needs: quality-gates if: github.event_name != 'pull_request' outputs: safe_ref: ${{ steps.ref.outputs.safe_ref }} diff --git a/.gitignore b/.gitignore index 74c75b3..cc10ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ extensions/*.php docker-compose* docker/ /vendor/ +var/ +scripts/smoke/fixtures/sync/*.txt +testenvs.env diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..6e8f61a --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,17 @@ +in([ + __DIR__ . '/model', + __DIR__ . '/scripts', + __DIR__ . '/services', + ]) + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(false) + ->setRules([ + '@PSR12' => true, + 'line_ending' => true, + ]) + ->setFinder($finder); diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8d1ea85 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +.PHONY: lint stan qa format format-check composer-validate composer-audit platform-check docker-config-check ci-check smoke smoke-dry-run smoke-web smoke-sync smoke-sync-record + +lint: + composer run lint + +stan: + composer run stan + +qa: + composer run qa + +format: + composer run format + +format-check: + composer run format:check + +composer-validate: + composer validate --strict + +composer-audit: + composer audit + +platform-check: + composer check-platform-reqs + +docker-config-check: + docker compose config -q + +ci-check: composer-validate composer-audit platform-check docker-config-check qa + +smoke: + bash scripts/smoke/run.sh + +smoke-dry-run: + bash scripts/smoke/run.sh --dry-run + +smoke-web: + bash scripts/smoke/run.sh --web-only + +smoke-sync: + bash scripts/smoke/run.sh --sync-only + +smoke-sync-record: + bash scripts/smoke/run.sh --sync-only --record-sync diff --git a/composer.json b/composer.json index 6b0f2ad..a098990 100644 --- a/composer.json +++ b/composer.json @@ -11,5 +11,21 @@ "ext-ssh2": "*", "phpseclib/phpseclib": "^3.0" }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.90", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^1.12" + }, + "scripts": { + "lint": "parallel-lint --exclude vendor --exclude .git --exclude docs .", + "stan": "phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G", + "format": "php-cs-fixer fix --config=.php-cs-fixer.dist.php", + "format:check": "php-cs-fixer fix --config=.php-cs-fixer.dist.php --dry-run --diff", + "smoke:dry-run": "bash scripts/smoke/run.sh --dry-run", + "qa": [ + "@lint", + "@stan" + ] + }, "license": "Apache-2.0" } diff --git a/composer.lock b/composer.lock index 85cea71..e7088d7 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": "11dc12443ee95a62eee7ef434402ae73", + "content-hash": "319ba3a85d3d854ca1859921f8574d45", "packages": [ { "name": "paragonie/constant_time_encoding", @@ -236,7 +236,2789 @@ "time": "2026-01-27T09:17:28+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.93.1", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "b3546ab487c0762c39f308dc1ec0ea2c461fc21a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/b3546ab487c0762c39f308dc1ec0ea2c461fc21a", + "reference": "b3546ab487c0762c39f308dc1ec0ea2c461fc21a", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.3", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.5", + "ext-filter": "*", + "ext-hash": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.3", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.33", + "symfony/polyfill-php80": "^1.33", + "symfony/polyfill-php81": "^1.33", + "symfony/polyfill-php84": "^1.33", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.7", + "infection/infection": "^0.32", + "justinrainbow/json-schema": "^6.6", + "keradus/cli-executor": "^2.3", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.9", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.6", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.6", + "phpunit/phpunit": "^9.6.31 || ^10.5.60 || ^11.5.48", + "symfony/polyfill-php85": "^1.33", + "symfony/var-dumper": "^5.4.48 || ^6.4.26 || ^7.4.0 || ^8.0", + "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/**/Internal/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.93.1" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2026-01-28T23:50:50+00:00" + }, + { + "name": "php-parallel-lint/php-parallel-lint", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-parallel-lint/PHP-Parallel-Lint.git", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-parallel-lint/PHP-Parallel-Lint/zipball/6db563514f27e19595a19f45a4bf757b6401194e", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.3.0" + }, + "replace": { + "grogy/php-parallel-lint": "*", + "jakub-onderka/php-parallel-lint": "*" + }, + "require-dev": { + "nette/tester": "^1.3 || ^2.0", + "php-parallel-lint/php-console-highlighter": "0.* || ^1.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "suggest": { + "php-parallel-lint/php-console-highlighter": "Highlight syntax in code snippet" + }, + "bin": [ + "parallel-lint" + ], + "type": "library", + "autoload": { + "classmap": [ + "./src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Jakub Onderka", + "email": "ahoj@jakubonderka.cz" + } + ], + "description": "This tool checks the syntax of PHP files about 20x faster than serial check.", + "homepage": "https://github.com/php-parallel-lint/PHP-Parallel-Lint", + "keywords": [ + "lint", + "static analysis" + ], + "support": { + "issues": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/issues", + "source": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/tree/v1.4.0" + }, + "time": "2024-03-27T12:14:49+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.12.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", + "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-09-30T10:16:31+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" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.7", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-12-23T15:25:20+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.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/v7.4.4" + }, + "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-13T11:36:38+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/event-dispatcher", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746", + "reference": "dc2c0eba1af673e736bb851d747d266108aea746", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "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": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4" + }, + "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-05T11:45:34+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "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 dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-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/filesystem", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "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": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.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-11-27T13:27:24+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "reference": "ad4daa7c38668dcb031e63bc99ea9bd42196a2cb", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.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/v7.4.5" + }, + "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-26T15:07:59+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "b38026df55197f9e39a44f3215788edf83187b80" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", + "reference": "b38026df55197f9e39a44f3215788edf83187b80", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "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": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.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-11-12T15:39:26+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T09:58:17+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-01-02T08:10:11+00:00" + }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "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.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/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-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "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.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/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-24T13:30:11+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "608476f4604102976d687c483ac63a79ba18cc97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", + "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "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": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.5" + }, + "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-26T15:07: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/stopwatch", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "8a24af0a2e8a872fb745047180649b8418303084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084", + "reference": "8a24af0a2e8a872fb745047180649b8418303084", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "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": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.4.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-08-04T07:05:15+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "reference": "1c4b10461bf2ec27537b5f36105337262f5f5d6f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.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/v7.4.4" + }, + "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-12T10:54:30+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": [], diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..8e7c009 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,46 @@ +parameters: + ignoreErrors: + - + message: "#^Function simplify_search\\(\\) should return array but return statement is missing\\.$#" + count: 1 + path: core.php + + - + message: "#^Function ldap_sort not found\\.$#" + count: 1 + path: ldap.php + + - + message: "#^Method Group\\:\\:update\\(\\) should return array but return statement is missing\\.$#" + count: 1 + path: model/group.php + + - + message: "#^Method Server\\:\\:update\\(\\) should return array but return statement is missing\\.$#" + count: 1 + path: model/server.php + + - + message: "#^Undefined variable\\: \\$hostname$#" + count: 1 + path: model/server.php + + - + message: "#^Method ServerAccount\\:\\:update\\(\\) should return array but return statement is missing\\.$#" + count: 1 + path: model/serveraccount.php + + - + message: "#^Method User\\:\\:update\\(\\) should return array but return statement is missing\\.$#" + count: 1 + path: model/user.php + + - + message: "#^Method SyncProcess\\:\\:get_data\\(\\) should return string but return statement is missing\\.$#" + count: 1 + path: scripts/sync-common.php + + - + message: "#^Function history_username_env_format_is_valid not found\\.$#" + count: 1 + path: scripts/sync.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..f9227dd --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,23 @@ +parameters: + level: 0 + paths: + - core.php + - requesthandler.php + - router.php + - routes.php + - ldap.php + - email.php + - model + - scripts + - services + excludePaths: + - */vendor/* + - */migrations/* + - */templates/* + - */views/* + - */public_html/bootstrap/* + - */public_html/jquery/* + tmpDir: var/phpstan + +includes: + - phpstan-baseline.neon From b8cffbb17869861ea60f4c990b0160b8d6028098 Mon Sep 17 00:00:00 2001 From: Msprg Date: Tue, 10 Feb 2026 00:49:27 +0000 Subject: [PATCH 02/85] test: add smoke harness for critical workflows --- docs/smoke-tests.md | 87 +++++++++ scripts/smoke/fixtures/sync/README.md | 34 ++++ .../helpers/find_account_access_rule.php | 65 +++++++ .../smoke/helpers/find_user_public_key.php | 65 +++++++ scripts/smoke/lib.sh | 45 +++++ scripts/smoke/run.sh | 57 ++++++ scripts/smoke/sync-preview.sh | 63 +++++++ scripts/smoke/web-workflows.sh | 172 ++++++++++++++++++ 8 files changed, 588 insertions(+) create mode 100644 docs/smoke-tests.md create mode 100644 scripts/smoke/fixtures/sync/README.md create mode 100755 scripts/smoke/helpers/find_account_access_rule.php create mode 100755 scripts/smoke/helpers/find_user_public_key.php create mode 100755 scripts/smoke/lib.sh create mode 100755 scripts/smoke/run.sh create mode 100755 scripts/smoke/sync-preview.sh create mode 100755 scripts/smoke/web-workflows.sh diff --git a/docs/smoke-tests.md b/docs/smoke-tests.md new file mode 100644 index 0000000..b7637d2 --- /dev/null +++ b/docs/smoke-tests.md @@ -0,0 +1,87 @@ +# Smoke Test Harness (Phase 2) + +This harness validates critical SKA workflows while modernization work is in progress. + +## Covered workflows + +- LDAP login/auth flow +- Public key add/remove flow (for the authenticated user) +- Access rule add/remove flow (for a target server account) +- Sync preview output diff against a recorded fixture + +## Commands + +- Dry-run script validation (safe for CI): + +```bash +make smoke-dry-run +``` + +- Run all smoke checks: + +```bash +make smoke +``` + +- Run only web workflows: + +```bash +make smoke-web +``` + +- Run only sync fixture comparison: + +```bash +make smoke-sync +``` + +- Record/update sync fixture snapshot: + +```bash +make smoke-sync-record +``` + +## Required environment variables + +### Web workflows (`make smoke-web` or `make smoke`) + +- `SKA_SMOKE_BASE_URL` (example: `http://localhost:8080`) +- `SKA_SMOKE_USERNAME` +- `SKA_SMOKE_PASSWORD` +- `SKA_SMOKE_ACCESS_SERVER_HOSTNAME` +- `SKA_SMOKE_ACCESS_ACCOUNT_NAME` +- `SKA_SMOKE_ACCESS_SOURCE_USER` + +Notes: +- The authenticated account must have privileges to manage target access rules. +- `SKA_SMOKE_ACCESS_SOURCE_USER` must not already have access to the target account before the run. + +### Sync preview snapshot (`make smoke-sync`, `make smoke-sync-record`, or `make smoke`) + +- `SKA_SMOKE_SYNC_SERVER_ID` + +Optional: +- `SKA_SMOKE_SYNC_USERNAME` (limit preview to one account) +- `SKA_SMOKE_SYNC_FIXTURE` (custom fixture file path) + +## Fixture behavior + +Sync preview fixtures are stored under `scripts/smoke/fixtures/sync/`. + +During comparison, the harness normalizes output by stripping: +- ANSI color escape sequences +- timestamp prefixes + +That keeps fixture diffs focused on keyfile content behavior. + +## CI behavior + +CI runs only `composer run smoke:dry-run`, which validates harness scripts without requiring LDAP/SSH or environment-specific credentials. + +## Sync troubleshooting + +If sync process spawning fails due to timeout utility differences across environments, inspect runtime detection output: + +```bash +php scripts/sync.php --diagnostics +``` diff --git a/scripts/smoke/fixtures/sync/README.md b/scripts/smoke/fixtures/sync/README.md new file mode 100644 index 0000000..70accd0 --- /dev/null +++ b/scripts/smoke/fixtures/sync/README.md @@ -0,0 +1,34 @@ +# Sync Preview Fixtures + +This directory stores normalized snapshots of `scripts/sync.php --preview` output used by the smoke harness. + +## Naming + +Default fixture path generated by the harness: + +- `server-.txt` +- `server--user-.txt` (when `SKA_SMOKE_SYNC_USERNAME` is set) + +## Record a fixture + +Run: + +```bash +SKA_SMOKE_SYNC_SERVER_ID= scripts/smoke/sync-preview.sh --record +``` + +Or through the orchestrator: + +```bash +SKA_SMOKE_SYNC_SERVER_ID= scripts/smoke/run.sh --sync-only --record-sync +``` + +## Compare against fixture + +Run: + +```bash +SKA_SMOKE_SYNC_SERVER_ID= scripts/smoke/sync-preview.sh +``` + +The harness strips timestamps and ANSI colors before comparison, then fails on any diff. diff --git a/scripts/smoke/helpers/find_account_access_rule.php b/scripts/smoke/helpers/find_account_access_rule.php new file mode 100755 index 0000000..b68978f --- /dev/null +++ b/scripts/smoke/helpers/find_account_access_rule.php @@ -0,0 +1,65 @@ +#!/usr/bin/env php + --source-user=\n"); + exit(2); +} + +if (!is_file($htmlPath)) { + fwrite(STDERR, "HTML file not found: {$htmlPath}\n"); + exit(2); +} + +$html = file_get_contents($htmlPath); +if ($html === false) { + fwrite(STDERR, "Failed to read HTML file: {$htmlPath}\n"); + exit(2); +} + +$doc = new DOMDocument(); +libxml_use_internal_errors(true); +$loaded = $doc->loadHTML($html); +libxml_clear_errors(); +if (!$loaded) { + fwrite(STDERR, "Failed to parse HTML file: {$htmlPath}\n"); + exit(2); +} + +$xpath = new DOMXPath($doc); +$buttons = $xpath->query('//button[@name="delete_access"]'); +if ($buttons === false) { + exit(1); +} + +foreach ($buttons as $button) { + $row = $button; + while ($row !== null && $row->nodeName !== 'tr') { + $row = $row->parentNode; + } + if ($row === null) { + continue; + } + $userLinks = $xpath->query('.//a[contains(concat(" ", normalize-space(@class), " "), " user ")]', $row); + if ($userLinks === false) { + continue; + } + foreach ($userLinks as $link) { + $text = trim((string)$link->textContent); + if ($text === $sourceUser) { + $id = trim((string)$button->getAttribute('value')); + if ($id !== '') { + echo $id; + exit(0); + } + } + } +} + +exit(1); diff --git a/scripts/smoke/helpers/find_user_public_key.php b/scripts/smoke/helpers/find_user_public_key.php new file mode 100755 index 0000000..a2bf40b --- /dev/null +++ b/scripts/smoke/helpers/find_user_public_key.php @@ -0,0 +1,65 @@ +#!/usr/bin/env php + --comment=\n"); + exit(2); +} + +if (!is_file($htmlPath)) { + fwrite(STDERR, "HTML file not found: {$htmlPath}\n"); + exit(2); +} + +$html = file_get_contents($htmlPath); +if ($html === false) { + fwrite(STDERR, "Failed to read HTML file: {$htmlPath}\n"); + exit(2); +} + +$doc = new DOMDocument(); +libxml_use_internal_errors(true); +$loaded = $doc->loadHTML($html); +libxml_clear_errors(); +if (!$loaded) { + fwrite(STDERR, "Failed to parse HTML file: {$htmlPath}\n"); + exit(2); +} + +$xpath = new DOMXPath($doc); +$buttons = $xpath->query('//button[@name="delete_public_key"]'); +if ($buttons === false) { + exit(1); +} + +foreach ($buttons as $button) { + $row = $button; + while ($row !== null && $row->nodeName !== 'tr') { + $row = $row->parentNode; + } + if ($row === null) { + continue; + } + $cells = $xpath->query('.//td', $row); + if ($cells === false) { + continue; + } + foreach ($cells as $cell) { + $text = trim((string)$cell->textContent); + if ($text === $comment) { + $id = trim((string)$button->getAttribute('value')); + if ($id !== '') { + echo $id; + exit(0); + } + } + } +} + +exit(1); diff --git a/scripts/smoke/lib.sh b/scripts/smoke/lib.sh new file mode 100755 index 0000000..92811ed --- /dev/null +++ b/scripts/smoke/lib.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +smoke_log() { + printf '[smoke] %s\n' "$*" +} + +smoke_err() { + printf '[smoke][error] %s\n' "$*" >&2 +} + +smoke_die() { + smoke_err "$*" + exit 1 +} + +smoke_require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || smoke_die "Required command not found: $cmd" +} + +smoke_require_env() { + local var_name="$1" + if [ -z "${!var_name:-}" ]; then + smoke_die "Required environment variable is not set: $var_name" + fi +} + +smoke_urlencode() { + php -r 'echo rawurlencode($argv[1]);' "$1" +} + +smoke_extract_csrf() { + local html_file="$1" + sed -n 's/.*name="csrf_token" value="\([^"]*\)".*/\1/p' "$html_file" | head -n 1 +} + +smoke_cleanup_dir() { + local dir="$1" + if [ -d "$dir" ]; then + find "$dir" -mindepth 1 -type f -delete + find "$dir" -mindepth 1 -type d -empty -delete + rmdir "$dir" 2>/dev/null || true + fi +} diff --git a/scripts/smoke/run.sh b/scripts/smoke/run.sh new file mode 100755 index 0000000..5a005a3 --- /dev/null +++ b/scripts/smoke/run.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib.sh" + +MODE="all" +DRY_RUN=0 +RECORD_SYNC=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --dry-run) + DRY_RUN=1 + ;; + --record-sync) + RECORD_SYNC=1 + ;; + --web-only) + MODE="web" + ;; + --sync-only) + MODE="sync" + ;; + *) + smoke_die "Unknown option: $1" + ;; + esac + shift +done + +if [ "$DRY_RUN" -eq 1 ]; then + smoke_log "Dry-run mode: validating smoke harness scripts" + bash -n "$SCRIPT_DIR/lib.sh" + bash -n "$SCRIPT_DIR/web-workflows.sh" + bash -n "$SCRIPT_DIR/sync-preview.sh" + bash -n "$SCRIPT_DIR/run.sh" + php -l "$SCRIPT_DIR/helpers/find_user_public_key.php" >/dev/null + php -l "$SCRIPT_DIR/helpers/find_account_access_rule.php" >/dev/null + smoke_log "Smoke harness dry-run checks passed" + exit 0 +fi + +if [ "$MODE" = "all" ] || [ "$MODE" = "web" ]; then + "$SCRIPT_DIR/web-workflows.sh" +fi + +if [ "$MODE" = "all" ] || [ "$MODE" = "sync" ]; then + if [ "$RECORD_SYNC" -eq 1 ]; then + "$SCRIPT_DIR/sync-preview.sh" --record + else + "$SCRIPT_DIR/sync-preview.sh" + fi +fi + +smoke_log "All requested smoke checks completed" diff --git a/scripts/smoke/sync-preview.sh b/scripts/smoke/sync-preview.sh new file mode 100755 index 0000000..188cdcc --- /dev/null +++ b/scripts/smoke/sync-preview.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd) +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib.sh" + +smoke_require_cmd php +smoke_require_cmd sed +smoke_require_cmd diff + +RECORD_FIXTURE=0 +if [ "${1:-}" = "--record" ]; then + RECORD_FIXTURE=1 +fi + +smoke_require_env SKA_SMOKE_SYNC_SERVER_ID + +SYNC_USER_SUFFIX="" +SYNC_USER_ARG=() +if [ -n "${SKA_SMOKE_SYNC_USERNAME:-}" ]; then + SYNC_USER_SUFFIX="-user-${SKA_SMOKE_SYNC_USERNAME}" + SYNC_USER_ARG=(--user "$SKA_SMOKE_SYNC_USERNAME") +fi + +DEFAULT_FIXTURE="$SCRIPT_DIR/fixtures/sync/server-${SKA_SMOKE_SYNC_SERVER_ID}${SYNC_USER_SUFFIX}.txt" +FIXTURE_PATH="${SKA_SMOKE_SYNC_FIXTURE:-$DEFAULT_FIXTURE}" + +TMP_DIR=$(mktemp -d) +RAW_OUTPUT="$TMP_DIR/sync-preview.raw.txt" +NORMALIZED_OUTPUT="$TMP_DIR/sync-preview.normalized.txt" + +cleanup() { + smoke_cleanup_dir "$TMP_DIR" +} +trap cleanup EXIT + +smoke_log "Running sync preview for server id ${SKA_SMOKE_SYNC_SERVER_ID}" +( + cd "$REPO_ROOT" + php scripts/sync.php --id "$SKA_SMOKE_SYNC_SERVER_ID" --preview "${SYNC_USER_ARG[@]}" +) > "$RAW_OUTPUT" + +# Normalize volatile output (timestamps and ANSI color codes). +sed -E 's/\x1B\[[0-9;]*[[:alpha:]]//g' "$RAW_OUTPUT" \ + | sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[^ ]+ ([^:]+): /\1: /' \ + | sed -E 's/[[:space:]]+$//' \ + > "$NORMALIZED_OUTPUT" + +if [ "$RECORD_FIXTURE" -eq 1 ]; then + mkdir -p "$(dirname "$FIXTURE_PATH")" + cp "$NORMALIZED_OUTPUT" "$FIXTURE_PATH" + smoke_log "Recorded fixture: $FIXTURE_PATH" + exit 0 +fi + +[ -f "$FIXTURE_PATH" ] || smoke_die "Fixture not found: $FIXTURE_PATH. Record one with: scripts/smoke/sync-preview.sh --record" + +diff -u "$FIXTURE_PATH" "$NORMALIZED_OUTPUT" >/dev/null \ + || smoke_die "Sync preview differs from fixture: $FIXTURE_PATH" + +smoke_log "Sync preview matches fixture: $FIXTURE_PATH" diff --git a/scripts/smoke/web-workflows.sh b/scripts/smoke/web-workflows.sh new file mode 100755 index 0000000..c481f28 --- /dev/null +++ b/scripts/smoke/web-workflows.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib.sh" + +smoke_require_cmd curl +smoke_require_cmd php +smoke_require_cmd ssh-keygen + +smoke_require_env SKA_SMOKE_BASE_URL +smoke_require_env SKA_SMOKE_USERNAME +smoke_require_env SKA_SMOKE_PASSWORD +smoke_require_env SKA_SMOKE_ACCESS_SERVER_HOSTNAME +smoke_require_env SKA_SMOKE_ACCESS_ACCOUNT_NAME +smoke_require_env SKA_SMOKE_ACCESS_SOURCE_USER + +BASE_URL="${SKA_SMOKE_BASE_URL%/}" +TMP_DIR=$(mktemp -d) +COOKIE_JAR="$TMP_DIR/cookies.txt" + +cleanup() { + smoke_cleanup_dir "$TMP_DIR" +} +trap cleanup EXIT + +smoke_log "Checking login page" +curl -fsS -L -c "$COOKIE_JAR" "$BASE_URL/login" -o "$TMP_DIR/login.html" +LOGIN_CSRF=$(smoke_extract_csrf "$TMP_DIR/login.html") +[ -n "$LOGIN_CSRF" ] || smoke_die "Login CSRF token not found" + +smoke_log "Executing LDAP login flow" +curl -fsS -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + --data-urlencode "csrf_token=$LOGIN_CSRF" \ + --data-urlencode "username=$SKA_SMOKE_USERNAME" \ + --data-urlencode "password=$SKA_SMOKE_PASSWORD" \ + "$BASE_URL/login" -o "$TMP_DIR/post-login.html" + +grep -q '>Logout<' "$TMP_DIR/post-login.html" || smoke_die "Login failed or did not reach authenticated UI" + +smoke_log "Adding and deleting a public key for logged-in user" +KEY_COMMENT="ska-smoke-$(date +%s)" +KEY_PATH="$TMP_DIR/smoke-key" +ssh-keygen -q -t ed25519 -N '' -C "$KEY_COMMENT" -f "$KEY_PATH" >/dev/null +PUBLIC_KEY=$(cat "$KEY_PATH.pub") + +curl -fsS -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" "$BASE_URL/" -o "$TMP_DIR/home-before-key.html" +HOME_CSRF=$(smoke_extract_csrf "$TMP_DIR/home-before-key.html") +[ -n "$HOME_CSRF" ] || smoke_die "Home page CSRF token not found before key add" + +curl -fsS -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + --data-urlencode "csrf_token=$HOME_CSRF" \ + --data-urlencode "add_public_key=$PUBLIC_KEY" \ + "$BASE_URL/" -o "$TMP_DIR/home-after-key-add.html" + +if ! KEY_ID=$(php "$SCRIPT_DIR/helpers/find_user_public_key.php" \ + --html "$TMP_DIR/home-after-key-add.html" \ + --comment "$KEY_COMMENT"); then + smoke_die "Added key was not found in active key set" +fi + +curl -fsS -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" "$BASE_URL/" -o "$TMP_DIR/home-before-key-delete.html" +HOME_DELETE_CSRF=$(smoke_extract_csrf "$TMP_DIR/home-before-key-delete.html") +[ -n "$HOME_DELETE_CSRF" ] || smoke_die "Home page CSRF token not found before key delete" + +curl -fsS -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + --data-urlencode "csrf_token=$HOME_DELETE_CSRF" \ + --data-urlencode "delete_public_key=$KEY_ID" \ + "$BASE_URL/" -o "$TMP_DIR/home-after-key-delete.html" + +if php "$SCRIPT_DIR/helpers/find_user_public_key.php" \ + --html "$TMP_DIR/home-after-key-delete.html" \ + --comment "$KEY_COMMENT" >/dev/null 2>&1; then + smoke_die "Deleted key is still active" +fi + +smoke_log "Adding and removing access rule on target account" + +TARGET_SERVER=$(smoke_urlencode "$SKA_SMOKE_ACCESS_SERVER_HOSTNAME") +TARGET_ACCOUNT=$(smoke_urlencode "$SKA_SMOKE_ACCESS_ACCOUNT_NAME") +TARGET_PATH="/servers/${TARGET_SERVER}/accounts/${TARGET_ACCOUNT}" + +fetch_account_page() { + local output_file="$1" + local http_code + http_code=$(curl -sS -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + -o "$output_file" \ + -w "%{http_code}" \ + "$BASE_URL$TARGET_PATH") + if [ "$http_code" != "200" ]; then + smoke_die "Target account page request failed (HTTP ${http_code}) at path '${TARGET_PATH}'. Verify SKA_SMOKE_BASE_URL and target server/account env vars." + fi + grep -q '>Logout<' "$output_file" || smoke_die "Authenticated session was lost while accessing '${TARGET_PATH}'" + grep -q 'name="add_access"' "$output_file" || smoke_die "Authenticated user cannot manage access on '${SKA_SMOKE_ACCESS_ACCOUNT_NAME}@${SKA_SMOKE_ACCESS_SERVER_HOSTNAME}' (add_access form missing)" +} + +require_post_status_ok() { + local status_code="$1" + local action_name="$2" + case "$status_code" in + 200|302|303) + ;; + *) + smoke_die "${action_name} failed (HTTP ${status_code}) at path '${TARGET_PATH}'" + ;; + esac +} + +fetch_account_page "$TMP_DIR/account-before-access.html" +set +e +EXISTING_ACCESS_ID=$(php "$SCRIPT_DIR/helpers/find_account_access_rule.php" \ + --html "$TMP_DIR/account-before-access.html" \ + --source-user "$SKA_SMOKE_ACCESS_SOURCE_USER") +EXISTING_ACCESS_LOOKUP_RC=$? +set -e +if [ "$EXISTING_ACCESS_LOOKUP_RC" -eq 0 ]; then + smoke_log "Existing access rule detected for '${SKA_SMOKE_ACCESS_SOURCE_USER}' (id=${EXISTING_ACCESS_ID}); removing it before add/remove smoke cycle" + ACCOUNT_CSRF=$(smoke_extract_csrf "$TMP_DIR/account-before-access.html") + [ -n "$ACCOUNT_CSRF" ] || smoke_die "Account page CSRF token not found before pre-clean delete" + + PRECLEAN_DELETE_HTTP_CODE=$(curl -sS -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + --data-urlencode "csrf_token=$ACCOUNT_CSRF" \ + --data-urlencode "delete_access=$EXISTING_ACCESS_ID" \ + -o "$TMP_DIR/account-after-preclean-delete.html" \ + -w "%{http_code}" \ + "$BASE_URL$TARGET_PATH") + require_post_status_ok "$PRECLEAN_DELETE_HTTP_CODE" "Pre-clean access delete request" + fetch_account_page "$TMP_DIR/account-before-access.html" +fi + +ACCOUNT_CSRF=$(smoke_extract_csrf "$TMP_DIR/account-before-access.html") +[ -n "$ACCOUNT_CSRF" ] || smoke_die "Account page CSRF token not found before access add" + +ADD_ACCESS_HTTP_CODE=$(curl -sS -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + --data-urlencode "csrf_token=$ACCOUNT_CSRF" \ + --data-urlencode "username=$SKA_SMOKE_ACCESS_SOURCE_USER" \ + --data-urlencode "add_access=2" \ + -o "$TMP_DIR/account-after-access-add.html" \ + -w "%{http_code}" \ + "$BASE_URL$TARGET_PATH") +require_post_status_ok "$ADD_ACCESS_HTTP_CODE" "Access add request" + +fetch_account_page "$TMP_DIR/account-after-access-add.html" + +if ! ACCESS_ID=$(php "$SCRIPT_DIR/helpers/find_account_access_rule.php" \ + --html "$TMP_DIR/account-after-access-add.html" \ + --source-user "$SKA_SMOKE_ACCESS_SOURCE_USER"); then + smoke_die "Added access rule was not found" +fi + +curl -fsS -L -b "$COOKIE_JAR" -c "$COOKIE_JAR" "$BASE_URL$TARGET_PATH" -o "$TMP_DIR/account-before-access-delete.html" +ACCOUNT_DELETE_CSRF=$(smoke_extract_csrf "$TMP_DIR/account-before-access-delete.html") +[ -n "$ACCOUNT_DELETE_CSRF" ] || smoke_die "Account page CSRF token not found before access delete" + +DELETE_ACCESS_HTTP_CODE=$(curl -sS -b "$COOKIE_JAR" -c "$COOKIE_JAR" \ + --data-urlencode "csrf_token=$ACCOUNT_DELETE_CSRF" \ + --data-urlencode "delete_access=$ACCESS_ID" \ + -o "$TMP_DIR/account-after-access-delete.html" \ + -w "%{http_code}" \ + "$BASE_URL$TARGET_PATH") +require_post_status_ok "$DELETE_ACCESS_HTTP_CODE" "Access delete request" + +fetch_account_page "$TMP_DIR/account-after-access-delete.html" + +if php "$SCRIPT_DIR/helpers/find_account_access_rule.php" \ + --html "$TMP_DIR/account-after-access-delete.html" \ + --source-user "$SKA_SMOKE_ACCESS_SOURCE_USER" >/dev/null 2>&1; then + smoke_die "Deleted access rule is still present" +fi + +smoke_log "Web workflows completed successfully" From 48c06e28241c1b7ae447c86dc1ff2145ec10d643 Mon Sep 17 00:00:00 2001 From: Msprg Date: Tue, 10 Feb 2026 00:49:41 +0000 Subject: [PATCH 03/85] refactor: modernize runtime boundaries and sync/security flows --- config/config.ini.example | 23 +++ core.php | 81 ++++++++-- model/dbdirectory.php | 9 +- model/entity.php | 24 ++- model/entityevent.php | 1 - model/externalkey.php | 24 ++- model/group.php | 21 ++- model/publickey.php | 15 +- model/record.php | 18 ++- model/report.php | 4 +- model/server.php | 28 +++- model/serveraccount.php | 26 +++- model/serverevent.php | 1 - model/servernote.php | 6 +- model/user.php | 52 +++++-- model/userdirectory.php | 7 +- requesthandler.php | 130 ++++++---------- scripts/ssh.php | 80 +++++++++- scripts/sync-common.php | 37 +++-- scripts/sync-failure.php | 166 +++++++++++++++++++++ scripts/sync-runtime.php | 87 +++++++++++ scripts/sync.php | 137 ++++++++++++++--- services/access_rule_service.php | 101 +++++++++++++ services/key_lifecycle_service.php | 54 +++++++ services/login_flow.php | 90 +++++++++++ services/relation_lifecycle_service.php | 190 ++++++++++++++++++++++++ services/request_auth_guard.php | 45 ++++++ services/request_context.php | 21 +++ services/request_csrf_guard.php | 15 ++ services/request_exception_handler.php | 23 +++ services/request_policy_guard.php | 20 +++ services/request_router_dispatcher.php | 29 ++++ services/response_security_headers.php | 115 ++++++++++++++ services/runtime_state.php | 26 ++++ views/group.php | 50 ++----- views/home.php | 15 +- views/login.php | 77 +--------- views/pubkeys.php | 3 +- views/server.php | 23 +-- views/serveraccount.php | 66 ++------ views/servers.php | 41 ++--- views/servers_bulk_action.php | 31 ++-- views/user.php | 28 +--- views/user_pubkeys.php | 7 +- 44 files changed, 1579 insertions(+), 468 deletions(-) create mode 100644 scripts/sync-failure.php create mode 100644 scripts/sync-runtime.php create mode 100644 services/access_rule_service.php create mode 100644 services/key_lifecycle_service.php create mode 100644 services/login_flow.php create mode 100644 services/relation_lifecycle_service.php create mode 100644 services/request_auth_guard.php create mode 100644 services/request_context.php create mode 100644 services/request_csrf_guard.php create mode 100644 services/request_exception_handler.php create mode 100644 services/request_policy_guard.php create mode 100644 services/request_router_dispatcher.php create mode 100644 services/response_security_headers.php create mode 100644 services/runtime_state.php diff --git a/config/config.ini.example b/config/config.ini.example index 0db2bf2..c812e4d 100644 --- a/config/config.ini.example +++ b/config/config.ini.example @@ -26,6 +26,21 @@ default_key_supervision = full ; Set to 0 to disable automatic logout session_timeout = 28800 +; Content Security Policy (CSP) for web responses. +; Leave unset to use default: default-src 'self' +;content_security_policy = "default-src 'self'" +; Set to 1 to emit CSP in report-only mode while tuning policy. +csp_report_only = 0 +; Optional report collection endpoint for CSP violations. +;content_security_policy_report_uri = "https://ska.example.com/csp-report" +; Enable HSTS header for HTTPS responses only. +hsts_enabled = 0 +; HSTS max-age in seconds. +hsts_max_age = 31536000 +; Add includeSubDomains directive. +hsts_include_subdomains = 0 +; Add preload directive (requires includeSubDomains and long max-age). +hsts_preload = 0 ; It is important that SKA is able to verify that it has connected to the ; server that it expected to connect to (otherwise it could be tricked into @@ -45,6 +60,14 @@ host_key_reset_restriction = 1 ; It is not recommended to leave this set to '0' indefinitely host_key_collision_protection = 1 +; SSH jumphost chain trust behavior: +; 0: Keep current compatibility mode (no host key check for jumphost chain) +; 1: Require host key checks for each jump and target tunnel step +jumphost_strict_host_key_checking = 0 +; Path to known_hosts file used when opening jumphost chain SSH commands. +; Keep /dev/null for compatibility mode above. +jumphost_known_hosts_file = /dev/null + ; Minimum key strength requirements for different key types ; RSA keys must be at least this many bits min_rsa_bits = 4096 diff --git a/core.php b/core.php index eaf592a..8f603d8 100644 --- a/core.php +++ b/core.php @@ -23,6 +23,7 @@ spl_autoload_register('autoload_model'); require('pagesection.php'); +require('services/runtime_state.php'); $config_file = 'config/config.ini'; if(file_exists($config_file)) { @@ -30,6 +31,10 @@ } else { throw new Exception("Config file $config_file does not exist."); } +RuntimeState::set_many(array( + 'base_path' => $base_path, + 'config' => $config, +)); // Session optimizations ini_set('session.cookie_httponly', 1); @@ -55,9 +60,20 @@ $ldap_options[LDAP_OPT_PROTOCOL_VERSION] = 3; $ldap_options[LDAP_OPT_REFERRALS] = !empty($config['ldap']['follow_referrals']); $ldap = new LDAP($config['ldap']['host'], $config['ldap']['starttls'], $config['ldap']['bind_dn'], $config['ldap']['bind_password'], $ldap_options); -setup_database(); +RuntimeState::set('ldap', $ldap); +$runtime_services = setup_database($config); +$database = $runtime_services['database']; +$driver = $runtime_services['driver']; +$pubkey_dir = $runtime_services['pubkey_dir']; +$user_dir = $runtime_services['user_dir']; +$group_dir = $runtime_services['group_dir']; +$server_dir = $runtime_services['server_dir']; +$server_account_dir = $runtime_services['server_account_dir']; +$event_dir = $runtime_services['event_dir']; +$sync_request_dir = $runtime_services['sync_request_dir']; $relative_frontend_base_url = (string)parse_url($config['web']['baseurl'], PHP_URL_PATH); +RuntimeState::set('relative_frontend_base_url', $relative_frontend_base_url); // Convert all non-fatal errors into exceptions function exception_error_handler($errno, $errstr, $errfile, $errline) { @@ -66,24 +82,38 @@ function exception_error_handler($errno, $errstr, $errfile, $errline) { // Autoload needed model files function autoload_model($classname) { - global $base_path; + $original_classname = $classname; $classname = preg_replace('/[^a-z]/', '', strtolower($classname)); # Prevent directory traversal and sanitize name + $base_path = RuntimeState::get('base_path', dirname(__FILE__)); $filename = path_join($base_path, 'model', $classname.'.php'); if(file_exists($filename)) { include($filename); } else { - eval("class $classname {}"); - throw new InvalidArgumentException("Attempted to load a class $classname that did not exist."); + throw new InvalidArgumentException("Attempted to load class {$original_classname} but model file {$filename} does not exist."); } } // Autoload composer libraries require __DIR__ . '/vendor/autoload.php'; // Setup database connection and models with optimizations -function setup_database() { - global $config, $database, $driver, $pubkey_dir, $user_dir, $group_dir, $server_dir, $server_account_dir, $event_dir, $sync_request_dir; +function setup_database($config = null) { + if(is_null($config)) { + $config = RuntimeState::get('config', null); + if(is_null($config) && isset($GLOBALS['config']) && is_array($GLOBALS['config'])) { + $config = $GLOBALS['config']; + } + } + if(is_null($config) || !is_array($config)) { + throw new InvalidArgumentException('Database setup requires configuration.'); + } try { - $database = new mysqli($config['database']['hostname'], $config['database']['username'], $config['database']['password'], $config['database']['database'], $config['database']['port']); + $database = new mysqli( + $config['database']['hostname'], + $config['database']['username'], + $config['database']['password'], + $config['database']['database'], + $config['database']['port'] + ); } catch(ErrorException $e) { throw new DBConnectionFailedException($e->getMessage()); } @@ -96,6 +126,9 @@ function setup_database() { $driver = new mysqli_driver(); $driver->report_mode = MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT; + // Keep legacy model constructors (DBDirectory) working during bootstrap. + $GLOBALS['database'] = $database; + $GLOBALS['driver'] = $driver; $migration_dir = new MigrationDirectory; $pubkey_dir = new PublicKeyDirectory; $user_dir = new UserDirectory; @@ -104,6 +137,22 @@ function setup_database() { $server_account_dir = new ServerAccountDirectory; $event_dir = new EventDirectory; $sync_request_dir = new SyncRequestDirectory; + $services = array( + 'database' => $database, + 'driver' => $driver, + 'pubkey_dir' => $pubkey_dir, + 'user_dir' => $user_dir, + 'group_dir' => $group_dir, + 'server_dir' => $server_dir, + 'server_account_dir' => $server_account_dir, + 'event_dir' => $event_dir, + 'sync_request_dir' => $sync_request_dir, + ); + RuntimeState::set_many($services); + foreach($services as $service_name => $service_value) { + $GLOBALS[$service_name] = $service_value; + } + return $services; } /** @@ -139,7 +188,7 @@ function out($string, $escaping = ESC_HTML) { if (is_null($string)) return ''; switch($escaping) { case ESC_HTML: - echo htmlspecialchars($string); + echo htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); break; case ESC_URL: echo urlencode($string); @@ -161,8 +210,7 @@ function out($string, $escaping = ESC_HTML) { * @return string root-relative URL */ function rrurl($url) { - global $relative_frontend_base_url; - return $relative_frontend_base_url.$url; + return RuntimeState::get('relative_frontend_base_url', '').$url; } /** @@ -180,7 +228,7 @@ function outurl($url) { */ function hesc($string) { if (is_null($string)) return ''; - return htmlspecialchars($string); + return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); } function english_list($array) { @@ -194,13 +242,20 @@ function english_list($array) { * @param string $type HTTP response code/name to use */ function redirect($url = null, $type = '303 See other') { - global $absolute_request_url, $relative_frontend_base_url; + $absolute_request_url = RuntimeState::get('absolute_request_url', null); + $relative_frontend_base_url = RuntimeState::get('relative_frontend_base_url', ''); if(is_null($url)) { // Redirect is to current URL $url = $absolute_request_url; + if(is_null($url) || $url === '') { + $url = '/'; + } } elseif(substr($url, 0, 1) !== '#') { $url = $relative_frontend_base_url.$url; } + if(strpos($url, "\r") !== false || strpos($url, "\n") !== false) { + throw new InvalidArgumentException('Redirect URL contains invalid newline characters.'); + } header("HTTP/1.1 $type"); header("Location: $url"); print("\n"); @@ -216,7 +271,7 @@ function redirect($url = null, $type = '303 See other') { * @return array result of combining defaults and querystring data */ function simplify_search($defaults, $values) { - global $relative_request_url; + $relative_request_url = RuntimeState::get('relative_request_url', ''); $simplify = false; $simplified = array(); foreach($defaults as $key => $default) { diff --git a/model/dbdirectory.php b/model/dbdirectory.php index ceb91f2..fb0f832 100644 --- a/model/dbdirectory.php +++ b/model/dbdirectory.php @@ -25,8 +25,13 @@ abstract class DBDirectory { * Sets up the local $database object for use by the inheriting classes. */ public function __construct() { - global $database; - $this->database = $database; + if(array_key_exists('database', $GLOBALS)) { + $this->database = $GLOBALS['database']; + } elseif(class_exists('RuntimeState', false)) { + $this->database = RuntimeState::get('database'); + } else { + $this->database = null; + } } } diff --git a/model/entity.php b/model/entity.php index bb87a31..e7eba58 100644 --- a/model/entity.php +++ b/model/entity.php @@ -34,7 +34,17 @@ abstract class Entity extends Record { * @return Entity An instance of User, Group or ServerAccount, or null if no entity with this id exists */ public static function load(int $id): ?Entity { - global $database; + if(class_exists('RuntimeState', false)) { + $database = RuntimeState::get('database', null); + } else { + $database = null; + } + if(is_null($database) && array_key_exists('database', $GLOBALS)) { + $database = $GLOBALS['database']; + } + if(is_null($database)) { + throw new BadMethodCallException('Database connection is not available.'); + } $stmt = $database->prepare("SELECT type FROM entity WHERE id = ?"); $stmt->bind_param('d', $id); @@ -459,7 +469,9 @@ public function sync_remote_access(&$seen = array()) { $access->dest_entity->sync_access(); } // Sync whatever groups this entity is a member of - global $group_dir; + $group_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('group_dir', array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null) + : (array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null); $memberships = $group_dir->list_group_membership($this); foreach($memberships as $group) { if(!isset($seen[$group->entity_id])) { @@ -467,8 +479,12 @@ public function sync_remote_access(&$seen = array()) { } } // If this is a user, also sync across LDAP-based servers - global $server_dir; - global $sync_request_dir; + $server_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('server_dir', array_key_exists('server_dir', $GLOBALS) ? $GLOBALS['server_dir'] : null) + : (array_key_exists('server_dir', $GLOBALS) ? $GLOBALS['server_dir'] : null); + $sync_request_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('sync_request_dir', array_key_exists('sync_request_dir', $GLOBALS) ? $GLOBALS['sync_request_dir'] : null) + : (array_key_exists('sync_request_dir', $GLOBALS) ? $GLOBALS['sync_request_dir'] : null); if(get_class($this) == 'User') { $servers = $server_dir->list_servers(array(), array('authorization' => array('manual LDAP', 'automatic LDAP'))); foreach($servers as $server) { diff --git a/model/entityevent.php b/model/entityevent.php index 01e1268..c38687d 100644 --- a/model/entityevent.php +++ b/model/entityevent.php @@ -31,7 +31,6 @@ abstract class EntityEvent extends Record { * @return mixed data stored in field */ public function &__get($field) { - global $user_dir; switch($field) { case 'actor': $actor = new User($this->data['actor_id']); diff --git a/model/externalkey.php b/model/externalkey.php index 2612715..fd0f5ea 100644 --- a/model/externalkey.php +++ b/model/externalkey.php @@ -43,7 +43,17 @@ class ExternalKey extends Record { * @return array An array of ExternalKey objects */ public static function list_external_keys($with_hostnames = false) { - global $database; + if(class_exists('RuntimeState', false)) { + $database = RuntimeState::get('database', null); + } else { + $database = null; + } + if(is_null($database) && array_key_exists('database', $GLOBALS)) { + $database = $GLOBALS['database']; + } + if(is_null($database)) { + throw new BadMethodCallException('Database connection is not available.'); + } // load the keys $result = $database->query("SELECT * FROM external_key"); @@ -87,7 +97,17 @@ public static function list_external_keys($with_hostnames = false) { * @return ExternalKey|null The loaded key, or null if there was no key with this id */ public static function get_by_id(int $id) { - global $database; + if(class_exists('RuntimeState', false)) { + $database = RuntimeState::get('database', null); + } else { + $database = null; + } + if(is_null($database) && array_key_exists('database', $GLOBALS)) { + $database = $GLOBALS['database']; + } + if(is_null($database)) { + throw new BadMethodCallException('Database connection is not available.'); + } $stmt = $database->prepare("SELECT * FROM external_key WHERE id = ?"); $stmt->bind_param("i", $id); $stmt->execute(); diff --git a/model/group.php b/model/group.php index e9abd92..aa5fa51 100644 --- a/model/group.php +++ b/model/group.php @@ -20,6 +20,17 @@ * Class that represents a grouping of users or server accounts */ class Group extends Entity { + private static function runtime_value($key, $default = null) { + if(class_exists('RuntimeState', false)) { + return RuntimeState::get($key, $default); + } + return $default; + } + + private static function runtime_config() { + return self::runtime_value('config', array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()); + } + /** * Defines the database table that this object is stored in */ @@ -82,7 +93,7 @@ public function get_log() { * @param User $user to add as administrator */ public function add_admin(User $user) { - global $config; + $config = self::runtime_config(); parent::add_admin($user); $url = $config['web']['baseurl'].'/groups/'.urlencode($this->name); $email = new Email; @@ -217,7 +228,7 @@ public function add_multiple_accounts(array $accounts, array &$errors): ?array { * @param User $actor The user who performs this action. */ private function send_mail_addmember(Entity $entity, User $actor) { - global $config; + $config = self::runtime_config(); switch (get_class($entity)) { case 'User': $mailsubject = "{$entity->uid} added to {$this->name} group by {$actor->uid}"; @@ -248,7 +259,7 @@ private function send_mail_addmember(Entity $entity, User $actor) { * @param array $success_list Array of strings, describing all accounts that have been added successfully */ private function send_bulkmail_addaccounts(array $success_list) { - global $config; + $config = self::runtime_config(); $actor = $this->active_user; $count = count($success_list); if ($count == 1) { @@ -341,7 +352,7 @@ public function list_members() { * @param array $access_options array of AccessOption rules to apply to the granted access */ public function add_access(Entity $entity, array $access_options) { - global $config; + $config = self::runtime_config(); if(is_null($this->entity_id)) throw new BadMethodCallException('Group must be in directory before access can be added'); if(is_null($entity->entity_id)) throw new InvalidArgumentException('Entity must be in directory before it can be granted access to a group'); $access = new Access; @@ -424,7 +435,7 @@ public function delete_access(Access $access) { * @return array of Group objects */ public function list_group_membership() { - global $group_dir; + $group_dir = self::runtime_value('group_dir', array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null); return $group_dir->list_group_membership($this); } diff --git a/model/publickey.php b/model/publickey.php index e54bd28..ab516a4 100644 --- a/model/publickey.php +++ b/model/publickey.php @@ -25,6 +25,13 @@ class PublicKey extends Record { */ protected $table = 'public_key'; + private static function get_runtime_config() { + if(class_exists('RuntimeState', false)) { + return RuntimeState::get('config', array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()); + } + return array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array(); + } + /** * Import all key data from a provided OpenSSH-text-format public key. * Cope with some possible correctable whitespace data issues. @@ -34,7 +41,7 @@ class PublicKey extends Record { * @throws InvalidArgumentException if the public key cannot be parsed or is not sufficiently secure */ public function import($key, $uid = null, $force = false) { - global $config; + $config = self::get_runtime_config(); // Remove newlines (often included by accident) and trim $key = str_replace(array("\r", "\n"), array(), trim($key)); @@ -185,7 +192,7 @@ public function export() { * @return string key in OpenSSH-text-format */ public function export_userkey_with_fixed_comment(User $owner, int $comment) { - global $config; + $config = self::get_runtime_config(); if ($comment == 1) { if ($this->creation_date === null) { $date = ''; @@ -209,7 +216,7 @@ public function export_userkey_with_fixed_comment(User $owner, int $comment) { * @return string key in OpenSSH-text-format */ public function export_serverkey_with_fixed_comment(ServerAccount $owner, int $comment) { - global $config; + $config = self::get_runtime_config(); if ($comment == 1) { if ($this->creation_date === null) { $date = ''; @@ -230,7 +237,7 @@ public function export_serverkey_with_fixed_comment(ServerAccount $owner, int $c * @return string text summary */ public function summarize_key_information() { - global $config; + $config = self::get_runtime_config(); $url = $config['web']['baseurl'].'/pubkeys/'.urlencode($this->id); $output = "The key fingerprint is:\n"; $output .= " MD5:{$this->fingerprint_md5}\n"; diff --git a/model/record.php b/model/record.php index d025a82..cf312ae 100644 --- a/model/record.php +++ b/model/record.php @@ -50,10 +50,20 @@ abstract class Record { public $id; public function __construct($id = null, $preload_data = array()) { - global $database; - global $active_user; - $this->database = &$database; - $this->active_user = &$active_user; + if(array_key_exists('database', $GLOBALS)) { + $this->database = &$GLOBALS['database']; + } elseif(class_exists('RuntimeState', false)) { + $this->database = RuntimeState::get('database'); + } else { + $this->database = null; + } + if(array_key_exists('active_user', $GLOBALS)) { + $this->active_user = &$GLOBALS['active_user']; + } elseif(class_exists('RuntimeState', false)) { + $this->active_user = RuntimeState::get('active_user'); + } else { + $this->active_user = null; + } $this->id = $id; $this->data = array(); foreach($preload_data as $field => $value) { diff --git a/model/report.php b/model/report.php index 7718855..4ea53bb 100644 --- a/model/report.php +++ b/model/report.php @@ -36,7 +36,9 @@ private function __construct($leaders_report, $access_report, $server_to_server_ * @return Report Contains the resulting report data */ public static function create(): Report { - global $server_dir; + $server_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('server_dir', array_key_exists('server_dir', $GLOBALS) ? $GLOBALS['server_dir'] : null) + : (array_key_exists('server_dir', $GLOBALS) ? $GLOBALS['server_dir'] : null); // Server leader report $servers = $server_dir->list_servers([], ['key_management' => ['keys']]); diff --git a/model/server.php b/model/server.php index ad7ec79..057c4e6 100644 --- a/model/server.php +++ b/model/server.php @@ -20,6 +20,17 @@ * Class that represents a server */ class Server extends Record { + private static function runtime_value($key, $default = null) { + if(class_exists('RuntimeState', false)) { + return RuntimeState::get($key, $default); + } + return $default; + } + + private static function runtime_config() { + return self::runtime_value('config', array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()); + } + /** * Defines the database table that this object is stored in */ @@ -162,7 +173,7 @@ public function get_last_sync_event() { * @return bool True if the entity has been added as leader, false if it was already a leader. */ public function add_admin(Entity $entity, bool $send_mail = true): bool { - global $config; + $config = self::runtime_config(); if(is_null($this->id)) throw new BadMethodCallException('Server must be in directory before leaders can be added'); if(is_null($entity->entity_id)) throw new InvalidArgumentException('User or group must be in directory before it can be made leader'); $entity_id = $entity->entity_id; @@ -292,7 +303,8 @@ public function list_effective_admins() { * groups. */ public function add_standard_accounts() { - global $group_dir, $config; + $group_dir = self::runtime_value('group_dir', array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null); + $config = self::runtime_config(); if(!isset($config['defaults']['account_groups'])) return; foreach($config['defaults']['account_groups'] as $account_name => $group_name) { $account = new ServerAccount; @@ -568,7 +580,8 @@ public function list_notes() { * @throws SSHException If the connection fails for some reason */ public function connect_ssh(): SSH { - global $config, $server_dir; + $config = self::runtime_config(); + $server_dir = self::runtime_value('server_dir', array_key_exists('server_dir', $GLOBALS) ? $GLOBALS['server_dir'] : null); $this->ip_address = gethostbyname($this->hostname); $this->update(); @@ -594,7 +607,8 @@ public function connect_ssh(): SSH { 'keys-sync', 'config/keys-sync.pub', 'config/keys-sync', - $this->host_key + $this->host_key, + SSH::build_jumphost_security_options($config) ); $this->update(); // fingerprint might have changed @@ -639,7 +653,7 @@ public function connect_ssh(): SSH { * Trigger a sync for all accounts on this server. */ public function sync_access() { - global $sync_request_dir; + $sync_request_dir = self::runtime_value('sync_request_dir', array_key_exists('sync_request_dir', $GLOBALS) ? $GLOBALS['sync_request_dir'] : null); $sync_request = new SyncRequest; $sync_request->server_id = $this->id; $sync_request->account_name = null; @@ -681,7 +695,7 @@ public function delete_all_sync_requests() { * Delete sync requests for this server and schedule a new request in 30 minutes */ public function reschedule_sync_request() { - global $sync_request_dir; + $sync_request_dir = self::runtime_value('sync_request_dir', array_key_exists('sync_request_dir', $GLOBALS) ? $GLOBALS['sync_request_dir'] : null); $this->delete_all_sync_requests(); $req = new SyncRequest(); @@ -697,7 +711,7 @@ public function reschedule_sync_request() { * @param SSH $connection The ssh connection instance to this server */ public function update_status_file(SSH $connection) { - global $config; + $config = self::runtime_config(); $timeout = (int)($config['monitoring']['status_file_timeout'] ?? 7200); $expire = date('r', time() + $timeout); $lastlogmsg = $this->get_last_sync_event(); diff --git a/model/serveraccount.php b/model/serveraccount.php index 5df926f..6b8239f 100644 --- a/model/serveraccount.php +++ b/model/serveraccount.php @@ -29,13 +29,23 @@ class ServerAccount extends Entity { */ protected $idfield = 'entity_id'; + private static function runtime_value($key, $default = null) { + if(class_exists('RuntimeState', false)) { + return RuntimeState::get($key, $default); + } + return $default; + } + + private static function runtime_config() { + return self::runtime_value('config', array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()); + } + /** * Magic getter method - if server field requested, return Server object * @param string $field to retrieve * @return mixed data stored in field */ public function &__get($field) { - global $user_dir; switch($field) { case 'server': $server = new Server($this->server_id); @@ -50,7 +60,7 @@ public function &__get($field) { * Triggers a resync of the server if account is activated/deactivated. */ public function update() { - global $config; + $config = self::runtime_config(); // Make it impossible to set default accounts to inactive if(is_array($config['defaults']['account_groups'])) { if(array_key_exists($this->data['name'], $config['defaults']['account_groups'])) { @@ -106,7 +116,7 @@ public function get_log() { * @param User $user to add as leader */ public function add_admin(User $user) { - global $config; + $config = self::runtime_config(); parent::add_admin($user); $url = $config['web']['baseurl'].'/servers/'.urlencode($this->server->hostname).'/accounts/'.urlencode($this->name); $email = new Email; @@ -136,7 +146,7 @@ public function delete_admin(User $user) { * @param PublicKey $key to be added */ public function add_public_key(PublicKey $key) { - global $config; + $config = self::runtime_config(); parent::add_public_key($key); $url = $config['web']['baseurl'].'/pubkeys/'.urlencode($key->id); $email = new Email; @@ -166,7 +176,7 @@ public function delete_public_key(PublicKey $key) { * @param Entity $entity to request access for */ public function add_access_request(Entity $entity) { - global $config; + $config = self::runtime_config(); if(is_null($this->entity_id)) throw new BadMethodCallException('Server account must be added to server before access can be requested'); try { $request = new AccessRequest; @@ -311,7 +321,7 @@ public function reject_access_request(AccessRequest $request) { * @param array $access_options array of AccessOption rules to apply to the granted access */ public function add_access(Entity $entity, array $access_options) { - global $config; + $config = self::runtime_config(); if(is_null($this->entity_id)) throw new BadMethodCallException('Server account must be added to server before access can be added'); if($this->sync_status == 'proposed') { $this->sync_status = 'not synced yet'; @@ -406,7 +416,7 @@ public function delete_access(Access $access) { * @return array of Group objects */ public function list_group_membership() { - global $group_dir; + $group_dir = self::runtime_value('group_dir', array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null); return $group_dir->list_group_membership($this); } @@ -414,7 +424,7 @@ public function list_group_membership() { * Trigger a sync for this account. */ public function sync_access() { - global $sync_request_dir; + $sync_request_dir = self::runtime_value('sync_request_dir', array_key_exists('sync_request_dir', $GLOBALS) ? $GLOBALS['sync_request_dir'] : null); $sync_request = new SyncRequest; $sync_request->server_id = $this->server_id; $sync_request->account_name = $this->name; diff --git a/model/serverevent.php b/model/serverevent.php index b054f37..2608763 100644 --- a/model/serverevent.php +++ b/model/serverevent.php @@ -31,7 +31,6 @@ class ServerEvent extends Record { * @return mixed data stored in field */ public function &__get($field) { - global $user_dir; switch($field) { case 'actor': $actor = new User($this->data['actor_id']); diff --git a/model/servernote.php b/model/servernote.php index b933836..70fcb92 100644 --- a/model/servernote.php +++ b/model/servernote.php @@ -26,8 +26,9 @@ class ServerNote extends Record { public function __construct($id = null, $preload_data = array()) { parent::__construct($id, $preload_data); - global $active_user; - if(is_null($id)) $this->entity_id = $active_user->entity_id; + if(is_null($id) && $this->active_user) { + $this->entity_id = $this->active_user->entity_id; + } } /** @@ -37,7 +38,6 @@ public function __construct($id = null, $preload_data = array()) { * @return mixed data stored in field */ public function &__get($field) { - global $user_dir; switch($field) { case 'user': $user = new User($this->entity_id); diff --git a/model/user.php b/model/user.php index 5214151..561a9c4 100644 --- a/model/user.php +++ b/model/user.php @@ -39,8 +39,11 @@ class User extends Entity { public function __construct($id = null, $preload_data = array()) { parent::__construct($id, $preload_data); - global $ldap; - $this->ldap = $ldap; + if(class_exists('RuntimeState', false)) { + $this->ldap = RuntimeState::get('ldap', array_key_exists('ldap', $GLOBALS) ? $GLOBALS['ldap'] : null); + } else { + $this->ldap = array_key_exists('ldap', $GLOBALS) ? $GLOBALS['ldap'] : null; + } } /** @@ -77,7 +80,6 @@ public function update() { * @return mixed data stored in field */ public function &__get($field) { - global $user_dir; switch($field) { case 'superior': if(is_null($this->superior_entity_id)) $superior = null; @@ -94,7 +96,9 @@ public function &__get($field) { * @return array of *Event objects */ public function list_events($include = array()) { - global $event_dir; + $event_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('event_dir', array_key_exists('event_dir', $GLOBALS) ? $GLOBALS['event_dir'] : null) + : (array_key_exists('event_dir', $GLOBALS) ? $GLOBALS['event_dir'] : null); if(is_null($this->entity_id)) throw new BadMethodCallException('User must be in directory before events can be listed'); return $event_dir->list_events($include, array('admin' => $this->entity_id)); } @@ -105,7 +109,9 @@ public function list_events($include = array()) { * @return array of Server objects */ public function list_admined_servers($include = array()) { - global $server_dir; + $server_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('server_dir', array_key_exists('server_dir', $GLOBALS) ? $GLOBALS['server_dir'] : null) + : (array_key_exists('server_dir', $GLOBALS) ? $GLOBALS['server_dir'] : null); if(is_null($this->entity_id)) throw new BadMethodCallException('User must be in directory before admined servers can be listed'); return $server_dir->list_servers($include, array('admin' => $this->entity_id, 'key_management' => array('none', 'keys', 'other'))); } @@ -116,7 +122,9 @@ public function list_admined_servers($include = array()) { * @return array of Group objects */ public function list_admined_groups($include = array()) { - global $group_dir; + $group_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('group_dir', array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null) + : (array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null); if(is_null($this->entity_id)) throw new BadMethodCallException('User must be in directory before admined group can be listed'); $groups = $group_dir->list_groups($include, array('admin' => $this->entity_id)); return $groups; @@ -128,7 +136,9 @@ public function list_admined_groups($include = array()) { * @return array of Group objects */ public function list_group_memberships($include = array()) { - global $group_dir; + $group_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('group_dir', array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null) + : (array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null); if(is_null($this->entity_id)) throw new BadMethodCallException('User must be in directory before group memberships can be listed'); $groups = $group_dir->list_groups($include, array('member' => $this->entity_id)); return $groups; @@ -193,7 +203,12 @@ public function member_of(Group $group) { * @param PublicKey $key to be added */ public function add_public_key(PublicKey $key) { - global $active_user, $config; + $active_user = class_exists('RuntimeState', false) + ? RuntimeState::get('active_user', array_key_exists('active_user', $GLOBALS) ? $GLOBALS['active_user'] : null) + : (array_key_exists('active_user', $GLOBALS) ? $GLOBALS['active_user'] : null); + $config = class_exists('RuntimeState', false) + ? RuntimeState::get('config', array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()) + : (array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()); parent::add_public_key($key); $url = $config['web']['baseurl'].'/pubkeys/'.urlencode($key->id); $email = new Email; @@ -216,7 +231,6 @@ public function add_public_key(PublicKey $key) { * @param PublicKey $key to be removed */ public function delete_public_key(PublicKey $key) { - global $active_user; parent::delete_public_key($key); $this->log(array('action' => 'Pubkey remove', 'value' => $key->fingerprint_md5)); } @@ -306,7 +320,9 @@ public function check_csrf_token($token) { * @throws UserNotFoundException if the user is not found in LDAP */ public function get_details_from_ldap() { - global $config; + $config = class_exists('RuntimeState', false) + ? RuntimeState::get('config', array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()) + : (array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()); $attributes = array(); $attributes[] = 'dn'; $attributes[] = $config['ldap']['user_id']; @@ -371,7 +387,6 @@ public function get_details_from_ldap() { * @return string[] guids of the groups this user is member of */ public function get_ldap_group_guids() { - global $config; if ($this->ldap_group_guids === null) { $this->get_details_from_ldap(); } @@ -382,7 +397,9 @@ public function get_ldap_group_guids() { * Adds the user to ldap groups or removes him from ldap groups, based on the current status on the directory server. */ public function update_group_memberships() { - global $group_dir; + $group_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('group_dir', array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null) + : (array_key_exists('group_dir', $GLOBALS) ? $GLOBALS['group_dir'] : null); foreach ($group_dir->get_sys_groups() as $sys_group) { $should_be_member = $this->active && in_array($sys_group->ldap_guid, $this->get_ldap_group_guids()); if ($should_be_member && !$this->member_of($sys_group)) { @@ -402,7 +419,12 @@ public function update_group_memberships() { * @throws UserNotFoundException if the user is not found in LDAP */ public function get_superior_from_ldap() { - global $user_dir, $config; + $user_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('user_dir', array_key_exists('user_dir', $GLOBALS) ? $GLOBALS['user_dir'] : null) + : (array_key_exists('user_dir', $GLOBALS) ? $GLOBALS['user_dir'] : null); + $config = class_exists('RuntimeState', false) + ? RuntimeState::get('config', array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()) + : (array_key_exists('config', $GLOBALS) ? $GLOBALS['config'] : array()); if(is_null($this->entity_id)) throw new BadMethodCallException('User must be in directory before superior employee can be looked up'); if(!isset($config['ldap']['user_superior'])) { throw new BadMethodCallException("Cannot retrieve user's superior if user_superior is not configured"); @@ -436,7 +458,9 @@ public function get_superior_from_ldap() { * @return User An instance of the keys-sync user */ public static function get_keys_sync_user() { - global $user_dir; + $user_dir = class_exists('RuntimeState', false) + ? RuntimeState::get('user_dir', array_key_exists('user_dir', $GLOBALS) ? $GLOBALS['user_dir'] : null) + : (array_key_exists('user_dir', $GLOBALS) ? $GLOBALS['user_dir'] : null); try { $keys_sync = $user_dir->get_user_by_uid('keys-sync'); } catch(UserNotFoundException $e) { diff --git a/model/userdirectory.php b/model/userdirectory.php index 58437a1..09562e7 100644 --- a/model/userdirectory.php +++ b/model/userdirectory.php @@ -31,8 +31,11 @@ class UserDirectory extends DBDirectory { public function __construct() { parent::__construct(); - global $ldap; - $this->ldap = $ldap; + if(class_exists('RuntimeState', false)) { + $this->ldap = RuntimeState::get('ldap', array_key_exists('ldap', $GLOBALS) ? $GLOBALS['ldap'] : null); + } else { + $this->ldap = array_key_exists('ldap', $GLOBALS) ? $GLOBALS['ldap'] : null; + } $this->cache_uid = array(); } diff --git a/requesthandler.php b/requesthandler.php index 671a2ce..27a25a8 100644 --- a/requesthandler.php +++ b/requesthandler.php @@ -17,103 +17,61 @@ chdir(dirname(__FILE__)); require('core.php'); +require('services/request_context.php'); +require('services/request_auth_guard.php'); +require('services/request_csrf_guard.php'); +require('services/login_flow.php'); +require('services/request_policy_guard.php'); +require('services/request_router_dispatcher.php'); +require('services/request_exception_handler.php'); +require('services/key_lifecycle_service.php'); +require('services/access_rule_service.php'); +require('services/relation_lifecycle_service.php'); +require('services/response_security_headers.php'); ob_start(); -set_exception_handler('exception_handler'); -// Helper function to check if a route is public -function isPublicRoute($request_path) { - global $public_routes; - foreach ($public_routes as $route => $is_public) { - if ($is_public) { - // Convert route pattern to regex for matching - $pattern = preg_replace('/\{[^}]+\}/', '[^/]+', $route); - if (preg_match('|^' . $pattern . '$|', $request_path)) { - return true; - } - } - } - return false; -} +$active_user = null; +$exception_handler = new RequestExceptionHandler($active_user, $config); +set_exception_handler(array($exception_handler, 'handle')); + +$request_context = RequestContext::from_globals(); +$base_url = $request_context->base_url; +$request_url = $request_context->request_url; +$relative_request_url = $request_context->relative_request_url; +$absolute_request_url = $request_context->absolute_request_url; +RuntimeState::set_many(array( + 'request_context' => $request_context, + 'base_url' => $base_url, + 'request_url' => $request_url, + 'relative_request_url' => $relative_request_url, + 'absolute_request_url' => $absolute_request_url, +)); -// Work out where we are on the server -$base_url = dirname($_SERVER['SCRIPT_NAME']); -$request_url = $_SERVER['REQUEST_URI']; -$relative_request_url = preg_replace('/^'.preg_quote($base_url, '/').'/', '/', $request_url); -$absolute_request_url = 'http'.(isset($_SERVER['HTTPS']) ? 's' : '').'://'.$_SERVER['HTTP_HOST'].$request_url; +$response_security_headers = new ResponseSecurityHeaders(); +$response_security_headers->apply($config); // Initialize authentication service $auth_service = new AuthService($ldap, $user_dir, $config); +$auth_guard = new RequestAuthGuard($auth_service, $public_routes); +$csrf_guard = new RequestCsrfGuard(); +$policy_guard = new RequestPolicyGuard(); +$router_dispatcher = new RequestRouterDispatcher($policy_guard); -// Check if user is authenticated -$active_user = $auth_service->getCurrentUser(); +$active_user = $auth_guard->resolve_active_user($request_context); +RuntimeState::set('active_user', $active_user); -// If no active user and not on a public route, redirect to login -if (!$active_user && !isPublicRoute($relative_request_url)) { - // Store the current URL to redirect back after login - $_SESSION['redirect_after_login'] = $_SERVER['REQUEST_URI']; - redirect('/login'); -} +$policy_guard->enforce_web_enabled($config); +$policy_guard->enforce_active_user_status($active_user, $relative_request_url, $absolute_request_url); -// Prevent authenticated users from accessing login page (they're already logged in) -if ($active_user && $relative_request_url === '/login') { - $redirect_url = $_SESSION['redirect_after_login'] ?? '/'; - unset($_SESSION['redirect_after_login']); - redirect($redirect_url); -} +$csrf_guard->validate($active_user, $request_context, $_POST); -// Prevent logged out users from accessing logout page (they're already logged out) -if (!$active_user && $relative_request_url === '/logout') { - // They're already logged out, just redirect to login - redirect('/login'); -} - -if(empty($config['web']['enabled'])) { - require('views/error503.php'); - die; -} - -if($active_user && (!$active_user->active || $active_user->force_disable)) { - require('views/error403.php'); -} - -if(!empty($_POST) && $active_user) { - // Check CSRF token - if(isset($_SERVER['HTTP_X_BYPASS_CSRF_PROTECTION']) && $_SERVER['HTTP_X_BYPASS_CSRF_PROTECTION'] == 1) { - // This is being called from script, not a web browser - } elseif(!$active_user->check_csrf_token($_POST['csrf_token'])) { - require('views/csrf.php'); - die; - } -} - -// Route request to the correct view -$router = new Router; -foreach($routes as $path => $service) { - $public = array_key_exists($path, $public_routes); - $router->add_route($path, $service, $public); -} +$router = $router_dispatcher->build_router($routes, $public_routes); $router->handle_request($relative_request_url); -if(isset($router->view)) { - $view = path_join($base_path, 'views', $router->view.'.php'); - if(file_exists($view)) { - if($router->public || ($active_user && $active_user->auth_realm == 'LDAP')) { - require($view); - } else { - require('views/error403.php'); - } +$view = $router_dispatcher->resolve_view_path($router, $base_path); +if(!is_null($view)) { + if($policy_guard->can_render_view($router->public, $active_user)) { + require($view); } else { - throw new Exception("View file $view missing."); - } -} - -// Handler for uncaught exceptions -function exception_handler($e) { - global $active_user, $config; - $error_number = time(); - error_log("$error_number: ".str_replace("\n", "\n$error_number: ", $e)); - while(ob_get_length()) { - ob_end_clean(); + require('views/error403.php'); } - require('views/error500.php'); - die; } diff --git a/scripts/ssh.php b/scripts/ssh.php index c2bf833..f7f923d 100644 --- a/scripts/ssh.php +++ b/scripts/ssh.php @@ -74,21 +74,22 @@ public static function connect_with_pubkey( string $username, string $pubkey_file_path, string $privkey_file_path, - ?string &$host_key + ?string &$host_key, + array $jumphost_security_options = array() ): SSH { try { - $ssh = self::build_connection($host, $port, $jumphosts); + $ssh = self::build_connection($host, $port, $jumphosts, $jumphost_security_options); $ssh->connection->setKeepAlive(30); $received_key = $ssh->connection->getServerPublicHostKey(); } catch(SSHException | ErrorException $e) { - throw new SSHException("SSH connection failed"); + throw new SSHException("SSH connection failed", 0, $e); } if ($received_key === false) { $err = "Could not receive host key from target server"; if ($ssh->jump_cmd_stderr !== null) { - $stderr = stream_get_contents($ssh->jump_cmd_stderr); - if ($stderr != "") { - $err = "The tunnel connection via jumphost(s) failed"; + $stderr_summary = self::summarize_stderr(stream_get_contents($ssh->jump_cmd_stderr)); + if ($stderr_summary !== "") { + $err = "The tunnel connection via jumphost(s) failed: {$stderr_summary}"; } } throw new SSHException($err); @@ -104,19 +105,65 @@ public static function connect_with_pubkey( return $ssh; } + /** + * Build jumphost SSH command options from configuration. + * + * @param array $config SKA application config + * @return array string options: + * - strict_host_key_checking: yes|no + * - user_known_hosts_file: absolute path or /dev/null + */ + public static function build_jumphost_security_options(array $config): array { + $strict_checking = isset($config['security']['jumphost_strict_host_key_checking']) + && (int)$config['security']['jumphost_strict_host_key_checking'] === 1; + + $known_hosts_file = '/dev/null'; + if($strict_checking) { + $known_hosts_file = '/etc/ssh/ssh_known_hosts'; + } + + if(isset($config['security']['jumphost_known_hosts_file'])) { + $configured_file = trim((string)$config['security']['jumphost_known_hosts_file']); + if($configured_file !== '') { + $known_hosts_file = $configured_file; + } + } + + return array( + 'strict_host_key_checking' => $strict_checking ? 'yes' : 'no', + 'user_known_hosts_file' => $known_hosts_file, + ); + } + + /** + * @param array $config SKA application config + * @return array diagnostics for sync runtime reports + */ + public static function diagnostics(array $config): array { + $options = self::build_jumphost_security_options($config); + + return array( + 'jumphost_strict_host_key_checking' => $options['strict_host_key_checking'], + 'jumphost_known_hosts_file' => $options['user_known_hosts_file'], + ); + } + /** * Create an SFTP instance, connected to the target server, but do not authenticate. * * @param string $host Hostname of the target server * @param int $port Port number of the target server * @param array $jumphosts An array of jumphosts where each element contains "user", "host", "port". + * @param array $jumphost_security_options SSH command options for jumphost chain. * @return SFTP The connected SFTP instance */ - private static function build_connection(string $host, int $port, array $jumphosts): SSH { + private static function build_connection(string $host, int $port, array $jumphosts, array $jumphost_security_options): SSH { if (empty($jumphosts)) { return new SSH(new SFTP($host, $port)); } - $fix_options = "-o StrictHostKeyChecking=off -o UserKnownHostsFile=/dev/null -i config/keys-sync"; + $strict_host_key_checking = isset($jumphost_security_options['strict_host_key_checking']) ? $jumphost_security_options['strict_host_key_checking'] : 'no'; + $user_known_hosts_file = isset($jumphost_security_options['user_known_hosts_file']) ? $jumphost_security_options['user_known_hosts_file'] : '/dev/null'; + $fix_options = " -o BatchMode=yes -o StrictHostKeyChecking=".escapeshellarg($strict_host_key_checking)." -o UserKnownHostsFile=".escapeshellarg($user_known_hosts_file)." -i config/keys-sync"; $jumphosts[] = [ "user" => "keys-sync", "host" => $host, @@ -150,6 +197,23 @@ private static function build_connection(string $host, int $port, array $jumphos return $ssh; } + /** + * Keep stderr reporting useful and bounded when tunnel setup fails. + * + * @param string $stderr + * @return string + */ + private static function summarize_stderr(string $stderr): string { + $single_line = trim((string)preg_replace('/\s+/', ' ', $stderr)); + if($single_line === '') { + return ''; + } + if(strlen($single_line) > 200) { + return substr($single_line, 0, 200).'...'; + } + return $single_line; + } + /** * Execute the given command and return its output * diff --git a/scripts/sync-common.php b/scripts/sync-common.php index a344ad4..f97c6f5 100644 --- a/scripts/sync-common.php +++ b/scripts/sync-common.php @@ -16,6 +16,9 @@ ## limitations under the License. ## +require_once(__DIR__.'/sync-runtime.php'); +require_once(__DIR__.'/sync-failure.php'); + /** * Represent the message of an exception (that might consist of multiple, chained * exceptions) as one string. @@ -69,6 +72,7 @@ class SyncProcess { private $errors; private $request; private $exit_status; + private $spawn_failed = false; /** * Create a new sync process @@ -78,7 +82,6 @@ class SyncProcess { */ public function __construct($command, $args, $request = null) { global $config; - $timeout_util = $config['general']['timeout_util']; $this->request = $request; $this->output = ''; @@ -88,15 +91,17 @@ public function __construct($command, $args, $request = null) { 2 => array("pipe", "w"), // stderr 3 => array("pipe", "w") // ); - switch ($timeout_util) { - case "BusyBox": - $commandline = '/usr/bin/timeout -t 60 '.$command.' '.implode(' ', array_map('escapeshellarg', $args)); - break; - default: - $commandline = '/usr/bin/timeout 60s '.$command.' '.implode(' ', array_map('escapeshellarg', $args)); - } + $commandline = SyncRuntime::build_timeout_wrapped_command($command, $args, $config, 60); $this->handle = proc_open($commandline, $descriptorspec, $this->pipes); + if(!is_resource($this->handle)) { + $this->spawn_failed = true; + $this->finished = true; + $this->errors = "Failed to start sync worker process"; + $this->exit_status = 1; + $this->pipes = array(); + return; + } stream_set_blocking($this->pipes[1], 0); stream_set_blocking($this->pipes[2], 0); } @@ -106,6 +111,13 @@ public function __construct($command, $args, $request = null) { * @return string output from the child process */ public function get_data() { + if($this->spawn_failed) { + $this->spawn_failed = false; + if($this->errors) { + echo $this->errors."\n"; + } + return array('done' => true, 'output' => ''); + } if(isset($this->handle) && is_resource($this->handle)) { if (!$this->finished) { $data = read_streams([$this->pipes[1], $this->pipes[2]]); @@ -137,8 +149,13 @@ public function finish() { $this->get_data(); if ($this->exit_status !== 0) { $server = $server_dir->get_server_by_id($this->request->server_id); - $server->sync_report('sync failure', "Internal error during sync"); - $server->reschedule_sync_request(); + SyncFailureReporter::report_server_failure( + $server, + 'Internal error during sync', + 'Sync worker process exited with non-zero status', + 'worker_process_error', + true + ); $server->update(); } $sync_request_dir->delete_sync_request($this->request); diff --git a/scripts/sync-failure.php b/scripts/sync-failure.php new file mode 100644 index 0000000..1593ade --- /dev/null +++ b/scripts/sync-failure.php @@ -0,0 +1,166 @@ +sync_report('sync failure', $message); + if($reschedule) { + $server->reschedule_sync_request(); + } + } + + /** + * @param string|null $reason + * @return string + */ + public static function classify_reason($reason) { + $normalized = strtolower(self::normalize_reason($reason)); + if($normalized === '') { + return 'sync_failure'; + } + if(strpos($normalized, 'timed out') !== false) { + return 'ssh_timeout'; + } + if(strpos($normalized, 'host key verification failed') !== false) { + return 'host_key_verification_failed'; + } + if(strpos($normalized, 'multiple hosts with same host key') !== false) { + return 'host_key_collision'; + } + if(strpos($normalized, 'multiple hosts with same ip address') !== false) { + return 'ip_collision'; + } + if(strpos($normalized, 'hostname check failed') !== false) { + return 'hostname_verification_failed'; + } + if(strpos($normalized, 'could not read /var/local/keys-sync/.hostnames') !== false) { + return 'hostname_allowlist_unreadable'; + } + if(strpos($normalized, 'ssh authentication failed') !== false) { + return 'ssh_authentication_failed'; + } + if(strpos($normalized, 'tunnel connection via jumphost') !== false) { + return 'jumphost_tunnel_failed'; + } + if(strpos($normalized, 'could not receive host key') !== false) { + return 'host_key_unavailable'; + } + if(strpos($normalized, 'cannot access key directory') !== false) { + return 'key_directory_access_failed'; + } + if(strpos($normalized, 'internal error during sync') !== false) { + return 'worker_process_error'; + } + + return 'ssh_connection_failed'; + } + + /** + * @return array + */ + public static function diagnostics() { + return array( + 'reschedule_delay_minutes' => self::RESCHEDULE_DELAY_MINUTES, + ); + } + + /** + * Build a classified sync message. + * + * @param string $summary + * @param string|null $reason + * @param string $code + * @param bool $reschedule + * @return string + */ + public static function build_message($summary, $reason = null, $code = 'sync_info', $reschedule = false) { + return self::compose_message($summary, $reason, $code, $reschedule); + } + + /** + * Record a classified non-fatal sync issue in server log events. + * + * @param Server $server + * @param string $summary + * @param string|null $reason + * @param string $code + */ + public static function log_server_nonfatal_issue(Server $server, $summary, $reason = null, $code = 'sync_nonfatal_issue') { + $message = self::compose_message($summary, $reason, $code, false); + $server->log( + array( + 'action' => 'Sync non-fatal issue', + 'value' => $message, + ), + LOG_WARNING + ); + } + + /** + * @param string $summary + * @param string|null $reason + * @param string $code + * @param bool $reschedule + * @return string + */ + private static function compose_message($summary, $reason, $code, $reschedule) { + $parts = array(); + $parts[] = trim((string)$summary); + if(self::normalize_reason($reason) !== '') { + $parts[] = 'reason='.self::normalize_reason($reason); + } + $parts[] = 'code='.$code; + if($reschedule) { + $parts[] = 'retry='.self::RESCHEDULE_DELAY_MINUTES.'m'; + } + return implode('; ', $parts); + } + + /** + * @param string|null $reason + * @return string + */ + private static function normalize_reason($reason) { + $text = trim((string)$reason); + $text = trim((string)preg_replace('/\s+/', ' ', $text)); + if($text === '') { + return ''; + } + if(strlen($text) > 240) { + return substr($text, 0, 240).'...'; + } + return $text; + } +} diff --git a/scripts/sync-runtime.php b/scripts/sync-runtime.php new file mode 100644 index 0000000..6c006ff --- /dev/null +++ b/scripts/sync-runtime.php @@ -0,0 +1,87 @@ + self::resolve_timeout_util($config), + 'timeout_binary' => self::resolve_timeout_binary($config), + 'timeout_seconds' => 60, + ); + } + + /** + * @param array $config + * @return string GNU or BusyBox + */ + private static function resolve_timeout_util(array $config) { + $configured = isset($config['general']['timeout_util']) ? trim((string)$config['general']['timeout_util']) : ''; + if($configured === 'BusyBox') { + return 'BusyBox'; + } + return 'GNU'; + } + + /** + * @param array $config + * @return string + */ + private static function resolve_timeout_binary(array $config) { + if(isset($config['general']['timeout_binary'])) { + $configured = trim((string)$config['general']['timeout_binary']); + if($configured !== '') { + return $configured; + } + } + + if(is_executable('/usr/bin/timeout')) { + return '/usr/bin/timeout'; + } + + if(is_executable('/bin/timeout')) { + return '/bin/timeout'; + } + + return 'timeout'; + } +} diff --git a/scripts/sync.php b/scripts/sync.php index 160cbf5..5b9f5c9 100755 --- a/scripts/sync.php +++ b/scripts/sync.php @@ -22,17 +22,17 @@ require_once(__DIR__.'/../history_username_env_common.php'); require('sync-common.php'); require('ssh.php'); -$required_files = array('config/keys-sync', 'config/keys-sync.pub'); -foreach($required_files as $file) { - if(!file_exists($file)) die("Sync cannot start - $file not found.\n"); -} // Parse the command-line arguments -$options = getopt('h:i:au:p', array('help', 'host:', 'id:', 'all', 'user:', 'preview')); +$options = getopt('h:i:au:p', array('help', 'host:', 'id:', 'all', 'user:', 'preview', 'diagnostics')); if(isset($options['help'])) { show_help(); exit(0); } +if(isset($options['diagnostics'])) { + show_diagnostics(); + exit(0); +} $short_to_long = array( 'h' => 'host', 'i' => 'id', @@ -64,6 +64,11 @@ } $preview = isset($options['preview']); +$required_files = array('config/keys-sync', 'config/keys-sync.pub'); +foreach($required_files as $file) { + if(!file_exists($file)) die("Sync cannot start - $file not found.\n"); +} + // Use 'keys-sync' user as the active user (create if it does not yet exist) try { $active_user = $user_dir->get_user_by_uid('keys-sync'); @@ -150,10 +155,25 @@ function show_help() { -u, --user sync only the specified user account -p, --preview perform no changes, display content of all keyfiles + --diagnostics display sync runtime diagnostics and exit --help display this help and exit sync_report('sync failure', 'SSH connection timed out during decommission'); - $server->reschedule_sync_request(); + SyncFailureReporter::report_server_failure( + $server, + 'SSH connection timed out during decommission', + null, + 'ssh_timeout', + true + ); }, function($reason) use ($server) { - $server->sync_report('sync failure', 'Failed to connect during decommission: '.$reason); - $server->reschedule_sync_request(); + SyncFailureReporter::report_server_failure( + $server, + 'Failed to connect during decommission', + $reason, + null, + true + ); } ); if (is_null($connection)) { @@ -221,8 +251,13 @@ function($reason) use ($server) { } catch (SSHException $e) { $cleanup_errors++; echo date('c')." {$hostname}: Cannot access key directory: ".describe_oneline($e)."\n"; - $server->sync_report('sync failure', 'Cannot access key directory during decommission: '.describe_oneline($e)); - $server->reschedule_sync_request(); + SyncFailureReporter::report_server_failure( + $server, + 'Cannot access key directory during decommission', + describe_oneline($e), + 'key_directory_access_failed', + true + ); return; } @@ -279,8 +314,13 @@ function($reason) use ($server) { // Update status if($cleanup_errors > 0) { - $server->sync_report('sync failure', 'Failed to remove '.$cleanup_errors.' key file'.($cleanup_errors == 1 ? '' : 's').' during decommission'); - $server->reschedule_sync_request(); + SyncFailureReporter::report_server_failure( + $server, + 'Failed to remove '.$cleanup_errors.' key file'.($cleanup_errors == 1 ? '' : 's').' during decommission', + null, + 'decommission_cleanup_failed', + true + ); } else { $server->sync_report('sync success', 'Decommissioned: removed '.$removed_count.' key file'.($removed_count == 1 ? '' : 's').' (keys-sync access preserved)'); } @@ -288,7 +328,14 @@ function($reason) use ($server) { try { $server->update_status_file($connection); } catch (SSHException $e) { - // Ignore status file update errors during decommission + $reason = describe_oneline($e); + echo date('c')." {$hostname}: Warning: monitoring status file update failed during decommission: {$reason}\n"; + SyncFailureReporter::log_server_nonfatal_issue( + $server, + 'Monitoring status file update failed during decommission', + $reason, + 'monitoring_status_write_failed' + ); } echo date('c')." {$hostname}: Decommission completed\n"; @@ -390,13 +437,23 @@ function sync_server($id, $only_username = null, $preview = false) { $server, $hostname, function() use ($server, $keyfiles) { - $server->sync_report('sync failure', 'SSH connection timed out'); - $server->reschedule_sync_request(); + SyncFailureReporter::report_server_failure( + $server, + 'SSH connection timed out', + null, + 'ssh_timeout', + true + ); report_all_accounts_failed($keyfiles); }, function($reason) use ($server, $keyfiles) { - $server->sync_report('sync failure', $reason); - $server->reschedule_sync_request(); + SyncFailureReporter::report_server_failure( + $server, + 'Failed to connect', + $reason, + null, + true + ); report_all_accounts_failed($keyfiles); } ); @@ -479,10 +536,22 @@ function($reason) use ($server, $keyfiles) { } $failure_occurred = false; if($cleanup_errors > 0) { - $server->sync_report('sync failure', 'Failed to clean up '.$cleanup_errors.' file'.($cleanup_errors == 1 ? '' : 's')); + SyncFailureReporter::report_server_failure( + $server, + 'Failed to clean up '.$cleanup_errors.' file'.($cleanup_errors == 1 ? '' : 's'), + null, + 'cleanup_failed', + false + ); $failure_occurred = true; } elseif($account_errors > 0) { - $server->sync_report('sync failure', $account_errors.' account'.($account_errors == 1 ? '' : 's').' failed to sync'); + SyncFailureReporter::report_server_failure( + $server, + $account_errors.' account'.($account_errors == 1 ? '' : 's').' failed to sync', + null, + 'account_sync_failed', + false + ); $failure_occurred = true; } elseif($sync_warning) { $server->sync_report('sync warning', $sync_warning); @@ -492,7 +561,33 @@ function($reason) use ($server, $keyfiles) { if ($failure_occurred) { $server->reschedule_sync_request(); } - $server->update_status_file($connection); + $status_file_update_reason = null; + try { + $server->update_status_file($connection); + } catch (SSHException $e) { + $status_file_update_reason = describe_oneline($e); + echo date('c')." {$hostname}: Warning: monitoring status file update failed: {$status_file_update_reason}\n"; + } + if(!is_null($status_file_update_reason)) { + if($failure_occurred || $sync_warning) { + SyncFailureReporter::log_server_nonfatal_issue( + $server, + 'Monitoring status file update failed', + $status_file_update_reason, + 'monitoring_status_write_failed' + ); + } else { + $server->sync_report( + 'sync warning', + SyncFailureReporter::build_message( + 'Monitoring status file update failed', + $status_file_update_reason, + 'monitoring_status_write_failed', + false + ) + ); + } + } echo date('c')." {$hostname}: Sync finished\n"; } diff --git a/services/access_rule_service.php b/services/access_rule_service.php new file mode 100644 index 0000000..2f14f85 --- /dev/null +++ b/services/access_rule_service.php @@ -0,0 +1,101 @@ + $value) { + if(!isset($value['enabled'])) { + continue; + } + + $option = new AccessOption(); + $option->option = $key; + $option->value = isset($value['value']) ? $value['value'] : null; + $options[] = $option; + } + + return $options; + } + + /** + * Create access for source entity onto target account. + * @param ServerAccount $account + * @param Entity $entity + * @param array $access_options + */ + public function add_access(ServerAccount $account, Entity $entity, array $access_options) { + $account->add_access($entity, $access_options); + } + + /** + * Create access for source entity onto target group. + * @param Group $group + * @param Entity $entity + * @param array $access_options + */ + public function add_group_access(Group $group, Entity $entity, array $access_options) { + $group->add_access($entity, $access_options); + } + + /** + * Resolve and delete access by identifier from a pre-fetched access list. + * @param ServerAccount $account + * @param array $account_access list of Access objects + * @param mixed $access_id identifier from request payload + */ + public function delete_access_by_id(ServerAccount $account, array $account_access, $access_id) { + $access = $this->find_access_by_id($account_access, $access_id); + if($access instanceof Access) { + $account->delete_access($access); + } + } + + /** + * Resolve and delete group access by identifier from a pre-fetched access list. + * @param Group $group + * @param array $group_access list of Access objects + * @param mixed $access_id identifier from request payload + */ + public function delete_group_access_by_id(Group $group, array $group_access, $access_id) { + $access = $this->find_access_by_id($group_access, $access_id); + if($access instanceof Access) { + $group->delete_access($access); + } + } + + /** + * @param array $account_access list of Access objects + * @param mixed $access_id + * @return Access|null + */ + public function find_access_by_id(array $account_access, $access_id) { + foreach($account_access as $access) { + if($access instanceof Access && $access->id == $access_id) { + return $access; + } + } + + return null; + } + + /** + * @param array $access_requests list of AccessRequest objects + * @param mixed $request_id + * @return AccessRequest|null + */ + public function find_access_request_by_id(array $access_requests, $request_id) { + foreach($access_requests as $request) { + if($request instanceof AccessRequest && $request->id == $request_id) { + return $request; + } + } + + return null; + } +} diff --git a/services/key_lifecycle_service.php b/services/key_lifecycle_service.php new file mode 100644 index 0000000..8d49133 --- /dev/null +++ b/services/key_lifecycle_service.php @@ -0,0 +1,54 @@ +import($raw_public_key, $user->uid); + $user->add_public_key($public_key); + } + + /** + * Import and attach a public key for a server account entity. + * @param ServerAccount $account + * @param string $raw_public_key + * @param bool $allow_weak_key force key import for admins + */ + public function add_server_account_public_key(ServerAccount $account, $raw_public_key, $allow_weak_key = false) { + $public_key = new PublicKey; + $public_key->import($raw_public_key, null, $allow_weak_key); + $account->add_public_key($public_key); + } + + /** + * Resolve and delete a public key by identifier from a pre-fetched list. + * @param Entity $entity + * @param array $existing_public_keys list of PublicKey objects + * @param mixed $public_key_id identifier from request payload + */ + public function delete_entity_public_key(Entity $entity, array $existing_public_keys, $public_key_id) { + $public_key = $this->find_public_key_by_id($existing_public_keys, $public_key_id); + if($public_key instanceof PublicKey) { + $entity->delete_public_key($public_key); + } + } + + /** + * @param array $existing_public_keys list of PublicKey objects + * @param mixed $public_key_id + * @return PublicKey|null + */ + public function find_public_key_by_id(array $existing_public_keys, $public_key_id) { + foreach($existing_public_keys as $public_key) { + if($public_key instanceof PublicKey && $public_key->id == $public_key_id) { + return $public_key; + } + } + + return null; + } +} diff --git a/services/login_flow.php b/services/login_flow.php new file mode 100644 index 0000000..4607578 --- /dev/null +++ b/services/login_flow.php @@ -0,0 +1,90 @@ +auth_service = $auth_service; + } + + public function handle_request($request_method, $post_data) { + $this->ensure_state(); + $error_message = ''; + $success_message = ''; + + if($request_method === 'POST') { + if(!isset($post_data['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $post_data['csrf_token'])) { + $error_message = 'Invalid request token. Please refresh the page and try again.'; + } else { + $username = trim($post_data['username'] ?? ''); + $password = $post_data['password'] ?? ''; + + if(!preg_match('/^[a-zA-Z0-9._-]+$/', $username)) { + $error_message = 'Invalid username format. Username can only contain letters, numbers, dots, hyphens, and underscores.'; + } elseif($username === '' || $password === '') { + $error_message = 'Please enter both username and password.'; + } else { + $current_time = time(); + $user_attempts = $_SESSION['login_attempts'][$username] ?? null; + + if($this->is_rate_limited($user_attempts, $current_time)) { + $remaining_time = 900 - ($current_time - $user_attempts['time']); + $error_message = 'Too many login attempts. Please try again in '.ceil($remaining_time / 60).' minutes.'; + } else { + try { + $user = $this->auth_service->authenticate($username, $password); + if($user) { + unset($_SESSION['login_attempts'][$username]); + $redirect_url = $_SESSION['redirect_after_login'] ?? '/'; + unset($_SESSION['redirect_after_login']); + redirect($redirect_url); + } + $this->record_failed_attempt($username, $current_time); + $error_message = 'Invalid username or password.'; + } catch(Exception $e) { + $error_message = 'Authentication error. Please try again.'; + } + } + } + } + $_SESSION['csrf_token'] = $this->generate_csrf_token(); + } + + return array( + 'error_message' => $error_message, + 'success_message' => $success_message, + 'csrf_token' => $_SESSION['csrf_token'], + ); + } + + private function ensure_state() { + if(!isset($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = $this->generate_csrf_token(); + } + if(!isset($_SESSION['login_attempts'])) { + $_SESSION['login_attempts'] = array(); + } + } + + private function generate_csrf_token() { + return bin2hex(random_bytes(32)); + } + + private function is_rate_limited($user_attempts, $current_time) { + if(!is_array($user_attempts)) { + return false; + } + if(!isset($user_attempts['count']) || !isset($user_attempts['time'])) { + return false; + } + return $user_attempts['count'] >= 5 && ($current_time - $user_attempts['time']) < 900; + } + + private function record_failed_attempt($username, $current_time) { + if(!isset($_SESSION['login_attempts'][$username])) { + $_SESSION['login_attempts'][$username] = array('count' => 0, 'time' => $current_time); + } + $_SESSION['login_attempts'][$username]['count']++; + $_SESSION['login_attempts'][$username]['time'] = $current_time; + } +} diff --git a/services/relation_lifecycle_service.php b/services/relation_lifecycle_service.php new file mode 100644 index 0000000..0385620 --- /dev/null +++ b/services/relation_lifecycle_service.php @@ -0,0 +1,190 @@ +get_user_by_uid($name); + } catch(UserNotFoundException $e) { + try { + return $group_dir->get_group_by_name($name); + } catch(GroupNotFoundException $e) { + return null; + } + } + } + + /** + * @param array $entities + * @param mixed $entity_id + * @return Entity|null + */ + public function find_entity_by_id(array $entities, $entity_id) { + foreach($entities as $entity) { + if($entity instanceof Entity && $entity->id == $entity_id) { + return $entity; + } + } + + return null; + } + + /** + * @param array $entities + * @param mixed $entity_id + * @return Entity|null + */ + public function find_entity_by_entity_id(array $entities, $entity_id) { + foreach($entities as $entity) { + if($entity instanceof Entity && $entity->entity_id == $entity_id) { + return $entity; + } + } + + return null; + } + + /** + * @param Server $server + * @param array $admins + * @param mixed $admin_id + */ + public function delete_server_admin_by_id(Server $server, array $admins, $admin_id) { + $admin = $this->find_entity_by_id($admins, $admin_id); + if($admin instanceof Entity) { + $server->delete_admin($admin); + } + } + + /** + * Add server leader relation. + * @param Server $server + * @param Entity $entity + * @param bool $send_mail + * @return bool true when relation inserted, false when already present + */ + public function add_server_admin(Server $server, Entity $entity, $send_mail = true) { + $this->assert_server_admin_entity_supported($entity); + return $server->add_admin($entity, $send_mail); + } + + /** + * Remove server leader relation. + * @param Server $server + * @param Entity $entity + * @return bool true when relation removed, false when relation absent + */ + public function delete_server_admin(Server $server, Entity $entity) { + $this->assert_server_admin_entity_supported($entity); + return $server->delete_admin($entity); + } + + /** + * Reassign leaders for selected servers. + * @param array $servers list of Server objects + * @param array $selected_server_hostnames list of hostname strings + * @param Entity $from_admin + * @param Entity $to_admin + */ + public function reassign_server_admins_by_hostname(array $servers, array $selected_server_hostnames, Entity $from_admin, Entity $to_admin) { + foreach($servers as $server) { + if(!($server instanceof Server)) { + continue; + } + if(in_array($server->hostname, $selected_server_hostnames, true)) { + $this->add_server_admin($server, $to_admin); + $this->delete_server_admin($server, $from_admin); + } + } + } + + /** + * @param array $servers list of Server objects + * @param Entity $entity + * @param bool $send_mail + * @return array affected Server objects + */ + public function add_server_admin_bulk(array $servers, Entity $entity, $send_mail = false) { + $affected_servers = array(); + foreach($servers as $server) { + if(!($server instanceof Server)) { + continue; + } + if($this->add_server_admin($server, $entity, $send_mail)) { + $affected_servers[] = $server; + } + } + + return $affected_servers; + } + + /** + * @param array $servers list of Server objects + * @param Entity $entity + * @return array affected Server objects + */ + public function delete_server_admin_bulk(array $servers, Entity $entity) { + $affected_servers = array(); + foreach($servers as $server) { + if(!($server instanceof Server)) { + continue; + } + if($this->delete_server_admin($server, $entity)) { + $affected_servers[] = $server; + } + } + + return $affected_servers; + } + + /** + * @param ServerAccount $account + * @param array $admins + * @param mixed $admin_id + */ + public function delete_server_account_admin_by_id(ServerAccount $account, array $admins, $admin_id) { + $admin = $this->find_entity_by_id($admins, $admin_id); + if($admin instanceof User) { + $account->delete_admin($admin); + } + } + + /** + * @param Group $group + * @param array $admins + * @param mixed $admin_id + */ + public function delete_group_admin_by_id(Group $group, array $admins, $admin_id) { + $admin = $this->find_entity_by_id($admins, $admin_id); + if($admin instanceof User) { + $group->delete_admin($admin); + } + } + + /** + * @param Group $group + * @param array $members + * @param mixed $member_entity_id + */ + public function delete_group_member_by_entity_id(Group $group, array $members, $member_entity_id) { + $member = $this->find_entity_by_entity_id($members, $member_entity_id); + if($member instanceof Entity && !$group->system) { + $group->delete_member($member); + } + } + + /** + * @param Entity $entity + */ + private function assert_server_admin_entity_supported(Entity $entity) { + if(!($entity instanceof User) && !($entity instanceof Group)) { + throw new InvalidArgumentException('Only user or group entities can be server leaders'); + } + } +} diff --git a/services/request_auth_guard.php b/services/request_auth_guard.php new file mode 100644 index 0000000..be2ff21 --- /dev/null +++ b/services/request_auth_guard.php @@ -0,0 +1,45 @@ +auth_service = $auth_service; + $this->public_routes = $public_routes; + } + + public function resolve_active_user($request_context) { + $request_path = $request_context->relative_request_url; + $active_user = $this->auth_service->getCurrentUser(); + + if(!$active_user && !$this->is_public_route($request_path)) { + $_SESSION['redirect_after_login'] = $request_context->request_url; + redirect('/login'); + } + + if($active_user && $request_path === '/login') { + $redirect_url = $_SESSION['redirect_after_login'] ?? '/'; + unset($_SESSION['redirect_after_login']); + redirect($redirect_url); + } + + if(!$active_user && $request_path === '/logout') { + redirect('/login'); + } + + return $active_user; + } + + private function is_public_route($request_path) { + foreach($this->public_routes as $route => $is_public) { + if($is_public) { + $pattern = preg_replace('/\{[^}]+\}/', '[^/]+', $route); + if(preg_match('|^'.$pattern.'$|', $request_path)) { + return true; + } + } + } + return false; + } +} diff --git a/services/request_context.php b/services/request_context.php new file mode 100644 index 0000000..2639419 --- /dev/null +++ b/services/request_context.php @@ -0,0 +1,21 @@ +base_url = dirname($_SERVER['SCRIPT_NAME']); + $context->request_url = $_SERVER['REQUEST_URI']; + $context->relative_request_url = preg_replace('/^'.preg_quote($context->base_url, '/').'/', '/', $context->request_url); + $context->absolute_request_url = 'http'.(isset($_SERVER['HTTPS']) ? 's' : '').'://'.$_SERVER['HTTP_HOST'].$context->request_url; + $context->request_method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $context->bypass_csrf_protection = isset($_SERVER['HTTP_X_BYPASS_CSRF_PROTECTION']) && $_SERVER['HTTP_X_BYPASS_CSRF_PROTECTION'] == 1; + return $context; + } +} diff --git a/services/request_csrf_guard.php b/services/request_csrf_guard.php new file mode 100644 index 0000000..d87a19f --- /dev/null +++ b/services/request_csrf_guard.php @@ -0,0 +1,15 @@ +bypass_csrf_protection) { + return; + } + if(!$active_user->check_csrf_token($post_data['csrf_token'])) { + require('views/csrf.php'); + die; + } + } + } +} diff --git a/services/request_exception_handler.php b/services/request_exception_handler.php new file mode 100644 index 0000000..cef5317 --- /dev/null +++ b/services/request_exception_handler.php @@ -0,0 +1,23 @@ +active_user = &$active_user; + $this->config = &$config; + } + + public function handle($e) { + $error_number = time(); + error_log("$error_number: ".str_replace("\n", "\n$error_number: ", $e)); + while(ob_get_length()) { + ob_end_clean(); + } + $active_user = $this->active_user; + $config = $this->config; + require('views/error500.php'); + die; + } +} diff --git a/services/request_policy_guard.php b/services/request_policy_guard.php new file mode 100644 index 0000000..da4c37d --- /dev/null +++ b/services/request_policy_guard.php @@ -0,0 +1,20 @@ +active || $active_user->force_disable)) { + require('views/error403.php'); + } + } + + public function can_render_view($is_public, $active_user) { + return $is_public || ($active_user && $active_user->auth_realm == 'LDAP'); + } +} diff --git a/services/request_router_dispatcher.php b/services/request_router_dispatcher.php new file mode 100644 index 0000000..dfd0dac --- /dev/null +++ b/services/request_router_dispatcher.php @@ -0,0 +1,29 @@ +policy_guard = $policy_guard; + } + + public function build_router($routes, $public_routes) { + $router = new Router; + foreach($routes as $path => $service) { + $public = array_key_exists($path, $public_routes); + $router->add_route($path, $service, $public); + } + return $router; + } + + public function resolve_view_path($router, $base_path) { + if(!isset($router->view)) { + return null; + } + $view = path_join($base_path, 'views', $router->view.'.php'); + if(!file_exists($view)) { + throw new Exception("View file $view missing."); + } + return $view; + } +} diff --git a/services/response_security_headers.php b/services/response_security_headers.php new file mode 100644 index 0000000..cfeaa07 --- /dev/null +++ b/services/response_security_headers.php @@ -0,0 +1,115 @@ +build_content_security_policy($config); + if($csp === '') { + $this->apply_hsts_if_enabled($config); + return; + } + + if($this->is_csp_report_only($config)) { + header('Content-Security-Policy-Report-Only: '.$csp); + } else { + header('Content-Security-Policy: '.$csp); + } + + $this->apply_hsts_if_enabled($config); + } + + /** + * @param array $config + * @return string + */ + private function build_content_security_policy(array $config) { + $policy = self::DEFAULT_CSP; + if(isset($config['security']['content_security_policy'])) { + $configured = trim((string)$config['security']['content_security_policy']); + if($configured !== '') { + $policy = $configured; + } + } + + $report_uri = ''; + if(isset($config['security']['content_security_policy_report_uri'])) { + $report_uri = trim((string)$config['security']['content_security_policy_report_uri']); + } + if($report_uri !== '' && stripos($policy, 'report-uri ') === false && stripos($policy, 'report-to ') === false) { + $policy .= '; report-uri '.$report_uri; + } + + return $policy; + } + + /** + * @param array $config + * @return bool + */ + private function is_csp_report_only(array $config) { + return isset($config['security']['csp_report_only']) && (int)$config['security']['csp_report_only'] === 1; + } + + /** + * @param array $config + */ + private function apply_hsts_if_enabled(array $config) { + $hsts_enabled = isset($config['security']['hsts_enabled']) && (int)$config['security']['hsts_enabled'] === 1; + if(!$hsts_enabled || !$this->is_https_request()) { + return; + } + + $max_age = 31536000; + if(isset($config['security']['hsts_max_age']) && is_numeric($config['security']['hsts_max_age'])) { + $candidate = (int)$config['security']['hsts_max_age']; + if($candidate > 0) { + $max_age = $candidate; + } + } + + $hsts = 'max-age='.$max_age; + if(isset($config['security']['hsts_include_subdomains']) && (int)$config['security']['hsts_include_subdomains'] === 1) { + $hsts .= '; includeSubDomains'; + } + if(isset($config['security']['hsts_preload']) && (int)$config['security']['hsts_preload'] === 1) { + $hsts .= '; preload'; + } + + header('Strict-Transport-Security: '.$hsts); + } + + /** + * @return bool + */ + private function is_https_request() { + if(isset($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off' && $_SERVER['HTTPS'] !== '') { + return true; + } + if(isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443) { + return true; + } + if(isset($_SERVER['HTTP_X_FORWARDED_PROTO'])) { + $proto = strtolower(trim((string)$_SERVER['HTTP_X_FORWARDED_PROTO'])); + if($proto === 'https') { + return true; + } + } + return false; + } +} diff --git a/services/runtime_state.php b/services/runtime_state.php new file mode 100644 index 0000000..c25f78e --- /dev/null +++ b/services/runtime_state.php @@ -0,0 +1,26 @@ + $value) { + self::$state[$key] = $value; + } + } + + public static function has($key) { + return array_key_exists($key, self::$state); + } + + public static function get($key, $default = null) { + if(self::has($key)) { + return self::$state[$key]; + } + return $default; + } +} diff --git a/views/group.php b/views/group.php index 62c5835..94cd4ac 100644 --- a/views/group.php +++ b/views/group.php @@ -24,7 +24,7 @@ * @return array Database entities of the server accounts */ function find_server_accounts(string $text, array &$errors): array { - global $server_dir; + $server_dir = RuntimeState::get('server_dir'); $lines = explode("\n", $text); $accounts = []; @@ -66,6 +66,8 @@ function find_server_accounts(string $text, array &$errors): array { $group_remote_access = $group->list_remote_access(); $group_admins = $group->list_admins(); $group_admin = $active_user->admin_of($group); +$access_rule_service = new AccessRuleService(); +$relation_lifecycle_service = new RelationLifecycleService(); if(isset($_POST['add_admin']) && ($active_user->admin)) { try { @@ -78,14 +80,7 @@ function find_server_accounts(string $text, array &$errors): array { redirect('#admins'); } } elseif(isset($_POST['delete_admin']) && ($active_user->admin)) { - foreach($group_admins as $admin) { - if($admin->id == $_POST['delete_admin']) { - $admin_to_delete = $admin; - } - } - if(isset($admin_to_delete)) { - $group->delete_admin($admin_to_delete); - } + $relation_lifecycle_service->delete_group_admin_by_id($group, $group_admins, $_POST['delete_admin']); redirect('#admins'); } elseif(isset($_POST['add_member']) && ($group_admin || $active_user->admin)) { if(isset($_POST['username'])) { @@ -152,14 +147,7 @@ function find_server_accounts(string $text, array &$errors): array { } redirect('#members'); } elseif(isset($_POST['delete_member']) && ($group_admin || $active_user->admin)) { - foreach($group_members as $member) { - if($member->entity_id == $_POST['delete_member']) { - $member_to_delete = $member; - } - } - if(isset($member_to_delete) && !$group->system) { - $group->delete_member($member_to_delete); - } + $relation_lifecycle_service->delete_group_member_by_entity_id($group, $group_members, $_POST['delete_member']); redirect('#members'); } elseif(isset($_POST['add_access']) && ($group_admin || $active_user->admin)) { if(isset($_POST['username'])) { @@ -186,22 +174,9 @@ function find_server_accounts(string $text, array &$errors): array { } if(isset($entity)) { if($_POST['add_access'] == '2') { - $options = array(); - if(isset($_POST['access_option'])) { - foreach($_POST['access_option'] as $k => $v) { - if(isset($v['enabled'])) { - $option = new AccessOption(); - $option->option = $k; - if(isset($v['value'])) { - $option->value = $v['value']; - } else { - $option->value = null; - } - $options[] = $option; - } - } - } - $group->add_access($entity, $options); + $options_payload = isset($_POST['access_option']) && is_array($_POST['access_option']) ? $_POST['access_option'] : array(); + $options = $access_rule_service->build_access_options_from_payload($options_payload); + $access_rule_service->add_group_access($group, $entity, $options); redirect('#access'); } else { $content = new PageSection('access_options'); @@ -211,14 +186,7 @@ function find_server_accounts(string $text, array &$errors): array { } } } elseif(isset($_POST['delete_access']) && ($group_admin || $active_user->admin)) { - foreach($group_access as $access) { - if($access->id == $_POST['delete_access']) { - $access_to_delete = $access; - } - } - if(isset($access_to_delete)) { - $group->delete_access($access_to_delete); - } + $access_rule_service->delete_group_access_by_id($group, $group_access, $_POST['delete_access']); redirect('#access'); } elseif(isset($_POST['edit_group']) && ($active_user->admin)) { $name = trim($_POST['name']); diff --git a/views/home.php b/views/home.php index c866c47..ab19966 100644 --- a/views/home.php +++ b/views/home.php @@ -17,15 +17,13 @@ $public_keys = $active_user->list_public_keys(); $admined_servers = $active_user->list_admined_servers(array('pending_requests', 'admins')); +$key_lifecycle_service = new KeyLifecycleService(); if(isset($_POST['add_public_key'])) { try { - $public_key = new PublicKey; - $public_key->import($_POST['add_public_key'], $active_user->uid); - $active_user->add_public_key($public_key); + $key_lifecycle_service->add_user_public_key($active_user, $_POST['add_public_key']); redirect(); } catch(InvalidArgumentException $e) { - global $config; $content = new PageSection('key_upload_fail'); $error_message = $e->getMessage(); if(preg_match('/^Insufficient bits in public key: (\d+) < (\d+)$/', $error_message, $matches)) { @@ -37,14 +35,7 @@ } } } elseif(isset($_POST['delete_public_key'])) { - foreach($public_keys as $public_key) { - if($public_key->id == $_POST['delete_public_key']) { - $key_to_delete = $public_key; - } - } - if(isset($key_to_delete)) { - $active_user->delete_public_key($key_to_delete); - } + $key_lifecycle_service->delete_entity_public_key($active_user, $public_keys, $_POST['delete_public_key']); redirect(); } else { $content = new PageSection('home'); diff --git a/views/login.php b/views/login.php index c992d95..17cecb3 100644 --- a/views/login.php +++ b/views/login.php @@ -16,83 +16,16 @@ ## limitations under the License. ## -// Generate CSRF token for this session -if (!isset($_SESSION['csrf_token'])) { - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); -} - -// Initialize rate limiting -if (!isset($_SESSION['login_attempts'])) { - $_SESSION['login_attempts'] = []; -} - -// Handle login form submission -$error_message = ''; -$success_message = ''; - -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // Validate CSRF token - if (!isset($_POST['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { - $error_message = 'Invalid request token. Please refresh the page and try again.'; - } else { - $username = trim($_POST['username'] ?? ''); - $password = $_POST['password'] ?? ''; - - // Validate username format (alphanumeric, dots, hyphens, underscores) - if (!preg_match('/^[a-zA-Z0-9._-]+$/', $username)) { - $error_message = 'Invalid username format. Username can only contain letters, numbers, dots, hyphens, and underscores.'; - } elseif (empty($username) || empty($password)) { - $error_message = 'Please enter both username and password.'; - } else { - // Check rate limiting - $current_time = time(); - $user_attempts = &$_SESSION['login_attempts'][$username]; - - if (isset($user_attempts) && - $user_attempts['count'] >= 5 && - ($current_time - $user_attempts['time']) < 900) { // 15 minutes = 900 seconds - - $remaining_time = 900 - ($current_time - $user_attempts['time']); - $error_message = "Too many login attempts. Please try again in " . ceil($remaining_time / 60) . " minutes."; - } else { - try { - $auth_service = new AuthService($ldap, $user_dir, $config); - $user = $auth_service->authenticate($username, $password); - - if ($user) { - // Clear login attempts on successful authentication - unset($_SESSION['login_attempts'][$username]); - - // Redirect to the page they were trying to access, or home - $redirect_url = $_SESSION['redirect_after_login'] ?? '/'; - unset($_SESSION['redirect_after_login']); - redirect($redirect_url); - } else { - // Increment failed login attempts - if (!isset($user_attempts)) { - $user_attempts = ['count' => 0, 'time' => $current_time]; - } - $user_attempts['count']++; - $user_attempts['time'] = $current_time; - - $error_message = 'Invalid username or password.'; - } - } catch (Exception $e) { - $error_message = 'Authentication error. Please try again.'; - } - } - } - } - - // Regenerate CSRF token after form submission - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); -} +$login_flow = new LoginFlowService($auth_service); +$login_state = $login_flow->handle_request($_SERVER['REQUEST_METHOD'], $_POST); +$error_message = $login_state['error_message']; +$success_message = $login_state['success_message']; // Create the login content $login_content = new PageSection('login'); $login_content->set('error_message', $error_message); $login_content->set('success_message', $success_message); -$login_content->set('csrf_token', $_SESSION['csrf_token']); +$login_content->set('csrf_token', $login_state['csrf_token']); // Create the main page $page = new PageSection('base'); diff --git a/views/pubkeys.php b/views/pubkeys.php index 26d585f..aa404fb 100644 --- a/views/pubkeys.php +++ b/views/pubkeys.php @@ -22,7 +22,8 @@ * @param ExternalKey $key The key that has just been allowed */ function send_mail_key_allowed(ExternalKey $key) { - global $active_user, $config; + $active_user = RuntimeState::get('active_user'); + $config = RuntimeState::get('config', array()); $email = new Email(); $email->add_recipient($config['email']['report_address'], $config['email']['report_name']); diff --git a/views/server.php b/views/server.php index bfa0acd..2a7ccd2 100644 --- a/views/server.php +++ b/views/server.php @@ -37,34 +37,23 @@ $all_accounts = $server->list_accounts(); $ldap_access_options = $server->list_ldap_access_options(); $server_admin_can_reset_host_key = (isset($config['security']) && isset($config['security']['host_key_reset_restriction']) && $config['security']['host_key_reset_restriction'] == 0); +$relation_lifecycle_service = new RelationLifecycleService(); require_once('history_username_env_common.php'); if(isset($_POST['sync'])) { $server->sync_access(); redirect(); } elseif(isset($_POST['add_admin']) && ($active_user->admin)) { - try { - $entity = $user_dir->get_user_by_uid($_POST['user_name']); - } catch(UserNotFoundException $e) { - try { - $entity = $group_dir->get_group_by_name($_POST['user_name']); - } catch(GroupNotFoundException $e) { - $content = new PageSection('user_not_found'); - } + $entity = $relation_lifecycle_service->resolve_user_or_group_by_name($user_dir, $group_dir, $_POST['user_name']); + if($entity === null) { + $content = new PageSection('user_not_found'); } if(isset($entity)) { - $server->add_admin($entity); + $relation_lifecycle_service->add_server_admin($server, $entity); redirect('#admins'); } } elseif(isset($_POST['delete_admin']) && ($active_user->admin)) { - foreach($server_admins as $admin) { - if($admin->id == $_POST['delete_admin']) { - $admin_to_delete = $admin; - } - } - if(isset($admin_to_delete)) { - $server->delete_admin($admin_to_delete); - } + $relation_lifecycle_service->delete_server_admin_by_id($server, $server_admins, $_POST['delete_admin']); redirect('#admins'); } elseif(isset($_POST['add_account']) && ($server_admin || $active_user->admin)) { $account = new ServerAccount(); diff --git a/views/serveraccount.php b/views/serveraccount.php index 6e469f6..fe9789f 100644 --- a/views/serveraccount.php +++ b/views/serveraccount.php @@ -53,6 +53,9 @@ $account_groups = $account->list_group_membership(); $account_admins = $account->list_admins(); $pubkeys = $account->list_public_keys(); +$key_lifecycle_service = new KeyLifecycleService(); +$access_rule_service = new AccessRuleService(); +$relation_lifecycle_service = new RelationLifecycleService(); if(isset($_POST['add_access']) && ($server_admin || $account_admin || $active_user->admin)) { if(isset($_POST['username'])) { try { @@ -78,22 +81,9 @@ } if(isset($entity)) { if($_POST['add_access'] == '2') { - $options = array(); - if(isset($_POST['access_option'])) { - foreach($_POST['access_option'] as $k => $v) { - if(isset($v['enabled'])) { - $option = new AccessOption(); - $option->option = $k; - if(isset($v['value'])) { - $option->value = $v['value']; - } else { - $option->value = null; - } - $options[] = $option; - } - } - } - $account->add_access($entity, $options); + $options_payload = isset($_POST['access_option']) && is_array($_POST['access_option']) ? $_POST['access_option'] : array(); + $options = $access_rule_service->build_access_options_from_payload($options_payload); + $access_rule_service->add_access($account, $entity, $options); redirect('#access'); } else { $content = new PageSection('access_options'); @@ -103,31 +93,16 @@ } } } elseif(isset($_POST['delete_access']) && ($server_admin || $account_admin || $active_user->admin)) { - foreach($account_access as $access) { - if($access->id == $_POST['delete_access']) { - $access_to_delete = $access; - } - } - if(isset($access_to_delete)) { - $account->delete_access($access_to_delete); - } + $access_rule_service->delete_access_by_id($account, $account_access, $_POST['delete_access']); redirect('#access'); } elseif(isset($_POST['approve_access']) && ($server_admin || $account_admin || $active_user->admin)) { - foreach($account_access_requests as $request) { - if($request->id == $_POST['approve_access']) { - $request_to_approve = $request; - } - } + $request_to_approve = $access_rule_service->find_access_request_by_id($account_access_requests, $_POST['approve_access']); if(isset($request_to_approve)) { $account->approve_access_request($request_to_approve); redirect('#access'); } } elseif(isset($_POST['reject_access']) && ($server_admin || $account_admin || $active_user->admin)) { - foreach($account_access_requests as $request) { - if($request->id == $_POST['reject_access']) { - $request_to_reject = $request; - } - } + $request_to_reject = $access_rule_service->find_access_request_by_id($account_access_requests, $_POST['reject_access']); if(isset($request_to_reject)) { $sync_status = $account->sync_status; $account->reject_access_request($request_to_reject); @@ -141,12 +116,9 @@ } } elseif(isset($_POST['add_public_key']) && ($server_admin || $account_admin || $active_user->admin)) { try { - $public_key = new PublicKey; - $public_key->import($_POST['add_public_key'], null, isset($_POST['force']) && $active_user->admin); - $account->add_public_key($public_key); + $key_lifecycle_service->add_server_account_public_key($account, $_POST['add_public_key'], isset($_POST['force']) && $active_user->admin); redirect('#pubkeys'); } catch(InvalidArgumentException $e) { - global $config; $content = new PageSection('key_upload_fail'); $error_message = $e->getMessage(); if(preg_match('/^Insufficient bits in public key: (\d+) < (\d+)$/', $error_message, $matches)) { @@ -158,14 +130,7 @@ } } } elseif(isset($_POST['delete_public_key']) && ($server_admin || $account_admin || $active_user->admin)) { - foreach($pubkeys as $pubkey) { - if($pubkey->id == $_POST['delete_public_key']) { - $key_to_delete = $pubkey; - } - } - if(isset($key_to_delete)) { - $account->delete_public_key($key_to_delete); - } + $key_lifecycle_service->delete_entity_public_key($account, $pubkeys, $_POST['delete_public_key']); redirect('#pubkeys'); } elseif(isset($_POST['add_admin']) && ($server_admin || $active_user->admin)) { try { @@ -178,14 +143,7 @@ redirect('#admins'); } } elseif(isset($_POST['delete_admin']) && ($server_admin || $active_user->admin)) { - foreach($account_admins as $admin) { - if($admin->id == $_POST['delete_admin']) { - $admin_to_delete = $admin; - } - } - if(isset($admin_to_delete)) { - $account->delete_admin($admin_to_delete); - } + $relation_lifecycle_service->delete_server_account_admin_by_id($account, $account_admins, $_POST['delete_admin']); redirect('#admins'); } else { $content = new PageSection('serveraccount'); diff --git a/views/servers.php b/views/servers.php index 0c3fa1a..ec21e05 100644 --- a/views/servers.php +++ b/views/servers.php @@ -26,7 +26,13 @@ * @param string $error_ref Reference to a variable where error messages are stored * @return array|NULL Prepared information about the hosts, needed for the bulk import or null in case of an error */ -function prepare_import(string $csv_document, &$error_ref): ?array { +function prepare_import( + string $csv_document, + &$error_ref, + RelationLifecycleService $relation_lifecycle_service, + UserDirectory $user_dir, + GroupDirectory $group_dir +): ?array { $errors = ""; $lines = explode("\n", $csv_document); $line_num = 0; @@ -73,7 +79,7 @@ function prepare_import(string $csv_document, &$error_ref): ?array { $admin_names = explode(";", $cells[3]); $admins = []; foreach ($admin_names as $name) { - $entity = user_or_group_by_name($name); + $entity = $relation_lifecycle_service->resolve_user_or_group_by_name($user_dir, $group_dir, $name); if ($entity !== null) { $admins[] = $entity; } else { @@ -91,27 +97,6 @@ function prepare_import(string $csv_document, &$error_ref): ?array { return $errors == "" ? $entries : null; } -/** - * Search for a user with the given login name. If no such user exists, search for - * a group with the given name. If also no matching group exists, null is returned. - * - * @param string $name The name of the user/group - * @return Entity|NULL The user or group, or null if nothing was found - */ -function user_or_group_by_name(string $name): ?Entity { - global $user_dir, $group_dir; - - try { - return $user_dir->get_user_by_uid($name); - } catch(UserNotFoundException $e) { - try { - return $group_dir->get_group_by_name($name); - } catch(GroupNotFoundException $e) { - return null; - } - } -} - /** * Read the setting default_key_supervision from the config, but map invalid * values to the default value "full". @@ -119,7 +104,7 @@ function user_or_group_by_name(string $name): ?Entity { * @return string "full", "rootonly" or "off" */ function default_key_scan_setting(): string { - global $config; + $config = RuntimeState::get('config', array()); $setting = $config["general"]["default_key_supervision"]; if ($setting == "") { @@ -139,7 +124,7 @@ function default_key_scan_setting(): string { * @return array Statistics array about the number of added servers and number of already existing servers */ function run_import(array $entries): array { - global $server_dir; + $server_dir = RuntimeState::get('server_dir'); $imported = 0; $existed = 0; @@ -165,6 +150,8 @@ function run_import(array $entries): array { ]; } +$relation_lifecycle_service = new RelationLifecycleService(); + if(isset($_POST['add_server'])) { $hostname = trim($_POST['hostname']); if(!Server::hostname_valid($hostname)) { @@ -174,7 +161,7 @@ function run_import(array $entries): array { $admin_names = preg_split('/[\s,]+/', $_POST['admins'], -1, PREG_SPLIT_NO_EMPTY); $admins = array(); foreach($admin_names as $admin_name) { - $new_admin = user_or_group_by_name($admin_name); + $new_admin = $relation_lifecycle_service->resolve_user_or_group_by_name($user_dir, $group_dir, $admin_name); if ($new_admin !== null) { $admins[] = $new_admin; } else { @@ -214,7 +201,7 @@ function run_import(array $entries): array { } } else if (isset($_POST['add_bulk'])) { $csv_document = $_POST['import']; - $entries = prepare_import($csv_document, $errors); + $entries = prepare_import($csv_document, $errors, $relation_lifecycle_service, $user_dir, $group_dir); $alert = new UserAlert; if ($entries !== null) { $result = run_import($entries); diff --git a/views/servers_bulk_action.php b/views/servers_bulk_action.php index 6426096..6122425 100644 --- a/views/servers_bulk_action.php +++ b/views/servers_bulk_action.php @@ -22,7 +22,8 @@ * @param array $affected_servers Array of servers where the given leader has been added */ function send_bulk_add_mail(Entity $entity, array $affected_servers) { - global $active_user, $config; + $active_user = RuntimeState::get('active_user'); + $config = RuntimeState::get('config', array()); $servers_desc = count($affected_servers) == 1 ? "1 server" : count($affected_servers) . " servers"; @@ -63,28 +64,19 @@ function send_bulk_add_mail(Entity $entity, array $affected_servers) { $server_names = $_POST['selected_servers'] ?? []; $selected_servers = array_map(function($name) { - global $server_dir; + $server_dir = RuntimeState::get('server_dir'); return $server_dir->get_server_by_hostname($name); }, $server_names); +$relation_lifecycle_service = new RelationLifecycleService(); $content = null; if (isset($_POST['add_admin'])) { - try { - $entity = $user_dir->get_user_by_uid($_POST['user_name']); - } catch(UserNotFoundException $e) { - try { - $entity = $group_dir->get_group_by_name($_POST['user_name']); - } catch(GroupNotFoundException $e) { - $content = new PageSection('user_not_found'); - } + $entity = $relation_lifecycle_service->resolve_user_or_group_by_name($user_dir, $group_dir, $_POST['user_name']); + if($entity === null) { + $content = new PageSection('user_not_found'); } if (isset($entity)) { - $affected_servers = []; - foreach ($selected_servers as $server) { - if ($server->add_admin($entity, false)) { - $affected_servers[] = $server; - } - } + $affected_servers = $relation_lifecycle_service->add_server_admin_bulk($selected_servers, $entity, false); if (!empty($affected_servers)) { send_bulk_add_mail($entity, $affected_servers); } @@ -104,12 +96,7 @@ function send_bulk_add_mail(Entity $entity, array $affected_servers) { $content = new PageSection('user_not_found'); } if (isset($entity)) { - $affected_servers = []; - foreach ($selected_servers as $server) { - if ($server->delete_admin($entity)) { - $affected_servers[] = $server; - } - } + $affected_servers = $relation_lifecycle_service->delete_server_admin_bulk($selected_servers, $entity); $num = count($affected_servers); if ($entity instanceof User) { $type = "User"; diff --git a/views/user.php b/views/user.php index 0586048..537f31f 100644 --- a/views/user.php +++ b/views/user.php @@ -27,25 +27,17 @@ $admined_groups = $user->list_admined_groups(array('members', 'admins')); $groups = $user->list_group_memberships(array('members', 'admins')); $active_user_keys = $user->list_public_keys(null, null, false); +$key_lifecycle_service = new KeyLifecycleService(); +$relation_lifecycle_service = new RelationLifecycleService(); usort($admined_servers, function($a, $b) {return strnatcasecmp($a->hostname, $b->hostname);}); if(isset($_POST['reassign_servers']) && is_array($_POST['servers']) && $active_user && $active_user->admin) { - try { - $new_admin = $user_dir->get_user_by_uid($_POST['reassign_to']); - } catch(UserNotFoundException $e) { - try { - $new_admin = $group_dir->get_group_by_name($_POST['reassign_to']); - } catch(GroupNotFoundException $e) { - $content = new PageSection('user_not_found'); - } + $new_admin = $relation_lifecycle_service->resolve_user_or_group_by_name($user_dir, $group_dir, $_POST['reassign_to']); + if($new_admin === null) { + $content = new PageSection('user_not_found'); } if(isset($new_admin)) { - foreach($admined_servers as $server) { - if(in_array($server->hostname, $_POST['servers'])) { - $server->add_admin($new_admin); - $server->delete_admin($user); - } - } + $relation_lifecycle_service->reassign_server_admins_by_hostname($admined_servers, $_POST['servers'], $user, $new_admin); redirect('#details'); } } elseif(isset($_POST['delete_public_key']) && $active_user && $active_user->admin) { @@ -53,13 +45,7 @@ $delete_key_id = filter_var($delete_key_raw, FILTER_VALIDATE_INT); if($delete_key_id !== false) { - $delete_key_id = (int)$delete_key_id; - foreach($active_user_keys as $public_key) { - if((int)$public_key->id === $delete_key_id) { - $user->delete_public_key($public_key); - break; - } - } + $key_lifecycle_service->delete_entity_public_key($user, $active_user_keys, (int)$delete_key_id); } redirect('#details'); diff --git a/views/user_pubkeys.php b/views/user_pubkeys.php index 2ed2eeb..307557d 100644 --- a/views/user_pubkeys.php +++ b/views/user_pubkeys.php @@ -25,6 +25,7 @@ $is_target_active_user = $active_user && $active_user->entity_id == $user->entity_id; $can_admin_add_for_user = $active_user && $active_user->admin && !$is_target_active_user; $can_submit_key = $is_target_active_user || $can_admin_add_for_user; +$key_lifecycle_service = new KeyLifecycleService(); if(isset($_POST['add_public_key'])) { if(!$can_submit_key) { @@ -32,12 +33,9 @@ die; } try { - $public_key = new PublicKey; - $public_key->import($_POST['add_public_key'], $user->uid); - $user->add_public_key($public_key); + $key_lifecycle_service->add_user_public_key($user, $_POST['add_public_key']); redirect(); } catch(InvalidArgumentException $e) { - global $config; $content = new PageSection('key_upload_fail'); $error_message = $e->getMessage(); if(preg_match('/^Insufficient bits in public key: (\d+) < (\d+)$/', $error_message, $matches)) { @@ -48,7 +46,6 @@ $content->set('message', "The public key you submitted doesn't look valid."); } } catch(BadMethodCallException $e) { - global $config; $content = new PageSection('key_upload_fail'); $content->set('message', "Unable to add public key: " . $e->getMessage()); } From 46081ae3886b35a1b2a0e1a53dc8b829818673b0 Mon Sep 17 00:00:00 2001 From: Msprg Date: Tue, 10 Feb 2026 00:49:51 +0000 Subject: [PATCH 04/85] feat: add bootstrap5 compatibility layer and page migrations --- public_html/bootstrap5-compat.css | 175 ++++++++++++++++++++++++++++++ public_html/bootstrap5-compat.js | 64 +++++++++++ public_html/extra.js | 11 +- templates/access_options.php | 14 +-- templates/base.php | 16 +-- templates/bulk_mail.php | 6 +- templates/functions.php | 8 +- templates/group.php | 52 ++++----- templates/groups.php | 12 +- templates/home.php | 18 +-- templates/login.php | 22 ++-- templates/pubkey.php | 18 +-- templates/pubkeys.php | 16 +-- templates/server.php | 52 ++++----- templates/serveraccount.php | 38 +++---- templates/servers.php | 20 ++-- templates/servers_bulk_action.php | 4 +- templates/user.php | 4 +- templates/user_pubkeys.php | 2 +- 19 files changed, 396 insertions(+), 156 deletions(-) create mode 100644 public_html/bootstrap5-compat.css create mode 100644 public_html/bootstrap5-compat.js diff --git a/public_html/bootstrap5-compat.css b/public_html/bootstrap5-compat.css new file mode 100644 index 0000000..62f5239 --- /dev/null +++ b/public_html/bootstrap5-compat.css @@ -0,0 +1,175 @@ +/* +## Bootstrap 5 compatibility layer for Bootstrap 3 runtime. +## Keeps existing SKA behavior while allowing gradual mixed-markup migration. +*/ + +/* Spacing aliases (Bootstrap 5 ms/me utilities) */ +.ms-0 { margin-left: 0 !important; } +.ms-1 { margin-left: .25rem !important; } +.ms-2 { margin-left: .5rem !important; } +.ms-3 { margin-left: 1rem !important; } +.ms-4 { margin-left: 1.5rem !important; } +.ms-5 { margin-left: 3rem !important; } + +.me-0 { margin-right: 0 !important; } +.me-1 { margin-right: .25rem !important; } +.me-2 { margin-right: .5rem !important; } +.me-3 { margin-right: 1rem !important; } +.me-4 { margin-right: 1.5rem !important; } +.me-5 { margin-right: 3rem !important; } + +.ps-0 { padding-left: 0 !important; } +.ps-1 { padding-left: .25rem !important; } +.ps-2 { padding-left: .5rem !important; } +.ps-3 { padding-left: 1rem !important; } +.ps-4 { padding-left: 1.5rem !important; } +.ps-5 { padding-left: 3rem !important; } + +.pe-0 { padding-right: 0 !important; } +.pe-1 { padding-right: .25rem !important; } +.pe-2 { padding-right: .5rem !important; } +.pe-3 { padding-right: 1rem !important; } +.pe-4 { padding-right: 1.5rem !important; } +.pe-5 { padding-right: 3rem !important; } + +.mt-0 { margin-top: 0 !important; } +.mt-1 { margin-top: .25rem !important; } +.mt-2 { margin-top: .5rem !important; } +.mt-3 { margin-top: 1rem !important; } +.mt-4 { margin-top: 1.5rem !important; } +.mt-5 { margin-top: 3rem !important; } + +.mb-0 { margin-bottom: 0 !important; } +.mb-1 { margin-bottom: .25rem !important; } +.mb-2 { margin-bottom: .5rem !important; } +.mb-3 { margin-bottom: 1rem !important; } +.mb-4 { margin-bottom: 1.5rem !important; } +.mb-5 { margin-bottom: 3rem !important; } + +/* Text / float aliases */ +.text-start { text-align: left !important; } +.text-end { text-align: right !important; } +.float-start { float: left !important; } +.float-end { float: right !important; } +.fw-bold { font-weight: 700 !important; } +.fw-semibold { font-weight: 600 !important; } +.fw-normal { font-weight: 400 !important; } + +/* Visibility helpers */ +.visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.visually-hidden-focusable:active, +.visually-hidden-focusable:focus { + position: static !important; + width: auto !important; + height: auto !important; + margin: 0 !important; + overflow: visible !important; + clip: auto !important; + white-space: normal !important; +} + +/* Dropdown and dismissible alias support */ +.dropdown-menu-end { + right: 0; + left: auto; +} + +.alert-dismissible, +.alert-dismissable { + padding-right: 35px; +} + +/* Bootstrap 5 style close button on Bootstrap 3 */ +.btn-close { + float: right; + font-size: 21px; + font-weight: 700; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + background: transparent; + border: 0; + opacity: .2; +} + +.btn-close:focus, +.btn-close:hover { + color: #000; + text-decoration: none; + cursor: pointer; + opacity: .5; +} + +/* Form and utility primitives used by Bootstrap 5 markup */ +.form-select { + display: block; + width: 100%; + height: 34px; + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + color: #555; + background-color: #fff; + background-image: none; + border: 1px solid #ccc; + border-radius: 4px; +} + +.form-select:focus { + border-color: #66afe9; + outline: 0; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(102, 175, 233, .6); +} + +.form-check { + position: relative; + display: block; + margin-top: 10px; + margin-bottom: 10px; +} + +.form-check-input { + margin-right: 6px; +} + +.form-check-label { + font-weight: 400; +} + +.d-grid { + display: grid !important; +} + +.d-none { + display: none !important; +} + +.d-block { + display: block !important; +} + +.w-100 { + width: 100% !important; +} + +.gap-1 { gap: .25rem !important; } +.gap-2 { gap: .5rem !important; } +.gap-3 { gap: 1rem !important; } +.gap-4 { gap: 1.5rem !important; } +.gap-5 { gap: 3rem !important; } + +.text-bg-success { color: #fff !important; background-color: #5cb85c !important; } +.text-bg-danger { color: #fff !important; background-color: #d9534f !important; } +.text-bg-warning { color: #fff !important; background-color: #f0ad4e !important; } +.text-bg-info { color: #fff !important; background-color: #5bc0de !important; } diff --git a/public_html/bootstrap5-compat.js b/public_html/bootstrap5-compat.js new file mode 100644 index 0000000..8cdce54 --- /dev/null +++ b/public_html/bootstrap5-compat.js @@ -0,0 +1,64 @@ +/* +## Bootstrap 5 attribute/class compatibility shim for Bootstrap 3 runtime. +*/ +'use strict'; + +(function() { + function mapBootstrap5ToBootstrap3() { + var attrMap = [ + ['data-bs-toggle', 'data-toggle'], + ['data-bs-target', 'data-target'], + ['data-bs-dismiss', 'data-dismiss'], + ['data-bs-parent', 'data-parent'], + ['data-bs-spy', 'data-spy'], + ['data-bs-ride', 'data-ride'] + ]; + + for(var i = 0; i < attrMap.length; i++) { + var srcAttr = attrMap[i][0]; + var dstAttr = attrMap[i][1]; + var nodes = document.querySelectorAll('[' + srcAttr + ']'); + for(var n = 0; n < nodes.length; n++) { + if(!nodes[n].hasAttribute(dstAttr)) { + nodes[n].setAttribute(dstAttr, nodes[n].getAttribute(srcAttr)); + } + } + } + + var dropdownEnds = document.querySelectorAll('.dropdown-menu-end'); + for(var d = 0; d < dropdownEnds.length; d++) { + dropdownEnds[d].classList.add('dropdown-menu-right'); + } + + var floatEnds = document.querySelectorAll('.float-end'); + for(var fe = 0; fe < floatEnds.length; fe++) { + floatEnds[fe].classList.add('pull-right'); + } + + var floatStarts = document.querySelectorAll('.float-start'); + for(var fs = 0; fs < floatStarts.length; fs++) { + floatStarts[fs].classList.add('pull-left'); + } + + var hidden = document.querySelectorAll('.visually-hidden'); + for(var h = 0; h < hidden.length; h++) { + hidden[h].classList.add('sr-only'); + } + + var hiddenFocusable = document.querySelectorAll('.visually-hidden-focusable'); + for(var hf = 0; hf < hiddenFocusable.length; hf++) { + hiddenFocusable[hf].classList.add('sr-only-focusable'); + } + + var dismissible = document.querySelectorAll('.alert-dismissible'); + for(var a = 0; a < dismissible.length; a++) { + dismissible[a].classList.add('alert-dismissable'); + } + } + + if(document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', mapBootstrap5ToBootstrap3); + } else { + mapBootstrap5ToBootstrap3(); + } +})(); diff --git a/public_html/extra.js b/public_html/extra.js index d13acf2..b2daa72 100644 --- a/public_html/extra.js +++ b/public_html/extra.js @@ -21,8 +21,9 @@ // Handle 'navigate-back' links $(function() { $('a.navigate-back').on('click', function(e) { + e.preventDefault(); window.history.back(); - event.stopPropagation(); + e.stopPropagation(); }); }); @@ -130,16 +131,16 @@ $(function() { // Home page dynamic add pubkey form $(function() { $('#add_key_button').on('click', function() { - $('#help').hide().removeClass('hidden'); - $('#add_key_form').hide().removeClass('hidden'); + $('#help').hide().removeClass('hidden d-none'); + $('#add_key_form').hide().removeClass('hidden d-none'); $('#add_key_form').show('fast'); $('#add_key_button').hide(); $('#add_public_key').focus(); }); - $('#add_key_form button[type=button].btn-info').on('click', function() { + $('#add_key_form [data-action="toggle-help"], #add_key_form button[type=button].btn-info').on('click', function() { $('#help').toggle('fast'); }); - $('#add_key_form button[type=button].btn-default').on('click', function() { + $('#add_key_form [data-action="cancel-add-key"], #add_key_form button[type=button].btn-default').on('click', function() { $('#add_key_form').hide('fast'); $('#add_key_button').show(); }); diff --git a/templates/access_options.php b/templates/access_options.php index 5722b2e..6fa725d 100644 --- a/templates/access_options.php +++ b/templates/access_options.php @@ -70,7 +70,7 @@

- + Advanced options

@@ -88,13 +88,13 @@
-
+
-
+
@@ -113,15 +113,15 @@
-
+
- +
diff --git a/templates/base.php b/templates/base.php index 5b01cce..a4df1a9 100644 --- a/templates/base.php +++ b/templates/base.php @@ -16,8 +16,6 @@ ## limitations under the License. ## $web_config = $this->get('web_config'); -header('X-Frame-Options: DENY'); -header("Content-Security-Policy: default-src 'self'"); $footer=str_replace("%v", "1.5.0", $web_config['footer']); ?> @@ -26,12 +24,14 @@ <?php out($this->get('title'))?> + + get('head'), ESC_NONE) ?>
-Skip to main content -