commit 2fbddd7dbc3af60c5378cc9a37c022fb7d5ea1b1 Author: root Date: Sun Dec 21 10:09:54 2025 -0500 Initial Version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f6a527 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/public/ +/static/ + +# Backend development +/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +logs +*.log +*.log* + +# Runtime +/modules/ +/storage/ +/var/ \ No newline at end of file diff --git a/.stubs/mongodb.stub.php b/.stubs/mongodb.stub.php new file mode 100644 index 0000000..80ed022 --- /dev/null +++ b/.stubs/mongodb.stub.php @@ -0,0 +1,143 @@ +=8.2", + "ext-ctype": "*", + "ext-iconv": "*", + "mongodb/mongodb": "^2.1", + "php-di/php-di": "*", + "phpseclib/phpseclib": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "KTXC\\": "core/lib/", + "KTXF\\": "shared/lib/" + } + }, + "autoload-dev": { + "psr-4": { + "KTXT\\": "tests/php/" + } + }, + "scripts": { + "post-install-cmd": [ + ], + "post-update-cmd": [ + ], + "test": "phpunit --colors=always --testdox" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..44b9483 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2470 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "9b9f3e5ee2fe48f2f736eb5d0a74be7c", + "packages": [ + { + "name": "laravel/serializable-closure", + "version": "v2.0.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2025-11-21T20:52:36+00:00" + }, + { + "name": "mongodb/mongodb", + "version": "2.1.2", + "source": { + "type": "git", + "url": "https://github.com/mongodb/mongo-php-library.git", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0", + "ext-mongodb": "^2.1", + "php": "^8.1", + "psr/log": "^1.1.4|^2|^3", + "symfony/polyfill-php85": "^1.32" + }, + "replace": { + "mongodb/builder": "*" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0", + "phpunit/phpunit": "^10.5.35", + "rector/rector": "^2.1.4", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "6.5.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "MongoDB\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Andreas Braun", + "email": "andreas.braun@mongodb.com" + }, + { + "name": "Jeremy Mikola", + "email": "jmikola@gmail.com" + }, + { + "name": "Jérôme Tamarelle", + "email": "jerome.tamarelle@mongodb.com" + } + ], + "description": "MongoDB driver library", + "homepage": "https://jira.mongodb.org/browse/PHPLIB", + "keywords": [ + "database", + "driver", + "mongodb", + "persistence" + ], + "support": { + "issues": "https://github.com/mongodb/mongo-php-library/issues", + "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" + }, + "time": "2025-10-06T12:12:40+00:00" + }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "php-di/invoker", + "version": "2.3.7", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/Invoker.git", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "psr/container": "^1.0|^2.0" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "mnapoli/hard-mode": "~0.3.0", + "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Invoker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Generic and extensible callable invoker", + "homepage": "https://github.com/PHP-DI/Invoker", + "keywords": [ + "callable", + "dependency", + "dependency-injection", + "injection", + "invoke", + "invoker" + ], + "support": { + "issues": "https://github.com/PHP-DI/Invoker/issues", + "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2025-08-30T10:22:22+00:00" + }, + { + "name": "php-di/php-di", + "version": "7.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHP-DI/PHP-DI.git", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352", + "reference": "f88054cc052e40dbe7b383c8817c19442d480352", + "shasum": "" + }, + "require": { + "laravel/serializable-closure": "^1.0 || ^2.0", + "php": ">=8.0", + "php-di/invoker": "^2.0", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "friendsofphp/proxy-manager-lts": "^1", + "mnapoli/phpunit-easymock": "^1.3", + "phpunit/phpunit": "^9.6 || ^10 || ^11", + "vimeo/psalm": "^5|^6" + }, + "suggest": { + "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "DI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The dependency injection container for humans", + "homepage": "https://php-di.org/", + "keywords": [ + "PSR-11", + "container", + "container-interop", + "dependency injection", + "di", + "ioc", + "psr11" + ], + "support": { + "issues": "https://github.com/PHP-DI/PHP-DI/issues", + "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/php-di/php-di", + "type": "tidelift" + } + ], + "time": "2025-08-16T11:10:48+00:00" + }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.48", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2025-12-15T11:51:42+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/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": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "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\\Php85\\": "" + }, + "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.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/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-23T16:12:55+00:00" + } + ], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.11", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.4.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.2" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-08-27T14:37:49+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.46", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/75dfe79a2aa30085b7132bb84377c24062193f33", + "reference": "75dfe79a2aa30085b7132bb84377c24062193f33", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.11", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.2", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.46" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-12-06T08:01:15+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", + "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-10T08:07:46+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+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": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "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" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "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": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-ctype": "*", + "ext-iconv": "*" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/config/system.php b/config/system.php new file mode 100644 index 0000000..18b3160 --- /dev/null +++ b/config/system.php @@ -0,0 +1,49 @@ + [ + // MongoDB connection URI (include credentials if needed) + 'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false', + 'database' => 'ktrix', + // optional driver options + 'options' => [], + 'driverOptions' => [], + ], + + /** + * Cache Configuration + * + * Set the cache store classes for different cache types. + * Uncomment and adjust the class names as needed. + * + * Available Cache Stores: + * - Ephemeral Cache: Short-lived, in-memory or file-based cache for sessions, rate limits, etc. + * - Persistent Cache: Long-lived cache for routes, modules, compiled configs, etc. + * - Blob Cache: Large binary objects storage. + * + * Predefined cache types: + * file - File-based cache store + * redis - Redis-based cache store + * memcached - Memcached-based cache store + */ + //'cache.ephemeral' => 'file', + //'cache.persistent' => 'file', + //'cache.blob' => 'file', + + // Security Configuration + 'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b', + + // Application Configuration + 'app' => [ + 'environment' => 'dev', + 'debug' => true, + 'name' => 'Ktrix', + ], + + // Domain Configuration + //'domain' => [ + // 'default' => 'ktrix', + //], + +]; diff --git a/core/lib/Controllers/AuthenticationController.php b/core/lib/Controllers/AuthenticationController.php new file mode 100644 index 0000000..aebce0b --- /dev/null +++ b/core/lib/Controllers/AuthenticationController.php @@ -0,0 +1,361 @@ +authManager->handle($request); + + return $this->buildJsonResponse($response); + } + + /** + * Identify user for identity-first login flow + */ + #[AnonymousRoute('/auth/identify', name: 'auth.identify', methods: ['POST'])] + public function identify(string $session, string $identity): JsonResponse + { + if (empty($session) || empty($identity)) { + return new JsonResponse( + ['error' => 'Session and identity are required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $request = AuthenticationRequest::identify($session, trim($identity)); + $response = $this->authManager->handle($request); + + return $this->buildJsonResponse($response); + } + + /** + * Start a challenge for methods that require it (SMS, email, TOTP) + */ + #[AnonymousRoute('/auth/challenge', name: 'auth.challenge', methods: ['POST'])] + public function challenge(string $session, string $method): JsonResponse + { + if (empty($session) || empty($method)) { + return new JsonResponse( + ['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $request = AuthenticationRequest::challenge($session, $method); + $response = $this->authManager->handle($request); + + return $this->buildJsonResponse($response); + } + + /** + * Verify a credential or challenge response + */ + #[AnonymousRoute('/auth/verify', name: 'auth.verify', methods: ['POST'])] + public function verify(string $session, string $method, string $response): JsonResponse + { + if (empty($session) || empty($method)) { + return new JsonResponse( + ['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $request = AuthenticationRequest::verify($session, $method, $response); + $authResponse = $this->authManager->handle($request); + + return $this->buildJsonResponse($authResponse); + } + + /** + * Begin redirect-based authentication (OIDC/SAML) + */ + #[AnonymousRoute('/auth/redirect', name: 'auth.redirect', methods: ['POST'])] + public function redirect(Request $request): JsonResponse + { + $data = $this->getRequestData($request); + + $sessionId = $data['session'] ?? ''; + $method = $data['method'] ?? ''; + $returnUrl = $data['return_url'] ?? '/'; + + if (empty($sessionId) || empty($method)) { + return new JsonResponse( + ['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $scheme = $request->isSecure() ? 'https' : 'http'; + $host = $request->getHost(); + $callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}"; + + $authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl); + $response = $this->authManager->handle($authRequest); + + return $this->buildJsonResponse($response); + } + + /** + * Handle callback from identity provider (OIDC/SAML) + */ + #[AnonymousRoute('/auth/callback/{provider}', name: 'auth.callback', methods: ['GET', 'POST'])] + public function callback(Request $request, string $provider): JsonResponse|RedirectResponse + { + $params = $request->isMethod('POST') + ? $request->request->all() + : $request->query->all(); + + $sessionId = $params['state'] ?? null; + + if (!$sessionId) { + return $this->redirectWithError('Missing state parameter'); + } + + $authRequest = AuthenticationRequest::callback($sessionId, $provider, $params); + $response = $this->authManager->handle($authRequest); + + if ($response->isSuccess()) { + $returnUrl = $response->returnUrl ?? '/'; + $httpResponse = new RedirectResponse($returnUrl); + + if ($response->hasTokens()) { + return $this->setTokenCookies($httpResponse, $response->tokens, $request->isSecure()); + } + + return $httpResponse; + } + + if ($response->isPending()) { + return new RedirectResponse('/login/mfa?session=' . urlencode($response->sessionId)); + } + + return $this->redirectWithError($response->errorMessage ?? 'Authentication failed'); + } + + /** + * Get current session status + */ + #[AnonymousRoute('/auth/status', name: 'auth.status', methods: ['GET'])] + public function status(Request $request): JsonResponse + { + $sessionId = $request->query->get('session', ''); + + if (empty($sessionId)) { + return new JsonResponse( + ['error' => 'Session ID is required', 'error_code' => 'invalid_request'], + JsonResponse::HTTP_BAD_REQUEST + ); + } + + $authRequest = AuthenticationRequest::status($sessionId); + $response = $this->authManager->handle($authRequest); + + return $this->buildJsonResponse($response); + } + + /** + * Cancel authentication session + */ + #[AnonymousRoute('/auth/session', name: 'auth.session.cancel', methods: ['DELETE'])] + public function cancel(Request $request): JsonResponse + { + $sessionId = $request->query->get('session', ''); + + $authRequest = AuthenticationRequest::cancel($sessionId); + $this->authManager->handle($authRequest); + + return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']); + } + + // ========================================================================= + // Token Operations + // ========================================================================= + + /** + * Refresh access token + */ + #[AnonymousRoute('/auth/refresh', name: 'auth.refresh', methods: ['POST'])] + public function refresh(Request $request): JsonResponse + { + $refreshToken = $request->cookies->get('refreshToken'); + + if (!$refreshToken) { + return new JsonResponse( + ['error' => 'Refresh token required', 'error_code' => 'missing_token'], + JsonResponse::HTTP_UNAUTHORIZED + ); + } + + $authRequest = AuthenticationRequest::refresh($refreshToken); + $response = $this->authManager->handle($authRequest); + + if ($response->isFailed()) { + $httpResponse = new JsonResponse($response->toArray(), $response->httpStatus); + return $this->clearTokenCookies($httpResponse); + } + + $httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']); + + if ($response->tokens && isset($response->tokens['access'])) { + $httpResponse->headers->setCookie( + Cookie::create('accessToken') + ->withValue($response->tokens['access']) + ->withExpires(time() + 900) + ->withPath('/') + ->withSecure($request->isSecure()) + ->withHttpOnly(true) + ->withSameSite(Cookie::SAMESITE_STRICT) + ); + } + + return $httpResponse; + } + + /** + * Logout current device + */ + #[AuthenticatedRoute('/auth/logout', name: 'auth.logout', methods: ['POST'])] + public function logout(Request $request): JsonResponse + { + $token = $request->cookies->get('accessToken'); + + $authRequest = AuthenticationRequest::logout($token, false); + $this->authManager->handle($authRequest); + + $response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']); + return $this->clearTokenCookies($response); + } + + /** + * Logout all devices + */ + #[AuthenticatedRoute('/auth/logout-all', name: 'auth.logout.all', methods: ['POST'])] + public function logoutAll(Request $request): JsonResponse + { + $token = $request->cookies->get('accessToken'); + + $authRequest = AuthenticationRequest::logout($token, true); + $this->authManager->handle($authRequest); + + $response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']); + return $this->clearTokenCookies($response); + } + + // ========================================================================= + // Response Helpers + // ========================================================================= + + /** + * Build JSON response from AuthenticationResponse + */ + private function buildJsonResponse(AuthenticationResponse $response): JsonResponse + { + $httpResponse = new JsonResponse($response->toArray(), $response->httpStatus); + + // Set token cookies if present + if ($response->hasTokens()) { + return $this->setTokenCookies($httpResponse, $response->tokens, true); + } + + return $httpResponse; + } + + /** + * Set authentication token cookies + */ + private function setTokenCookies(JsonResponse|RedirectResponse $response, array $tokens, bool $secure = true): JsonResponse|RedirectResponse + { + if (isset($tokens['access'])) { + $response->headers->setCookie( + Cookie::create('accessToken') + ->withValue($tokens['access']) + ->withExpires(time() + 900) + ->withPath('/') + ->withSecure($secure) + ->withHttpOnly(true) + ->withSameSite(Cookie::SAMESITE_STRICT) + ); + } + + if (isset($tokens['refresh'])) { + $response->headers->setCookie( + Cookie::create('refreshToken') + ->withValue($tokens['refresh']) + ->withExpires(time() + 604800) + ->withPath('/auth/refresh') + ->withSecure($secure) + ->withHttpOnly(true) + ->withSameSite(Cookie::SAMESITE_STRICT) + ); + } + + return $response; + } + + /** + * Clear authentication token cookies + */ + private function clearTokenCookies(JsonResponse $response): JsonResponse + { + $response->headers->clearCookie('accessToken', '/'); + $response->headers->clearCookie('refreshToken', '/auth/refresh'); + return $response; + } + + /** + * Redirect with error message + */ + private function redirectWithError(string $error): RedirectResponse + { + return new RedirectResponse('/login?error=' . urlencode($error)); + } + + /** + * Get request data from JSON body or form data + */ + private function getRequestData(Request $request): array + { + $contentType = $request->headers->get('Content-Type', ''); + + if (str_contains($contentType, 'application/json')) { + try { + return $request->toArray(); + } catch (\Throwable) { + return []; + } + } + + return $request->request->all(); + } +} diff --git a/core/lib/Controllers/DefaultController.php b/core/lib/Controllers/DefaultController.php new file mode 100644 index 0000000..248a3d4 --- /dev/null +++ b/core/lib/Controllers/DefaultController.php @@ -0,0 +1,145 @@ +identity->identifier()) { + return new FileResponse( + Server::runtimeRootLocation() . '/public/private.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + } + + // User is not authenticated - serve the public app + // If there's an accessToken cookie present but invalid, clear it + $response = new FileResponse( + Server::runtimeRootLocation() . '/public/public.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + + // Clear any stale auth cookies since the user is not authenticated + if ($request->cookies->has('accessToken')) { + $response->headers->clearCookie('accessToken', '/'); + } + if ($request->cookies->has('refreshToken')) { + $response->headers->clearCookie('refreshToken', '/security/refresh'); + } + + return $response; + } + + #[AnonymousRoute('/login', name: 'login', methods: ['GET'])] + public function login(): Response + { + return new FileResponse( + Server::runtimeRootLocation() . '/public/public.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + } + + #[AnonymousRoute('/logout', name: 'logout_get', methods: ['GET'])] + public function logoutGet(Request $request): Response + { + // Blacklist the current access token if present + $accessToken = $request->cookies->get('accessToken'); + if ($accessToken) { + $claims = $this->securityService->extractTokenClaims($accessToken); + if ($claims && isset($claims['jti'])) { + $this->securityService->logout($claims['jti'], $claims['exp'] ?? null); + } + } + + $response = new RedirectResponse( + '/login', + Response::HTTP_SEE_OTHER + ); + + // Clear both authentication cookies + $response->headers->clearCookie('accessToken', '/'); + $response->headers->clearCookie('refreshToken', '/security/refresh'); + + return $response; + } + + #[AnonymousRoute('/logout', name: 'logout_post', methods: ['POST'])] + public function logoutPost(Request $request): Response + { + // Blacklist the current access token if present + $accessToken = $request->cookies->get('accessToken'); + if ($accessToken) { + $claims = $this->securityService->extractTokenClaims($accessToken); + if ($claims && isset($claims['jti'])) { + $this->securityService->logout($claims['jti'], $claims['exp'] ?? null); + } + } + + $response = new JsonResponse(['message' => 'Logged out successfully']); + + // Clear both authentication cookies + $response->headers->clearCookie('accessToken', '/'); + $response->headers->clearCookie('refreshToken', '/security/refresh'); + + return $response; + } + + /** + * Catch-all route for SPA routing. + * Serves the appropriate HTML based on authentication status, + * allowing client-side routing to handle the actual path. + */ + #[AnonymousRoute('/{path}', name: 'spa_catchall', methods: ['GET'])] + public function catchAll(Request $request, string $path = ''): Response + { + // If an authenticated identity is available, serve the private app + if ($this->identity->identifier()) { + return new FileResponse( + Server::runtimeRootLocation() . '/public/private.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + } + + // User is not authenticated - serve the public app + $response = new FileResponse( + Server::runtimeRootLocation() . '/public/public.html', + Response::HTTP_OK, + ['Content-Type' => 'text/html'] + ); + + // Clear any stale auth cookies since the user is not authenticated + if ($request->cookies->has('accessToken')) { + $response->headers->clearCookie('accessToken', '/'); + } + if ($request->cookies->has('refreshToken')) { + $response->headers->clearCookie('refreshToken', '/security/refresh'); + } + + return $response; + } +} diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php new file mode 100644 index 0000000..d6ff660 --- /dev/null +++ b/core/lib/Controllers/InitController.php @@ -0,0 +1,43 @@ +moduleManager->list() as $module) { + if (!method_exists($module, 'bootUi')) { + continue; + } + $configuration['modules'][$module->handle()] = $module->bootUi(); + } + + // tenant + $configuration['tenant'] = [ + 'id' => $this->tenant->identifier(), + 'domain' => $this->tenant->domain(), + 'label' => $this->tenant->label(), + ]; + + return new JsonResponse($configuration); + + } + +} diff --git a/core/lib/Controllers/ModuleController.php b/core/lib/Controllers/ModuleController.php new file mode 100644 index 0000000..8291c48 --- /dev/null +++ b/core/lib/Controllers/ModuleController.php @@ -0,0 +1,58 @@ +moduleManager->list(false); + + return new JsonResponse(['modules' => $modules]); + } + + #[AuthenticatedRoute('/modules/manage', name: 'modules.manage', methods: ['POST'])] + public function manage(string $handle, string $action): JsonResponse + { + // Verify module exists + $moduleInstance = $this->moduleManager->moduleInstance($handle, null); + if (!$moduleInstance) { + return new JsonResponse(['error' => 'Module "' . $handle . '" not found.'], 404); + } + + switch ($action) { + case 'install': + $this->moduleManager->install($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" installed successfully.']); + + case 'uninstall': + $this->moduleManager->uninstall($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" uninstalled successfully.']); + + case 'enable': + $this->moduleManager->enable($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" enabled successfully.']); + + case 'disable': + $this->moduleManager->disable($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" disabled successfully.']); + + case 'upgrade': + $this->moduleManager->upgrade($handle); + return new JsonResponse(['message' => 'Module "' . $handle . '" upgraded successfully.']); + + default: + return new JsonResponse(['error' => 'Invalid action.'], 400); + } + } +} diff --git a/core/lib/Controllers/UserSettingsController.php b/core/lib/Controllers/UserSettingsController.php new file mode 100644 index 0000000..0a673d0 --- /dev/null +++ b/core/lib/Controllers/UserSettingsController.php @@ -0,0 +1,58 @@ +tenantIdentity->identifier(); + $userId = $this->userIdentity->identifier(); + + return $this->userService->fetchSettings($tenantId, $userId, $settings); + } + + /** + * store user settings + * + * @param array $settings key-value pairs of settings to store + * + * @example request body: + * { + * "key1": "value1", + * "key2": "value2" + * } + */ + #[AuthenticatedRoute('/user/settings/write', name: 'user.settings.write', methods: ['PUT', 'PATCH'])] + public function write(array $settings): JsonResponse + { + return new JsonResponse(['status' => 'not_implemented'], JsonResponse::HTTP_NOT_IMPLEMENTED); + } + +} diff --git a/core/lib/Db/Client.php b/core/lib/Db/Client.php new file mode 100644 index 0000000..a2f7a3d --- /dev/null +++ b/core/lib/Db/Client.php @@ -0,0 +1,76 @@ +client = new MongoClient($uri, $uriOptions, $driverOptions); + } + + /** + * Select a database + * + * @param string $databaseName Database name + * @param array $options Database options + * @return Database + */ + public function selectDatabase(string $databaseName, array $options = []): Database + { + $mongoDatabase = $this->client->selectDatabase($databaseName, $options); + return new Database($mongoDatabase); + } + + /** + * List databases + */ + public function listDatabases(array $options = []): array + { + $databases = []; + foreach ($this->client->listDatabases($options) as $databaseInfo) { + $databases[] = $databaseInfo; + } + return $databases; + } + + /** + * Drop a database + */ + public function dropDatabase(string $databaseName, array $options = []): array|object|null + { + return $this->client->dropDatabase($databaseName, $options); + } + + /** + * Get the underlying MongoDB Client + * Use sparingly - prefer using wrapper methods + */ + public function getMongoClient(): MongoClient + { + return $this->client; + } + + /** + * Magic method to access database as property + */ + public function __get(string $databaseName): Database + { + return $this->selectDatabase($databaseName); + } +} diff --git a/core/lib/Db/Collection.php b/core/lib/Db/Collection.php new file mode 100644 index 0000000..857e8ef --- /dev/null +++ b/core/lib/Db/Collection.php @@ -0,0 +1,295 @@ +collection = $collection; + + // Set type map to return plain arrays instead of objects + // This converts BSON types to PHP native types + $this->collection = $collection->withOptions([ + 'typeMap' => [ + 'root' => 'array', + 'document' => 'array', + 'array' => 'array' + ] + ]); + } + + /** + * Find documents in the collection + * + * @param array $filter Query filter + * @param array $options Query options + * @return Cursor + */ + public function find(array $filter = [], array $options = []): Cursor + { + $filter = $this->convertFilter($filter); + /** @var \Iterator $cursor */ + $cursor = $this->collection->find($filter, $options); + return new Cursor($cursor); + } + + /** + * Find a single document + * + * @param array $filter Query filter + * @param array $options Query options + * @return array|null Returns array with _id as string + */ + public function findOne(array $filter = [], array $options = []): ?array + { + $filter = $this->convertFilter($filter); + $result = $this->collection->findOne($filter, $options); + + if ($result === null) { + return null; + } + + // Convert to array if it's an object + if (is_object($result)) { + $result = (array) $result; + } + + return $this->convertBsonToNative($result); + } + + /** + * Insert a single document + * + * @param array|object $document Document to insert + * @param array $options Insert options + * @return InsertOneResult + */ + public function insertOne(array|object $document, array $options = []): InsertOneResult + { + $document = $this->convertDocument($document); + return $this->collection->insertOne($document, $options); + } + + /** + * Insert multiple documents + * + * @param array $documents Documents to insert + * @param array $options Insert options + */ + public function insertMany(array $documents, array $options = []): mixed + { + $documents = array_map(fn($doc) => $this->convertDocument($doc), $documents); + return $this->collection->insertMany($documents, $options); + } + + /** + * Update a single document + * + * @param array $filter Query filter + * @param array $update Update operations + * @param array $options Update options + * @return UpdateResult + */ + public function updateOne(array $filter, array $update, array $options = []): UpdateResult + { + $filter = $this->convertFilter($filter); + $update = $this->convertDocument($update); + return $this->collection->updateOne($filter, $update, $options); + } + + /** + * Update multiple documents + * + * @param array $filter Query filter + * @param array $update Update operations + * @param array $options Update options + * @return UpdateResult + */ + public function updateMany(array $filter, array $update, array $options = []): UpdateResult + { + $filter = $this->convertFilter($filter); + $update = $this->convertDocument($update); + return $this->collection->updateMany($filter, $update, $options); + } + + /** + * Delete a single document + * + * @param array $filter Query filter + * @param array $options Delete options + * @return DeleteResult + */ + public function deleteOne(array $filter, array $options = []): DeleteResult + { + $filter = $this->convertFilter($filter); + return $this->collection->deleteOne($filter, $options); + } + + /** + * Delete multiple documents + * + * @param array $filter Query filter + * @param array $options Delete options + * @return DeleteResult + */ + public function deleteMany(array $filter, array $options = []): DeleteResult + { + $filter = $this->convertFilter($filter); + return $this->collection->deleteMany($filter, $options); + } + + /** + * Count documents matching filter + * + * @param array $filter Query filter + * @param array $options Count options + * @return int + */ + public function countDocuments(array $filter = [], array $options = []): int + { + $filter = $this->convertFilter($filter); + return $this->collection->countDocuments($filter, $options); + } + + /** + * Execute aggregation pipeline + * + * @param array $pipeline Aggregation pipeline + * @param array $options Aggregation options + * @return Cursor + */ + public function aggregate(array $pipeline, array $options = []): Cursor + { + /** @var \Iterator $cursor */ + $cursor = $this->collection->aggregate($pipeline, $options); + return new Cursor($cursor); + } + + /** + * Create an index + * + * @param array $key Index specification + * @param array $options Index options + * @return string Index name + */ + public function createIndex(array $key, array $options = []): string + { + return $this->collection->createIndex($key, $options); + } + + /** + * Drop the collection + */ + public function drop(): array|object|null + { + return $this->collection->drop(); + } + + /** + * Get collection name + */ + public function getCollectionName(): string + { + return $this->collection->getCollectionName(); + } + + /** + * Get database name + */ + public function getDatabaseName(): string + { + return $this->collection->getDatabaseName(); + } + + /** + * Convert ObjectId instances in filter to MongoDB ObjectId + */ + private function convertFilter(array $filter): array + { + return $this->convertArray($filter); + } + + /** + * Convert ObjectId instances in document to MongoDB ObjectId + */ + private function convertDocument(array|object $document): array|object + { + if (is_array($document)) { + return $this->convertArray($document); + } + return $document; + } + + /** + * Recursively convert ObjectId and UTCDateTime instances + */ + private function convertArray(array $data): array + { + foreach ($data as $key => $value) { + if ($value instanceof ObjectId) { + $data[$key] = $value->toBSON(); + } elseif ($value instanceof UTCDateTime) { + $data[$key] = $value->toBSON(); + } elseif (is_array($value)) { + $data[$key] = $this->convertArray($value); + } + } + return $data; + } + + /** + * Get the underlying MongoDB Collection + * Use sparingly - prefer using wrapper methods + */ + public function getMongoCollection(): MongoCollection + { + return $this->collection; + } + + /** + * Convert BSON objects to native PHP types + * Handles ObjectId, UTCDateTime, and other BSON types + */ + private function convertBsonToNative(mixed $data): mixed + { + if (is_array($data)) { + foreach ($data as $key => $value) { + $data[$key] = $this->convertBsonToNative($value); + } + return $data; + } + + if (is_object($data)) { + // Convert MongoDB BSON ObjectId to string + if ($data instanceof \MongoDB\BSON\ObjectId) { + return (string) $data; + } + + // Convert MongoDB BSON UTCDateTime to string or DateTime + if ($data instanceof \MongoDB\BSON\UTCDateTime) { + return (string) $data->toDateTime()->format('c'); + } + + // Convert other objects to arrays recursively + if (method_exists($data, 'bsonSerialize')) { + return $this->convertBsonToNative($data->bsonSerialize()); + } + + return (array) $data; + } + + return $data; + } +} diff --git a/core/lib/Db/Cursor.php b/core/lib/Db/Cursor.php new file mode 100644 index 0000000..13ef296 --- /dev/null +++ b/core/lib/Db/Cursor.php @@ -0,0 +1,86 @@ +cursor = $cursor; + } + + /** + * Convert cursor to array with BSON types converted to native PHP types + */ + public function toArray(): array + { + $result = iterator_to_array($this->cursor); + return $this->convertBsonToNative($result); + } + + /** + * Get iterator for foreach loops + * Note: Items will be returned as-is (may contain BSON objects) + * Use toArray() if you need full conversion + */ + public function getIterator(): Traversable + { + return $this->cursor; + } + + /** + * Get underlying MongoDB cursor + */ + public function getMongoCursor(): Iterator + { + return $this->cursor; + } + + /** + * Convert BSON objects to native PHP types + * Handles ObjectId, UTCDateTime, and other BSON types + */ + private function convertBsonToNative(mixed $data): mixed + { + if (is_array($data)) { + foreach ($data as $key => $value) { + $data[$key] = $this->convertBsonToNative($value); + } + return $data; + } + + if (is_object($data)) { + // Convert MongoDB BSON ObjectId to string + if ($data instanceof \MongoDB\BSON\ObjectId) { + return (string) $data; + } + + // Convert MongoDB BSON UTCDateTime to ISO8601 string + if ($data instanceof \MongoDB\BSON\UTCDateTime) { + return $data->toDateTime()->format('c'); + } + + // Convert other objects to arrays recursively + if (method_exists($data, 'bsonSerialize')) { + return $this->convertBsonToNative($data->bsonSerialize()); + } + + // Convert stdClass and other objects to array + $array = (array) $data; + return $this->convertBsonToNative($array); + } + + return $data; + } +} diff --git a/core/lib/Db/DataStore.php b/core/lib/Db/DataStore.php new file mode 100644 index 0000000..2bbbccf --- /dev/null +++ b/core/lib/Db/DataStore.php @@ -0,0 +1,97 @@ +configuration = $configuration; + + $uri = $configuration['uri']; + $databaseName = $configuration['database']; + $options = $configuration['options'] ?? []; + $driverOptions = $configuration['driverOptions'] ?? []; + + $this->client = new Client($uri, $options, $driverOptions); + $this->database = $this->client->selectDatabase($databaseName, $options); + } + + /** + * Select a collection from the database + * + * @param string $collectionName Collection name + * @param array $options Collection options + * @return Collection + */ + public function selectCollection(string $collectionName, array $options = []): Collection + { + return $this->database->selectCollection($collectionName, $options); + } + + /** + * Get the underlying Database instance + */ + public function getDatabase(): Database + { + return $this->database; + } + + /** + * Get the Client instance + */ + public function getClient(): Client + { + return $this->client; + } + + /** + * List all collections + */ + public function listCollections(array $options = []): array + { + return $this->database->listCollections($options); + } + + /** + * Create a collection + */ + public function createCollection(string $collectionName, array $options = []): Collection + { + return $this->database->createCollection($collectionName, $options); + } + + /** + * Drop a collection + */ + public function dropCollection(string $collectionName, array $options = []): array|object + { + return $this->database->dropCollection($collectionName, $options); + } + + /** + * Get database name + */ + public function getDatabaseName(): string + { + return $this->database->getDatabaseName(); + } + + /** + * Magic method to access collection as property + */ + public function __get(string $collectionName): Collection + { + return $this->selectCollection($collectionName); + } +} diff --git a/core/lib/Db/Database.php b/core/lib/Db/Database.php new file mode 100644 index 0000000..02db600 --- /dev/null +++ b/core/lib/Db/Database.php @@ -0,0 +1,104 @@ +database = $database; + } + + /** + * Select a collection + * + * @param string $collectionName Collection name + * @param array $options Collection options + * @return Collection + */ + public function selectCollection(string $collectionName, array $options = []): Collection + { + $mongoCollection = $this->database->selectCollection($collectionName, $options); + return new Collection($mongoCollection); + } + + /** + * List collections + */ + public function listCollections(array $options = []): array + { + $collections = []; + foreach ($this->database->listCollections($options) as $collectionInfo) { + $collections[] = $collectionInfo; + } + return $collections; + } + + /** + * Drop the database + */ + public function drop(array $options = []): array|object|null + { + return $this->database->drop($options); + } + + /** + * Get database name + */ + public function getDatabaseName(): string + { + return $this->database->getDatabaseName(); + } + + /** + * Create a collection + */ + public function createCollection(string $collectionName, array $options = []): Collection|null + { + $mongoCollection = $this->database->createCollection($collectionName, $options); + return $mongoCollection ? new Collection($mongoCollection) : null; + } + + /** + * Drop a collection + */ + public function dropCollection(string $collectionName, array $options = []): array|object|null + { + return $this->database->dropCollection($collectionName, $options); + } + + /** + * Execute a database command + */ + public function command(array|object $command, array $options = []): Cursor + { + /** @var \Iterator $cursor */ + $cursor = $this->database->command($command, $options); + return new Cursor($cursor); + } + + /** + * Get the underlying MongoDB Database + * Use sparingly - prefer using wrapper methods + */ + public function getMongoDatabase(): MongoDatabase + { + return $this->database; + } + + /** + * Magic method to access collection as property + */ + public function __get(string $collectionName): Collection + { + return $this->selectCollection($collectionName); + } +} diff --git a/core/lib/Db/ObjectId.php b/core/lib/Db/ObjectId.php new file mode 100644 index 0000000..4b53e49 --- /dev/null +++ b/core/lib/Db/ObjectId.php @@ -0,0 +1,71 @@ +objectId = $id; + } elseif (is_string($id)) { + $this->objectId = new MongoObjectId($id); + } else { + $this->objectId = new MongoObjectId(); + } + } + + /** + * Get the string representation of the ObjectId + */ + public function __toString(): string + { + return (string) $this->objectId; + } + + /** + * Get the underlying MongoDB ObjectId + * Used internally when interacting with MongoDB driver + */ + public function toBSON(): MongoObjectId + { + return $this->objectId; + } + + /** + * Get the timestamp from the ObjectId + */ + public function getTimestamp(): int + { + return $this->objectId->getTimestamp(); + } + + /** + * Create ObjectId from string + */ + public static function fromString(string $id): self + { + return new self($id); + } + + /** + * Check if a string is a valid ObjectId + */ + public static function isValid(string $id): bool + { + return MongoObjectId::isValid($id); + } +} diff --git a/core/lib/Db/UTCDateTime.php b/core/lib/Db/UTCDateTime.php new file mode 100644 index 0000000..d83afbf --- /dev/null +++ b/core/lib/Db/UTCDateTime.php @@ -0,0 +1,89 @@ +dateTime = new MongoUTCDateTime($milliseconds); + } else { + // Fallback for environments without MongoDB extension (testing, linting) + $this->dateTime = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(DATE_ATOM); + } + } + + /** + * Get the string representation + */ + public function __toString(): string + { + if ($this->dateTime instanceof MongoUTCDateTime) { + return $this->dateTime->toDateTime()->format(DATE_ATOM); + } + return $this->dateTime; + } + + /** + * Get the underlying MongoDB UTCDateTime or fallback string + * Used internally when interacting with MongoDB driver + */ + public function toBSON(): MongoUTCDateTime|string + { + return $this->dateTime; + } + + /** + * Convert to PHP DateTime + */ + public function toDateTime(): \DateTimeImmutable + { + if ($this->dateTime instanceof MongoUTCDateTime) { + return \DateTimeImmutable::createFromMutable($this->dateTime->toDateTime()); + } + return new \DateTimeImmutable($this->dateTime); + } + + /** + * Get milliseconds since epoch + */ + public function toMilliseconds(): int + { + if ($this->dateTime instanceof MongoUTCDateTime) { + return (int) $this->dateTime; + } + return (int) ((new \DateTimeImmutable($this->dateTime))->getTimestamp() * 1000); + } + + /** + * Create from DateTime + */ + public static function fromDateTime(DateTimeInterface $dateTime): self + { + return new self($dateTime); + } + + /** + * Create current timestamp + */ + public static function now(): self + { + return new self(); + } +} diff --git a/core/lib/Http/Cookie.php b/core/lib/Http/Cookie.php new file mode 100644 index 0000000..1e0cc99 --- /dev/null +++ b/core/lib/Http/Cookie.php @@ -0,0 +1,407 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http; + +/** + * Represents a cookie. + * + * @author Johannes M. Schmitt + */ +class Cookie +{ + public const SAMESITE_NONE = 'none'; + public const SAMESITE_LAX = 'lax'; + public const SAMESITE_STRICT = 'strict'; + + protected int $expire; + protected string $path; + + private ?string $sameSite = null; + private bool $secureDefault = false; + + private const RESERVED_CHARS_LIST = "=,; \t\r\n\v\f"; + private const RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"]; + private const RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C']; + + /** + * Creates cookie from raw header string. + */ + public static function fromString(string $cookie, bool $decode = false): static + { + $data = [ + 'expires' => 0, + 'path' => '/', + 'domain' => null, + 'secure' => false, + 'httponly' => false, + 'raw' => !$decode, + 'samesite' => null, + 'partitioned' => false, + ]; + + $parts = HeaderUtils::split($cookie, ';='); + $part = array_shift($parts); + + $name = $decode ? urldecode($part[0]) : $part[0]; + $value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null; + + $data = HeaderUtils::combine($parts) + $data; + $data['expires'] = self::expiresTimestamp($data['expires']); + + if (isset($data['max-age']) && ($data['max-age'] > 0 || $data['expires'] > time())) { + $data['expires'] = time() + (int) $data['max-age']; + } + + return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite'], $data['partitioned']); + } + + /** + * @see self::__construct + * + * @param self::SAMESITE_*|''|null $sameSite + */ + public static function create(string $name, ?string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX, bool $partitioned = false): self + { + return new self($name, $value, $expire, $path, $domain, $secure, $httpOnly, $raw, $sameSite, $partitioned); + } + + /** + * @param string $name The name of the cookie + * @param string|null $value The value of the cookie + * @param int|string|\DateTimeInterface $expire The time the cookie expires + * @param string|null $path The path on the server in which the cookie will be available on + * @param string|null $domain The domain that the cookie is available to + * @param bool|null $secure Whether the client should send back the cookie only over HTTPS or null to auto-enable this when the request is already using HTTPS + * @param bool $httpOnly Whether the cookie will be made accessible only through the HTTP protocol + * @param bool $raw Whether the cookie value should be sent with no url encoding + * @param self::SAMESITE_*|''|null $sameSite Whether the cookie will be available for cross-site requests + * + * @throws \InvalidArgumentException + */ + public function __construct( + protected string $name, + protected ?string $value = null, + int|string|\DateTimeInterface $expire = 0, + ?string $path = '/', + protected ?string $domain = null, + protected ?bool $secure = null, + protected bool $httpOnly = true, + private bool $raw = false, + ?string $sameSite = self::SAMESITE_LAX, + private bool $partitioned = false, + ) { + // from PHP source code + if ($raw && false !== strpbrk($name, self::RESERVED_CHARS_LIST)) { + throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $name)); + } + + if (!$name) { + throw new \InvalidArgumentException('The cookie name cannot be empty.'); + } + + $this->expire = self::expiresTimestamp($expire); + $this->path = $path ?: '/'; + $this->sameSite = $this->withSameSite($sameSite)->sameSite; + } + + /** + * Creates a cookie copy with a new value. + */ + public function withValue(?string $value): static + { + $cookie = clone $this; + $cookie->value = $value; + + return $cookie; + } + + /** + * Creates a cookie copy with a new domain that the cookie is available to. + */ + public function withDomain(?string $domain): static + { + $cookie = clone $this; + $cookie->domain = $domain; + + return $cookie; + } + + /** + * Creates a cookie copy with a new time the cookie expires. + */ + public function withExpires(int|string|\DateTimeInterface $expire = 0): static + { + $cookie = clone $this; + $cookie->expire = self::expiresTimestamp($expire); + + return $cookie; + } + + /** + * Converts expires formats to a unix timestamp. + */ + private static function expiresTimestamp(int|string|\DateTimeInterface $expire = 0): int + { + // convert expiration time to a Unix timestamp + if ($expire instanceof \DateTimeInterface) { + $expire = $expire->format('U'); + } elseif (!is_numeric($expire)) { + $expire = strtotime($expire); + + if (false === $expire) { + throw new \InvalidArgumentException('The cookie expiration time is not valid.'); + } + } + + return 0 < $expire ? (int) $expire : 0; + } + + /** + * Creates a cookie copy with a new path on the server in which the cookie will be available on. + */ + public function withPath(string $path): static + { + $cookie = clone $this; + $cookie->path = '' === $path ? '/' : $path; + + return $cookie; + } + + /** + * Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client. + */ + public function withSecure(bool $secure = true): static + { + $cookie = clone $this; + $cookie->secure = $secure; + + return $cookie; + } + + /** + * Creates a cookie copy that be accessible only through the HTTP protocol. + */ + public function withHttpOnly(bool $httpOnly = true): static + { + $cookie = clone $this; + $cookie->httpOnly = $httpOnly; + + return $cookie; + } + + /** + * Creates a cookie copy that uses no url encoding. + */ + public function withRaw(bool $raw = true): static + { + if ($raw && false !== strpbrk($this->name, self::RESERVED_CHARS_LIST)) { + throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $this->name)); + } + + $cookie = clone $this; + $cookie->raw = $raw; + + return $cookie; + } + + /** + * Creates a cookie copy with SameSite attribute. + * + * @param self::SAMESITE_*|''|null $sameSite + */ + public function withSameSite(?string $sameSite): static + { + if ('' === $sameSite) { + $sameSite = null; + } elseif (null !== $sameSite) { + $sameSite = strtolower($sameSite); + } + + if (!\in_array($sameSite, [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE, null], true)) { + throw new \InvalidArgumentException('The "sameSite" parameter value is not valid.'); + } + + $cookie = clone $this; + $cookie->sameSite = $sameSite; + + return $cookie; + } + + /** + * Creates a cookie copy that is tied to the top-level site in cross-site context. + */ + public function withPartitioned(bool $partitioned = true): static + { + $cookie = clone $this; + $cookie->partitioned = $partitioned; + + return $cookie; + } + + /** + * Returns the cookie as a string. + */ + public function __toString(): string + { + if ($this->isRaw()) { + $str = $this->getName(); + } else { + $str = str_replace(self::RESERVED_CHARS_FROM, self::RESERVED_CHARS_TO, $this->getName()); + } + + $str .= '='; + + if ('' === (string) $this->getValue()) { + $str .= 'deleted; expires='.gmdate('D, d M Y H:i:s T', time() - 31536001).'; Max-Age=0'; + } else { + $str .= $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue()); + + if (0 !== $this->getExpiresTime()) { + $str .= '; expires='.gmdate('D, d M Y H:i:s T', $this->getExpiresTime()).'; Max-Age='.$this->getMaxAge(); + } + } + + if ($this->getPath()) { + $str .= '; path='.$this->getPath(); + } + + if ($this->getDomain()) { + $str .= '; domain='.$this->getDomain(); + } + + if ($this->isSecure()) { + $str .= '; secure'; + } + + if ($this->isHttpOnly()) { + $str .= '; httponly'; + } + + if (null !== $this->getSameSite()) { + $str .= '; samesite='.$this->getSameSite(); + } + + if ($this->isPartitioned()) { + $str .= '; partitioned'; + } + + return $str; + } + + /** + * Gets the name of the cookie. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Gets the value of the cookie. + */ + public function getValue(): ?string + { + return $this->value; + } + + /** + * Gets the domain that the cookie is available to. + */ + public function getDomain(): ?string + { + return $this->domain; + } + + /** + * Gets the time the cookie expires. + */ + public function getExpiresTime(): int + { + return $this->expire; + } + + /** + * Gets the max-age attribute. + */ + public function getMaxAge(): int + { + $maxAge = $this->expire - time(); + + return max(0, $maxAge); + } + + /** + * Gets the path on the server in which the cookie will be available on. + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client. + */ + public function isSecure(): bool + { + return $this->secure ?? $this->secureDefault; + } + + /** + * Checks whether the cookie will be made accessible only through the HTTP protocol. + */ + public function isHttpOnly(): bool + { + return $this->httpOnly; + } + + /** + * Whether this cookie is about to be cleared. + */ + public function isCleared(): bool + { + return 0 !== $this->expire && $this->expire < time(); + } + + /** + * Checks if the cookie value should be sent with no url encoding. + */ + public function isRaw(): bool + { + return $this->raw; + } + + /** + * Checks whether the cookie should be tied to the top-level site in cross-site context. + */ + public function isPartitioned(): bool + { + return $this->partitioned; + } + + /** + * @return self::SAMESITE_*|null + */ + public function getSameSite(): ?string + { + return $this->sameSite; + } + + /** + * @param bool $default The default value of the "secure" flag when it is set to null + */ + public function setSecureDefault(bool $default): void + { + $this->secureDefault = $default; + } +} diff --git a/core/lib/Http/Exception/BadRequestException.php b/core/lib/Http/Exception/BadRequestException.php new file mode 100644 index 0000000..7f55cce --- /dev/null +++ b/core/lib/Http/Exception/BadRequestException.php @@ -0,0 +1,11 @@ +originalName = $this->getName($originalName); + $this->mimeType = $mimeType ?? 'application/octet-stream'; + $this->error = $error ?? \UPLOAD_ERR_OK; + $this->test = $test; + + parent::__construct($path); + } + + /** + * Returns the original file name. + * + * It is extracted from the request from which the file has been uploaded. + * This should not be considered as a safe value to use for a file name on your servers. + * + * @return string The original name + */ + public function getClientOriginalName(): string + { + return $this->originalName; + } + + /** + * Returns the original file extension. + * + * It is extracted from the original file name that was uploaded. + * This should not be considered as a safe value to use for a file name on your servers. + * + * @return string The extension + */ + public function getClientOriginalExtension(): string + { + return pathinfo($this->originalName, \PATHINFO_EXTENSION); + } + + /** + * Returns the file mime type. + * + * The client mime type is extracted from the request from which the file was uploaded, + * so it should not be considered as a safe value. + * + * @return string The mime type + */ + public function getClientMimeType(): string + { + return $this->mimeType; + } + + /** + * Returns the extension based on the client mime type. + * + * If the mime type is unknown, returns null. + * + * This method uses a built-in list of mime type / extension pairs. + * + * @return string|null The guessed extension or null if it cannot be guessed + */ + public function guessClientExtension(): ?string + { + return self::mimeToExtension($this->mimeType); + } + + /** + * Returns the upload error. + * + * If the upload was successful, the constant UPLOAD_ERR_OK is returned. + * Otherwise one of the other UPLOAD_ERR_XXX constants is returned. + * + * @return int The upload error + */ + public function getError(): int + { + return $this->error; + } + + /** + * Returns whether the file has been uploaded with HTTP and no error occurred. + * + * @return bool True if the file is valid, false otherwise + */ + public function isValid(): bool + { + $isOk = \UPLOAD_ERR_OK === $this->error; + + return $this->test ? $isOk : $isOk && is_uploaded_file($this->getPathname()); + } + + /** + * Moves the file to a new location. + * + * @param string $directory The destination folder + * @param string|null $name The new file name + * + * @return \SplFileInfo A SplFileInfo object for the new file + * + * @throws \RuntimeException if the file cannot be moved + */ + public function move(string $directory, ?string $name = null): \SplFileInfo + { + if ($this->isValid()) { + if ($this->test) { + return $this->doMove($directory, $name); + } + + $target = $this->getTargetFile($directory, $name); + + if (!@move_uploaded_file($this->getPathname(), $target)) { + $error = error_get_last(); + throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error'))); + } + + @chmod($target, 0666 & ~umask()); + + return new \SplFileInfo($target); + } + + throw new \RuntimeException($this->getErrorMessage()); + } + + /** + * Returns the maximum size of an uploaded file as configured in php.ini. + * + * @return int|float The maximum size of an uploaded file in bytes (returns float on 32-bit for large values) + */ + public static function getMaxFilesize(): int|float + { + $sizePostMax = self::parseFilesize(\ini_get('post_max_size')); + $sizeUploadMax = self::parseFilesize(\ini_get('upload_max_filesize')); + + return min($sizePostMax ?: \PHP_INT_MAX, $sizeUploadMax ?: \PHP_INT_MAX); + } + + /** + * Returns an informative upload error message. + * + * @return string The error message regarding the specified error code + */ + public function getErrorMessage(): string + { + return match ($this->error) { + \UPLOAD_ERR_INI_SIZE => 'The file "%s" exceeds your upload_max_filesize ini directive.', + \UPLOAD_ERR_FORM_SIZE => 'The file "%s" exceeds the upload limit defined in your form.', + \UPLOAD_ERR_PARTIAL => 'The file "%s" was only partially uploaded.', + \UPLOAD_ERR_NO_FILE => 'No file was uploaded.', + \UPLOAD_ERR_CANT_WRITE => 'The file "%s" could not be written on disk.', + \UPLOAD_ERR_NO_TMP_DIR => 'File could not be uploaded: missing temporary directory.', + \UPLOAD_ERR_EXTENSION => 'File upload was stopped by a PHP extension.', + default => 'The file "%s" was not uploaded due to an unknown error.', + }; + } + + /** + * Returns locale independent base name of the given path. + * + * @param string $name The new file name + * + * @return string The base name + */ + protected function getName(string $name): string + { + $originalName = str_replace('\\', '/', $name); + $pos = strrpos($originalName, '/'); + $originalName = false === $pos ? $originalName : substr($originalName, $pos + 1); + + return $originalName; + } + + protected function getTargetFile(string $directory, ?string $name = null): string + { + if (!is_dir($directory)) { + if (false === @mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new \RuntimeException(sprintf('Unable to create the "%s" directory.', $directory)); + } + } elseif (!is_writable($directory)) { + throw new \RuntimeException(sprintf('Unable to write in the "%s" directory.', $directory)); + } + + $target = rtrim($directory, '/\\') . \DIRECTORY_SEPARATOR . (null === $name ? $this->getBasename() : $this->getName($name)); + + return $target; + } + + /** + * Moves the file to a new location (used in test mode). + */ + protected function doMove(string $directory, ?string $name = null): \SplFileInfo + { + $target = $this->getTargetFile($directory, $name); + + if (!@rename($this->getPathname(), $target)) { + $error = error_get_last(); + throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error'))); + } + + @chmod($target, 0666 & ~umask()); + + return new \SplFileInfo($target); + } + + private static function parseFilesize(string $size): int|float + { + if ('' === $size) { + return 0; + } + + $size = strtolower($size); + + $max = ltrim($size, '+'); + if (str_starts_with($max, '0x')) { + $max = \intval($max, 16); + } elseif (str_starts_with($max, '0')) { + $max = \intval($max, 8); + } else { + $max = (int) $max; + } + + switch (substr($size, -1)) { + case 't': $max *= 1024; + // no break + case 'g': $max *= 1024; + // no break + case 'm': $max *= 1024; + // no break + case 'k': $max *= 1024; + } + + return $max; + } + + private static function mimeToExtension(string $mimeType): ?string + { + $map = [ + 'application/pdf' => 'pdf', + 'application/zip' => 'zip', + 'application/json' => 'json', + 'application/xml' => 'xml', + 'application/octet-stream' => 'bin', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/svg+xml' => 'svg', + 'text/plain' => 'txt', + 'text/html' => 'html', + 'text/css' => 'css', + 'text/javascript' => 'js', + 'audio/mpeg' => 'mp3', + 'audio/wav' => 'wav', + 'video/mp4' => 'mp4', + 'video/webm' => 'webm', + ]; + + return $map[$mimeType] ?? null; + } +} diff --git a/core/lib/Http/HeaderParameters.php b/core/lib/Http/HeaderParameters.php new file mode 100644 index 0000000..21b216e --- /dev/null +++ b/core/lib/Http/HeaderParameters.php @@ -0,0 +1,275 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http; + +/** + * HeaderBag is a container for HTTP headers. + * + * @author Fabien Potencier + * + * @implements \IteratorAggregate> + */ +class HeaderParameters implements \IteratorAggregate, \Countable, \Stringable +{ + protected const UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + protected const LOWER = '-abcdefghijklmnopqrstuvwxyz'; + + /** + * @var array> + */ + protected array $headers = []; + protected array $cacheControl = []; + + public function __construct(array $headers = []) + { + foreach ($headers as $key => $values) { + $this->set($key, $values); + } + } + + /** + * Returns the headers as a string. + */ + public function __toString(): string + { + if (!$headers = $this->all()) { + return ''; + } + + ksort($headers); + $max = max(array_map('strlen', array_keys($headers))) + 1; + $content = ''; + foreach ($headers as $name => $values) { + $name = ucwords($name, '-'); + foreach ($values as $value) { + $content .= \sprintf("%-{$max}s %s\r\n", $name.':', $value); + } + } + + return $content; + } + + /** + * Returns the headers. + * + * @param string|null $key The name of the headers to return or null to get them all + * + * @return ($key is null ? array> : list) + */ + public function all(?string $key = null): array + { + if (null !== $key) { + return $this->headers[strtr($key, self::UPPER, self::LOWER)] ?? []; + } + + return $this->headers; + } + + /** + * Returns the parameter keys. + * + * @return string[] + */ + public function keys(): array + { + return array_keys($this->all()); + } + + /** + * Replaces the current HTTP headers by a new set. + */ + public function replace(array $headers = []): void + { + $this->headers = []; + $this->add($headers); + } + + /** + * Adds new headers the current HTTP headers set. + */ + public function add(array $headers): void + { + foreach ($headers as $key => $values) { + $this->set($key, $values); + } + } + + /** + * Returns the first header by name or the default one. + */ + public function get(string $key, ?string $default = null): ?string + { + $headers = $this->all($key); + + if (!$headers) { + return $default; + } + + if (null === $headers[0]) { + return null; + } + + return $headers[0]; + } + + /** + * Sets a header by name. + * + * @param string|string[]|null $values The value or an array of values + * @param bool $replace Whether to replace the actual value or not (true by default) + */ + public function set(string $key, string|array|null $values, bool $replace = true): void + { + $key = strtr($key, self::UPPER, self::LOWER); + + if (\is_array($values)) { + $values = array_values($values); + + if (true === $replace || !isset($this->headers[$key])) { + $this->headers[$key] = $values; + } else { + $this->headers[$key] = array_merge($this->headers[$key], $values); + } + } else { + if (true === $replace || !isset($this->headers[$key])) { + $this->headers[$key] = [$values]; + } else { + $this->headers[$key][] = $values; + } + } + + if ('cache-control' === $key) { + $this->cacheControl = $this->parseCacheControl(implode(', ', $this->headers[$key])); + } + } + + /** + * Returns true if the HTTP header is defined. + */ + public function has(string $key): bool + { + return \array_key_exists(strtr($key, self::UPPER, self::LOWER), $this->all()); + } + + /** + * Returns true if the given HTTP header contains the given value. + */ + public function contains(string $key, string $value): bool + { + return \in_array($value, $this->all($key), true); + } + + /** + * Removes a header. + */ + public function remove(string $key): void + { + $key = strtr($key, self::UPPER, self::LOWER); + + unset($this->headers[$key]); + + if ('cache-control' === $key) { + $this->cacheControl = []; + } + } + + /** + * Returns the HTTP header value converted to a date. + * + * @throws \RuntimeException When the HTTP header is not parseable + */ + public function getDate(string $key, ?\DateTimeInterface $default = null): ?\DateTimeImmutable + { + if (null === $value = $this->get($key)) { + return null !== $default ? \DateTimeImmutable::createFromInterface($default) : null; + } + + if (false === $date = \DateTimeImmutable::createFromFormat(\DATE_RFC2822, $value)) { + throw new \RuntimeException(\sprintf('The "%s" HTTP header is not parseable (%s).', $key, $value)); + } + + return $date; + } + + /** + * Adds a custom Cache-Control directive. + */ + public function addCacheControlDirective(string $key, bool|string $value = true): void + { + $this->cacheControl[$key] = $value; + + $this->set('Cache-Control', $this->getCacheControlHeader()); + } + + /** + * Returns true if the Cache-Control directive is defined. + */ + public function hasCacheControlDirective(string $key): bool + { + return \array_key_exists($key, $this->cacheControl); + } + + /** + * Returns a Cache-Control directive value by name. + */ + public function getCacheControlDirective(string $key): bool|string|null + { + return $this->cacheControl[$key] ?? null; + } + + /** + * Removes a Cache-Control directive. + */ + public function removeCacheControlDirective(string $key): void + { + unset($this->cacheControl[$key]); + + $this->set('Cache-Control', $this->getCacheControlHeader()); + } + + /** + * Returns an iterator for headers. + * + * @return \ArrayIterator> + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->headers); + } + + /** + * Returns the number of headers. + */ + public function count(): int + { + return \count($this->headers); + } + + protected function getCacheControlHeader(): string + { + ksort($this->cacheControl); + + return HeaderUtils::toString($this->cacheControl, ','); + } + + /** + * Parses a Cache-Control HTTP header. + */ + protected function parseCacheControl(string $header): array + { + $parts = HeaderUtils::split($header, ',='); + + return HeaderUtils::combine($parts); + } +} diff --git a/core/lib/Http/HeaderUtils.php b/core/lib/Http/HeaderUtils.php new file mode 100644 index 0000000..f57d261 --- /dev/null +++ b/core/lib/Http/HeaderUtils.php @@ -0,0 +1,298 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http; + +/** + * HTTP header utility functions. + * + * @author Christian Schmidt + */ +class HeaderUtils +{ + public const DISPOSITION_ATTACHMENT = 'attachment'; + public const DISPOSITION_INLINE = 'inline'; + + /** + * This class should not be instantiated. + */ + private function __construct() + { + } + + /** + * Splits an HTTP header by one or more separators. + * + * Example: + * + * HeaderUtils::split('da, en-gb;q=0.8', ',;') + * # returns [['da'], ['en-gb', 'q=0.8']] + * + * @param string $separators List of characters to split on, ordered by + * precedence, e.g. ',', ';=', or ',;=' + * + * @return array Nested array with as many levels as there are characters in + * $separators + */ + public static function split(string $header, string $separators): array + { + if ('' === $separators) { + throw new \InvalidArgumentException('At least one separator must be specified.'); + } + + $quotedSeparators = preg_quote($separators, '/'); + + preg_match_all(' + / + (?!\s) + (?: + # quoted-string + "(?:[^"\\\\]|\\\\.)*(?:"|\\\\|$) + | + # token + [^"'.$quotedSeparators.']+ + )+ + (?['.$quotedSeparators.']) + \s* + /x', trim($header), $matches, \PREG_SET_ORDER); + + return self::groupParts($matches, $separators); + } + + /** + * Combines an array of arrays into one associative array. + * + * Each of the nested arrays should have one or two elements. The first + * value will be used as the keys in the associative array, and the second + * will be used as the values, or true if the nested array only contains one + * element. Array keys are lowercased. + * + * Example: + * + * HeaderUtils::combine([['foo', 'abc'], ['bar']]) + * // => ['foo' => 'abc', 'bar' => true] + */ + public static function combine(array $parts): array + { + $assoc = []; + foreach ($parts as $part) { + $name = strtolower($part[0]); + $value = $part[1] ?? true; + $assoc[$name] = $value; + } + + return $assoc; + } + + /** + * Joins an associative array into a string for use in an HTTP header. + * + * The key and value of each entry are joined with '=', and all entries + * are joined with the specified separator and an additional space (for + * readability). Values are quoted if necessary. + * + * Example: + * + * HeaderUtils::toString(['foo' => 'abc', 'bar' => true, 'baz' => 'a b c'], ',') + * // => 'foo=abc, bar, baz="a b c"' + */ + public static function toString(array $assoc, string $separator): string + { + $parts = []; + foreach ($assoc as $name => $value) { + if (true === $value) { + $parts[] = $name; + } else { + $parts[] = $name.'='.self::quote($value); + } + } + + return implode($separator.' ', $parts); + } + + /** + * Encodes a string as a quoted string, if necessary. + * + * If a string contains characters not allowed by the "token" construct in + * the HTTP specification, it is backslash-escaped and enclosed in quotes + * to match the "quoted-string" construct. + */ + public static function quote(string $s): string + { + if (preg_match('/^[a-z0-9!#$%&\'*.^_`|~-]+$/i', $s)) { + return $s; + } + + return '"'.addcslashes($s, '"\\"').'"'; + } + + /** + * Decodes a quoted string. + * + * If passed an unquoted string that matches the "token" construct (as + * defined in the HTTP specification), it is passed through verbatim. + */ + public static function unquote(string $s): string + { + return preg_replace('/\\\\(.)|"/', '$1', $s); + } + + /** + * Generates an HTTP Content-Disposition field-value. + * + * @param string $disposition One of "inline" or "attachment" + * @param string $filename A unicode string + * @param string $filenameFallback A string containing only ASCII characters that + * is semantically equivalent to $filename. If the filename is already ASCII, + * it can be omitted, or just copied from $filename + * + * @throws \InvalidArgumentException + * + * @see RFC 6266 + */ + public static function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string + { + if (!\in_array($disposition, [self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE])) { + throw new \InvalidArgumentException(\sprintf('The disposition must be either "%s" or "%s".', self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE)); + } + + if ('' === $filenameFallback) { + $filenameFallback = $filename; + } + + // filenameFallback is not ASCII. + if (!preg_match('/^[\x20-\x7e]*$/', $filenameFallback)) { + throw new \InvalidArgumentException('The filename fallback must only contain ASCII characters.'); + } + + // percent characters aren't safe in fallback. + if (str_contains($filenameFallback, '%')) { + throw new \InvalidArgumentException('The filename fallback cannot contain the "%" character.'); + } + + // path separators aren't allowed in either. + if (str_contains($filename, '/') || str_contains($filename, '\\') || str_contains($filenameFallback, '/') || str_contains($filenameFallback, '\\')) { + throw new \InvalidArgumentException('The filename and the fallback cannot contain the "/" and "\\" characters.'); + } + + $params = ['filename' => $filenameFallback]; + if ($filename !== $filenameFallback) { + $params['filename*'] = "utf-8''".rawurlencode($filename); + } + + return $disposition.'; '.self::toString($params, ';'); + } + + /** + * Like parse_str(), but preserves dots in variable names. + */ + public static function parseQuery(string $query, bool $ignoreBrackets = false, string $separator = '&'): array + { + $q = []; + + foreach (explode($separator, $query) as $v) { + if (false !== $i = strpos($v, "\0")) { + $v = substr($v, 0, $i); + } + + if (false === $i = strpos($v, '=')) { + $k = urldecode($v); + $v = ''; + } else { + $k = urldecode(substr($v, 0, $i)); + $v = substr($v, $i); + } + + if (false !== $i = strpos($k, "\0")) { + $k = substr($k, 0, $i); + } + + $k = ltrim($k, ' '); + + if ($ignoreBrackets) { + $q[$k][] = urldecode(substr($v, 1)); + + continue; + } + + if (false === $i = strpos($k, '[')) { + $q[] = bin2hex($k).$v; + } else { + $q[] = bin2hex(substr($k, 0, $i)).rawurlencode(substr($k, $i)).$v; + } + } + + if ($ignoreBrackets) { + return $q; + } + + parse_str(implode('&', $q), $q); + + $query = []; + + foreach ($q as $k => $v) { + if (false !== $i = strpos($k, '_')) { + $query[substr_replace($k, hex2bin(substr($k, 0, $i)).'[', 0, 1 + $i)] = $v; + } else { + $query[hex2bin($k)] = $v; + } + } + + return $query; + } + + private static function groupParts(array $matches, string $separators, bool $first = true): array + { + $separator = $separators[0]; + $separators = substr($separators, 1) ?: ''; + $i = 0; + + if ('' === $separators && !$first) { + $parts = ['']; + + foreach ($matches as $match) { + if (!$i && isset($match['separator'])) { + $i = 1; + $parts[1] = ''; + } else { + $parts[$i] .= self::unquote($match[0]); + } + } + + return $parts; + } + + $parts = []; + $partMatches = []; + + foreach ($matches as $match) { + if (($match['separator'] ?? null) === $separator) { + ++$i; + } else { + $partMatches[$i][] = $match; + } + } + + foreach ($partMatches as $matches) { + if ('' === $separators && '' !== $unquoted = self::unquote($matches[0][0])) { + $parts[] = $unquoted; + } elseif ($groupedParts = self::groupParts($matches, $separators, false)) { + $parts[] = $groupedParts; + } + } + + return $parts; + } +} diff --git a/core/lib/Http/Request/Request.php b/core/lib/Http/Request/Request.php new file mode 100644 index 0000000..892144f --- /dev/null +++ b/core/lib/Http/Request/Request.php @@ -0,0 +1,2107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\Exception\BadRequestException; +use KTXC\Http\Exception\ConflictingHeadersException; +use KTXC\Http\Exception\JsonException; +use KTXC\Http\Exception\SessionNotFoundException; +use KTXC\Http\Exception\SuspiciousOperationException; +use KTXC\Http\HeaderUtils; +use KTXC\Http\Session\SessionInterface; +use KTXF\IpUtils; + +// Help opcache.preload discover always-needed symbols +class_exists(RequestHeaderAccept::class); +class_exists(RequestFileCollection::class); +class_exists(RequestHeaderParameters::class); +class_exists(HeaderUtils::class); +class_exists(RequestInputParameters::class); +class_exists(RequestServerParameters::class); + +/** + * Request represents an HTTP request. + * + * The methods dealing with URL accept / return a raw path (% encoded): + * * getBasePath + * * getBaseUrl + * * getPathInfo + * * getRequestUri + * * getUri + * * getUriForPath + * + * @author Fabien Potencier + */ +class Request +{ + public const HEADER_FORWARDED = 0b000001; // When using RFC 7239 + public const HEADER_X_FORWARDED_FOR = 0b000010; + public const HEADER_X_FORWARDED_HOST = 0b000100; + public const HEADER_X_FORWARDED_PROTO = 0b001000; + public const HEADER_X_FORWARDED_PORT = 0b010000; + public const HEADER_X_FORWARDED_PREFIX = 0b100000; + + public const HEADER_X_FORWARDED_AWS_ELB = 0b0011010; // AWS ELB doesn't send X-Forwarded-Host + public const HEADER_X_FORWARDED_TRAEFIK = 0b0111110; // All "X-Forwarded-*" headers sent by Traefik reverse proxy + + public const METHOD_HEAD = 'HEAD'; + public const METHOD_GET = 'GET'; + public const METHOD_POST = 'POST'; + public const METHOD_PUT = 'PUT'; + public const METHOD_PATCH = 'PATCH'; + public const METHOD_DELETE = 'DELETE'; + public const METHOD_PURGE = 'PURGE'; + public const METHOD_OPTIONS = 'OPTIONS'; + public const METHOD_TRACE = 'TRACE'; + public const METHOD_CONNECT = 'CONNECT'; + + /** + * @var string[] + */ + protected static array $trustedProxies = []; + + /** + * @var string[] + */ + protected static array $trustedHostPatterns = []; + + /** + * @var string[] + */ + protected static array $trustedHosts = []; + + protected static bool $httpMethodParameterOverride = false; + + /** + * Custom parameters. + */ + public RequestParameters $attributes; + + /** + * Request body parameters ($_POST). + * + * @see getPayload() for portability between content types + */ + public RequestInputParameters $request; + + /** + * Query string parameters ($_GET). + */ + public RequestInputParameters $query; + + /** + * Server and execution environment parameters ($_SERVER). + */ + public RequestServerParameters $server; + + /** + * Uploaded files ($_FILES). + */ + public RequestFileCollection $files; + + /** + * Cookies ($_COOKIE). + */ + public RequestInputParameters $cookies; + + /** + * Headers (taken from the $_SERVER). + */ + public RequestHeaderParameters $headers; + + /** + * @var string|resource|false|null + */ + protected $content; + + /** + * @var string[]|null + */ + protected ?array $languages = null; + + /** + * @var string[]|null + */ + protected ?array $charsets = null; + + /** + * @var string[]|null + */ + protected ?array $encodings = null; + + /** + * @var string[]|null + */ + protected ?array $acceptableContentTypes = null; + + protected ?string $pathInfo = null; + protected ?string $requestUri = null; + protected ?string $baseUrl = null; + protected ?string $basePath = null; + protected ?string $method = null; + protected ?string $format = null; + protected SessionInterface|\Closure|null $session = null; + protected ?string $locale = null; + protected string $defaultLocale = 'en'; + + /** + * @var array|null + */ + protected static ?array $formats = null; + + protected static ?\Closure $requestFactory = null; + + private ?string $preferredFormat = null; + + private bool $isHostValid = true; + private bool $isForwardedValid = true; + private bool $isSafeContentPreferred; + + private array $trustedValuesCache = []; + + private static int $trustedHeaderSet = -1; + + private const FORWARDED_PARAMS = [ + self::HEADER_X_FORWARDED_FOR => 'for', + self::HEADER_X_FORWARDED_HOST => 'host', + self::HEADER_X_FORWARDED_PROTO => 'proto', + self::HEADER_X_FORWARDED_PORT => 'host', + ]; + + /** + * Names for headers that can be trusted when + * using trusted proxies. + * + * The FORWARDED header is the standard as of rfc7239. + * + * The other headers are non-standard, but widely used + * by popular reverse proxies (like Apache mod_proxy or Amazon EC2). + */ + private const TRUSTED_HEADERS = [ + self::HEADER_FORWARDED => 'FORWARDED', + self::HEADER_X_FORWARDED_FOR => 'X_FORWARDED_FOR', + self::HEADER_X_FORWARDED_HOST => 'X_FORWARDED_HOST', + self::HEADER_X_FORWARDED_PROTO => 'X_FORWARDED_PROTO', + self::HEADER_X_FORWARDED_PORT => 'X_FORWARDED_PORT', + self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX', + ]; + + private bool $isIisRewrite = false; + + /** + * @param array $query The GET parameters + * @param array $request The POST parameters + * @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...) + * @param array $cookies The COOKIE parameters + * @param array $files The FILES parameters + * @param array $server The SERVER parameters + * @param string|resource|null $content The raw body data + */ + public function __construct(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null) + { + $this->initialize($query, $request, $attributes, $cookies, $files, $server, $content); + } + + /** + * Sets the parameters for this request. + * + * This method also re-initializes all properties. + * + * @param array $query The GET parameters + * @param array $request The POST parameters + * @param array $attributes The request attributes (parameters parsed from the PATH_INFO, ...) + * @param array $cookies The COOKIE parameters + * @param array $files The FILES parameters + * @param array $server The SERVER parameters + * @param string|resource|null $content The raw body data + */ + public function initialize(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null): void + { + $this->request = new RequestInputParameters($request); + $this->query = new RequestInputParameters($query); + $this->attributes = new RequestParameters($attributes); + $this->cookies = new RequestInputParameters($cookies); + $this->files = new RequestFileCollection($files); + $this->server = new RequestServerParameters($server); + $this->headers = new RequestHeaderParameters($this->server->getHeaders()); + + $this->content = $content; + $this->languages = null; + $this->charsets = null; + $this->encodings = null; + $this->acceptableContentTypes = null; + $this->pathInfo = null; + $this->requestUri = null; + $this->baseUrl = null; + $this->basePath = null; + $this->method = null; + $this->format = null; + } + + /** + * Creates a new request with values from PHP's super globals. + */ + public static function createFromGlobals(): static + { + $request = self::createRequestFromFactory($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER); + + if (str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded') + && \in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'DELETE', 'PATCH'], true) + ) { + parse_str($request->getContent(), $data); + $request->request = new RequestInputParameters($data); + } + + return $request; + } + + /** + * Creates a Request based on a given URI and configuration. + * + * The information contained in the URI always take precedence + * over the other information (server and parameters). + * + * @param string $uri The URI + * @param string $method The HTTP method + * @param array $parameters The query (GET) or request (POST) parameters + * @param array $cookies The request cookies ($_COOKIE) + * @param array $files The request files ($_FILES) + * @param array $server The server parameters ($_SERVER) + * @param string|resource|null $content The raw body data + * + * @throws BadRequestException When the URI is invalid + */ + public static function create(string $uri, string $method = 'GET', array $parameters = [], array $cookies = [], array $files = [], array $server = [], $content = null): static + { + $server = array_replace([ + 'SERVER_NAME' => 'localhost', + 'SERVER_PORT' => 80, + 'HTTP_HOST' => 'localhost', + 'HTTP_USER_AGENT' => 'Symfony', + 'HTTP_ACCEPT' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'HTTP_ACCEPT_LANGUAGE' => 'en-us,en;q=0.5', + 'HTTP_ACCEPT_CHARSET' => 'ISO-8859-1,utf-8;q=0.7,*;q=0.7', + 'REMOTE_ADDR' => '127.0.0.1', + 'SCRIPT_NAME' => '', + 'SCRIPT_FILENAME' => '', + 'SERVER_PROTOCOL' => 'HTTP/1.1', + 'REQUEST_TIME' => time(), + 'REQUEST_TIME_FLOAT' => microtime(true), + ], $server); + + $server['PATH_INFO'] = ''; + $server['REQUEST_METHOD'] = strtoupper($method); + + if (false === $components = parse_url(\strlen($uri) !== strcspn($uri, '?#') ? $uri : $uri.'#')) { + throw new BadRequestException('Invalid URI.'); + } + + if (false !== ($i = strpos($uri, '\\')) && $i < strcspn($uri, '?#')) { + throw new BadRequestException('Invalid URI: A URI cannot contain a backslash.'); + } + if (\strlen($uri) !== strcspn($uri, "\r\n\t")) { + throw new BadRequestException('Invalid URI: A URI cannot contain CR/LF/TAB characters.'); + } + if ('' !== $uri && (\ord($uri[0]) <= 32 || \ord($uri[-1]) <= 32)) { + throw new BadRequestException('Invalid URI: A URI must not start nor end with ASCII control characters or spaces.'); + } + + if (isset($components['host'])) { + $server['SERVER_NAME'] = $components['host']; + $server['HTTP_HOST'] = $components['host']; + } + + if (isset($components['scheme'])) { + if ('https' === $components['scheme']) { + $server['HTTPS'] = 'on'; + $server['SERVER_PORT'] = 443; + } else { + unset($server['HTTPS']); + $server['SERVER_PORT'] = 80; + } + } + + if (isset($components['port'])) { + $server['SERVER_PORT'] = $components['port']; + $server['HTTP_HOST'] .= ':'.$components['port']; + } + + if (isset($components['user'])) { + $server['PHP_AUTH_USER'] = $components['user']; + } + + if (isset($components['pass'])) { + $server['PHP_AUTH_PW'] = $components['pass']; + } + + if (!isset($components['path'])) { + $components['path'] = '/'; + } + + switch (strtoupper($method)) { + case 'POST': + case 'PUT': + case 'DELETE': + if (!isset($server['CONTENT_TYPE'])) { + $server['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + } + // no break + case 'PATCH': + $request = $parameters; + $query = []; + break; + default: + $request = []; + $query = $parameters; + break; + } + + $queryString = ''; + if (isset($components['query'])) { + parse_str(html_entity_decode($components['query']), $qs); + + if ($query) { + $query = array_replace($qs, $query); + $queryString = http_build_query($query, '', '&'); + } else { + $query = $qs; + $queryString = $components['query']; + } + } elseif ($query) { + $queryString = http_build_query($query, '', '&'); + } + + $server['REQUEST_URI'] = $components['path'].('' !== $queryString ? '?'.$queryString : ''); + $server['QUERY_STRING'] = $queryString; + + return self::createRequestFromFactory($query, $request, [], $cookies, $files, $server, $content); + } + + /** + * Sets a callable able to create a Request instance. + * + * This is mainly useful when you need to override the Request class + * to keep BC with an existing system. It should not be used for any + * other purpose. + */ + public static function setFactory(?callable $callable): void + { + self::$requestFactory = null === $callable ? null : $callable(...); + } + + /** + * Clones a request and overrides some of its parameters. + * + * @param array|null $query The GET parameters + * @param array|null $request The POST parameters + * @param array|null $attributes The request attributes (parameters parsed from the PATH_INFO, ...) + * @param array|null $cookies The COOKIE parameters + * @param array|null $files The FILES parameters + * @param array|null $server The SERVER parameters + */ + public function duplicate(?array $query = null, ?array $request = null, ?array $attributes = null, ?array $cookies = null, ?array $files = null, ?array $server = null): static + { + $dup = clone $this; + if (null !== $query) { + $dup->query = new RequestInputParameters($query); + } + if (null !== $request) { + $dup->request = new RequestInputParameters($request); + } + if (null !== $attributes) { + $dup->attributes = new RequestParameters($attributes); + } + if (null !== $cookies) { + $dup->cookies = new RequestInputParameters($cookies); + } + if (null !== $files) { + $dup->files = new RequestFileCollection($files); + } + if (null !== $server) { + $dup->server = new RequestServerParameters($server); + $dup->headers = new RequestHeaderParameters($dup->server->getHeaders()); + } + $dup->languages = null; + $dup->charsets = null; + $dup->encodings = null; + $dup->acceptableContentTypes = null; + $dup->pathInfo = null; + $dup->requestUri = null; + $dup->baseUrl = null; + $dup->basePath = null; + $dup->method = null; + $dup->format = null; + + if (!$dup->get('_format') && $this->get('_format')) { + $dup->attributes->set('_format', $this->get('_format')); + } + + if (!$dup->getRequestFormat(null)) { + $dup->setRequestFormat($this->getRequestFormat(null)); + } + + return $dup; + } + + /** + * Clones the current request. + * + * Note that the session is not cloned as duplicated requests + * are most of the time sub-requests of the main one. + */ + public function __clone() + { + $this->query = clone $this->query; + $this->request = clone $this->request; + $this->attributes = clone $this->attributes; + $this->cookies = clone $this->cookies; + $this->files = clone $this->files; + $this->server = clone $this->server; + $this->headers = clone $this->headers; + } + + public function __toString(): string + { + $content = $this->getContent(); + + $cookieHeader = ''; + $cookies = []; + + foreach ($this->cookies as $k => $v) { + $cookies[] = \is_array($v) ? http_build_query([$k => $v], '', '; ', \PHP_QUERY_RFC3986) : "$k=$v"; + } + + if ($cookies) { + $cookieHeader = 'Cookie: '.implode('; ', $cookies)."\r\n"; + } + + return + \sprintf('%s %s %s', $this->getMethod(), $this->getRequestUri(), $this->server->get('SERVER_PROTOCOL'))."\r\n". + $this->headers. + $cookieHeader."\r\n". + $content; + } + + /** + * Overrides the PHP global variables according to this request instance. + * + * It overrides $_GET, $_POST, $_REQUEST, $_SERVER, $_COOKIE. + * $_FILES is never overridden, see rfc1867 + */ + public function overrideGlobals(): void + { + $this->server->set('QUERY_STRING', static::normalizeQueryString(http_build_query($this->query->all(), '', '&'))); + + $_GET = $this->query->all(); + $_POST = $this->request->all(); + $_SERVER = $this->server->all(); + $_COOKIE = $this->cookies->all(); + + foreach ($this->headers->all() as $key => $value) { + $key = strtoupper(str_replace('-', '_', $key)); + if (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true)) { + $_SERVER[$key] = implode(', ', $value); + } else { + $_SERVER['HTTP_'.$key] = implode(', ', $value); + } + } + + $request = ['g' => $_GET, 'p' => $_POST, 'c' => $_COOKIE]; + + $requestOrder = \ini_get('request_order') ?: \ini_get('variables_order'); + $requestOrder = preg_replace('#[^cgp]#', '', strtolower($requestOrder)) ?: 'gp'; + + $_REQUEST = [[]]; + + foreach (str_split($requestOrder) as $order) { + $_REQUEST[] = $request[$order]; + } + + $_REQUEST = array_merge(...$_REQUEST); + } + + /** + * Sets a list of trusted proxies. + * + * You should only list the reverse proxies that you manage directly. + * + * @param array $proxies A list of trusted proxies, the string 'REMOTE_ADDR' will be replaced with $_SERVER['REMOTE_ADDR'] and 'PRIVATE_SUBNETS' by IpUtils::PRIVATE_SUBNETS + * @param int-mask-of $trustedHeaderSet A bit field to set which headers to trust from your proxies + */ + public static function setTrustedProxies(array $proxies, int $trustedHeaderSet): void + { + if (false !== $i = array_search('REMOTE_ADDR', $proxies, true)) { + if (isset($_SERVER['REMOTE_ADDR'])) { + $proxies[$i] = $_SERVER['REMOTE_ADDR']; + } else { + unset($proxies[$i]); + $proxies = array_values($proxies); + } + } + + if (false !== ($i = array_search('PRIVATE_SUBNETS', $proxies, true)) || false !== ($i = array_search('private_ranges', $proxies, true))) { + unset($proxies[$i]); + $proxies = array_merge($proxies, IpUtils::PRIVATE_SUBNETS); + } + + self::$trustedProxies = $proxies; + self::$trustedHeaderSet = $trustedHeaderSet; + } + + /** + * Gets the list of trusted proxies. + * + * @return string[] + */ + public static function getTrustedProxies(): array + { + return self::$trustedProxies; + } + + /** + * Gets the set of trusted headers from trusted proxies. + * + * @return int A bit field of Request::HEADER_* that defines which headers are trusted from your proxies + */ + public static function getTrustedHeaderSet(): int + { + return self::$trustedHeaderSet; + } + + /** + * Sets a list of trusted host patterns. + * + * You should only list the hosts you manage using regexs. + * + * @param array $hostPatterns A list of trusted host patterns + */ + public static function setTrustedHosts(array $hostPatterns): void + { + self::$trustedHostPatterns = array_map(fn ($hostPattern) => \sprintf('{%s}i', $hostPattern), $hostPatterns); + // we need to reset trusted hosts on trusted host patterns change + self::$trustedHosts = []; + } + + /** + * Gets the list of trusted host patterns. + * + * @return string[] + */ + public static function getTrustedHosts(): array + { + return self::$trustedHostPatterns; + } + + /** + * Normalizes a query string. + * + * It builds a normalized query string, where keys/value pairs are alphabetized, + * have consistent escaping and unneeded delimiters are removed. + */ + public static function normalizeQueryString(?string $qs): string + { + if ('' === ($qs ?? '')) { + return ''; + } + + $qs = HeaderUtils::parseQuery($qs); + ksort($qs); + + return http_build_query($qs, '', '&', \PHP_QUERY_RFC3986); + } + + /** + * Enables support for the _method request parameter to determine the intended HTTP method. + * + * Be warned that enabling this feature might lead to CSRF issues in your code. + * Check that you are using CSRF tokens when required. + * If the HTTP method parameter override is enabled, an html-form with method "POST" can be altered + * and used to send a "PUT" or "DELETE" request via the _method request parameter. + * If these methods are not protected against CSRF, this presents a possible vulnerability. + * + * The HTTP method can only be overridden when the real HTTP method is POST. + */ + public static function enableHttpMethodParameterOverride(): void + { + self::$httpMethodParameterOverride = true; + } + + /** + * Checks whether support for the _method request parameter is enabled. + */ + public static function getHttpMethodParameterOverride(): bool + { + return self::$httpMethodParameterOverride; + } + + /** + * Gets a "parameter" value from any bag. + * + * This method is mainly useful for libraries that want to provide some flexibility. If you don't need the + * flexibility in controllers, it is better to explicitly get request parameters from the appropriate + * public property instead (attributes, query, request). + * + * Order of precedence: PATH (routing placeholders or custom attributes), GET, POST + * + * @internal use explicit input sources instead + */ + public function get(string $key, mixed $default = null): mixed + { + if ($this !== $result = $this->attributes->get($key, $this)) { + return $result; + } + + if ($this->query->has($key)) { + return $this->query->all()[$key]; + } + + if ($this->request->has($key)) { + return $this->request->all()[$key]; + } + + return $default; + } + + /** + * Gets the Session. + * + * @throws SessionNotFoundException When session is not set properly + */ + public function getSession(): SessionInterface + { + $session = $this->session; + if (!$session instanceof SessionInterface && null !== $session) { + $this->setSession($session = $session()); + } + + if (null === $session) { + throw new SessionNotFoundException('Session has not been set.'); + } + + return $session; + } + + /** + * Whether the request contains a Session which was started in one of the + * previous requests. + */ + public function hasPreviousSession(): bool + { + // the check for $this->session avoids malicious users trying to fake a session cookie with proper name + return $this->hasSession() && $this->cookies->has($this->getSession()->getName()); + } + + /** + * Whether the request contains a Session object. + * + * This method does not give any information about the state of the session object, + * like whether the session is started or not. It is just a way to check if this Request + * is associated with a Session instance. + * + * @param bool $skipIfUninitialized When true, ignores factories injected by `setSessionFactory` + */ + public function hasSession(bool $skipIfUninitialized = false): bool + { + return null !== $this->session && (!$skipIfUninitialized || $this->session instanceof SessionInterface); + } + + public function setSession(SessionInterface $session): void + { + $this->session = $session; + } + + /** + * @internal + * + * @param callable(): SessionInterface $factory + */ + public function setSessionFactory(callable $factory): void + { + $this->session = $factory(...); + } + + /** + * Returns the client IP addresses. + * + * In the returned array the most trusted IP address is first, and the + * least trusted one last. The "real" client IP address is the last one, + * but this is also the least trusted one. Trusted proxies are stripped. + * + * Use this method carefully; you should use getClientIp() instead. + * + * @see getClientIp() + */ + public function getClientIps(): array + { + $ip = $this->server->get('REMOTE_ADDR'); + + if (!$this->isFromTrustedProxy()) { + return [$ip]; + } + + return $this->getTrustedValues(self::HEADER_X_FORWARDED_FOR, $ip) ?: [$ip]; + } + + /** + * Returns the client IP address. + * + * This method can read the client IP address from the "X-Forwarded-For" header + * when trusted proxies were set via "setTrustedProxies()". The "X-Forwarded-For" + * header value is a comma+space separated list of IP addresses, the left-most + * being the original client, and each successive proxy that passed the request + * adding the IP address where it received the request from. + * + * If your reverse proxy uses a different header name than "X-Forwarded-For", + * ("Client-Ip" for instance), configure it via the $trustedHeaderSet + * argument of the Request::setTrustedProxies() method instead. + * + * @see getClientIps() + * @see https://wikipedia.org/wiki/X-Forwarded-For + */ + public function getClientIp(): ?string + { + return $this->getClientIps()[0]; + } + + /** + * Returns current script name. + */ + public function getScriptName(): string + { + return $this->server->get('SCRIPT_NAME', $this->server->get('ORIG_SCRIPT_NAME', '')); + } + + /** + * Returns the path being requested relative to the executed script. + * + * The path info always starts with a /. + * + * Suppose this request is instantiated from /mysite on localhost: + * + * * http://localhost/mysite returns an empty string + * * http://localhost/mysite/about returns '/about' + * * http://localhost/mysite/enco%20ded returns '/enco%20ded' + * * http://localhost/mysite/about?var=1 returns '/about' + * + * @return string The raw path (i.e. not urldecoded) + */ + public function getPathInfo(): string + { + return $this->pathInfo ??= $this->preparePathInfo(); + } + + /** + * Returns the root path from which this request is executed. + * + * Suppose that an index.php file instantiates this request object: + * + * * http://localhost/index.php returns an empty string + * * http://localhost/index.php/page returns an empty string + * * http://localhost/web/index.php returns '/web' + * * http://localhost/we%20b/index.php returns '/we%20b' + * + * @return string The raw path (i.e. not urldecoded) + */ + public function getBasePath(): string + { + return $this->basePath ??= $this->prepareBasePath(); + } + + /** + * Returns the root URL from which this request is executed. + * + * The base URL never ends with a /. + * + * This is similar to getBasePath(), except that it also includes the + * script filename (e.g. index.php) if one exists. + * + * @return string The raw URL (i.e. not urldecoded) + */ + public function getBaseUrl(): string + { + $trustedPrefix = ''; + + // the proxy prefix must be prepended to any prefix being needed at the webserver level + if ($this->isFromTrustedProxy() && $trustedPrefixValues = $this->getTrustedValues(self::HEADER_X_FORWARDED_PREFIX)) { + $trustedPrefix = rtrim($trustedPrefixValues[0], '/'); + } + + return $trustedPrefix.$this->getBaseUrlReal(); + } + + /** + * Returns the real base URL received by the webserver from which this request is executed. + * The URL does not include trusted reverse proxy prefix. + * + * @return string The raw URL (i.e. not urldecoded) + */ + private function getBaseUrlReal(): string + { + return $this->baseUrl ??= $this->prepareBaseUrl(); + } + + /** + * Gets the request's scheme. + */ + public function getScheme(): string + { + return $this->isSecure() ? 'https' : 'http'; + } + + /** + * Returns the port on which the request is made. + * + * This method can read the client port from the "X-Forwarded-Port" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Port" header must contain the client port. + * + * @return int|string|null Can be a string if fetched from the server bag + */ + public function getPort(): int|string|null + { + if ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_PORT)) { + $host = $host[0]; + } elseif ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_HOST)) { + $host = $host[0]; + } elseif (!$host = $this->headers->get('HOST')) { + return $this->server->get('SERVER_PORT'); + } + + if ('[' === $host[0]) { + $pos = strpos($host, ':', strrpos($host, ']')); + } else { + $pos = strrpos($host, ':'); + } + + if (false !== $pos && $port = substr($host, $pos + 1)) { + return (int) $port; + } + + return 'https' === $this->getScheme() ? 443 : 80; + } + + /** + * Returns the user. + */ + public function getUser(): ?string + { + return $this->headers->get('PHP_AUTH_USER'); + } + + /** + * Returns the password. + */ + public function getPassword(): ?string + { + return $this->headers->get('PHP_AUTH_PW'); + } + + /** + * Gets the user info. + * + * @return string|null A user name if any and, optionally, scheme-specific information about how to gain authorization to access the server + */ + public function getUserInfo(): ?string + { + $userinfo = $this->getUser(); + + $pass = $this->getPassword(); + if ('' != $pass) { + $userinfo .= ":$pass"; + } + + return $userinfo; + } + + /** + * Returns the HTTP host being requested. + * + * The port name will be appended to the host if it's non-standard. + */ + public function getHttpHost(): string + { + $scheme = $this->getScheme(); + $port = $this->getPort(); + + if (('http' === $scheme && 80 == $port) || ('https' === $scheme && 443 == $port)) { + return $this->getHost(); + } + + return $this->getHost().':'.$port; + } + + /** + * Returns the requested URI (path and query string). + * + * @return string The raw URI (i.e. not URI decoded) + */ + public function getRequestUri(): string + { + return $this->requestUri ??= $this->prepareRequestUri(); + } + + /** + * Gets the scheme and HTTP host. + * + * If the URL was called with basic authentication, the user + * and the password are not added to the generated string. + */ + public function getSchemeAndHttpHost(): string + { + return $this->getScheme().'://'.$this->getHttpHost(); + } + + /** + * Generates a normalized URI (URL) for the Request. + * + * @see getQueryString() + */ + public function getUri(): string + { + if (null !== $qs = $this->getQueryString()) { + $qs = '?'.$qs; + } + + return $this->getSchemeAndHttpHost().$this->getBaseUrl().$this->getPathInfo().$qs; + } + + /** + * Generates a normalized URI for the given path. + * + * @param string $path A path to use instead of the current one + */ + public function getUriForPath(string $path): string + { + return $this->getSchemeAndHttpHost().$this->getBaseUrl().$path; + } + + /** + * Returns the path as relative reference from the current Request path. + * + * Only the URIs path component (no schema, host etc.) is relevant and must be given. + * Both paths must be absolute and not contain relative parts. + * Relative URLs from one resource to another are useful when generating self-contained downloadable document archives. + * Furthermore, they can be used to reduce the link size in documents. + * + * Example target paths, given a base path of "/a/b/c/d": + * - "/a/b/c/d" -> "" + * - "/a/b/c/" -> "./" + * - "/a/b/" -> "../" + * - "/a/b/c/other" -> "other" + * - "/a/x/y" -> "../../x/y" + */ + public function getRelativeUriForPath(string $path): string + { + // be sure that we are dealing with an absolute path + if (!isset($path[0]) || '/' !== $path[0]) { + return $path; + } + + if ($path === $basePath = $this->getPathInfo()) { + return ''; + } + + $sourceDirs = explode('/', isset($basePath[0]) && '/' === $basePath[0] ? substr($basePath, 1) : $basePath); + $targetDirs = explode('/', substr($path, 1)); + array_pop($sourceDirs); + $targetFile = array_pop($targetDirs); + + foreach ($sourceDirs as $i => $dir) { + if (isset($targetDirs[$i]) && $dir === $targetDirs[$i]) { + unset($sourceDirs[$i], $targetDirs[$i]); + } else { + break; + } + } + + $targetDirs[] = $targetFile; + $path = str_repeat('../', \count($sourceDirs)).implode('/', $targetDirs); + + // A reference to the same base directory or an empty subdirectory must be prefixed with "./". + // This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used + // as the first segment of a relative-path reference, as it would be mistaken for a scheme name + // (see https://tools.ietf.org/html/rfc3986#section-4.2). + return !isset($path[0]) || '/' === $path[0] + || false !== ($colonPos = strpos($path, ':')) && ($colonPos < ($slashPos = strpos($path, '/')) || false === $slashPos) + ? "./$path" : $path; + } + + /** + * Generates the normalized query string for the Request. + * + * It builds a normalized query string, where keys/value pairs are alphabetized + * and have consistent escaping. + */ + public function getQueryString(): ?string + { + $qs = static::normalizeQueryString($this->server->get('QUERY_STRING')); + + return '' === $qs ? null : $qs; + } + + /** + * Checks whether the request is secure or not. + * + * This method can read the client protocol from the "X-Forwarded-Proto" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Proto" header must contain the protocol: "https" or "http". + */ + public function isSecure(): bool + { + if ($this->isFromTrustedProxy() && $proto = $this->getTrustedValues(self::HEADER_X_FORWARDED_PROTO)) { + return \in_array(strtolower($proto[0]), ['https', 'on', 'ssl', '1'], true); + } + + $https = $this->server->get('HTTPS'); + + return $https && 'off' !== strtolower($https); + } + + /** + * Returns the host name. + * + * This method can read the client host name from the "X-Forwarded-Host" header + * when trusted proxies were set via "setTrustedProxies()". + * + * The "X-Forwarded-Host" header must contain the client host name. + * + * @throws SuspiciousOperationException when the host name is invalid or not trusted + */ + public function getHost(): string + { + if ($this->isFromTrustedProxy() && $host = $this->getTrustedValues(self::HEADER_X_FORWARDED_HOST)) { + $host = $host[0]; + } elseif (!$host = $this->headers->get('HOST')) { + if (!$host = $this->server->get('SERVER_NAME')) { + $host = $this->server->get('SERVER_ADDR', ''); + } + } + + // trim and remove port number from host + // host is lowercase as per RFC 952/2181 + $host = strtolower(preg_replace('/:\d+$/', '', trim($host))); + + // as the host can come from the user (HTTP_HOST and depending on the configuration, SERVER_NAME too can come from the user) + // check that it does not contain forbidden characters (see RFC 952 and RFC 2181) + // use preg_replace() instead of preg_match() to prevent DoS attacks with long host names + if ($host && '' !== preg_replace('/(?:^\[)?[a-zA-Z0-9-:\]_]+\.?/', '', $host)) { + if (!$this->isHostValid) { + return ''; + } + $this->isHostValid = false; + + throw new SuspiciousOperationException(\sprintf('Invalid Host "%s".', $host)); + } + + if (\count(self::$trustedHostPatterns) > 0) { + // to avoid host header injection attacks, you should provide a list of trusted host patterns + + if (\in_array($host, self::$trustedHosts, true)) { + return $host; + } + + foreach (self::$trustedHostPatterns as $pattern) { + if (preg_match($pattern, $host)) { + self::$trustedHosts[] = $host; + + return $host; + } + } + + if (!$this->isHostValid) { + return ''; + } + $this->isHostValid = false; + + throw new SuspiciousOperationException(\sprintf('Untrusted Host "%s".', $host)); + } + + return $host; + } + + /** + * Sets the request method. + */ + public function setMethod(string $method): void + { + $this->method = null; + $this->server->set('REQUEST_METHOD', $method); + } + + /** + * Gets the request "intended" method. + * + * If the X-HTTP-Method-Override header is set, and if the method is a POST, + * then it is used to determine the "real" intended HTTP method. + * + * The _method request parameter can also be used to determine the HTTP method, + * but only if enableHttpMethodParameterOverride() has been called. + * + * The method is always an uppercased string. + * + * @see getRealMethod() + */ + public function getMethod(): string + { + if (null !== $this->method) { + return $this->method; + } + + $this->method = strtoupper($this->server->get('REQUEST_METHOD', 'GET')); + + if ('POST' !== $this->method) { + return $this->method; + } + + $method = $this->headers->get('X-HTTP-METHOD-OVERRIDE'); + + if (!$method && self::$httpMethodParameterOverride) { + $method = $this->request->get('_method', $this->query->get('_method', 'POST')); + } + + if (!\is_string($method)) { + return $this->method; + } + + $method = strtoupper($method); + + if (\in_array($method, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'CONNECT', 'OPTIONS', 'PATCH', 'PURGE', 'TRACE'], true)) { + return $this->method = $method; + } + + if (!preg_match('/^[A-Z]++$/D', $method)) { + throw new SuspiciousOperationException('Invalid HTTP method override.'); + } + + return $this->method = $method; + } + + /** + * Gets the "real" request method. + * + * @see getMethod() + */ + public function getRealMethod(): string + { + return strtoupper($this->server->get('REQUEST_METHOD', 'GET')); + } + + /** + * Gets the mime type associated with the format. + */ + public function getMimeType(string $format): ?string + { + if (null === static::$formats) { + static::initializeFormats(); + } + + return isset(static::$formats[$format]) ? static::$formats[$format][0] : null; + } + + /** + * Gets the mime types associated with the format. + * + * @return string[] + */ + public static function getMimeTypes(string $format): array + { + if (null === static::$formats) { + static::initializeFormats(); + } + + return static::$formats[$format] ?? []; + } + + /** + * Gets the format associated with the mime type. + */ + public function getFormat(?string $mimeType): ?string + { + $canonicalMimeType = null; + if ($mimeType && false !== $pos = strpos($mimeType, ';')) { + $canonicalMimeType = trim(substr($mimeType, 0, $pos)); + } + + if (null === static::$formats) { + static::initializeFormats(); + } + + foreach (static::$formats as $format => $mimeTypes) { + if (\in_array($mimeType, (array) $mimeTypes, true)) { + return $format; + } + if (null !== $canonicalMimeType && \in_array($canonicalMimeType, (array) $mimeTypes, true)) { + return $format; + } + } + + return null; + } + + /** + * Associates a format with mime types. + * + * @param string|string[] $mimeTypes The associated mime types (the preferred one must be the first as it will be used as the content type) + */ + public function setFormat(?string $format, string|array $mimeTypes): void + { + if (null === static::$formats) { + static::initializeFormats(); + } + + static::$formats[$format] = \is_array($mimeTypes) ? $mimeTypes : [$mimeTypes]; + } + + /** + * Gets the request format. + * + * Here is the process to determine the format: + * + * * format defined by the user (with setRequestFormat()) + * * _format request attribute + * * $default + * + * @see getPreferredFormat + */ + public function getRequestFormat(?string $default = 'html'): ?string + { + $this->format ??= $this->attributes->get('_format'); + + return $this->format ?? $default; + } + + /** + * Sets the request format. + */ + public function setRequestFormat(?string $format): void + { + $this->format = $format; + } + + /** + * Gets the usual name of the format associated with the request's media type (provided in the Content-Type header). + * + * @see Request::$formats + */ + public function getContentTypeFormat(): ?string + { + return $this->getFormat($this->headers->get('CONTENT_TYPE', '')); + } + + /** + * Sets the default locale. + */ + public function setDefaultLocale(string $locale): void + { + $this->defaultLocale = $locale; + + if (null === $this->locale) { + $this->setPhpDefaultLocale($locale); + } + } + + /** + * Get the default locale. + */ + public function getDefaultLocale(): string + { + return $this->defaultLocale; + } + + /** + * Sets the locale. + */ + public function setLocale(string $locale): void + { + $this->setPhpDefaultLocale($this->locale = $locale); + } + + /** + * Get the locale. + */ + public function getLocale(): string + { + return $this->locale ?? $this->defaultLocale; + } + + /** + * Checks if the request method is of specified type. + * + * @param string $method Uppercase request method (GET, POST etc) + */ + public function isMethod(string $method): bool + { + return $this->getMethod() === strtoupper($method); + } + + /** + * Checks whether or not the method is safe. + * + * @see https://tools.ietf.org/html/rfc7231#section-4.2.1 + */ + public function isMethodSafe(): bool + { + return \in_array($this->getMethod(), ['GET', 'HEAD', 'OPTIONS', 'TRACE']); + } + + /** + * Checks whether or not the method is idempotent. + */ + public function isMethodIdempotent(): bool + { + return \in_array($this->getMethod(), ['HEAD', 'GET', 'PUT', 'DELETE', 'TRACE', 'OPTIONS', 'PURGE']); + } + + /** + * Checks whether the method is cacheable or not. + * + * @see https://tools.ietf.org/html/rfc7231#section-4.2.3 + */ + public function isMethodCacheable(): bool + { + return \in_array($this->getMethod(), ['GET', 'HEAD']); + } + + /** + * Returns the protocol version. + * + * If the application is behind a proxy, the protocol version used in the + * requests between the client and the proxy and between the proxy and the + * server might be different. This returns the former (from the "Via" header) + * if the proxy is trusted (see "setTrustedProxies()"), otherwise it returns + * the latter (from the "SERVER_PROTOCOL" server parameter). + */ + public function getProtocolVersion(): ?string + { + if ($this->isFromTrustedProxy()) { + preg_match('~^(HTTP/)?([1-9]\.[0-9])\b~', $this->headers->get('Via') ?? '', $matches); + + if ($matches) { + return 'HTTP/'.$matches[2]; + } + } + + return $this->server->get('SERVER_PROTOCOL'); + } + + /** + * Returns the request body content. + * + * @param bool $asResource If true, a resource will be returned + * + * @return string|resource + * + * @psalm-return ($asResource is true ? resource : string) + */ + public function getContent(bool $asResource = false) + { + $currentContentIsResource = \is_resource($this->content); + + if (true === $asResource) { + if ($currentContentIsResource) { + rewind($this->content); + + return $this->content; + } + + // Content passed in parameter (test) + if (\is_string($this->content)) { + $resource = fopen('php://temp', 'r+'); + fwrite($resource, $this->content); + rewind($resource); + + return $resource; + } + + $this->content = false; + + return fopen('php://input', 'r'); + } + + if ($currentContentIsResource) { + rewind($this->content); + + return stream_get_contents($this->content); + } + + if (null === $this->content || false === $this->content) { + $this->content = file_get_contents('php://input'); + } + + return $this->content; + } + + /** + * Gets the decoded form or json request body. + * + * @throws JsonException When the body cannot be decoded to an array + */ + public function getPayload(): RequestInputParameters + { + if ($this->request->count()) { + return clone $this->request; + } + + if ('' === $content = $this->getContent()) { + return new RequestInputParameters([]); + } + + try { + $content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new JsonException('Could not decode request body.', $e->getCode(), $e); + } + + if (!\is_array($content)) { + throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); + } + + return new RequestInputParameters($content); + } + + /** + * Gets the request body decoded as array, typically from a JSON payload. + * + * @see getPayload() for portability between content types + * + * @throws JsonException When the body cannot be decoded to an array + */ + public function toArray(): array + { + if ('' === $content = $this->getContent()) { + throw new JsonException('Request body is empty.'); + } + + try { + $content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new JsonException('Could not decode request body.', $e->getCode(), $e); + } + + if (!\is_array($content)) { + throw new JsonException(\sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); + } + + return $content; + } + + /** + * Gets the Etags. + */ + public function getETags(): array + { + return preg_split('/\s*,\s*/', $this->headers->get('If-None-Match', ''), -1, \PREG_SPLIT_NO_EMPTY); + } + + public function isNoCache(): bool + { + return $this->headers->hasCacheControlDirective('no-cache') || 'no-cache' == $this->headers->get('Pragma'); + } + + /** + * Gets the preferred format for the response by inspecting, in the following order: + * * the request format set using setRequestFormat; + * * the values of the Accept HTTP header. + * + * Note that if you use this method, you should send the "Vary: Accept" header + * in the response to prevent any issues with intermediary HTTP caches. + */ + public function getPreferredFormat(?string $default = 'html'): ?string + { + if (!isset($this->preferredFormat) && null !== $preferredFormat = $this->getRequestFormat(null)) { + $this->preferredFormat = $preferredFormat; + } + + if ($this->preferredFormat ?? null) { + return $this->preferredFormat; + } + + foreach ($this->getAcceptableContentTypes() as $mimeType) { + if ($this->preferredFormat = $this->getFormat($mimeType)) { + return $this->preferredFormat; + } + } + + return $default; + } + + /** + * Returns the preferred language. + * + * @param string[] $locales An array of ordered available locales + */ + public function getPreferredLanguage(?array $locales = null): ?string + { + $preferredLanguages = $this->getLanguages(); + + if (!$locales) { + return $preferredLanguages[0] ?? null; + } + + $locales = array_map($this->formatLocale(...), $locales); + if (!$preferredLanguages) { + return $locales[0]; + } + + $combinations = array_merge(...array_map($this->getLanguageCombinations(...), $preferredLanguages)); + foreach ($combinations as $combination) { + foreach ($locales as $locale) { + if (str_starts_with($locale, $combination)) { + return $locale; + } + } + } + + return $locales[0]; + } + + /** + * Gets a list of languages acceptable by the client browser ordered in the user browser preferences. + * + * @return string[] + */ + public function getLanguages(): array + { + if (null !== $this->languages) { + return $this->languages; + } + + $languages = RequestHeaderAccept::fromString($this->headers->get('Accept-Language'))->all(); + $this->languages = []; + foreach ($languages as $item) { + $lang = $item->getValue(); + $this->languages[] = self::formatLocale($lang); + } + $this->languages = array_unique($this->languages); + + return $this->languages; + } + + /** + * Strips the locale to only keep the canonicalized language value. + * + * Depending on the $locale value, this method can return values like : + * - language_Script_REGION: "fr_Latn_FR", "zh_Hans_TW" + * - language_Script: "fr_Latn", "zh_Hans" + * - language_REGION: "fr_FR", "zh_TW" + * - language: "fr", "zh" + * + * Invalid locale values are returned as is. + * + * @see https://wikipedia.org/wiki/IETF_language_tag + * @see https://datatracker.ietf.org/doc/html/rfc5646 + */ + private static function formatLocale(string $locale): string + { + [$language, $script, $region] = self::getLanguageComponents($locale); + + return implode('_', array_filter([$language, $script, $region])); + } + + /** + * Returns an array of all possible combinations of the language components. + * + * For instance, if the locale is "fr_Latn_FR", this method will return: + * - "fr_Latn_FR" + * - "fr_Latn" + * - "fr_FR" + * - "fr" + * + * @return string[] + */ + private static function getLanguageCombinations(string $locale): array + { + [$language, $script, $region] = self::getLanguageComponents($locale); + + return array_unique([ + implode('_', array_filter([$language, $script, $region])), + implode('_', array_filter([$language, $script])), + implode('_', array_filter([$language, $region])), + $language, + ]); + } + + /** + * Returns an array with the language components of the locale. + * + * For example: + * - If the locale is "fr_Latn_FR", this method will return "fr", "Latn", "FR" + * - If the locale is "fr_FR", this method will return "fr", null, "FR" + * - If the locale is "zh_Hans", this method will return "zh", "Hans", null + * + * @see https://wikipedia.org/wiki/IETF_language_tag + * @see https://datatracker.ietf.org/doc/html/rfc5646 + * + * @return array{string, string|null, string|null} + */ + private static function getLanguageComponents(string $locale): array + { + $locale = str_replace('_', '-', strtolower($locale)); + $pattern = '/^([a-zA-Z]{2,3}|i-[a-zA-Z]{5,})(?:-([a-zA-Z]{4}))?(?:-([a-zA-Z]{2}))?(?:-(.+))?$/'; + if (!preg_match($pattern, $locale, $matches)) { + return [$locale, null, null]; + } + if (str_starts_with($matches[1], 'i-')) { + // Language not listed in ISO 639 that are not variants + // of any listed language, which can be registered with the + // i-prefix, such as i-cherokee + $matches[1] = substr($matches[1], 2); + } + + return [ + $matches[1], + isset($matches[2]) ? ucfirst(strtolower($matches[2])) : null, + isset($matches[3]) ? strtoupper($matches[3]) : null, + ]; + } + + /** + * Gets a list of charsets acceptable by the client browser in preferable order. + * + * @return string[] + */ + public function getCharsets(): array + { + return $this->charsets ??= array_map('strval', array_keys(RequestHeaderAccept::fromString($this->headers->get('Accept-Charset'))->all())); + } + + /** + * Gets a list of encodings acceptable by the client browser in preferable order. + * + * @return string[] + */ + public function getEncodings(): array + { + return $this->encodings ??= array_map('strval', array_keys(RequestHeaderAccept::fromString($this->headers->get('Accept-Encoding'))->all())); + } + + /** + * Gets a list of content types acceptable by the client browser in preferable order. + * + * @return string[] + */ + public function getAcceptableContentTypes(): array + { + return $this->acceptableContentTypes ??= array_map('strval', array_keys(RequestHeaderAccept::fromString($this->headers->get('Accept'))->all())); + } + + /** + * Returns true if the request is an XMLHttpRequest. + * + * It works if your JavaScript library sets an X-Requested-With HTTP header. + * It is known to work with common JavaScript frameworks: + * + * @see https://wikipedia.org/wiki/List_of_Ajax_frameworks#JavaScript + */ + public function isXmlHttpRequest(): bool + { + return 'XMLHttpRequest' == $this->headers->get('X-Requested-With'); + } + + /** + * Checks whether the client browser prefers safe content or not according to RFC8674. + * + * @see https://tools.ietf.org/html/rfc8674 + */ + public function preferSafeContent(): bool + { + if (isset($this->isSafeContentPreferred)) { + return $this->isSafeContentPreferred; + } + + if (!$this->isSecure()) { + // see https://tools.ietf.org/html/rfc8674#section-3 + return $this->isSafeContentPreferred = false; + } + + return $this->isSafeContentPreferred = RequestHeaderAccept::fromString($this->headers->get('Prefer'))->has('safe'); + } + + /* + * The following methods are derived from code of the Zend Framework (1.10dev - 2010-01-24) + * + * Code subject to the new BSD license (https://framework.zend.com/license). + * + * Copyright (c) 2005-2010 Zend Technologies USA Inc. (https://www.zend.com/) + */ + + protected function prepareRequestUri(): string + { + $requestUri = ''; + + if ($this->isIisRewrite() && '' != $this->server->get('UNENCODED_URL')) { + // IIS7 with URL Rewrite: make sure we get the unencoded URL (double slash problem) + $requestUri = $this->server->get('UNENCODED_URL'); + $this->server->remove('UNENCODED_URL'); + } elseif ($this->server->has('REQUEST_URI')) { + $requestUri = $this->server->get('REQUEST_URI'); + + if ('' !== $requestUri && '/' === $requestUri[0]) { + // To only use path and query remove the fragment. + if (false !== $pos = strpos($requestUri, '#')) { + $requestUri = substr($requestUri, 0, $pos); + } + } else { + // HTTP proxy reqs setup request URI with scheme and host [and port] + the URL path, + // only use URL path. + $uriComponents = parse_url($requestUri); + + if (isset($uriComponents['path'])) { + $requestUri = $uriComponents['path']; + } + + if (isset($uriComponents['query'])) { + $requestUri .= '?'.$uriComponents['query']; + } + } + } elseif ($this->server->has('ORIG_PATH_INFO')) { + // IIS 5.0, PHP as CGI + $requestUri = $this->server->get('ORIG_PATH_INFO'); + if ('' != $this->server->get('QUERY_STRING')) { + $requestUri .= '?'.$this->server->get('QUERY_STRING'); + } + $this->server->remove('ORIG_PATH_INFO'); + } + + // normalize the request URI to ease creating sub-requests from this request + $this->server->set('REQUEST_URI', $requestUri); + + return $requestUri; + } + + /** + * Prepares the base URL. + */ + protected function prepareBaseUrl(): string + { + $filename = basename($this->server->get('SCRIPT_FILENAME', '')); + + if (basename($this->server->get('SCRIPT_NAME', '')) === $filename) { + $baseUrl = $this->server->get('SCRIPT_NAME'); + } elseif (basename($this->server->get('PHP_SELF', '')) === $filename) { + $baseUrl = $this->server->get('PHP_SELF'); + } elseif (basename($this->server->get('ORIG_SCRIPT_NAME', '')) === $filename) { + $baseUrl = $this->server->get('ORIG_SCRIPT_NAME'); // 1and1 shared hosting compatibility + } else { + // Backtrack up the script_filename to find the portion matching + // php_self + $path = $this->server->get('PHP_SELF', ''); + $file = $this->server->get('SCRIPT_FILENAME', ''); + $segs = explode('/', trim($file, '/')); + $segs = array_reverse($segs); + $index = 0; + $last = \count($segs); + $baseUrl = ''; + do { + $seg = $segs[$index]; + $baseUrl = '/'.$seg.$baseUrl; + ++$index; + } while ($last > $index && (false !== $pos = strpos($path, $baseUrl)) && 0 != $pos); + } + + // Does the baseUrl have anything in common with the request_uri? + $requestUri = $this->getRequestUri(); + if ('' !== $requestUri && '/' !== $requestUri[0]) { + $requestUri = '/'.$requestUri; + } + + if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, $baseUrl)) { + // full $baseUrl matches + return $prefix; + } + + if ($baseUrl && null !== $prefix = $this->getUrlencodedPrefix($requestUri, rtrim(\dirname($baseUrl), '/'.\DIRECTORY_SEPARATOR).'/')) { + // directory portion of $baseUrl matches + return rtrim($prefix, '/'.\DIRECTORY_SEPARATOR); + } + + $truncatedRequestUri = $requestUri; + if (false !== $pos = strpos($requestUri, '?')) { + $truncatedRequestUri = substr($requestUri, 0, $pos); + } + + $basename = basename($baseUrl ?? ''); + if (!$basename || !strpos(rawurldecode($truncatedRequestUri), $basename)) { + // no match whatsoever; set it blank + return ''; + } + + // If using mod_rewrite or ISAPI_Rewrite strip the script filename + // out of baseUrl. $pos !== 0 makes sure it is not matching a value + // from PATH_INFO or QUERY_STRING + if (\strlen($requestUri) >= \strlen($baseUrl) && (false !== $pos = strpos($requestUri, $baseUrl)) && 0 !== $pos) { + $baseUrl = substr($requestUri, 0, $pos + \strlen($baseUrl)); + } + + return rtrim($baseUrl, '/'.\DIRECTORY_SEPARATOR); + } + + /** + * Prepares the base path. + */ + protected function prepareBasePath(): string + { + $baseUrl = $this->getBaseUrl(); + if (!$baseUrl) { + return ''; + } + + $filename = basename($this->server->get('SCRIPT_FILENAME')); + if (basename($baseUrl) === $filename) { + $basePath = \dirname($baseUrl); + } else { + $basePath = $baseUrl; + } + + if ('\\' === \DIRECTORY_SEPARATOR) { + $basePath = str_replace('\\', '/', $basePath); + } + + return rtrim($basePath, '/'); + } + + /** + * Prepares the path info. + */ + protected function preparePathInfo(): string + { + if (null === ($requestUri = $this->getRequestUri())) { + return '/'; + } + + // Remove the query string from REQUEST_URI + if (false !== $pos = strpos($requestUri, '?')) { + $requestUri = substr($requestUri, 0, $pos); + } + if ('' !== $requestUri && '/' !== $requestUri[0]) { + $requestUri = '/'.$requestUri; + } + + if (null === ($baseUrl = $this->getBaseUrlReal())) { + return $requestUri; + } + + $pathInfo = substr($requestUri, \strlen($baseUrl)); + if ('' === $pathInfo) { + // If substr() returns false then PATH_INFO is set to an empty string + return '/'; + } + + return $pathInfo; + } + + /** + * Initializes HTTP request formats. + */ + protected static function initializeFormats(): void + { + static::$formats = [ + 'html' => ['text/html', 'application/xhtml+xml'], + 'txt' => ['text/plain'], + 'js' => ['application/javascript', 'application/x-javascript', 'text/javascript'], + 'css' => ['text/css'], + 'json' => ['application/json', 'application/x-json'], + 'jsonld' => ['application/ld+json'], + 'xml' => ['text/xml', 'application/xml', 'application/x-xml'], + 'rdf' => ['application/rdf+xml'], + 'atom' => ['application/atom+xml'], + 'rss' => ['application/rss+xml'], + 'form' => ['application/x-www-form-urlencoded', 'multipart/form-data'], + ]; + } + + private function setPhpDefaultLocale(string $locale): void + { + // if either the class Locale doesn't exist, or an exception is thrown when + // setting the default locale, the intl module is not installed, and + // the call can be ignored: + try { + if (class_exists(\Locale::class, false)) { + \Locale::setDefault($locale); + } + } catch (\Exception) { + } + } + + /** + * Returns the prefix as encoded in the string when the string starts with + * the given prefix, null otherwise. + */ + private function getUrlencodedPrefix(string $string, string $prefix): ?string + { + if ($this->isIisRewrite()) { + // ISS with UrlRewriteModule might report SCRIPT_NAME/PHP_SELF with wrong case + // see https://github.com/php/php-src/issues/11981 + if (0 !== stripos(rawurldecode($string), $prefix)) { + return null; + } + } elseif (!str_starts_with(rawurldecode($string), $prefix)) { + return null; + } + + $len = \strlen($prefix); + + if (preg_match(\sprintf('#^(%%[[:xdigit:]]{2}|.){%d}#', $len), $string, $match)) { + return $match[0]; + } + + return null; + } + + private static function createRequestFromFactory(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null): static + { + if (self::$requestFactory) { + $request = (self::$requestFactory)($query, $request, $attributes, $cookies, $files, $server, $content); + + if (!$request instanceof self) { + throw new \LogicException('The Request factory must return an instance of Symfony\Component\HttpFoundation\Request.'); + } + + return $request; + } + + return new static($query, $request, $attributes, $cookies, $files, $server, $content); + } + + /** + * Indicates whether this request originated from a trusted proxy. + * + * This can be useful to determine whether or not to trust the + * contents of a proxy-specific header. + */ + public function isFromTrustedProxy(): bool + { + return self::$trustedProxies && IpUtils::checkIp($this->server->get('REMOTE_ADDR', ''), self::$trustedProxies); + } + + /** + * This method is rather heavy because it splits and merges headers, and it's called by many other methods such as + * getPort(), isSecure(), getHost(), getClientIps(), getBaseUrl() etc. Thus, we try to cache the results for + * best performance. + */ + private function getTrustedValues(int $type, ?string $ip = null): array + { + $cacheKey = $type."\0".((self::$trustedHeaderSet & $type) ? $this->headers->get(self::TRUSTED_HEADERS[$type]) : ''); + $cacheKey .= "\0".$ip."\0".$this->headers->get(self::TRUSTED_HEADERS[self::HEADER_FORWARDED]); + + if (isset($this->trustedValuesCache[$cacheKey])) { + return $this->trustedValuesCache[$cacheKey]; + } + + $clientValues = []; + $forwardedValues = []; + + if ((self::$trustedHeaderSet & $type) && $this->headers->has(self::TRUSTED_HEADERS[$type])) { + foreach (explode(',', $this->headers->get(self::TRUSTED_HEADERS[$type])) as $v) { + $clientValues[] = (self::HEADER_X_FORWARDED_PORT === $type ? '0.0.0.0:' : '').trim($v); + } + } + + if ((self::$trustedHeaderSet & self::HEADER_FORWARDED) && (isset(self::FORWARDED_PARAMS[$type])) && $this->headers->has(self::TRUSTED_HEADERS[self::HEADER_FORWARDED])) { + $forwarded = $this->headers->get(self::TRUSTED_HEADERS[self::HEADER_FORWARDED]); + $parts = HeaderUtils::split($forwarded, ',;='); + $param = self::FORWARDED_PARAMS[$type]; + foreach ($parts as $subParts) { + if (null === $v = HeaderUtils::combine($subParts)[$param] ?? null) { + continue; + } + if (self::HEADER_X_FORWARDED_PORT === $type) { + if (str_ends_with($v, ']') || false === $v = strrchr($v, ':')) { + $v = $this->isSecure() ? ':443' : ':80'; + } + $v = '0.0.0.0'.$v; + } + $forwardedValues[] = $v; + } + } + + if (null !== $ip) { + $clientValues = $this->normalizeAndFilterClientIps($clientValues, $ip); + $forwardedValues = $this->normalizeAndFilterClientIps($forwardedValues, $ip); + } + + if ($forwardedValues === $clientValues || !$clientValues) { + return $this->trustedValuesCache[$cacheKey] = $forwardedValues; + } + + if (!$forwardedValues) { + return $this->trustedValuesCache[$cacheKey] = $clientValues; + } + + if (!$this->isForwardedValid) { + return $this->trustedValuesCache[$cacheKey] = null !== $ip ? ['0.0.0.0', $ip] : []; + } + $this->isForwardedValid = false; + + throw new ConflictingHeadersException(\sprintf('The request has both a trusted "%s" header and a trusted "%s" header, conflicting with each other. You should either configure your proxy to remove one of them, or configure your project to distrust the offending one.', self::TRUSTED_HEADERS[self::HEADER_FORWARDED], self::TRUSTED_HEADERS[$type])); + } + + private function normalizeAndFilterClientIps(array $clientIps, string $ip): array + { + if (!$clientIps) { + return []; + } + $clientIps[] = $ip; // Complete the IP chain with the IP the request actually came from + $firstTrustedIp = null; + + foreach ($clientIps as $key => $clientIp) { + if (strpos($clientIp, '.')) { + // Strip :port from IPv4 addresses. This is allowed in Forwarded + // and may occur in X-Forwarded-For. + $i = strpos($clientIp, ':'); + if ($i) { + $clientIps[$key] = $clientIp = substr($clientIp, 0, $i); + } + } elseif (str_starts_with($clientIp, '[')) { + // Strip brackets and :port from IPv6 addresses. + $i = strpos($clientIp, ']', 1); + $clientIps[$key] = $clientIp = substr($clientIp, 1, $i - 1); + } + + if (!filter_var($clientIp, \FILTER_VALIDATE_IP)) { + unset($clientIps[$key]); + + continue; + } + + if (IpUtils::checkIp($clientIp, self::$trustedProxies)) { + unset($clientIps[$key]); + + // Fallback to this when the client IP falls into the range of trusted proxies + $firstTrustedIp ??= $clientIp; + } + } + + // Now the IP chain contains only untrusted proxies and the client IP + return $clientIps ? array_reverse($clientIps) : [$firstTrustedIp]; + } + + /** + * Is this IIS with UrlRewriteModule? + * + * This method consumes, caches and removed the IIS_WasUrlRewritten env var, + * so we don't inherit it to sub-requests. + */ + private function isIisRewrite(): bool + { + if (1 === $this->server->getInt('IIS_WasUrlRewritten')) { + $this->isIisRewrite = true; + $this->server->remove('IIS_WasUrlRewritten'); + } + + return $this->isIisRewrite; + } +} diff --git a/core/lib/Http/Request/RequestFileCollection.php b/core/lib/Http/Request/RequestFileCollection.php new file mode 100644 index 0000000..a4c64cc --- /dev/null +++ b/core/lib/Http/Request/RequestFileCollection.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\File\UploadedFile; + +/** + * FileBag is a container for uploaded files. + * + * @author Fabien Potencier + * @author Bulat Shakirzyanov + */ +class RequestFileCollection extends RequestParameters +{ + private const FILE_KEYS = ['error', 'full_path', 'name', 'size', 'tmp_name', 'type']; + + /** + * @param array|UploadedFile[] $parameters An array of HTTP files + */ + public function __construct(array $parameters = []) + { + $this->replace($parameters); + } + + public function replace(array $files = []): void + { + $this->parameters = []; + $this->add($files); + } + + public function set(string $key, mixed $value): void + { + if (!\is_array($value) && !$value instanceof UploadedFile) { + throw new \InvalidArgumentException('An uploaded file must be an array or an instance of UploadedFile.'); + } + + parent::set($key, $this->convertFileInformation($value)); + } + + public function add(array $files = []): void + { + foreach ($files as $key => $file) { + $this->set($key, $file); + } + } + + /** + * Converts uploaded files to UploadedFile instances. + * + * @return UploadedFile[]|UploadedFile|null + */ + protected function convertFileInformation(array|UploadedFile $file): array|UploadedFile|null + { + if ($file instanceof UploadedFile) { + return $file; + } + + $file = $this->fixPhpFilesArray($file); + $keys = array_keys($file + ['full_path' => null]); + sort($keys); + + if (self::FILE_KEYS === $keys) { + if (\UPLOAD_ERR_NO_FILE === $file['error']) { + $file = null; + } else { + $file = new UploadedFile($file['tmp_name'], $file['full_path'] ?? $file['name'], $file['type'], $file['error'], false); + } + } else { + $file = array_map(fn ($v) => $v instanceof UploadedFile || \is_array($v) ? $this->convertFileInformation($v) : $v, $file); + if (array_is_list($file)) { + $file = array_filter($file); + } + } + + return $file; + } + + /** + * Fixes a malformed PHP $_FILES array. + * + * PHP has a bug that the format of the $_FILES array differs, depending on + * whether the uploaded file fields had normal field names or array-like + * field names ("normal" vs. "parent[child]"). + * + * This method fixes the array to look like the "normal" $_FILES array. + * + * It's safe to pass an already converted array, in which case this method + * just returns the original array unmodified. + */ + protected function fixPhpFilesArray(array $data): array + { + $keys = array_keys($data + ['full_path' => null]); + sort($keys); + + if (self::FILE_KEYS !== $keys || !isset($data['name']) || !\is_array($data['name'])) { + return $data; + } + + $files = $data; + foreach (self::FILE_KEYS as $k) { + unset($files[$k]); + } + + foreach ($data['name'] as $key => $name) { + $files[$key] = $this->fixPhpFilesArray([ + 'error' => $data['error'][$key], + 'name' => $name, + 'type' => $data['type'][$key], + 'tmp_name' => $data['tmp_name'][$key], + 'size' => $data['size'][$key], + ] + (isset($data['full_path'][$key]) ? [ + 'full_path' => $data['full_path'][$key], + ] : [])); + } + + return $files; + } +} diff --git a/core/lib/Http/Request/RequestHeaderAccept.php b/core/lib/Http/Request/RequestHeaderAccept.php new file mode 100644 index 0000000..47746d3 --- /dev/null +++ b/core/lib/Http/Request/RequestHeaderAccept.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\HeaderUtils; + +// Help opcache.preload discover always-needed symbols +class_exists(RequestHeaderAcceptItem::class); + +/** + * Represents an Accept-* header. + * + * An accept header is compound with a list of items, + * sorted by descending quality. + * + * @author Jean-François Simon + */ +class RequestHeaderAccept +{ + /** + * @var RequestHeaderAcceptItem[] + */ + private array $items = []; + + private bool $sorted = true; + + /** + * @param RequestHeaderAcceptItem[] $items + */ + public function __construct(array $items) + { + foreach ($items as $item) { + $this->add($item); + } + } + + /** + * Builds an AcceptHeader instance from a string. + */ + public static function fromString(?string $headerValue): self + { + $parts = HeaderUtils::split($headerValue ?? '', ',;='); + + return new self(array_map(function ($subParts) { + static $index = 0; + $part = array_shift($subParts); + $attributes = HeaderUtils::combine($subParts); + + $item = new RequestHeaderAcceptItem($part[0], $attributes); + $item->setIndex($index++); + + return $item; + }, $parts)); + } + + /** + * Returns header value's string representation. + */ + public function __toString(): string + { + return implode(',', $this->items); + } + + /** + * Tests if header has given value. + */ + public function has(string $value): bool + { + return isset($this->items[$value]); + } + + /** + * Returns given value's item, if exists. + */ + public function get(string $value): ?RequestHeaderAcceptItem + { + return $this->items[$value] ?? $this->items[explode('/', $value)[0].'/*'] ?? $this->items['*/*'] ?? $this->items['*'] ?? null; + } + + /** + * Adds an item. + * + * @return $this + */ + public function add(RequestHeaderAcceptItem $item): static + { + $this->items[$item->getValue()] = $item; + $this->sorted = false; + + return $this; + } + + /** + * Returns all items. + * + * @return RequestHeaderAcceptItem[] + */ + public function all(): array + { + $this->sort(); + + return $this->items; + } + + /** + * Filters items on their value using given regex. + */ + public function filter(string $pattern): self + { + return new self(array_filter($this->items, fn (RequestHeaderAcceptItem $item) => preg_match($pattern, $item->getValue()))); + } + + /** + * Returns first item. + */ + public function first(): ?RequestHeaderAcceptItem + { + $this->sort(); + + return $this->items ? reset($this->items) : null; + } + + /** + * Sorts items by descending quality. + */ + private function sort(): void + { + if (!$this->sorted) { + uasort($this->items, function (RequestHeaderAcceptItem $a, RequestHeaderAcceptItem $b) { + $qA = $a->getQuality(); + $qB = $b->getQuality(); + + if ($qA === $qB) { + return $a->getIndex() > $b->getIndex() ? 1 : -1; + } + + return $qA > $qB ? -1 : 1; + }); + + $this->sorted = true; + } + } +} diff --git a/core/lib/Http/Request/RequestHeaderAcceptItem.php b/core/lib/Http/Request/RequestHeaderAcceptItem.php new file mode 100644 index 0000000..6455ffe --- /dev/null +++ b/core/lib/Http/Request/RequestHeaderAcceptItem.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\HeaderUtils; + +/** + * Represents an Accept-* header item. + * + * @author Jean-François Simon + */ +class RequestHeaderAcceptItem +{ + private float $quality = 1.0; + private int $index = 0; + private array $attributes = []; + + public function __construct( + private string $value, + array $attributes = [], + ) { + foreach ($attributes as $name => $value) { + $this->setAttribute($name, $value); + } + } + + /** + * Builds an AcceptHeaderInstance instance from a string. + */ + public static function fromString(?string $itemValue): self + { + $parts = HeaderUtils::split($itemValue ?? '', ';='); + + $part = array_shift($parts); + $attributes = HeaderUtils::combine($parts); + + return new self($part[0], $attributes); + } + + /** + * Returns header value's string representation. + */ + public function __toString(): string + { + $string = $this->value.($this->quality < 1 ? ';q='.$this->quality : ''); + if (\count($this->attributes) > 0) { + $string .= '; '.HeaderUtils::toString($this->attributes, ';'); + } + + return $string; + } + + /** + * Set the item value. + * + * @return $this + */ + public function setValue(string $value): static + { + $this->value = $value; + + return $this; + } + + /** + * Returns the item value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Set the item quality. + * + * @return $this + */ + public function setQuality(float $quality): static + { + $this->quality = $quality; + + return $this; + } + + /** + * Returns the item quality. + */ + public function getQuality(): float + { + return $this->quality; + } + + /** + * Set the item index. + * + * @return $this + */ + public function setIndex(int $index): static + { + $this->index = $index; + + return $this; + } + + /** + * Returns the item index. + */ + public function getIndex(): int + { + return $this->index; + } + + /** + * Tests if an attribute exists. + */ + public function hasAttribute(string $name): bool + { + return isset($this->attributes[$name]); + } + + /** + * Returns an attribute by its name. + */ + public function getAttribute(string $name, mixed $default = null): mixed + { + return $this->attributes[$name] ?? $default; + } + + /** + * Returns all attributes. + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Set an attribute. + * + * @return $this + */ + public function setAttribute(string $name, string $value): static + { + if ('q' === $name) { + $this->quality = (float) $value; + } else { + $this->attributes[$name] = $value; + } + + return $this; + } +} diff --git a/core/lib/Http/Request/RequestHeaderParameters.php b/core/lib/Http/Request/RequestHeaderParameters.php new file mode 100644 index 0000000..3f3e4bd --- /dev/null +++ b/core/lib/Http/Request/RequestHeaderParameters.php @@ -0,0 +1,12 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\Exception\BadRequestException; +use KTXC\Http\Exception\UnexpectedValueException; + +/** + * InputBag is a container for user input values such as $_GET, $_POST, $_REQUEST, and $_COOKIE. + * + * @author Saif Eddin Gmati + */ +final class RequestInputParameters extends RequestParameters +{ + /** + * Returns an input value by name (scalar, Stringable, or array). + * + * Arrays are now allowed. (Previously only scalar values were permitted.) + * No deep validation of array contents is performed here; callers should + * sanitize nested values as needed. + * + * @param string|int|float|bool|array|null $default The default value if the key does not exist + * + * @return string|int|float|bool|array|null + * + * @throws BadRequestException if the stored input value is of an unsupported type + * @throws \InvalidArgumentException if the provided default is of an unsupported type + */ + public function get(string $key, mixed $default = null): string|int|float|bool|array|null + { + if (null !== $default && !\is_scalar($default) && !$default instanceof \Stringable && !\is_array($default)) { + throw new \InvalidArgumentException(\sprintf('Expected a scalar or array value as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($default))); + } + + $value = parent::get($key, $this); + + if (null !== $value && $this !== $value && !\is_scalar($value) && !$value instanceof \Stringable && !\is_array($value)) { + throw new BadRequestException(\sprintf('Input value "%s" contains an invalid (non-scalar, non-array, non-Stringable) value.', $key)); + } + + return $this === $value ? $default : $value; + } + + /** + * Replaces the current input values by a new set. + */ + public function replace(array $inputs = []): void + { + $this->parameters = []; + $this->add($inputs); + } + + /** + * Adds input values. + */ + public function add(array $inputs = []): void + { + foreach ($inputs as $input => $value) { + $this->set($input, $value); + } + } + + /** + * Sets an input by name. + * + * @param string|int|float|bool|array|null $value + */ + public function set(string $key, mixed $value): void + { + if (null !== $value && !\is_scalar($value) && !\is_array($value) && !$value instanceof \Stringable) { + throw new \InvalidArgumentException(\sprintf('Expected a scalar, or an array as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($value))); + } + + $this->parameters[$key] = $value; + } + + /** + * Returns the parameter value converted to an enum. + * + * @template T of \BackedEnum + * + * @param class-string $class + * @param ?T $default + * + * @return ?T + * + * @psalm-return ($default is null ? T|null : T) + * + * @throws BadRequestException if the input cannot be converted to an enum + */ + public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum + { + try { + return parent::getEnum($key, $class, $default); + } catch (UnexpectedValueException $e) { + throw new BadRequestException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Returns the parameter value converted to string. + * + * @throws BadRequestException if the input contains a non-scalar value + */ + public function getString(string $key, string $default = ''): string + { + // Shortcuts the parent method because the validation on scalar is already done in get(). + return (string) $this->get($key, $default); + } + + /** + * @throws BadRequestException if the input value is an array and \FILTER_REQUIRE_ARRAY or \FILTER_FORCE_ARRAY is not set + * @throws BadRequestException if the input value is invalid and \FILTER_NULL_ON_FAILURE is not set + */ + public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed + { + $value = $this->has($key) ? $this->all()[$key] : $default; + + // Always turn $options into an array - this allows filter_var option shortcuts. + if (!\is_array($options) && $options) { + $options = ['flags' => $options]; + } + + if (\is_array($value) && !(($options['flags'] ?? 0) & (\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY))) { + throw new BadRequestException(\sprintf('Input value "%s" contains an array, but "FILTER_REQUIRE_ARRAY" or "FILTER_FORCE_ARRAY" flags were not set.', $key)); + } + + if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) { + throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); + } + + $options['flags'] ??= 0; + $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + + $value = filter_var($value, $filter, $options); + + if (null !== $value || $nullOnFailure) { + return $value; + } + + throw new BadRequestException(\sprintf('Input value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); + } +} diff --git a/core/lib/Http/Request/RequestParameters.php b/core/lib/Http/Request/RequestParameters.php new file mode 100644 index 0000000..2ed484c --- /dev/null +++ b/core/lib/Http/Request/RequestParameters.php @@ -0,0 +1,260 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +use KTXC\Http\Exception\BadRequestException; +use KTXC\Http\Exception\UnexpectedValueException; + +/** + * ParameterBag is a container for key/value pairs. + * + * @author Fabien Potencier + * + * @implements \IteratorAggregate + */ +class RequestParameters implements \IteratorAggregate, \Countable +{ + public function __construct( + protected array $parameters = [], + ) { + } + + /** + * Returns the parameters. + * + * @param string|null $key The name of the parameter to return or null to get them all + * + * @throws BadRequestException if the value is not an array + */ + public function all(?string $key = null): array + { + if (null === $key) { + return $this->parameters; + } + + if (!\is_array($value = $this->parameters[$key] ?? [])) { + throw new BadRequestException(\sprintf('Unexpected value for parameter "%s": expecting "array", got "%s".', $key, get_debug_type($value))); + } + + return $value; + } + + /** + * Returns the parameter keys. + */ + public function keys(): array + { + return array_keys($this->parameters); + } + + /** + * Replaces the current parameters by a new set. + */ + public function replace(array $parameters = []): void + { + $this->parameters = $parameters; + } + + /** + * Adds parameters. + */ + public function add(array $parameters = []): void + { + $this->parameters = array_replace($this->parameters, $parameters); + } + + public function get(string $key, mixed $default = null): mixed + { + return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default; + } + + public function set(string $key, mixed $value): void + { + $this->parameters[$key] = $value; + } + + /** + * Returns true if the parameter is defined. + */ + public function has(string $key): bool + { + return \array_key_exists($key, $this->parameters); + } + + /** + * Removes a parameter. + */ + public function remove(string $key): void + { + unset($this->parameters[$key]); + } + + /** + * Returns the alphabetic characters of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string + */ + public function getAlpha(string $key, string $default = ''): string + { + return preg_replace('/[^[:alpha:]]/', '', $this->getString($key, $default)); + } + + /** + * Returns the alphabetic characters and digits of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string + */ + public function getAlnum(string $key, string $default = ''): string + { + return preg_replace('/[^[:alnum:]]/', '', $this->getString($key, $default)); + } + + /** + * Returns the digits of the parameter value. + * + * @throws UnexpectedValueException if the value cannot be converted to string + */ + public function getDigits(string $key, string $default = ''): string + { + return preg_replace('/[^[:digit:]]/', '', $this->getString($key, $default)); + } + + /** + * Returns the parameter as string. + * + * @throws UnexpectedValueException if the value cannot be converted to string + */ + public function getString(string $key, string $default = ''): string + { + $value = $this->get($key, $default); + if (!\is_scalar($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be converted to "string".', $key)); + } + + return (string) $value; + } + + /** + * Returns the parameter value converted to integer. + * + * @throws UnexpectedValueException if the value cannot be converted to integer + */ + public function getInt(string $key, int $default = 0): int + { + return $this->filter($key, $default, \FILTER_VALIDATE_INT, ['flags' => \FILTER_REQUIRE_SCALAR]); + } + + /** + * Returns the parameter value converted to boolean. + * + * @throws UnexpectedValueException if the value cannot be converted to a boolean + */ + public function getBoolean(string $key, bool $default = false): bool + { + return $this->filter($key, $default, \FILTER_VALIDATE_BOOL, ['flags' => \FILTER_REQUIRE_SCALAR]); + } + + /** + * Returns the parameter value converted to an enum. + * + * @template T of \BackedEnum + * + * @param class-string $class + * @param ?T $default + * + * @return ?T + * + * @psalm-return ($default is null ? T|null : T) + * + * @throws UnexpectedValueException if the parameter value cannot be converted to an enum + */ + public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum + { + $value = $this->get($key); + + if (null === $value) { + return $default; + } + + try { + return $class::from($value); + } catch (\ValueError|\TypeError $e) { + throw new UnexpectedValueException(\sprintf('Parameter "%s" cannot be converted to enum: %s.', $key, $e->getMessage()), $e->getCode(), $e); + } + } + + /** + * Filter key. + * + * @param int $filter FILTER_* constant + * @param int|array{flags?: int, options?: array} $options Flags from FILTER_* constants + * + * @see https://php.net/filter-var + * + * @throws UnexpectedValueException if the parameter value is a non-stringable object + * @throws UnexpectedValueException if the parameter value is invalid and \FILTER_NULL_ON_FAILURE is not set + */ + public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed + { + $value = $this->get($key, $default); + + // Always turn $options into an array - this allows filter_var option shortcuts. + if (!\is_array($options) && $options) { + $options = ['flags' => $options]; + } + + // Add a convenience check for arrays. + if (\is_array($value) && !isset($options['flags'])) { + $options['flags'] = \FILTER_REQUIRE_ARRAY; + } + + if (\is_object($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be filtered.', $key)); + } + + if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) { + throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); + } + + $options['flags'] ??= 0; + $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + + $value = filter_var($value, $filter, $options); + + if (null !== $value || $nullOnFailure) { + return $value; + } + + throw new \UnexpectedValueException(\sprintf('Parameter value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key)); + } + + /** + * Returns an iterator for parameters. + * + * @return \ArrayIterator + */ + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->parameters); + } + + /** + * Returns the number of parameters. + */ + public function count(): int + { + return \count($this->parameters); + } +} diff --git a/core/lib/Http/Request/RequestServerParameters.php b/core/lib/Http/Request/RequestServerParameters.php new file mode 100644 index 0000000..66ba4b6 --- /dev/null +++ b/core/lib/Http/Request/RequestServerParameters.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Request; + +/** + * ServerBag is a container for HTTP headers from the $_SERVER variable. + * + * @author Fabien Potencier + * @author Bulat Shakirzyanov + * @author Robert Kiss + */ +class RequestServerParameters extends RequestParameters +{ + /** + * Gets the HTTP headers. + */ + public function getHeaders(): array + { + $headers = []; + foreach ($this->parameters as $key => $value) { + if (str_starts_with($key, 'HTTP_')) { + $headers[substr($key, 5)] = $value; + } elseif (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true) && '' !== $value) { + $headers[$key] = $value; + } + } + + if (isset($this->parameters['PHP_AUTH_USER'])) { + $headers['PHP_AUTH_USER'] = $this->parameters['PHP_AUTH_USER']; + $headers['PHP_AUTH_PW'] = $this->parameters['PHP_AUTH_PW'] ?? ''; + } else { + /* + * php-cgi under Apache does not pass HTTP Basic user/pass to PHP by default + * For this workaround to work, add these lines to your .htaccess file: + * RewriteCond %{HTTP:Authorization} .+ + * RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] + * + * A sample .htaccess file: + * RewriteEngine On + * RewriteCond %{HTTP:Authorization} .+ + * RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] + * RewriteCond %{REQUEST_FILENAME} !-f + * RewriteRule ^(.*)$ index.php [QSA,L] + */ + + $authorizationHeader = null; + if (isset($this->parameters['HTTP_AUTHORIZATION'])) { + $authorizationHeader = $this->parameters['HTTP_AUTHORIZATION']; + } elseif (isset($this->parameters['REDIRECT_HTTP_AUTHORIZATION'])) { + $authorizationHeader = $this->parameters['REDIRECT_HTTP_AUTHORIZATION']; + } + + if (null !== $authorizationHeader) { + if (0 === stripos($authorizationHeader, 'basic ')) { + // Decode AUTHORIZATION header into PHP_AUTH_USER and PHP_AUTH_PW when authorization header is basic + $exploded = explode(':', base64_decode(substr($authorizationHeader, 6)), 2); + if (2 == \count($exploded)) { + [$headers['PHP_AUTH_USER'], $headers['PHP_AUTH_PW']] = $exploded; + } + } elseif (empty($this->parameters['PHP_AUTH_DIGEST']) && (0 === stripos($authorizationHeader, 'digest '))) { + // In some circumstances PHP_AUTH_DIGEST needs to be set + $headers['PHP_AUTH_DIGEST'] = $authorizationHeader; + $this->parameters['PHP_AUTH_DIGEST'] = $authorizationHeader; + } elseif (0 === stripos($authorizationHeader, 'bearer ')) { + /* + * XXX: Since there is no PHP_AUTH_BEARER in PHP predefined variables, + * I'll just set $headers['AUTHORIZATION'] here. + * https://php.net/reserved.variables.server + */ + $headers['AUTHORIZATION'] = $authorizationHeader; + } + } + } + + if (isset($headers['AUTHORIZATION'])) { + return $headers; + } + + // PHP_AUTH_USER/PHP_AUTH_PW + if (isset($headers['PHP_AUTH_USER'])) { + $headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.($headers['PHP_AUTH_PW'] ?? '')); + } elseif (isset($headers['PHP_AUTH_DIGEST'])) { + $headers['AUTHORIZATION'] = $headers['PHP_AUTH_DIGEST']; + } + + return $headers; + } +} diff --git a/core/lib/Http/Response/FileResponse.php b/core/lib/Http/Response/FileResponse.php new file mode 100644 index 0000000..de3b389 --- /dev/null +++ b/core/lib/Http/Response/FileResponse.php @@ -0,0 +1,78 @@ +filePath = $filePath; + + // Determine content type (very small helper; rely on common extensions) + $mime = self::guessMimeType($filePath) ?? 'application/octet-stream'; + $headers['Content-Type'] = $headers['Content-Type'] ?? $mime; + $headers['Content-Length'] = (string) filesize($filePath); + $headers['Last-Modified'] = gmdate('D, d M Y H:i:s', filemtime($filePath)) . ' GMT'; + $headers['Cache-Control'] = $headers['Cache-Control'] ?? 'public, max-age=60'; + + parent::__construct('', $status, $headers); + + // Defer reading file until sendContent to avoid memory usage. + } + + public function getFilePath(): string + { + return $this->filePath; + } + + public function sendContent(): static + { + // Output file contents directly + readfile($this->filePath); + return $this; + } + + private static function guessMimeType(string $filePath): ?string + { + $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + return match ($ext) { + 'html', 'htm' => 'text/html; charset=UTF-8', + 'css' => 'text/css; charset=UTF-8', + 'js' => 'application/javascript; charset=UTF-8', + 'json' => 'application/json; charset=UTF-8', + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'svg' => 'image/svg+xml', + 'txt' => 'text/plain; charset=UTF-8', + 'xml' => 'application/xml; charset=UTF-8', + default => self::finfoMime($filePath), + }; + } + + private static function finfoMime(string $filePath): ?string + { + if (function_exists('finfo_open')) { + $f = finfo_open(FILEINFO_MIME_TYPE); + if ($f) { + $mime = finfo_file($f, $filePath) ?: null; + finfo_close($f); + return $mime; + } + } + return null; + } +} diff --git a/core/lib/Http/Response/JsonResponse.php b/core/lib/Http/Response/JsonResponse.php new file mode 100644 index 0000000..45c04de --- /dev/null +++ b/core/lib/Http/Response/JsonResponse.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +/** + * Response represents an HTTP response in JSON format. + * + * Note that this class does not force the returned JSON content to be an + * object. It is however recommended that you do return an object as it + * protects yourself against XSSI and JSON-JavaScript Hijacking. + * + * @see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/AJAX_Security_Cheat_Sheet.md#always-return-json-with-an-object-on-the-outside + * + * @author Igor Wiedler + */ +class JsonResponse extends Response +{ + protected mixed $data; + protected ?string $callback = null; + + // Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML. + // 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT + public const DEFAULT_ENCODING_OPTIONS = 15; + + protected int $encodingOptions = self::DEFAULT_ENCODING_OPTIONS; + + /** + * @param bool $json If the data is already a JSON string + */ + public function __construct(mixed $data = null, int $status = 200, array $headers = [], bool $json = false) + { + parent::__construct('', $status, $headers); + + if ($json && !\is_string($data) && !is_numeric($data) && !$data instanceof \Stringable) { + throw new \TypeError(\sprintf('"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', __METHOD__, get_debug_type($data))); + } + + $data ??= new \ArrayObject(); + + $json ? $this->setJson($data) : $this->setData($data); + } + + /** + * Factory method for chainability. + * + * Example: + * + * return JsonResponse::fromJsonString('{"key": "value"}') + * ->setSharedMaxAge(300); + * + * @param string $data The JSON response string + * @param int $status The response status code (200 "OK" by default) + * @param array $headers An array of response headers + */ + public static function fromJsonString(string $data, int $status = 200, array $headers = []): static + { + return new static($data, $status, $headers, true); + } + + /** + * Sets the JSONP callback. + * + * @param string|null $callback The JSONP callback or null to use none + * + * @return $this + * + * @throws \InvalidArgumentException When the callback name is not valid + */ + public function setCallback(?string $callback): static + { + if (null !== $callback) { + // partially taken from https://geekality.net/2011/08/03/valid-javascript-identifier/ + // partially taken from https://github.com/willdurand/JsonpCallbackValidator + // JsonpCallbackValidator is released under the MIT License. See https://github.com/willdurand/JsonpCallbackValidator/blob/v1.1.0/LICENSE for details. + // (c) William Durand + $pattern = '/^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\x{200C}\x{200D}]*(?:\[(?:"(?:\\\.|[^"\\\])*"|\'(?:\\\.|[^\'\\\])*\'|\d+)\])*?$/u'; + $reserved = [ + 'break', 'do', 'instanceof', 'typeof', 'case', 'else', 'new', 'var', 'catch', 'finally', 'return', 'void', 'continue', 'for', 'switch', 'while', + 'debugger', 'function', 'this', 'with', 'default', 'if', 'throw', 'delete', 'in', 'try', 'class', 'enum', 'extends', 'super', 'const', 'export', + 'import', 'implements', 'let', 'private', 'public', 'yield', 'interface', 'package', 'protected', 'static', 'null', 'true', 'false', + ]; + $parts = explode('.', $callback); + foreach ($parts as $part) { + if (!preg_match($pattern, $part) || \in_array($part, $reserved, true)) { + throw new \InvalidArgumentException('The callback name is not valid.'); + } + } + } + + $this->callback = $callback; + + return $this->update(); + } + + /** + * Sets a raw string containing a JSON document to be sent. + * + * @return $this + */ + public function setJson(string $json): static + { + $this->data = $json; + + return $this->update(); + } + + /** + * Sets the data to be sent as JSON. + * + * @return $this + * + * @throws \InvalidArgumentException + */ + public function setData(mixed $data = []): static + { + try { + $data = json_encode($data, $this->encodingOptions); + } catch (\Exception $e) { + if ('Exception' === $e::class && str_starts_with($e->getMessage(), 'Failed calling ')) { + throw $e->getPrevious() ?: $e; + } + throw $e; + } + + if (\JSON_THROW_ON_ERROR & $this->encodingOptions) { + return $this->setJson($data); + } + + if (\JSON_ERROR_NONE !== json_last_error()) { + throw new \InvalidArgumentException(json_last_error_msg()); + } + + return $this->setJson($data); + } + + /** + * Returns options used while encoding data to JSON. + */ + public function getEncodingOptions(): int + { + return $this->encodingOptions; + } + + /** + * Sets options used while encoding data to JSON. + * + * @return $this + */ + public function setEncodingOptions(int $encodingOptions): static + { + $this->encodingOptions = $encodingOptions; + + return $this->setData(json_decode($this->data)); + } + + /** + * Updates the content and headers according to the JSON data and callback. + * + * @return $this + */ + protected function update(): static + { + if (null !== $this->callback) { + // Not using application/javascript for compatibility reasons with older browsers. + $this->headers->set('Content-Type', 'text/javascript'); + + return $this->setContent(\sprintf('/**/%s(%s);', $this->callback, $this->data)); + } + + // Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback) + // in order to not overwrite a custom definition. + if (!$this->headers->has('Content-Type') || 'text/javascript' === $this->headers->get('Content-Type')) { + $this->headers->set('Content-Type', 'application/json'); + } + + return $this->setContent($this->data); + } +} diff --git a/core/lib/Http/Response/RedirectResponse.php b/core/lib/Http/Response/RedirectResponse.php new file mode 100644 index 0000000..7c6f802 --- /dev/null +++ b/core/lib/Http/Response/RedirectResponse.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +/** + * RedirectResponse represents an HTTP response doing a redirect. + * + * @author Fabien Potencier + */ +class RedirectResponse extends Response +{ + protected string $targetUrl; + + /** + * Creates a redirect response so that it conforms to the rules defined for a redirect status code. + * + * @param string $url The URL to redirect to. The URL should be a full URL, with schema etc., + * but practically every browser redirects on paths only as well + * @param int $status The HTTP status code (302 "Found" by default) + * @param array $headers The headers (Location is always set to the given URL) + * + * @throws \InvalidArgumentException + * + * @see https://tools.ietf.org/html/rfc2616#section-10.3 + */ + public function __construct(string $url, int $status = 302, array $headers = []) + { + parent::__construct('', $status, $headers); + + $this->setTargetUrl($url); + + if (!$this->isRedirect()) { + throw new \InvalidArgumentException(\sprintf('The HTTP status code is not a redirect ("%s" given).', $status)); + } + + if (301 == $status && !\array_key_exists('cache-control', array_change_key_case($headers, \CASE_LOWER))) { + $this->headers->remove('cache-control'); + } + } + + /** + * Returns the target URL. + */ + public function getTargetUrl(): string + { + return $this->targetUrl; + } + + /** + * Sets the redirect target of this response. + * + * @return $this + * + * @throws \InvalidArgumentException + */ + public function setTargetUrl(string $url): static + { + if ('' === $url) { + throw new \InvalidArgumentException('Cannot redirect to an empty URL.'); + } + + $this->targetUrl = $url; + + $this->setContent( + \sprintf(' + + + + + + Redirecting to %1$s + + + Redirecting to %1$s. + +', htmlspecialchars($url, \ENT_QUOTES, 'UTF-8'))); + + $this->headers->set('Location', $url); + $this->headers->set('Content-Type', 'text/html; charset=utf-8'); + + return $this; + } +} diff --git a/core/lib/Http/Response/Response.php b/core/lib/Http/Response/Response.php new file mode 100644 index 0000000..8ea97e7 --- /dev/null +++ b/core/lib/Http/Response/Response.php @@ -0,0 +1,1336 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +use KTXC\Http\Request\Request; + +// Help opcache.preload discover always-needed symbols +class_exists(ResponseHeaderParameters::class); + +/** + * Response represents an HTTP response. + * + * @author Fabien Potencier + */ +class Response +{ + public const HTTP_CONTINUE = 100; + public const HTTP_SWITCHING_PROTOCOLS = 101; + public const HTTP_PROCESSING = 102; // RFC2518 + public const HTTP_EARLY_HINTS = 103; // RFC8297 + public const HTTP_OK = 200; + public const HTTP_CREATED = 201; + public const HTTP_ACCEPTED = 202; + public const HTTP_NON_AUTHORITATIVE_INFORMATION = 203; + public const HTTP_NO_CONTENT = 204; + public const HTTP_RESET_CONTENT = 205; + public const HTTP_PARTIAL_CONTENT = 206; + public const HTTP_MULTI_STATUS = 207; // RFC4918 + public const HTTP_ALREADY_REPORTED = 208; // RFC5842 + public const HTTP_IM_USED = 226; // RFC3229 + public const HTTP_MULTIPLE_CHOICES = 300; + public const HTTP_MOVED_PERMANENTLY = 301; + public const HTTP_FOUND = 302; + public const HTTP_SEE_OTHER = 303; + public const HTTP_NOT_MODIFIED = 304; + public const HTTP_USE_PROXY = 305; + public const HTTP_RESERVED = 306; + public const HTTP_TEMPORARY_REDIRECT = 307; + public const HTTP_PERMANENTLY_REDIRECT = 308; // RFC7238 + public const HTTP_BAD_REQUEST = 400; + public const HTTP_UNAUTHORIZED = 401; + public const HTTP_PAYMENT_REQUIRED = 402; + public const HTTP_FORBIDDEN = 403; + public const HTTP_NOT_FOUND = 404; + public const HTTP_METHOD_NOT_ALLOWED = 405; + public const HTTP_NOT_ACCEPTABLE = 406; + public const HTTP_PROXY_AUTHENTICATION_REQUIRED = 407; + public const HTTP_REQUEST_TIMEOUT = 408; + public const HTTP_CONFLICT = 409; + public const HTTP_GONE = 410; + public const HTTP_LENGTH_REQUIRED = 411; + public const HTTP_PRECONDITION_FAILED = 412; + public const HTTP_REQUEST_ENTITY_TOO_LARGE = 413; + public const HTTP_REQUEST_URI_TOO_LONG = 414; + public const HTTP_UNSUPPORTED_MEDIA_TYPE = 415; + public const HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416; + public const HTTP_EXPECTATION_FAILED = 417; + public const HTTP_I_AM_A_TEAPOT = 418; // RFC2324 + public const HTTP_MISDIRECTED_REQUEST = 421; // RFC7540 + public const HTTP_UNPROCESSABLE_ENTITY = 422; // RFC4918 + public const HTTP_LOCKED = 423; // RFC4918 + public const HTTP_FAILED_DEPENDENCY = 424; // RFC4918 + public const HTTP_TOO_EARLY = 425; // RFC-ietf-httpbis-replay-04 + public const HTTP_UPGRADE_REQUIRED = 426; // RFC2817 + public const HTTP_PRECONDITION_REQUIRED = 428; // RFC6585 + public const HTTP_TOO_MANY_REQUESTS = 429; // RFC6585 + public const HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE = 431; // RFC6585 + public const HTTP_UNAVAILABLE_FOR_LEGAL_REASONS = 451; // RFC7725 + public const HTTP_INTERNAL_SERVER_ERROR = 500; + public const HTTP_NOT_IMPLEMENTED = 501; + public const HTTP_BAD_GATEWAY = 502; + public const HTTP_SERVICE_UNAVAILABLE = 503; + public const HTTP_GATEWAY_TIMEOUT = 504; + public const HTTP_VERSION_NOT_SUPPORTED = 505; + public const HTTP_VARIANT_ALSO_NEGOTIATES_EXPERIMENTAL = 506; // RFC2295 + public const HTTP_INSUFFICIENT_STORAGE = 507; // RFC4918 + public const HTTP_LOOP_DETECTED = 508; // RFC5842 + public const HTTP_NOT_EXTENDED = 510; // RFC2774 + public const HTTP_NETWORK_AUTHENTICATION_REQUIRED = 511; // RFC6585 + + /** + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + */ + private const HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES = [ + 'must_revalidate' => false, + 'no_cache' => false, + 'no_store' => false, + 'no_transform' => false, + 'public' => false, + 'private' => false, + 'proxy_revalidate' => false, + 'max_age' => true, + 's_maxage' => true, + 'stale_if_error' => true, // RFC5861 + 'stale_while_revalidate' => true, // RFC5861 + 'immutable' => false, + 'last_modified' => true, + 'etag' => true, + ]; + + /** + * Default security headers applied to all responses. + * Can be overridden by passing headers to the constructor or setting them after. + */ + private const DEFAULT_SECURITY_HEADERS = [ + 'X-Frame-Options' => 'DENY', + 'X-Content-Type-Options' => 'nosniff', + 'X-XSS-Protection' => '1; mode=block', + 'Referrer-Policy' => 'strict-origin-when-cross-origin', + 'Permissions-Policy' => 'geolocation=(), microphone=(), camera=()', + ]; + + public ResponseHeaderParameters $headers; + + protected string $content; + protected string $version; + protected int $statusCode; + protected string $statusText; + protected ?string $charset = null; + + /** + * Status codes translation table. + * + * The list of codes is complete according to the + * {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml Hypertext Transfer Protocol (HTTP) Status Code Registry} + * (last updated 2021-10-01). + * + * Unless otherwise noted, the status code is defined in RFC2616. + * + * @var array + */ + public static array $statusTexts = [ + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', // RFC2518 + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', // RFC4918 + 208 => 'Already Reported', // RFC5842 + 226 => 'IM Used', // RFC3229 + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', // RFC7238 + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Content Too Large', // RFC-ietf-httpbis-semantics + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 418 => 'I\'m a teapot', // RFC2324 + 421 => 'Misdirected Request', // RFC7540 + 422 => 'Unprocessable Content', // RFC-ietf-httpbis-semantics + 423 => 'Locked', // RFC4918 + 424 => 'Failed Dependency', // RFC4918 + 425 => 'Too Early', // RFC-ietf-httpbis-replay-04 + 426 => 'Upgrade Required', // RFC2817 + 428 => 'Precondition Required', // RFC6585 + 429 => 'Too Many Requests', // RFC6585 + 431 => 'Request Header Fields Too Large', // RFC6585 + 451 => 'Unavailable For Legal Reasons', // RFC7725 + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', // RFC2295 + 507 => 'Insufficient Storage', // RFC4918 + 508 => 'Loop Detected', // RFC5842 + 510 => 'Not Extended', // RFC2774 + 511 => 'Network Authentication Required', // RFC6585 + ]; + + /** + * Tracks headers already sent in informational responses. + */ + private array $sentHeaders; + + /** + * @param int $status The HTTP status code (200 "OK" by default) + * + * @throws \InvalidArgumentException When the HTTP status code is not valid + */ + public function __construct(?string $content = '', int $status = 200, array $headers = []) + { + // Merge default security headers with provided headers (provided headers take precedence) + $headers = array_merge(self::DEFAULT_SECURITY_HEADERS, $headers); + + $this->headers = new ResponseHeaderParameters($headers); + $this->setContent($content); + $this->setStatusCode($status); + $this->setProtocolVersion('1.0'); + } + + /** + * Returns the Response as an HTTP string. + * + * The string representation of the Response is the same as the + * one that will be sent to the client only if the prepare() method + * has been called before. + * + * @see prepare() + */ + public function __toString(): string + { + return + \sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText)."\r\n". + $this->headers."\r\n". + $this->getContent(); + } + + /** + * Clones the current Response instance. + */ + public function __clone() + { + $this->headers = clone $this->headers; + } + + /** + * Prepares the Response before it is sent to the client. + * + * This method tweaks the Response to ensure that it is + * compliant with RFC 2616. Most of the changes are based on + * the Request that is "associated" with this Response. + * + * @return $this + */ + public function prepare(Request $request): static + { + $headers = $this->headers; + + if ($this->isInformational() || $this->isEmpty()) { + $this->setContent(null); + $headers->remove('Content-Type'); + $headers->remove('Content-Length'); + // prevent PHP from sending the Content-Type header based on default_mimetype + ini_set('default_mimetype', ''); + } else { + // Content-type based on the Request + if (!$headers->has('Content-Type')) { + $format = $request->getRequestFormat(null); + if (null !== $format && $mimeType = $request->getMimeType($format)) { + $headers->set('Content-Type', $mimeType); + } + } + + // Fix Content-Type + $charset = $this->charset ?: 'UTF-8'; + if (!$headers->has('Content-Type')) { + $headers->set('Content-Type', 'text/html; charset='.$charset); + } elseif (0 === stripos($headers->get('Content-Type') ?? '', 'text/') && false === stripos($headers->get('Content-Type') ?? '', 'charset')) { + // add the charset + $headers->set('Content-Type', $headers->get('Content-Type').'; charset='.$charset); + } + + // Fix Content-Length + if ($headers->has('Transfer-Encoding')) { + $headers->remove('Content-Length'); + } + + if ($request->isMethod('HEAD')) { + // cf. RFC2616 14.13 + $length = $headers->get('Content-Length'); + $this->setContent(null); + if ($length) { + $headers->set('Content-Length', $length); + } + } + } + + // Fix protocol + if ('HTTP/1.0' != $request->server->get('SERVER_PROTOCOL')) { + $this->setProtocolVersion('1.1'); + } + + // Check if we need to send extra expire info headers + if ('1.0' == $this->getProtocolVersion() && str_contains($headers->get('Cache-Control', ''), 'no-cache')) { + $headers->set('pragma', 'no-cache'); + $headers->set('expires', (string)-1); + } + + $this->ensureIEOverSSLCompatibility($request); + + if ($request->isSecure()) { + foreach ($headers->getCookies() as $cookie) { + $cookie->setSecureDefault(true); + } + } + + return $this; + } + + /** + * Sends HTTP headers. + * + * @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null + * + * @return $this + */ + public function sendHeaders(?int $statusCode = null): static + { + // headers have already been sent by the developer + if (headers_sent()) { + return $this; + } + + $informationalResponse = $statusCode >= 100 && $statusCode < 200; + if ($informationalResponse && !\function_exists('headers_send')) { + // skip informational responses if not supported by the SAPI + return $this; + } + + // headers + foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) { + // As recommended by RFC 8297, PHP automatically copies headers from previous 103 responses, we need to deal with that if headers changed + $previousValues = $this->sentHeaders[$name] ?? null; + if ($previousValues === $values) { + // Header already sent in a previous response, it will be automatically copied in this response by PHP + continue; + } + + $replace = 0 === strcasecmp($name, 'Content-Type'); + + if (null !== $previousValues && array_diff($previousValues, $values)) { + header_remove($name); + $previousValues = null; + } + + $newValues = null === $previousValues ? $values : array_diff($values, $previousValues); + + foreach ($newValues as $value) { + header($name.': '.$value, $replace, $this->statusCode); + } + + if ($informationalResponse) { + $this->sentHeaders[$name] = $values; + } + } + + // cookies + foreach ($this->headers->getCookies() as $cookie) { + header('Set-Cookie: '.$cookie, false, $this->statusCode); + } + + if ($informationalResponse) { + headers_send($statusCode); + + return $this; + } + + $statusCode ??= $this->statusCode; + + // status + header(\sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode); + + return $this; + } + + /** + * Sends content for the current web response. + * + * @return $this + */ + public function sendContent(): static + { + echo $this->content; + + return $this; + } + + /** + * Sends HTTP headers and content. + * + * @param bool $flush Whether output buffers should be flushed + * + * @return $this + */ + public function send(bool $flush = true): static + { + $this->sendHeaders(); + $this->sendContent(); + + if (!$flush) { + return $this; + } + + if (\function_exists('fastcgi_finish_request')) { + fastcgi_finish_request(); + } elseif (\function_exists('litespeed_finish_request')) { + litespeed_finish_request(); + } elseif (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) { + static::closeOutputBuffers(0, true); + flush(); + } + + return $this; + } + + /** + * Sets the response content. + * + * @return $this + */ + public function setContent(?string $content): static + { + $this->content = $content ?? ''; + + return $this; + } + + /** + * Gets the current response content. + */ + public function getContent(): string|false + { + return $this->content; + } + + /** + * Sets the HTTP protocol version (1.0 or 1.1). + * + * @return $this + * + * @final + */ + public function setProtocolVersion(string $version): static + { + $this->version = $version; + + return $this; + } + + /** + * Gets the HTTP protocol version. + * + * @final + */ + public function getProtocolVersion(): string + { + return $this->version; + } + + /** + * Sets the response status code. + * + * If the status text is null it will be automatically populated for the known + * status codes and left empty otherwise. + * + * @return $this + * + * @throws \InvalidArgumentException When the HTTP status code is not valid + * + * @final + */ + public function setStatusCode(int $code, ?string $text = null): static + { + $this->statusCode = $code; + if ($this->isInvalid()) { + throw new \InvalidArgumentException(\sprintf('The HTTP status code "%s" is not valid.', $code)); + } + + if (null === $text) { + $this->statusText = self::$statusTexts[$code] ?? 'unknown status'; + + return $this; + } + + $this->statusText = $text; + + return $this; + } + + /** + * Retrieves the status code for the current web response. + * + * @final + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * Sets the response charset. + * + * @return $this + * + * @final + */ + public function setCharset(string $charset): static + { + $this->charset = $charset; + + return $this; + } + + /** + * Retrieves the response charset. + * + * @final + */ + public function getCharset(): ?string + { + return $this->charset; + } + + /** + * Returns true if the response may safely be kept in a shared (surrogate) cache. + * + * Responses marked "private" with an explicit Cache-Control directive are + * considered uncacheable. + * + * Responses with neither a freshness lifetime (Expires, max-age) nor cache + * validator (Last-Modified, ETag) are considered uncacheable because there is + * no way to tell when or how to remove them from the cache. + * + * Note that RFC 7231 and RFC 7234 possibly allow for a more permissive implementation, + * for example "status codes that are defined as cacheable by default [...] + * can be reused by a cache with heuristic expiration unless otherwise indicated" + * (https://tools.ietf.org/html/rfc7231#section-6.1) + * + * @final + */ + public function isCacheable(): bool + { + if (!\in_array($this->statusCode, [200, 203, 300, 301, 302, 404, 410])) { + return false; + } + + if ($this->headers->hasCacheControlDirective('no-store') || $this->headers->getCacheControlDirective('private')) { + return false; + } + + return $this->isValidateable() || $this->isFresh(); + } + + /** + * Returns true if the response is "fresh". + * + * Fresh responses may be served from cache without any interaction with the + * origin. A response is considered fresh when it includes a Cache-Control/max-age + * indicator or Expires header and the calculated age is less than the freshness lifetime. + * + * @final + */ + public function isFresh(): bool + { + return $this->getTtl() > 0; + } + + /** + * Returns true if the response includes headers that can be used to validate + * the response with the origin server using a conditional GET request. + * + * @final + */ + public function isValidateable(): bool + { + return $this->headers->has('Last-Modified') || $this->headers->has('ETag'); + } + + /** + * Marks the response as "private". + * + * It makes the response ineligible for serving other clients. + * + * @return $this + * + * @final + */ + public function setPrivate(): static + { + $this->headers->removeCacheControlDirective('public'); + $this->headers->addCacheControlDirective('private'); + + return $this; + } + + /** + * Marks the response as "public". + * + * It makes the response eligible for serving other clients. + * + * @return $this + * + * @final + */ + public function setPublic(): static + { + $this->headers->addCacheControlDirective('public'); + $this->headers->removeCacheControlDirective('private'); + + return $this; + } + + /** + * Marks the response as "immutable". + * + * @return $this + * + * @final + */ + public function setImmutable(bool $immutable = true): static + { + if ($immutable) { + $this->headers->addCacheControlDirective('immutable'); + } else { + $this->headers->removeCacheControlDirective('immutable'); + } + + return $this; + } + + /** + * Returns true if the response is marked as "immutable". + * + * @final + */ + public function isImmutable(): bool + { + return $this->headers->hasCacheControlDirective('immutable'); + } + + /** + * Returns true if the response must be revalidated by shared caches once it has become stale. + * + * This method indicates that the response must not be served stale by a + * cache in any circumstance without first revalidating with the origin. + * When present, the TTL of the response should not be overridden to be + * greater than the value provided by the origin. + * + * @final + */ + public function mustRevalidate(): bool + { + return $this->headers->hasCacheControlDirective('must-revalidate') || $this->headers->hasCacheControlDirective('proxy-revalidate'); + } + + /** + * Returns the Date header as a DateTime instance. + * + * @throws \RuntimeException When the header is not parseable + * + * @final + */ + public function getDate(): ?\DateTimeImmutable + { + return $this->headers->getDate('Date'); + } + + /** + * Sets the Date header. + * + * @return $this + * + * @final + */ + public function setDate(\DateTimeInterface $date): static + { + $date = \DateTimeImmutable::createFromInterface($date); + $date = $date->setTimezone(new \DateTimeZone('UTC')); + $this->headers->set('Date', $date->format('D, d M Y H:i:s').' GMT'); + + return $this; + } + + /** + * Returns the age of the response in seconds. + * + * @final + */ + public function getAge(): int + { + if (null !== $age = $this->headers->get('Age')) { + return (int) $age; + } + + return max(time() - (int) $this->getDate()->format('U'), 0); + } + + /** + * Marks the response stale by setting the Age header to be equal to the maximum age of the response. + * + * @return $this + */ + public function expire(): static + { + if ($this->isFresh()) { + $this->headers->set('Age', $this->getMaxAge()); + $this->headers->remove('Expires'); + } + + return $this; + } + + /** + * Returns the value of the Expires header as a DateTime instance. + * + * @final + */ + public function getExpires(): ?\DateTimeImmutable + { + try { + return $this->headers->getDate('Expires'); + } catch (\RuntimeException) { + // according to RFC 2616 invalid date formats (e.g. "0" and "-1") must be treated as in the past + return \DateTimeImmutable::createFromFormat('U', (string)(time() - 172800)); + } + } + + /** + * Sets the Expires HTTP header with a DateTime instance. + * + * Passing null as value will remove the header. + * + * @return $this + * + * @final + */ + public function setExpires(?\DateTimeInterface $date): static + { + if (null === $date) { + $this->headers->remove('Expires'); + + return $this; + } + + $date = \DateTimeImmutable::createFromInterface($date); + $date = $date->setTimezone(new \DateTimeZone('UTC')); + $this->headers->set('Expires', $date->format('D, d M Y H:i:s').' GMT'); + + return $this; + } + + /** + * Returns the number of seconds after the time specified in the response's Date + * header when the response should no longer be considered fresh. + * + * First, it checks for a s-maxage directive, then a max-age directive, and then it falls + * back on an expires header. It returns null when no maximum age can be established. + * + * @final + */ + public function getMaxAge(): ?int + { + if ($this->headers->hasCacheControlDirective('s-maxage')) { + return (int) $this->headers->getCacheControlDirective('s-maxage'); + } + + if ($this->headers->hasCacheControlDirective('max-age')) { + return (int) $this->headers->getCacheControlDirective('max-age'); + } + + if (null !== $expires = $this->getExpires()) { + $maxAge = (int) $expires->format('U') - (int) $this->getDate()->format('U'); + + return max($maxAge, 0); + } + + return null; + } + + /** + * Sets the number of seconds after which the response should no longer be considered fresh. + * + * This method sets the Cache-Control max-age directive. + * + * @return $this + * + * @final + */ + public function setMaxAge(int $value): static + { + $this->headers->addCacheControlDirective('max-age', (string)$value); + + return $this; + } + + /** + * Sets the number of seconds after which the response should no longer be returned by shared caches when backend is down. + * + * This method sets the Cache-Control stale-if-error directive. + * + * @return $this + * + * @final + */ + public function setStaleIfError(int $value): static + { + $this->headers->addCacheControlDirective('stale-if-error', (string)$value); + + return $this; + } + + /** + * Sets the number of seconds after which the response should no longer return stale content by shared caches. + * + * This method sets the Cache-Control stale-while-revalidate directive. + * + * @return $this + * + * @final + */ + public function setStaleWhileRevalidate(int $value): static + { + $this->headers->addCacheControlDirective('stale-while-revalidate', (string)$value); + + return $this; + } + + /** + * Sets the number of seconds after which the response should no longer be considered fresh by shared caches. + * + * This method sets the Cache-Control s-maxage directive. + * + * @return $this + * + * @final + */ + public function setSharedMaxAge(int $value): static + { + $this->setPublic(); + $this->headers->addCacheControlDirective('s-maxage', (string)$value); + + return $this; + } + + /** + * Returns the response's time-to-live in seconds. + * + * It returns null when no freshness information is present in the response. + * + * When the response's TTL is 0, the response may not be served from cache without first + * revalidating with the origin. + * + * @final + */ + public function getTtl(): ?int + { + $maxAge = $this->getMaxAge(); + + return null !== $maxAge ? max($maxAge - $this->getAge(), 0) : null; + } + + /** + * Sets the response's time-to-live for shared caches in seconds. + * + * This method adjusts the Cache-Control/s-maxage directive. + * + * @return $this + * + * @final + */ + public function setTtl(int $seconds): static + { + $this->setSharedMaxAge($this->getAge() + $seconds); + + return $this; + } + + /** + * Sets the response's time-to-live for private/client caches in seconds. + * + * This method adjusts the Cache-Control/max-age directive. + * + * @return $this + * + * @final + */ + public function setClientTtl(int $seconds): static + { + $this->setMaxAge($this->getAge() + $seconds); + + return $this; + } + + /** + * Returns the Last-Modified HTTP header as a DateTime instance. + * + * @throws \RuntimeException When the HTTP header is not parseable + * + * @final + */ + public function getLastModified(): ?\DateTimeImmutable + { + return $this->headers->getDate('Last-Modified'); + } + + /** + * Sets the Last-Modified HTTP header with a DateTime instance. + * + * Passing null as value will remove the header. + * + * @return $this + * + * @final + */ + public function setLastModified(?\DateTimeInterface $date): static + { + if (null === $date) { + $this->headers->remove('Last-Modified'); + + return $this; + } + + $date = \DateTimeImmutable::createFromInterface($date); + $date = $date->setTimezone(new \DateTimeZone('UTC')); + $this->headers->set('Last-Modified', $date->format('D, d M Y H:i:s').' GMT'); + + return $this; + } + + /** + * Returns the literal value of the ETag HTTP header. + * + * @final + */ + public function getEtag(): ?string + { + return $this->headers->get('ETag'); + } + + /** + * Sets the ETag value. + * + * @param string|null $etag The ETag unique identifier or null to remove the header + * @param bool $weak Whether you want a weak ETag or not + * + * @return $this + * + * @final + */ + public function setEtag(?string $etag, bool $weak = false): static + { + if (null === $etag) { + $this->headers->remove('Etag'); + } else { + if (!str_starts_with($etag, '"')) { + $etag = '"'.$etag.'"'; + } + + $this->headers->set('ETag', (true === $weak ? 'W/' : '').$etag); + } + + return $this; + } + + /** + * Sets the response's cache headers (validation and/or expiration). + * + * Available options are: must_revalidate, no_cache, no_store, no_transform, public, private, proxy_revalidate, max_age, s_maxage, immutable, last_modified and etag. + * + * @return $this + * + * @throws \InvalidArgumentException + * + * @final + */ + public function setCache(array $options): static + { + if ($diff = array_diff(array_keys($options), array_keys(self::HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES))) { + throw new \InvalidArgumentException(\sprintf('Response does not support the following options: "%s".', implode('", "', $diff))); + } + + if (isset($options['etag'])) { + $this->setEtag($options['etag']); + } + + if (isset($options['last_modified'])) { + $this->setLastModified($options['last_modified']); + } + + if (isset($options['max_age'])) { + $this->setMaxAge($options['max_age']); + } + + if (isset($options['s_maxage'])) { + $this->setSharedMaxAge($options['s_maxage']); + } + + if (isset($options['stale_while_revalidate'])) { + $this->setStaleWhileRevalidate($options['stale_while_revalidate']); + } + + if (isset($options['stale_if_error'])) { + $this->setStaleIfError($options['stale_if_error']); + } + + foreach (self::HTTP_RESPONSE_CACHE_CONTROL_DIRECTIVES as $directive => $hasValue) { + if (!$hasValue && isset($options[$directive])) { + if ($options[$directive]) { + $this->headers->addCacheControlDirective(str_replace('_', '-', $directive)); + } else { + $this->headers->removeCacheControlDirective(str_replace('_', '-', $directive)); + } + } + } + + if (isset($options['public'])) { + if ($options['public']) { + $this->setPublic(); + } else { + $this->setPrivate(); + } + } + + if (isset($options['private'])) { + if ($options['private']) { + $this->setPrivate(); + } else { + $this->setPublic(); + } + } + + return $this; + } + + /** + * Modifies the response so that it conforms to the rules defined for a 304 status code. + * + * This sets the status, removes the body, and discards any headers + * that MUST NOT be included in 304 responses. + * + * @return $this + * + * @see https://tools.ietf.org/html/rfc2616#section-10.3.5 + * + * @final + */ + public function setNotModified(): static + { + $this->setStatusCode(304); + $this->setContent(null); + + // remove headers that MUST NOT be included with 304 Not Modified responses + foreach (['Allow', 'Content-Encoding', 'Content-Language', 'Content-Length', 'Content-MD5', 'Content-Type', 'Last-Modified'] as $header) { + $this->headers->remove($header); + } + + return $this; + } + + /** + * Returns true if the response includes a Vary header. + * + * @final + */ + public function hasVary(): bool + { + return null !== $this->headers->get('Vary'); + } + + /** + * Returns an array of header names given in the Vary header. + * + * @final + */ + public function getVary(): array + { + if (!$vary = $this->headers->all('Vary')) { + return []; + } + + $ret = []; + foreach ($vary as $item) { + $ret[] = preg_split('/[\s,]+/', $item); + } + + return array_merge([], ...$ret); + } + + /** + * Sets the Vary header. + * + * @param bool $replace Whether to replace the actual value or not (true by default) + * + * @return $this + * + * @final + */ + public function setVary(string|array $headers, bool $replace = true): static + { + $this->headers->set('Vary', $headers, $replace); + + return $this; + } + + /** + * Determines if the Response validators (ETag, Last-Modified) match + * a conditional value specified in the Request. + * + * If the Response is not modified, it sets the status code to 304 and + * removes the actual content by calling the setNotModified() method. + * + * @final + */ + public function isNotModified(Request $request): bool + { + if (!$request->isMethodCacheable()) { + return false; + } + + $notModified = false; + $lastModified = $this->headers->get('Last-Modified'); + $modifiedSince = $request->headers->get('If-Modified-Since'); + + if (($ifNoneMatchEtags = $request->getETags()) && (null !== $etag = $this->getEtag())) { + if (0 == strncmp($etag, 'W/', 2)) { + $etag = substr($etag, 2); + } + + // Use weak comparison as per https://tools.ietf.org/html/rfc7232#section-3.2. + foreach ($ifNoneMatchEtags as $ifNoneMatchEtag) { + if (0 == strncmp($ifNoneMatchEtag, 'W/', 2)) { + $ifNoneMatchEtag = substr($ifNoneMatchEtag, 2); + } + + if ($ifNoneMatchEtag === $etag || '*' === $ifNoneMatchEtag) { + $notModified = true; + break; + } + } + } + // Only do If-Modified-Since date comparison when If-None-Match is not present as per https://tools.ietf.org/html/rfc7232#section-3.3. + elseif ($modifiedSince && $lastModified) { + $notModified = strtotime($modifiedSince) >= strtotime($lastModified); + } + + if ($notModified) { + $this->setNotModified(); + } + + return $notModified; + } + + /** + * Is response invalid? + * + * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html + * + * @final + */ + public function isInvalid(): bool + { + return $this->statusCode < 100 || $this->statusCode >= 600; + } + + /** + * Is response informative? + * + * @final + */ + public function isInformational(): bool + { + return $this->statusCode >= 100 && $this->statusCode < 200; + } + + /** + * Is response successful? + * + * @final + */ + public function isSuccessful(): bool + { + return $this->statusCode >= 200 && $this->statusCode < 300; + } + + /** + * Is the response a redirect? + * + * @final + */ + public function isRedirection(): bool + { + return $this->statusCode >= 300 && $this->statusCode < 400; + } + + /** + * Is there a client error? + * + * @final + */ + public function isClientError(): bool + { + return $this->statusCode >= 400 && $this->statusCode < 500; + } + + /** + * Was there a server side error? + * + * @final + */ + public function isServerError(): bool + { + return $this->statusCode >= 500 && $this->statusCode < 600; + } + + /** + * Is the response OK? + * + * @final + */ + public function isOk(): bool + { + return 200 === $this->statusCode; + } + + /** + * Is the response forbidden? + * + * @final + */ + public function isForbidden(): bool + { + return 403 === $this->statusCode; + } + + /** + * Is the response a not found error? + * + * @final + */ + public function isNotFound(): bool + { + return 404 === $this->statusCode; + } + + /** + * Is the response a redirect of some form? + * + * @final + */ + public function isRedirect(?string $location = null): bool + { + return \in_array($this->statusCode, [201, 301, 302, 303, 307, 308]) && (null === $location ?: $location == $this->headers->get('Location')); + } + + /** + * Is the response empty? + * + * @final + */ + public function isEmpty(): bool + { + return \in_array($this->statusCode, [204, 304]); + } + + /** + * Cleans or flushes output buffers up to target level. + * + * Resulting level can be greater than target level if a non-removable buffer has been encountered. + * + * @final + */ + public static function closeOutputBuffers(int $targetLevel, bool $flush): void + { + $status = ob_get_status(true); + $level = \count($status); + $flags = \PHP_OUTPUT_HANDLER_REMOVABLE | ($flush ? \PHP_OUTPUT_HANDLER_FLUSHABLE : \PHP_OUTPUT_HANDLER_CLEANABLE); + + while ($level-- > $targetLevel && ($s = $status[$level]) && (!isset($s['del']) ? !isset($s['flags']) || ($s['flags'] & $flags) === $flags : $s['del'])) { + if ($flush) { + ob_end_flush(); + } else { + ob_end_clean(); + } + } + } + + /** + * Marks a response as safe according to RFC8674. + * + * @see https://tools.ietf.org/html/rfc8674 + */ + public function setContentSafe(bool $safe = true): void + { + if ($safe) { + $this->headers->set('Preference-Applied', 'safe'); + } elseif ('safe' === $this->headers->get('Preference-Applied')) { + $this->headers->remove('Preference-Applied'); + } + + $this->setVary('Prefer', false); + } + + /** + * Checks if we need to remove Cache-Control for SSL encrypted downloads when using IE < 9. + * + * @see http://support.microsoft.com/kb/323308 + * + * @final + */ + protected function ensureIEOverSSLCompatibility(Request $request): void + { + if (false !== stripos($this->headers->get('Content-Disposition') ?? '', 'attachment') && 1 == preg_match('/MSIE (.*?);/i', $request->server->get('HTTP_USER_AGENT') ?? '', $match) && true === $request->isSecure()) { + if ((int) preg_replace('/(MSIE )(.*?);/', '$2', $match[0]) < 9) { + $this->headers->remove('Cache-Control'); + } + } + } +} diff --git a/core/lib/Http/Response/ResponseHeaderParameters.php b/core/lib/Http/Response/ResponseHeaderParameters.php new file mode 100644 index 0000000..155b3ec --- /dev/null +++ b/core/lib/Http/Response/ResponseHeaderParameters.php @@ -0,0 +1,275 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +use KTXC\Http\Cookie; +use KTXC\Http\HeaderParameters; +use KTXC\Http\HeaderUtils; + +/** + * ResponseHeaderBag is a container for Response HTTP headers. + * + * @author Fabien Potencier + */ +class ResponseHeaderParameters extends HeaderParameters +{ + public const COOKIES_FLAT = 'flat'; + public const COOKIES_ARRAY = 'array'; + + public const DISPOSITION_ATTACHMENT = 'attachment'; + public const DISPOSITION_INLINE = 'inline'; + + protected array $computedCacheControl = []; + protected array $cookies = []; + protected array $headerNames = []; + + public function __construct(array $headers = []) + { + parent::__construct($headers); + + if (!isset($this->headers['cache-control'])) { + $this->set('Cache-Control', ''); + } + + /* RFC2616 - 14.18 says all Responses need to have a Date */ + if (!isset($this->headers['date'])) { + $this->initDate(); + } + } + + /** + * Returns the headers, with original capitalizations. + */ + public function allPreserveCase(): array + { + $headers = []; + foreach ($this->all() as $name => $value) { + $headers[$this->headerNames[$name] ?? $name] = $value; + } + + return $headers; + } + + public function allPreserveCaseWithoutCookies(): array + { + $headers = $this->allPreserveCase(); + if (isset($this->headerNames['set-cookie'])) { + unset($headers[$this->headerNames['set-cookie']]); + } + + return $headers; + } + + public function replace(array $headers = []): void + { + $this->headerNames = []; + + parent::replace($headers); + + if (!isset($this->headers['cache-control'])) { + $this->set('Cache-Control', ''); + } + + if (!isset($this->headers['date'])) { + $this->initDate(); + } + } + + public function all(?string $key = null): array + { + $headers = parent::all(); + + if (null !== $key) { + $key = strtr($key, self::UPPER, self::LOWER); + + return 'set-cookie' !== $key ? $headers[$key] ?? [] : array_map('strval', $this->getCookies()); + } + + foreach ($this->getCookies() as $cookie) { + $headers['set-cookie'][] = (string) $cookie; + } + + return $headers; + } + + public function set(string $key, string|array|null $values, bool $replace = true): void + { + $uniqueKey = strtr($key, self::UPPER, self::LOWER); + + if ('set-cookie' === $uniqueKey) { + if ($replace) { + $this->cookies = []; + } + foreach ((array) $values as $cookie) { + $this->setCookie(Cookie::fromString($cookie)); + } + $this->headerNames[$uniqueKey] = $key; + + return; + } + + $this->headerNames[$uniqueKey] = $key; + + parent::set($key, $values, $replace); + + // ensure the cache-control header has sensible defaults + if (\in_array($uniqueKey, ['cache-control', 'etag', 'last-modified', 'expires'], true) && '' !== $computed = $this->computeCacheControlValue()) { + $this->headers['cache-control'] = [$computed]; + $this->headerNames['cache-control'] = 'Cache-Control'; + $this->computedCacheControl = $this->parseCacheControl($computed); + } + } + + public function remove(string $key): void + { + $uniqueKey = strtr($key, self::UPPER, self::LOWER); + unset($this->headerNames[$uniqueKey]); + + if ('set-cookie' === $uniqueKey) { + $this->cookies = []; + + return; + } + + parent::remove($key); + + if ('cache-control' === $uniqueKey) { + $this->computedCacheControl = []; + } + + if ('date' === $uniqueKey) { + $this->initDate(); + } + } + + public function hasCacheControlDirective(string $key): bool + { + return \array_key_exists($key, $this->computedCacheControl); + } + + public function getCacheControlDirective(string $key): bool|string|null + { + return $this->computedCacheControl[$key] ?? null; + } + + public function setCookie(Cookie $cookie): void + { + $this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie; + $this->headerNames['set-cookie'] = 'Set-Cookie'; + } + + /** + * Removes a cookie from the array, but does not unset it in the browser. + */ + public function removeCookie(string $name, ?string $path = '/', ?string $domain = null): void + { + $path ??= '/'; + + unset($this->cookies[$domain][$path][$name]); + + if (empty($this->cookies[$domain][$path])) { + unset($this->cookies[$domain][$path]); + + if (empty($this->cookies[$domain])) { + unset($this->cookies[$domain]); + } + } + + if (!$this->cookies) { + unset($this->headerNames['set-cookie']); + } + } + + /** + * Returns an array with all cookies. + * + * @return Cookie[] + * + * @throws \InvalidArgumentException When the $format is invalid + */ + public function getCookies(string $format = self::COOKIES_FLAT): array + { + if (!\in_array($format, [self::COOKIES_FLAT, self::COOKIES_ARRAY])) { + throw new \InvalidArgumentException(\sprintf('Format "%s" invalid (%s).', $format, implode(', ', [self::COOKIES_FLAT, self::COOKIES_ARRAY]))); + } + + if (self::COOKIES_ARRAY === $format) { + return $this->cookies; + } + + $flattenedCookies = []; + foreach ($this->cookies as $path) { + foreach ($path as $cookies) { + foreach ($cookies as $cookie) { + $flattenedCookies[] = $cookie; + } + } + } + + return $flattenedCookies; + } + + /** + * Clears a cookie in the browser. + * + * @param bool $partitioned + */ + public function clearCookie(string $name, ?string $path = '/', ?string $domain = null, bool $secure = false, bool $httpOnly = true, ?string $sameSite = null /* , bool $partitioned = false */): void + { + $partitioned = 6 < \func_num_args() ? func_get_arg(6) : false; + + $this->setCookie(new Cookie($name, null, 1, $path, $domain, $secure, $httpOnly, false, $sameSite, $partitioned)); + } + + /** + * @see HeaderUtils::makeDisposition() + */ + public function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string + { + return HeaderUtils::makeDisposition($disposition, $filename, $filenameFallback); + } + + /** + * Returns the calculated value of the cache-control header. + * + * This considers several other headers and calculates or modifies the + * cache-control header to a sensible, conservative value. + */ + protected function computeCacheControlValue(): string + { + if (!$this->cacheControl) { + if ($this->has('Last-Modified') || $this->has('Expires')) { + return 'private, must-revalidate'; // allows for heuristic expiration (RFC 7234 Section 4.2.2) in the case of "Last-Modified" + } + + // conservative by default + return 'no-cache, private'; + } + + $header = $this->getCacheControlHeader(); + if (isset($this->cacheControl['public']) || isset($this->cacheControl['private'])) { + return $header; + } + + // public if s-maxage is defined, private otherwise + if (!isset($this->cacheControl['s-maxage'])) { + return $header.', private'; + } + + return $header; + } + + private function initDate(): void + { + $this->set('Date', gmdate('D, d M Y H:i:s').' GMT'); + } +} diff --git a/core/lib/Http/Response/StreamedJsonResponse.php b/core/lib/Http/Response/StreamedJsonResponse.php new file mode 100644 index 0000000..225ffbf --- /dev/null +++ b/core/lib/Http/Response/StreamedJsonResponse.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +/** + * StreamedJsonResponse represents a streamed HTTP response for JSON. + * + * A StreamedJsonResponse uses a structure and generics to create an + * efficient resource-saving JSON response. + * + * It is recommended to use flush() function after a specific number of items to directly stream the data. + * + * @see flush() + * + * @author Alexander Schranz + * + * Example usage: + * + * function loadArticles(): \Generator + * // some streamed loading + * yield ['title' => 'Article 1']; + * yield ['title' => 'Article 2']; + * yield ['title' => 'Article 3']; + * // recommended to use flush() after every specific number of items + * }), + * + * $response = new StreamedJsonResponse( + * // json structure with generators in which will be streamed + * [ + * '_embedded' => [ + * 'articles' => loadArticles(), // any generator which you want to stream as list of data + * ], + * ], + * ); + */ +class StreamedJsonResponse extends StreamedResponse +{ + private const PLACEHOLDER = '__symfony_json__'; + + /** + * @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator + * @param int $status The HTTP status code (200 "OK" by default) + * @param array $headers An array of HTTP headers + * @param int $encodingOptions Flags for the json_encode() function + */ + public function __construct( + private readonly iterable $data, + int $status = 200, + array $headers = [], + private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS, + ) { + parent::__construct($this->stream(...), $status, $headers); + + if (!$this->headers->get('Content-Type')) { + $this->headers->set('Content-Type', 'application/json'); + } + } + + private function stream(): void + { + $jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions; + $keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK; + + $this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions); + } + + private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + if (\is_array($data)) { + $this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions); + + return; + } + + if (is_iterable($data) && !$data instanceof \JsonSerializable) { + $this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions); + + return; + } + + echo json_encode($data, $jsonEncodingOptions); + } + + private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + $generators = []; + + array_walk_recursive($data, function (&$item, $key) use (&$generators) { + if (self::PLACEHOLDER === $key) { + // if the placeholder is already in the structure it should be replaced with a new one that explode + // works like expected for the structure + $generators[] = $key; + } + + // generators should be used but for better DX all kind of Traversable and objects are supported + if (\is_object($item)) { + $generators[] = $item; + $item = self::PLACEHOLDER; + } elseif (self::PLACEHOLDER === $item) { + // if the placeholder is already in the structure it should be replaced with a new one that explode + // works like expected for the structure + $generators[] = $item; + } + }); + + $jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions)); + + foreach ($generators as $index => $generator) { + // send first and between parts of the structure + echo $jsonParts[$index]; + + $this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions); + } + + // send last part of the structure + echo $jsonParts[array_key_last($jsonParts)]; + } + + private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + $isFirstItem = true; + $startTag = '['; + + foreach ($iterable as $key => $item) { + if ($isFirstItem) { + $isFirstItem = false; + // depending on the first elements key the generator is detected as a list or map + // we can not check for a whole list or map because that would hurt the performance + // of the streamed response which is the main goal of this response class + if (0 !== $key) { + $startTag = '{'; + } + + echo $startTag; + } else { + // if not first element of the generic, a separator is required between the elements + echo ','; + } + + if ('{' === $startTag) { + echo json_encode((string) $key, $keyEncodingOptions).':'; + } + + $this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions); + } + + if ($isFirstItem) { // indicates that the generator was empty + echo '['; + } + + echo '[' === $startTag ? ']' : '}'; + } +} diff --git a/core/lib/Http/Response/StreamedResponse.php b/core/lib/Http/Response/StreamedResponse.php new file mode 100644 index 0000000..15f926e --- /dev/null +++ b/core/lib/Http/Response/StreamedResponse.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXC\Http\Response; + +/** + * StreamedResponse represents a streamed HTTP response. + * + * A StreamedResponse uses a callback or an iterable of strings for its content. + * + * The callback should use the standard PHP functions like echo + * to stream the response back to the client. The flush() function + * can also be used if needed. + * + * @see flush() + * + * @author Fabien Potencier + */ +class StreamedResponse extends Response +{ + protected ?\Closure $callback = null; + protected bool $streamed = false; + + private bool $headersSent = false; + + /** + * @param callable|iterable|null $callbackOrChunks + * @param int $status The HTTP status code (200 "OK" by default) + */ + public function __construct(callable|iterable|null $callbackOrChunks = null, int $status = 200, array $headers = []) + { + parent::__construct(null, $status, $headers); + + if (\is_callable($callbackOrChunks)) { + $this->setCallback($callbackOrChunks); + } elseif ($callbackOrChunks) { + $this->setChunks($callbackOrChunks); + } + $this->streamed = false; + $this->headersSent = false; + } + + /** + * @param iterable $chunks + */ + public function setChunks(iterable $chunks): static + { + $this->callback = static function () use ($chunks): void { + foreach ($chunks as $chunk) { + echo $chunk; + @ob_flush(); + flush(); + } + }; + + return $this; + } + + /** + * Sets the PHP callback associated with this Response. + * + * @return $this + */ + public function setCallback(callable $callback): static + { + $this->callback = $callback(...); + + return $this; + } + + public function getCallback(): ?\Closure + { + if (!isset($this->callback)) { + return null; + } + + return ($this->callback)(...); + } + + /** + * This method only sends the headers once. + * + * @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null + * + * @return $this + */ + public function sendHeaders(?int $statusCode = null): static + { + if ($this->headersSent) { + return $this; + } + + if ($statusCode < 100 || $statusCode >= 200) { + $this->headersSent = true; + } + + return parent::sendHeaders($statusCode); + } + + /** + * This method only sends the content once. + * + * @return $this + */ + public function sendContent(): static + { + if ($this->streamed) { + return $this; + } + + $this->streamed = true; + + if (!isset($this->callback)) { + throw new \LogicException('The Response callback must be set.'); + } + + ($this->callback)(); + + return $this; + } + + /** + * @return $this + * + * @throws \LogicException when the content is not null + */ + public function setContent(?string $content): static + { + if (null !== $content) { + throw new \LogicException('The content cannot be set on a StreamedResponse instance.'); + } + + $this->streamed = true; + + return $this; + } + + public function getContent(): string|false + { + return false; + } +} diff --git a/core/lib/Http/Session/SessionInterface.php b/core/lib/Http/Session/SessionInterface.php new file mode 100644 index 0000000..b99c033 --- /dev/null +++ b/core/lib/Http/Session/SessionInterface.php @@ -0,0 +1,148 @@ + Attributes + */ + public function all(): array; + + /** + * Sets attributes. + * + * @param array $attributes Attributes + */ + public function replace(array $attributes): void; + + /** + * Removes an attribute. + * + * @param string $name The attribute name + * + * @return mixed The removed value or null when it does not exist + */ + public function remove(string $name): mixed; + + /** + * Clears all attributes. + */ + public function clear(): void; + + /** + * Checks if the session was started. + * + * @return bool True if started, false otherwise + */ + public function isStarted(): bool; +} diff --git a/core/lib/Injection/Builder.php b/core/lib/Injection/Builder.php new file mode 100644 index 0000000..0a9d544 --- /dev/null +++ b/core/lib/Injection/Builder.php @@ -0,0 +1,5 @@ +initialized = false; + $this->booted = false; + $this->container = null; + } + + private function initialize(): void + { + if ($this->debug) { + $this->startTime = microtime(true); + } + if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) { + if (\function_exists('putenv')) { + putenv('SHELL_VERBOSITY=3'); + } + $_ENV['SHELL_VERBOSITY'] = 3; + $_SERVER['SHELL_VERBOSITY'] = 3; + } + + $container = $this->initializeContainer(); + + $this->container = $container; + $this->initialized = true; + } + + public function boot(): void + { + if (!$this->initialized) { + $this->initialize(); + } + + if (!$this->booted) { + /** @var ModuleManager $moduleManager */ + $moduleManager = $this->container->get(ModuleManager::class); + $moduleManager->modulesBoot(); + $this->booted = true; + } + } + + public function reboot(): void + { + $this->shutdown(); + $this->boot(); + } + + public function shutdown(): void + { + if (false === $this->initialized) { + return; + } + + $this->initialized = false; + $this->booted = false; + $this->container = null; + } + + public function handle(Request $request): Response + { + if (!$this->booted) { + $this->boot(); + } + + /** @var SessionTenant $sessionTenant */ + $sessionTenant = $this->container->get(SessionTenant::class); + $sessionTenant->configure($request->getHost()); + if (!$sessionTenant->configured() && !$sessionTenant->enabled()) { + return new Response(Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED); + } + + /** @var FirewallService $firewall */ + $firewall = $this->container->get(FirewallService::class); + if (!$firewall->authorized($request)) { + return new Response(Response::$statusTexts[Response::HTTP_FORBIDDEN], Response::HTTP_FORBIDDEN); + } + + /** @var Router $router */ + $router = $this->container->get(Router::class); + if ($router) { + $match = $router->match($request); + if ($match instanceof Route) { + /** @var SecurityService $securityService */ + $securityService = $this->container->get(SecurityService::class); + $identity = $securityService->authenticate($request); + + if ($match->authenticated && $identity === null) { + return new Response(Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED); + } + + if ($identity) { + /** @var SessionIdentity $sessionIdentity */ + $sessionIdentity = $this->container->get(SessionIdentity::class); + $sessionIdentity->initialize($identity, true); + } + + $response = $router->dispatch($match, $request); + if ($response instanceof Response) { + return $response; + } + } + } + + return new Response(Response::$statusTexts[Response::HTTP_NOT_FOUND], Response::HTTP_NOT_FOUND); + } + + /** + * Process deferred events at the end of the request + */ + public function processEvents(): void + { + try { + if ($this->container && $this->container->has(EventBus::class)) { + /** @var EventBus $eventBus */ + $eventBus = $this->container->get(EventBus::class); + $eventBus->processDeferred(); + } + } catch (\Throwable $e) { + error_log('Event processing error: ' . $e->getMessage()); + } + } + + /** + * Returns the kernel parameters. + * + * @return array + */ + protected function parameters(): array + { + return [ + 'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(), + 'kernel.environment' => $this->environment, + 'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%', + 'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%', + 'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%', + 'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%', + 'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%', + 'kernel.debug' => $this->debug, + 'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(), + 'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(), + 'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(), + 'kernel.charset' => $this->getCharset(), + ]; + } + + public function environment(): string + { + return $this->environment; + } + + public function debug(): bool + { + return $this->debug; + } + + public function container(): ContainerInterface + { + if (!$this->container) { + throw new \LogicException('Cannot retrieve the container from a non-booted kernel.'); + } + + return $this->container; + } + + public function getStartTime(): float + { + return $this->debug && null !== $this->startTime ? $this->startTime : -\INF; + } + + /** + * Gets the application root dir (path of the project's composer file). + */ + public function folderRoot(): string + { + if (!isset($this->projectDir)) { + $r = new \ReflectionObject($this); + + if (!is_file($dir = $r->getFileName())) { + throw new \LogicException(\sprintf('Cannot auto-detect project dir for kernel of class "%s".', $r->name)); + } + + $dir = $rootDir = \dirname($dir); + while (!is_file($dir.'/composer.json')) { + if ($dir === \dirname($dir)) { + return $this->projectDir = $rootDir; + } + $dir = \dirname($dir); + } + $this->projectDir = $dir; + } + + return $this->projectDir; + } + + + /** + * Gets the path to the configuration directory. + */ + private function getConfigDir(): string + { + return $this->folderRoot().'/config'; + } + + public function getCacheDir(): string + { + return $this->folderRoot().'/var/cache/'.$this->environment; + } + + public function getBuildDir(): string + { + // Returns $this->getCacheDir() for backward compatibility + return $this->getCacheDir(); + } + + public function getLogDir(): string + { + return $this->folderRoot().'/var/log'; + } + + public function getCharset(): string + { + return 'UTF-8'; + } + + /** + * Gets a new container builder instance used to build the service container. + */ + protected function containerBuilder(): Builder + { + return new Builder(Container::class); + } + + /** + * Initializes the service container + */ + protected function initializeContainer(): Container + { + $container = $this->buildContainer(); + $container->set('kernel', $this); + + return $container; + } + + /** + * Builds the service container. + * + * @throws \RuntimeException + */ + protected function buildContainer(): Container + { + $builder = $this->containerBuilder(); + $builder->useAutowiring(true); + $builder->useAttributes(true); + $builder->addDefinitions($this->parameters()); + $this->configureContainer($builder); + + return $builder->build(); + } + + protected function configureContainer(Builder $builder): void + { + $builder->addDefinitions($this->getConfigDir() . '/system.php'); + // Service definitions + $logDir = $this->getLogDir(); + $projectDir = $this->folderRoot(); + $builder->addDefinitions([ + LoggerInterface::class => function() use ($logDir) { + return new FileLogger($logDir); + }, + // EventBus as singleton for consistent event handling + EventBus::class => \DI\create(EventBus::class), + // Ephemeral Cache - for short-lived data (sessions, rate limits, challenges) + EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { + $storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file'; + + $storeMap = [ + 'file' => FileEphemeralCache::class, + // 'redis' => RedisEphemeralCache::class, + ]; + + $storeClass = $storeMap[$storeType] ?? $storeType; + + if (!class_exists($storeClass)) { + throw new \RuntimeException("Ephemeral cache store not found: {$storeClass}"); + } + + $cache = new $storeClass($projectDir); + + // Set tenant/user context if available + if ($c->has(SessionTenant::class)) { + $tenant = $c->get(SessionTenant::class); + $cache->setTenantContext($tenant->identifier()); + } + if ($c->has(SessionIdentity::class)) { + $identity = $c->get(SessionIdentity::class); + $cache->setUserContext($identity->identifier()); + } + + return $cache; + }, + // Persistent Cache - for long-lived data (routes, modules, compiled configs) + PersistentCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { + $storeType = $c->has('cache.persistent') ? $c->get('cache.persistent') : 'file'; + + $storeMap = [ + 'file' => FilePersistentCache::class, + // 'database' => DatabasePersistentCache::class, + ]; + + $storeClass = $storeMap[$storeType] ?? $storeType; + + if (!class_exists($storeClass)) { + throw new \RuntimeException("Persistent cache store not found: {$storeClass}"); + } + + $cache = new $storeClass($projectDir); + + // Set tenant/user context if available + if ($c->has(SessionTenant::class)) { + $tenant = $c->get(SessionTenant::class); + $cache->setTenantContext($tenant->identifier()); + } + if ($c->has(SessionIdentity::class)) { + $identity = $c->get(SessionIdentity::class); + $cache->setUserContext($identity->identifier()); + } + + return $cache; + }, + // Blob Cache - for binary/media data (previews, thumbnails) + BlobCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { + $storeType = $c->has('cache.blob') ? $c->get('cache.blob') : 'file'; + + $storeMap = [ + 'file' => FileBlobCache::class, + // 's3' => S3BlobCache::class, + ]; + + $storeClass = $storeMap[$storeType] ?? $storeType; + + if (!class_exists($storeClass)) { + throw new \RuntimeException("Blob cache store not found: {$storeClass}"); + } + + $cache = new $storeClass($projectDir); + + // Set tenant/user context if available + if ($c->has(SessionTenant::class)) { + $tenant = $c->get(SessionTenant::class); + $cache->setTenantContext($tenant->identifier()); + } + if ($c->has(SessionIdentity::class)) { + $identity = $c->get(SessionIdentity::class); + $cache->setUserContext($identity->identifier()); + } + + return $cache; + }, + ]); + } + +} diff --git a/core/lib/Logger/FileLogger.php b/core/lib/Logger/FileLogger.php new file mode 100644 index 0000000..b3d2988 --- /dev/null +++ b/core/lib/Logger/FileLogger.php @@ -0,0 +1,125 @@ +useMicroseconds = $useMicroseconds; + $this->channel = $channel; + if (!is_dir($logDir)) { + @mkdir($logDir, 0775, true); + } + $this->logFile = rtrim($logDir, '/').'/'.$channel.'.log'; + } + + public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); } + public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); } + public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); } + public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); } + public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); } + public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); } + public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); } + public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); } + + public function log($level, $message, array $context = []): void + { + $timestamp = $this->formatTimestamp(); + $interpolated = $this->interpolate((string)$message, $context); + $payload = [ + 'time' => $timestamp, + 'level' => strtolower((string)$level), + 'channel' => $this->channel, + 'message' => $interpolated, + 'context' => $this->sanitizeContext($context), + ]; + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + // Fallback stringify if encoding fails (should be rare) + $json = json_encode([ + 'time' => $timestamp, + 'level' => strtolower((string)$level), + 'channel' => $this->channel, + 'message' => $interpolated, + 'context_error' => 'failed to encode context: '.json_last_error_msg(), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}'; + } + $this->write($json); + } + + private function formatTimestamp(): string + { + if ($this->useMicroseconds) { + $dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true))); + return $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s'); + } + return date('Y-m-d H:i:s'); + } + + private function interpolate(string $message, array $context): string + { + if (!str_contains($message, '{')) { + return $message; + } + $replace = []; + foreach ($context as $key => $val) { + if (is_array($val) || is_object($val)) { + continue; // don't inline complex values + } + $replace['{'.$key.'}'] = (string)$val; + } + return strtr($message, $replace); + } + + private function sanitizeContext(array $context): array + { + if (empty($context)) { return []; } + $clean = []; + foreach ($context as $k => $v) { + if ($v instanceof \Throwable) { + $clean[$k] = [ + 'type' => get_class($v), + 'message' => $v->getMessage(), + 'code' => $v->getCode(), + 'file' => $v->getFile(), + 'line' => $v->getLine(), + 'trace' => explode("\n", $v->getTraceAsString()), + ]; + } elseif (is_resource($v)) { + $clean[$k] = 'resource('.get_resource_type($v).')'; + } elseif (is_object($v)) { + // Try to extract serializable data + if (method_exists($v, '__toString')) { + $clean[$k] = (string)$v; + } else { + $clean[$k] = ['object' => get_class($v)]; + } + } else { + $clean[$k] = $v; + } + } + return $clean; + } + + private function write(string $line): void + { + $line = rtrim($line)."\n"; // newline-delimited JSON (JSONL) + @file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX); + } +} diff --git a/core/lib/Models/Firewall/FirewallLogObject.php b/core/lib/Models/Firewall/FirewallLogObject.php new file mode 100644 index 0000000..43dbdea --- /dev/null +++ b/core/lib/Models/Firewall/FirewallLogObject.php @@ -0,0 +1,255 @@ +id = $data['_id'] !== null ? (string)$data['_id'] : null; + } elseif (array_key_exists('id', $data)) { + $this->id = $data['id'] !== null ? (string)$data['id'] : null; + } + + if (array_key_exists('tenantId', $data)) { + $this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null; + } + if (array_key_exists('ipAddress', $data)) { + $this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null; + } + if (array_key_exists('deviceFingerprint', $data)) { + $this->deviceFingerprint = $data['deviceFingerprint'] !== null ? (string)$data['deviceFingerprint'] : null; + } + if (array_key_exists('userAgent', $data)) { + $this->userAgent = $data['userAgent'] !== null ? (string)$data['userAgent'] : null; + } + if (array_key_exists('requestPath', $data)) { + $this->requestPath = $data['requestPath'] !== null ? (string)$data['requestPath'] : null; + } + if (array_key_exists('requestMethod', $data)) { + $this->requestMethod = $data['requestMethod'] !== null ? (string)$data['requestMethod'] : null; + } + if (array_key_exists('eventType', $data)) { + $this->eventType = $data['eventType'] !== null ? (string)$data['eventType'] : null; + } + if (array_key_exists('result', $data)) { + $this->result = $data['result'] !== null ? (string)$data['result'] : null; + } + if (array_key_exists('ruleId', $data)) { + $this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null; + } + if (array_key_exists('identityId', $data)) { + $this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null; + } + if (array_key_exists('timestamp', $data)) { + $this->timestamp = $data['timestamp'] !== null + ? new \DateTimeImmutable($data['timestamp']) + : null; + } + if (array_key_exists('metadata', $data)) { + $this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null; + } + + return $this; + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'tenantId' => $this->tenantId, + 'ipAddress' => $this->ipAddress, + 'deviceFingerprint' => $this->deviceFingerprint, + 'userAgent' => $this->userAgent, + 'requestPath' => $this->requestPath, + 'requestMethod' => $this->requestMethod, + 'eventType' => $this->eventType, + 'result' => $this->result, + 'ruleId' => $this->ruleId, + 'identityId' => $this->identityId, + 'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM), + 'metadata' => $this->metadata, + ]; + } + + // Getters and setters + + public function getId(): ?string + { + return $this->id; + } + + public function setId(?string $id): self + { + $this->id = $id; + return $this; + } + + public function getTenantId(): ?string + { + return $this->tenantId; + } + + public function setTenantId(?string $tenantId): self + { + $this->tenantId = $tenantId; + return $this; + } + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): self + { + $this->ipAddress = $ipAddress; + return $this; + } + + public function getDeviceFingerprint(): ?string + { + return $this->deviceFingerprint; + } + + public function setDeviceFingerprint(?string $deviceFingerprint): self + { + $this->deviceFingerprint = $deviceFingerprint; + return $this; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setUserAgent(?string $userAgent): self + { + $this->userAgent = $userAgent; + return $this; + } + + public function getRequestPath(): ?string + { + return $this->requestPath; + } + + public function setRequestPath(?string $requestPath): self + { + $this->requestPath = $requestPath; + return $this; + } + + public function getRequestMethod(): ?string + { + return $this->requestMethod; + } + + public function setRequestMethod(?string $requestMethod): self + { + $this->requestMethod = $requestMethod; + return $this; + } + + public function getEventType(): ?string + { + return $this->eventType; + } + + public function setEventType(?string $eventType): self + { + $this->eventType = $eventType; + return $this; + } + + public function getResult(): ?string + { + return $this->result; + } + + public function setResult(?string $result): self + { + $this->result = $result; + return $this; + } + + public function getRuleId(): ?string + { + return $this->ruleId; + } + + public function setRuleId(?string $ruleId): self + { + $this->ruleId = $ruleId; + return $this; + } + + public function getIdentityId(): ?string + { + return $this->identityId; + } + + public function setIdentityId(?string $identityId): self + { + $this->identityId = $identityId; + return $this; + } + + public function getTimestamp(): ?\DateTimeImmutable + { + return $this->timestamp; + } + + public function setTimestamp(?\DateTimeImmutable $timestamp): self + { + $this->timestamp = $timestamp; + return $this; + } + + public function getMetadata(): ?array + { + return $this->metadata; + } + + public function setMetadata(?array $metadata): self + { + $this->metadata = $metadata; + return $this; + } +} diff --git a/core/lib/Models/Firewall/FirewallRuleObject.php b/core/lib/Models/Firewall/FirewallRuleObject.php new file mode 100644 index 0000000..3636242 --- /dev/null +++ b/core/lib/Models/Firewall/FirewallRuleObject.php @@ -0,0 +1,241 @@ +id = $data['_id'] !== null ? (string)$data['_id'] : null; + } elseif (array_key_exists('id', $data)) { + $this->id = $data['id'] !== null ? (string)$data['id'] : null; + } + + if (array_key_exists('tenantId', $data)) { + $this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null; + } + if (array_key_exists('type', $data)) { + $this->type = $data['type'] !== null ? (string)$data['type'] : null; + } + if (array_key_exists('action', $data)) { + $this->action = $data['action'] !== null ? (string)$data['action'] : null; + } + if (array_key_exists('value', $data)) { + $this->value = $data['value'] !== null ? (string)$data['value'] : null; + } + if (array_key_exists('reason', $data)) { + $this->reason = $data['reason'] !== null ? (string)$data['reason'] : null; + } + if (array_key_exists('createdBy', $data)) { + $this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null; + } + if (array_key_exists('createdAt', $data)) { + $this->createdAt = $data['createdAt'] !== null + ? new \DateTimeImmutable($data['createdAt']) + : null; + } + if (array_key_exists('expiresAt', $data)) { + $this->expiresAt = $data['expiresAt'] !== null + ? new \DateTimeImmutable($data['expiresAt']) + : null; + } + if (array_key_exists('enabled', $data)) { + $this->enabled = (bool)$data['enabled']; + } + if (array_key_exists('metadata', $data)) { + $this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null; + } + + return $this; + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'tenantId' => $this->tenantId, + 'type' => $this->type, + 'action' => $this->action, + 'value' => $this->value, + 'reason' => $this->reason, + 'createdBy' => $this->createdBy, + 'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM), + 'expiresAt' => $this->expiresAt?->format(\DateTimeInterface::ATOM), + 'enabled' => $this->enabled, + 'metadata' => $this->metadata, + ]; + } + + /** + * Check if this rule has expired + */ + public function isExpired(): bool + { + if ($this->expiresAt === null) { + return false; + } + return $this->expiresAt < new \DateTimeImmutable(); + } + + /** + * Check if this rule is currently active (enabled and not expired) + */ + public function isActive(): bool + { + return $this->enabled && !$this->isExpired(); + } + + // Getters and setters + + public function getId(): ?string + { + return $this->id; + } + + public function setId(?string $id): self + { + $this->id = $id; + return $this; + } + + public function getTenantId(): ?string + { + return $this->tenantId; + } + + public function setTenantId(?string $tenantId): self + { + $this->tenantId = $tenantId; + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(?string $type): self + { + $this->type = $type; + return $this; + } + + public function getAction(): ?string + { + return $this->action; + } + + public function setAction(?string $action): self + { + $this->action = $action; + return $this; + } + + public function getValue(): ?string + { + return $this->value; + } + + public function setValue(?string $value): self + { + $this->value = $value; + return $this; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function setReason(?string $reason): self + { + $this->reason = $reason; + return $this; + } + + public function getCreatedBy(): ?string + { + return $this->createdBy; + } + + public function setCreatedBy(?string $createdBy): self + { + $this->createdBy = $createdBy; + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): self + { + $this->createdAt = $createdAt; + return $this; + } + + public function getExpiresAt(): ?\DateTimeImmutable + { + return $this->expiresAt; + } + + public function setExpiresAt(?\DateTimeImmutable $expiresAt): self + { + $this->expiresAt = $expiresAt; + return $this; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $enabled): self + { + $this->enabled = $enabled; + return $this; + } + + public function getMetadata(): ?array + { + return $this->metadata; + } + + public function setMetadata(?array $metadata): self + { + $this->metadata = $metadata; + return $this; + } +} diff --git a/core/lib/Models/Identity/User.php b/core/lib/Models/Identity/User.php new file mode 100644 index 0000000..1ae0e83 --- /dev/null +++ b/core/lib/Models/Identity/User.php @@ -0,0 +1,156 @@ +id = $data['uid'] ?? null; // 'uid' maps to 'id' + $this->identity = $data['identity'] ?? null; + $this->label = $data['label'] ?? null; + $this->roles = (array)$data['roles'] ?? []; + $this->enabled = $data['enabled'] ?? null; + $this->provider = $data['provider'] ?? null; + $this->externalSubject = $data['external_subject'] ?? null; + $this->initialLogin = $data['initial_login'] ?? null; + $this->recentLogin = $data['recent_login'] ?? null; + $this->permissions = (array)$data['permissions'] ?? []; + } + + if ($source === 'jwt') { + $this->id = $data['identifier'] ?? null; + $this->identity = $data['identity'] ?? null; + $this->label = $data['label'] ?? null; + $this->roles = (array)$data['role'] ?? []; + $this->permissions = (array)$data['permissions'] ?? []; + $this->enabled = true; + } + + if ($source === 'external') { + $this->identity = $data['identity'] ?? null; + $this->label = $data['label'] ?? null; + $this->externalSubject = $data['external_subject'] ?? null; + $this->provider = $data['provider'] ?? null; + } + + } + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $value): void + { + $this->id = $value; + } + + public function getIdentity(): ?string + { + return $this->identity; + } + + public function setIdentity(string $value): void + { + $this->identity = $value; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(?string $value): void + { + $this->label = $value; + } + + public function getRoles(): array + { + return $this->roles; + } + + public function setRoles(array $values): void + { + $this->roles = $values; + } + + public function getEnabled(): ?bool + { + return $this->enabled; + } + + public function setEnabled(?bool $value): void + { + $this->enabled = $value; + } + + public function getProvider(): ?string + { + return $this->provider; + } + + public function setProvider(?string $value): void + { + $this->provider = $value; + } + + public function getExternalSubject(): ?string + { + return $this->externalSubject; + } + + public function setExternalSubject(?string $value): void + { + $this->externalSubject = $value; + } + + public function getInitialLogin(): ?int + { + return $this->initialLogin; + } + + public function setInitialLogin(?int $value): void + { + $this->initialLogin = $value; + } + + public function getRecentLogin(): ?int + { + return $this->recentLogin; + } + + public function setRecentLogin(?int $value): void + { + $this->recentLogin = $value; + } + + public function getPermissions(): array + { + return $this->permissions; + } + + public function setPermissions(array $permissions): void + { + $this->permissions = $permissions; + } + + public function hasPermission(string $permission): bool + { + return in_array($permission, $this->permissions, true); + } + +} diff --git a/core/lib/Models/Tenant/DomainCollection.php b/core/lib/Models/Tenant/DomainCollection.php new file mode 100644 index 0000000..2a241cf --- /dev/null +++ b/core/lib/Models/Tenant/DomainCollection.php @@ -0,0 +1,13 @@ +providers; + } + + public function methodsMinimal(): int { + return $this->methodsMinimal; + } +} diff --git a/core/lib/Models/Tenant/TenantCollection.php b/core/lib/Models/Tenant/TenantCollection.php new file mode 100644 index 0000000..0d56dda --- /dev/null +++ b/core/lib/Models/Tenant/TenantCollection.php @@ -0,0 +1,13 @@ +authentication = new TenantAuthentication(); + $this->security = new TenantSecurity(); + } + + public function authentication(): TenantAuthentication { + return $this->authentication; + } + + public function security(): TenantSecurity { + return $this->security; + } + +} diff --git a/core/lib/Models/Tenant/TenantObject.php b/core/lib/Models/Tenant/TenantObject.php new file mode 100644 index 0000000..50373d5 --- /dev/null +++ b/core/lib/Models/Tenant/TenantObject.php @@ -0,0 +1,148 @@ +id = $data['_id'] !== null ? (string)$data['_id'] : null; + elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null; + if (array_key_exists('identifier', $data)) $this->identifier = $data['identifier'] !== null ? (string)$data['identifier'] : null; + if (array_key_exists('enabled', $data)) $this->enabled = $data['enabled'] !== null ? (bool)$data['enabled'] : null; + if (array_key_exists('label', $data)) $this->label = $data['label'] !== null ? (string)$data['label'] : null; + if (array_key_exists('description', $data)) $this->description = $data['description'] !== null ? (string)$data['description'] : null; + if (array_key_exists('domains', $data)) { + $this->domains = (new DomainCollection((array)$data['domains'])); + } + if (array_key_exists('configuration', $data)) { + $this->configuration = (new TenantConfiguration)->jsonDeserialize($data['configuration']); + } + return $this; + } + + /** + * Serialize to JSON-friendly structure. + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'identifier' => $this->identifier, + 'enabled' => $this->enabled, + 'label' => $this->label, + 'description' => $this->description, + 'domains' => $this->domains, + 'configuration' => $this->configuration, + ]; + } + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $value): self + { + $this->id = $value; + return $this; + } + + public function getIdentifier(): ?string + { + return $this->identifier; + } + + public function setIdentifier(string $value): self + { + $this->identifier = $value; + return $this; + } + + public function getEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $value): self + { + $this->enabled = $value; + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $value): self + { + $this->label = $value; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(string $value): self + { + $this->description = $value; + return $this; + } + + public function getDomains(): ?DomainCollection + { + return $this->domains; + } + + public function setDomains(DomainCollection $value): self + { + $this->domains = $value; + return $this; + } + + public function getConfiguration(): TenantConfiguration + { + return $this->configuration; + } + + public function setConfiguration(TenantConfiguration $value): self + { + $this->configuration = $value; + return $this; + } + + public function getSettings(): array + { + return $this->configuration['settings'] ?? []; + } + + public function setSettings(array $value): self + { + $this->configuration['settings'] = $value; + return $this; + } + +} diff --git a/core/lib/Models/Tenant/TenantSecurity.php b/core/lib/Models/Tenant/TenantSecurity.php new file mode 100644 index 0000000..e85e429 --- /dev/null +++ b/core/lib/Models/Tenant/TenantSecurity.php @@ -0,0 +1,22 @@ +code = uniqid(); + } + + public function code(): string { + return $this->code; + } +} diff --git a/core/lib/Module/ModuleAutoloader.php b/core/lib/Module/ModuleAutoloader.php new file mode 100644 index 0000000..30f50c6 --- /dev/null +++ b/core/lib/Module/ModuleAutoloader.php @@ -0,0 +1,182 @@ +modulesRoot = rtrim($modulesRoot, '/'); + } + + /** + * Register the autoloader + */ + public function register(): void + { + spl_autoload_register([$this, 'loadClass']); + } + + /** + * Unregister the autoloader + */ + public function unregister(): void + { + spl_autoload_unregister([$this, 'loadClass']); + } + + /** + * Scan the modules directory and build a map of namespaces to folder paths + * This is called lazily on the first KTXM class request + */ + private function scan(): void + { + if ($this->scanned) { + return; + } + + $this->namespaceMap = []; + + if (!is_dir($this->modulesRoot)) { + $this->scanned = true; + return; + } + + $moduleDirs = glob($this->modulesRoot . '/*', GLOB_ONLYDIR); + foreach ($moduleDirs as $moduleDir) { + $moduleFile = $moduleDir . '/lib/Module.php'; + if (!file_exists($moduleFile)) { + continue; + } + + // Extract the namespace from Module.php + $namespace = $this->extractNamespace($moduleFile); + if ($namespace) { + $this->namespaceMap[$namespace] = basename($moduleDir); + } + } + + $this->scanned = true; + } + + /** + * Load a class by its fully qualified name + * + * @param string $className Fully qualified class name (e.g., KTXM\ContactsManager\Module) + * @return bool True if the class was loaded, false otherwise + */ + public function loadClass(string $className): bool + { + try { + // Only handle classes in the KTXM namespace + if (!str_starts_with($className, 'KTXM\\')) { + return false; + } + + // Extract the namespace segment (e.g., ContactsManager from KTXM\ContactsManager\Module) + $parts = explode('\\', $className); + if (count($parts) < 2) { + $this->logError("Invalid class name format: $className (expected at least 2 namespace parts)"); + return false; + } + + $namespaceSegment = $parts[1]; + + // Check if we already have a mapping for this namespace + if (!isset($this->namespaceMap[$namespaceSegment])) { + // Scan only if we haven't scanned yet (this happens once, on first module access) + if (!$this->scanned) { + $this->scan(); + + // Check again after scanning + if (!isset($this->namespaceMap[$namespaceSegment])) { + $this->logError("No module found for namespace segment: $namespaceSegment (class: $className)"); + return false; + } + } else { + $this->logError("Module not found after scan: $namespaceSegment (class: $className)"); + return false; + } + } + + $folderName = $this->namespaceMap[$namespaceSegment]; + + // Reconstruct the relative path + // KTXM\ContactsManager\Module -> contacts_manager/lib/Module.php + // KTXM\ContactsManager\Something -> contacts_manager/lib/Something.php + $relativePath = 'lib/' . implode('/', array_slice($parts, 2)) . '.php'; + $filePath = $this->modulesRoot . '/' . $folderName . '/' . $relativePath; + + if (file_exists($filePath)) { + require_once $filePath; + return true; + } + + $this->logError("File not found for class $className at path: $filePath"); + return false; + + } catch (\Throwable $e) { + $this->logError("Exception in ModuleAutoloader while loading $className: " . $e->getMessage(), [ + 'exception' => $e, + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + return false; + } + } + + /** + * Log an error from the autoloader + * + * @param string $message Error message + * @param array $context Additional context + */ + private function logError(string $message, array $context = []): void + { + // Log to PHP error log + error_log('[ModuleAutoloader] ' . $message); + + if (!empty($context)) { + error_log('[ModuleAutoloader Context] ' . json_encode($context)); + } + } + + /** + * Extract namespace from a Module.php file + * + * @param string $filePath Path to the Module.php file (at modules/{handle}/lib/Module.php) + * @return string|null The namespace segment (e.g., 'ContactsManager') + */ + private function extractNamespace(string $filePath): ?string + { + if (!file_exists($filePath)) { + return null; + } + + $content = file_get_contents($filePath); + if ($content === false) { + return null; + } + + // Match namespace declaration: namespace KTXM\; + if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/core/lib/Module/ModuleCollection.php b/core/lib/Module/ModuleCollection.php new file mode 100644 index 0000000..24a0502 --- /dev/null +++ b/core/lib/Module/ModuleCollection.php @@ -0,0 +1,23 @@ + $item) { + $result[$key] = $item; + } + return $result; + } +} diff --git a/core/lib/Module/ModuleManager.php b/core/lib/Module/ModuleManager.php new file mode 100644 index 0000000..297e2de --- /dev/null +++ b/core/lib/Module/ModuleManager.php @@ -0,0 +1,464 @@ +serverRoot = Server::runtimeRootLocation(); + } + + /** + * List all modules as unified Module objects + * + * @param bool $installedOnly If true, only return modules that are in the database + * @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly) + * @return Module[] + */ + public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection + { + $modules = New ModuleCollection(); + // load all modules from store + $entries = $this->repository->list(); + foreach ($entries as $entry) { + if ($enabledOnly && !$entry->getEnabled()) { + continue; // Skip disabled modules if filtering for enabled only + } + // instance module + $handle = $entry->getHandle(); + if (isset($this->moduleInstances[$entry->getHandle()])) { + $modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry); + } else { + $moduleInstance = $this->moduleInstance($handle, $entry->getNamespace()); + $modules[$handle] = new ModuleObject($moduleInstance, $entry); + $this->moduleInstances[$handle] = $moduleInstance; + } + } + // load all modules from filesystem + if ($installedOnly === false) { + $discovered = $this->modulesDiscover(); + foreach ($discovered as $moduleInstance) { + $handle = $moduleInstance->handle(); + if (!isset($modules[$handle])) { + $modules[$handle] = new ModuleObject($moduleInstance, null); + } + } + } + + return $modules; + } + + public function install(string $handle): void + { + // First, try to find the module by scanning the filesystem + $modulesDir = $this->serverRoot . '/modules'; + $namespace = null; + + // Scan for the module by checking if handle matches any folder or module's handle() method + if (is_dir($modulesDir)) { + $moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR); + foreach ($moduleDirs as $moduleDir) { + $testModuleFile = $moduleDir . '/lib/Module.php'; + if (!file_exists($testModuleFile)) { + continue; + } + + // Extract namespace from the Module.php file + $testNamespace = $this->extractNamespaceFromFile($testModuleFile); + if (!$testNamespace) { + continue; + } + + // Try to instantiate with a temporary handle to check if it matches + $folderName = basename($moduleDir); + $testInstance = $this->moduleInstance($folderName, $testNamespace); + + if ($testInstance && $testInstance->handle() === $handle) { + $namespace = $testNamespace; + break; + } + } + } + + if (!$namespace) { + $this->logger->error('Module not found for installation', ['handle' => $handle]); + return; + } + + $moduleInstance = $this->moduleInstance($handle, $namespace); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->install(); + } catch (Exception $e) { + $this->logger->error('Module installation failed: ' . $handle, [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $module = new ModuleEntry(); + $module->setHandle($handle); + $module->setVersion($moduleInstance->version()); + $module->setEnabled(false); + $module->setInstalled(true); + // Store the namespace we found + $module->setNamespace($namespace); + $this->repository->deposit($module); + } + + public function uninstall(string $handle): void + { + $moduleEntry = $this->repository->fetch($handle); + if (!$moduleEntry || !$moduleEntry->getInstalled()) { + $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); + throw new Exception('Module not installed: ' . $handle); + } + + $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->uninstall(); + } catch (Exception $e) { + $this->logger->error('Module uninstallation failed: ' . $moduleEntry->getHandle(), [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $this->repository->destroy($moduleEntry); + } + + public function enable(string $handle): void + { + $moduleEntry = $this->repository->fetch($handle); + if (!$moduleEntry || !$moduleEntry->getInstalled()) { + $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); + throw new Exception('Module not installed: ' . $handle); + } + + $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->enable(); + } catch (Exception $e) { + $this->logger->error('Module enabling failed: ' . $moduleEntry->getHandle(), [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $moduleEntry->setEnabled(true); + $this->repository->deposit($moduleEntry); + } + + public function disable(string $handle): void + { + $moduleEntry = $this->repository->fetch($handle); + if (!$moduleEntry || !$moduleEntry->getInstalled()) { + $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); + throw new Exception('Module not installed: ' . $handle); + } + + $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->disable(); + } catch (Exception $e) { + $this->logger->error('Module disabling failed: ' . $moduleEntry->getHandle(), [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $moduleEntry->setEnabled(false); + $this->repository->deposit($moduleEntry); + } + + public function upgrade(string $handle): void + { + $moduleEntry = $this->repository->fetch($handle); + if (!$moduleEntry || !$moduleEntry->getInstalled()) { + $this->logger->warning('Attempted to uninstall non-installed module: ' . $handle); + throw new Exception('Module not installed: ' . $handle); + } + + $moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace()); + if (!$moduleInstance) { + return; + } + + try { + $moduleInstance->upgrade(); + } catch (Exception $e) { + $this->logger->error('Module upgrade failed: ' . $moduleEntry->getHandle(), [ + 'exception' => [ + 'code' => $e->getCode(), + 'message' => $e->getMessage(), + ] + ]); + return; + } + + $moduleEntry->setVersion($moduleInstance->version()); + $this->repository->deposit($moduleEntry); + } + + /** + * Scan filesystem for module directories and return module instances + * + * @return array Map of handle => ModuleInstanceInterface + */ + private function modulesDiscover(): array + { + $modules = []; + $modulesDir = $this->serverRoot . '/modules'; + + if (!is_dir($modulesDir)) { + return $modules; + } + + // Get list of installed module handles to skip + $installedHandles = []; + foreach ($this->repository->list() as $entry) { + $installedHandles[] = $entry->getHandle(); + } + + // Scan for module directories + $moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR); + foreach ($moduleDirs as $moduleDir) { + $moduleFile = $moduleDir . '/lib/Module.php'; + if (!file_exists($moduleFile)) { + continue; + } + + // Extract namespace from the Module.php file + $namespace = $this->extractNamespaceFromFile($moduleFile); + if (!$namespace) { + $this->logger->warning('Could not extract namespace from Module.php', [ + 'file' => $moduleFile + ]); + continue; + } + + // Use the folder name as a temporary handle to instantiate the module + $folderName = basename($moduleDir); + $moduleInstance = $this->moduleInstance($folderName, $namespace); + if (!$moduleInstance) { + continue; + } + + // Get the actual handle from the module instance + $handle = $moduleInstance->handle(); + + // Skip if already installed + if (in_array($handle, $installedHandles)) { + continue; + } + + // Re-cache with the correct handle if different from folder name + if ($handle !== $folderName) { + unset($this->moduleInstances[$folderName]); + $this->moduleInstances[$handle] = $moduleInstance; + } + + $modules[$handle] = $moduleInstance; + } + + return $modules; + } + + /** + * Boot all enabled modules (must be called after container is ready). + */ + public function modulesBoot(): void + { + // Only load modules that are enabled in the database + $modules = $this->list(); + $this->logger->debug('Booting enabled modules', ['count' => count($modules)]); + foreach ($modules as $module) { + $handle = $module->handle(); + try { + $module->boot(); + $this->logger->debug('Module booted', ['handle' => $handle]); + } catch (Exception $e) { + $this->logger->error('Module boot failed: ' . $handle, [ + 'exception' => $e, + 'message' => $e->getMessage(), + 'code' => $e->getCode(), + ]); + } + } + } + + public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface + { + // Return from cache if already instantiated + if (isset($this->moduleInstances[$handle])) { + return $this->moduleInstances[$handle]; + } + + // Determine the namespace segment + // If namespace is provided, use it; otherwise derive from handle + $nsSegment = $namespace ?: $this->studly($handle); + + $className = 'KTXM\\' . $nsSegment . '\\Module'; + + if (!class_exists($className)) { + $this->logger->error('Module class not found', [ + 'handle' => $handle, + 'namespace' => $namespace, + 'resolved' => $className + ]); + return null; + } + + if (!in_array(ModuleInstanceInterface::class, class_implements($className))) { + $this->logger->error('Module class does not implement ModuleInstanceInterface', [ + 'class' => $className + ]); + return null; + } + + try { + $module = $this->moduleLoad($className); + } catch (Exception $e) { + $this->logger->error('Failed to lazily create module instance', [ + 'handle' => $handle, + 'namespace' => $namespace, + 'exception' => $e->getMessage() + ]); + return null; + } + + // Cache by handle + if ($module) { + $this->moduleInstances[$handle] = $module; + } + + return $module; + } + + private function moduleLoad(string $className): ?ModuleInstanceInterface + { + try { + // Use reflection to check constructor requirements + $reflectionClass = new ReflectionClass($className); + $constructor = $reflectionClass->getConstructor(); + + if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) { + // Simple instantiation for modules without dependencies + return new $className(); + } + + // For modules with dependencies, try to resolve them from the container + $container = Server::runtimeContainer(); + $parameters = $constructor->getParameters(); + $args = []; + + foreach ($parameters as $parameter) { + $type = $parameter->getType(); + if ($type && !$type->isBuiltin()) { + $typeName = $type->getName(); + + // Try to get service from container + if ($container->has($typeName)) { + $args[] = $container->get($typeName); + } elseif ($parameter->isDefaultValueAvailable()) { + $args[] = $parameter->getDefaultValue(); + } else { + // Cannot resolve dependency + $this->logger->warning('Cannot resolve dependency for module: ' . $className, [ + 'dependency' => $typeName + ]); + return null; + } + } elseif ($parameter->isDefaultValueAvailable()) { + $args[] = $parameter->getDefaultValue(); + } else { + // Cannot resolve primitive dependency + return null; + } + } + + return $reflectionClass->newInstanceArgs($args); + + } catch (Exception $e) { + $this->logger->error('Failed to instantiate module: ' . $className, [ + 'exception' => $e->getMessage() + ]); + return null; + } + } + + private function studly(string $value): string + { + $value = str_replace(['-', '_'], ' ', strtolower($value)); + $value = ucwords($value); + return str_replace(' ', '', $value); + } + + /** + * Extract the PHP namespace from a Module.php file by parsing its contents + * + * @param string $moduleFilePath Absolute path to the Module.php file (located at /modules/{handle}/lib/Module.php) + * @return string|null The namespace segment (e.g., 'ContactsManager' from 'KTXM\ContactsManager') + */ + private function extractNamespaceFromFile(string $moduleFilePath): ?string + { + if (!file_exists($moduleFilePath)) { + return null; + } + + $content = file_get_contents($moduleFilePath); + if ($content === false) { + return null; + } + + // Match namespace declaration: namespace KTXM\; + if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) { + return $matches[1]; + } + + return null; + } + +} diff --git a/core/lib/Module/ModuleObject.php b/core/lib/Module/ModuleObject.php new file mode 100644 index 0000000..56e3fdb --- /dev/null +++ b/core/lib/Module/ModuleObject.php @@ -0,0 +1,161 @@ +instance = $instance; + $this->entry = $entry; + } + + // ===== Serialization ===== + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id(), + 'handle' => $this->handle(), + 'version' => $this->version(), + 'namespace' => $this->namespace(), + 'installed' => $this->installed(), + 'enabled' => $this->enabled(), + 'needsUpgrade' => $this->needsUpgrade(), + ]; + } + + // ===== State from ModuleEntry (database) ===== + + public function id(): ?string + { + return $this->entry?->getId(); + } + + public function installed(): bool + { + return $this->entry?->getInstalled() ?? false; + } + + public function enabled(): bool + { + return $this->entry?->getEnabled() ?? false; + } + + // ===== Information from ModuleInterface (filesystem) ===== + + public function handle(): string + { + if ($this->instance) { + return $this->instance->handle(); + } + if ($this->entry) { + return $this->entry->getHandle(); + } + throw new \RuntimeException('Module has neither instance nor entry'); + } + + public function namespace(): ?string + { + if ($this->entry) { + return $this->entry->getNamespace(); + } + if ($this->instance) { + // Extract namespace from class name + $className = get_class($this->instance); + $parts = explode('\\', $className); + if (count($parts) >= 2 && $parts[0] === 'KTXM') { + return $parts[1]; + } + } + return null; + } + + public function version(): string + { + // Prefer current version from filesystem + if ($this->instance) { + return $this->instance->version(); + } + // Fallback to stored version + if ($this->entry) { + return $this->entry->getVersion(); + } + return '0.0.0'; + } + + // ===== Computed properties ===== + + public function needsUpgrade(): bool + { + if (!$this->instance || !$this->entry || !$this->installed()) { + return false; + } + $currentVersion = $this->instance->version(); + $storedVersion = $this->entry->getVersion(); + return version_compare($currentVersion, $storedVersion, '>'); + } + + // ===== Access to underlying objects ===== + + public function instance(): ?ModuleInstanceInterface + { + return $this->instance; + } + + public function entry(): ?ModuleEntry + { + return $this->entry; + } + + // ===== Lifecycle methods (delegate to instance) ===== + + public function boot(): void + { + $this->instance?->boot(); + } + + public function install(): void + { + $this->instance?->install(); + } + + public function uninstall(): void + { + $this->instance?->uninstall(); + } + + public function enable(): void + { + $this->instance?->enable(); + } + + public function disable(): void + { + $this->instance?->disable(); + } + + public function upgrade(): void + { + $this->instance?->upgrade(); + } + + public function bootUi(): array | null + { + return $this->instance?->bootUi() ?? null; + } + +} diff --git a/core/lib/Module/Store/ModuleEntry.php b/core/lib/Module/Store/ModuleEntry.php new file mode 100644 index 0000000..04b9a2e --- /dev/null +++ b/core/lib/Module/Store/ModuleEntry.php @@ -0,0 +1,119 @@ +id = $data['_id'] !== null ? (string)$data['_id'] : null; + elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null; + if (array_key_exists('namespace', $data)) $this->namespace = $data['namespace'] !== null ? (string)$data['namespace'] : null; + if (array_key_exists('handle', $data)) $this->handle = $data['handle'] !== null ? (string)$data['handle'] : null; + if (array_key_exists('installed', $data)) $this->installed = (bool)$data['installed']; + if (array_key_exists('enabled', $data)) $this->enabled = (bool)$data['enabled']; + if (array_key_exists('version', $data)) $this->version = (string)$data['version']; + + return $this; + } + + /** + * Serialize to JSON-friendly structure. + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'namespace' => $this->namespace, + 'handle' => $this->handle, + 'installed' => $this->installed, + 'enabled' => $this->enabled, + 'version' => $this->version, + ]; + } + + public function getId(): ?string + { + return $this->id; + } + + public function setId(string $value): self + { + $this->id = $value; + return $this; + } + + public function getNamespace(): ?string + { + return $this->namespace; + } + + public function setNamespace(string $value): self + { + $this->namespace = $value; + return $this; + } + + public function getHandle(): ?string + { + return $this->handle; + } + + public function setHandle(string $value): self + { + $this->handle = $value; + return $this; + } + + public function getInstalled(): bool + { + return $this->installed; + } + + public function setInstalled(bool $value): self + { + $this->installed = $value; + return $this; + } + + public function getEnabled(): bool + { + return $this->enabled; + } + + public function setEnabled(bool $value): self + { + $this->enabled = $value; + return $this; + } + + public function getVersion(): string + { + return $this->version; + } + + public function setVersion(string $value): self + { + $this->version = $value; + return $this; + } +} diff --git a/core/lib/Module/Store/ModuleStore.php b/core/lib/Module/Store/ModuleStore.php new file mode 100644 index 0000000..749cf3a --- /dev/null +++ b/core/lib/Module/Store/ModuleStore.php @@ -0,0 +1,66 @@ +dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]); + $modules = []; + foreach ($cursor as $entry) { + $entity = new ModuleEntry(); + $entity->jsonDeserialize((array)$entry); + $modules[$entity->getId()] = $entity; + } + return $modules; + } + + public function fetch(string $handle): ?ModuleEntry + { + $entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['handle' => $handle]); + if (!$entry) { return null; } + return (new ModuleEntry())->jsonDeserialize((array)$entry); + } + + public function deposit(ModuleEntry $entry): ?ModuleEntry + { + if ($entry->getId()) { + return $this->update($entry); + } else { + return $this->create($entry); + } + } + + private function create(ModuleEntry $entry): ?ModuleEntry + { + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize()); + $entry->setId((string)$result->getInsertedId()); + return $entry; + } + + private function update(ModuleEntry $entry): ?ModuleEntry + { + $id = $entry->getId(); + if (!$id) { return null; } + $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]); + return $entry; + } + + public function destroy(ModuleEntry $entry): void + { + $id = $entry->getId(); + if (!$id) { return; } + $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); + } + +} diff --git a/core/lib/Resolver.php b/core/lib/Resolver.php new file mode 100644 index 0000000..41e08db --- /dev/null +++ b/core/lib/Resolver.php @@ -0,0 +1,253 @@ +resolveWithContext($className, []); + } + + /** + * Resolve and instantiate a class with specific context instances + */ + public function resolveWithContext(string $className, array $contextInstances = []): object + { + // Store context instances temporarily + $originalContext = $this->contextInstances; + $this->contextInstances = array_merge($this->contextInstances, $contextInstances); + + try { + // Check cache first (but not when we have context overrides) + if (empty($contextInstances) && isset($this->instanceCache[$className])) { + return $this->instanceCache[$className]; + } + + // Check if class exists + if (!class_exists($className)) { + throw new \InvalidArgumentException("Class {$className} does not exist"); + } + + $reflectionClass = new ReflectionClass($className); + + // If class cannot be instantiated + if (!$reflectionClass->isInstantiable()) { + throw new \InvalidArgumentException("Class {$className} is not instantiable"); + } + + $constructor = $reflectionClass->getConstructor(); + + // If no constructor, just instantiate + if ($constructor === null) { + $instance = new $className(); + if (empty($contextInstances)) { + $this->instanceCache[$className] = $instance; + } + return $instance; + } + + // Resolve constructor dependencies + $dependencies = []; + foreach ($constructor->getParameters() as $parameter) { + $dependencies[] = $this->resolveParameter($parameter); + } + + // Create instance with resolved dependencies + $instance = $reflectionClass->newInstanceArgs($dependencies); + + // Only cache if no context overrides + if (empty($contextInstances)) { + $this->instanceCache[$className] = $instance; + } + + return $instance; + } finally { + // Restore original context + $this->contextInstances = $originalContext; + } + } + + /** + * Resolve a single parameter dependency + */ + private function resolveParameter(ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + // If no type hint, check if it has a default value + if ($type === null) { + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + throw new \InvalidArgumentException("Cannot resolve parameter {$parameter->getName()} without type hint"); + } + + // Handle union types (PHP 8+) + if ($type instanceof \ReflectionUnionType) { + throw new \InvalidArgumentException("Union types are not supported for parameter {$parameter->getName()}"); + } + + $typeName = $type->getName(); + + // Check if we have a context instance for this type + if (isset($this->contextInstances[$typeName])) { + return $this->contextInstances[$typeName]; + } + + // Check global context + if (isset(self::$globalContext[$typeName])) { + return self::$globalContext[$typeName]; + } + + // Handle built-in types + if ($type->isBuiltin()) { + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + throw new \InvalidArgumentException("Cannot resolve built-in type {$typeName} for parameter {$parameter->getName()}"); + } + + // Always try to get from container first for all non-builtin types + try { + $container = Server::getContainer(); + if ($container->has($typeName)) { + return $container->get($typeName); + } + } catch (\Exception $e) { + // Fall through to manual resolution + } + + // Only try manual resolution for classes in our namespace + if (strpos($typeName, 'KTXC\\') === 0) { + try { + return $this->resolve($typeName); + } catch (\Exception $e) { + // If still can't resolve and has default value, use it + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + // If parameter is nullable, return null + if ($parameter->allowsNull()) { + return null; + } + + throw new \InvalidArgumentException("Cannot resolve dependency {$typeName} for parameter {$parameter->getName()}: " . $e->getMessage()); + } + } + + // For non-Ktrix classes that aren't in the container, fail gracefully + if ($parameter->isDefaultValueAvailable()) { + return $parameter->getDefaultValue(); + } + + if ($parameter->allowsNull()) { + return null; + } + + throw new \InvalidArgumentException("Cannot resolve external dependency {$typeName} for parameter {$parameter->getName()}. This service should be registered in the container."); + } + + /** + * Resolve and instantiate a class with automatic context detection + */ + public function resolveWithAutoContext(string $className, object $contextSource = null): object + { + $contextInstances = []; + + if ($contextSource !== null) { + // Use reflection to get all properties of the context source + $reflection = new ReflectionClass($contextSource); + $properties = $reflection->getProperties(); + + foreach ($properties as $property) { + $property->setAccessible(true); + $value = $property->getValue($contextSource); + + if (is_object($value)) { + $contextInstances[get_class($value)] = $value; + } + } + } + + return $this->resolveWithContext($className, $contextInstances); + } + + /** + * Smart resolve - automatically finds dependencies from the call stack + */ + public function smartResolve(string $className): object + { + $contextInstances = []; + + // Get the call stack to find potential context sources + $backtrace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 10); + + foreach ($backtrace as $frame) { + if (isset($frame['object']) && is_object($frame['object'])) { + $sourceObject = $frame['object']; + $reflection = new ReflectionClass($sourceObject); + $properties = $reflection->getProperties(); + + foreach ($properties as $property) { + $property->setAccessible(true); + $value = $property->getValue($sourceObject); + + if (is_object($value)) { + $contextInstances[get_class($value)] = $value; + } + } + } + } + + return $this->resolveWithContext($className, $contextInstances); + } + + /** + * Clear the instance cache + */ + public function clearCache(): void + { + $this->instanceCache = []; + } + + /** + * Check if a class is cached + */ + public function isCached(string $className): bool + { + return isset($this->instanceCache[$className]); + } +} diff --git a/core/lib/Resource/ProviderManager.php b/core/lib/Resource/ProviderManager.php new file mode 100644 index 0000000..f4847aa --- /dev/null +++ b/core/lib/Resource/ProviderManager.php @@ -0,0 +1,88 @@ +registeredProviders[$type][$identifier] = $class; + } + + /** + * Unregister a provider + */ + public function unregister(string $type, string $identifier): void + { + unset($this->registeredProviders[$type][$identifier]); + unset($this->resolvedProviders[$type][$identifier]); + } + + /** + * Resolve a provider by ID + */ + public function resolve(string $type, string $identifier): ?ProviderInterface + { + if (isset($this->resolvedProviders[$type][$identifier])) { + return $this->resolvedProviders[$type][$identifier]; + } + + if (!isset($this->registeredProviders[$type][$identifier])) { + return null; + } + + try { + $provider = Server::runtimeContainer()->get($this->registeredProviders[$type][$identifier]); + $this->resolvedProviders[$type][$identifier] = $provider; + return $provider; + } catch (\Exception $e) { + error_log("Failed to resolve provider {$identifier}: " . $e->getMessage()); + return null; + } + } + + /** + * Resolve multiple providers + * + * @param array|null $filter Optional list of provider IDs to return + * @return array + */ + public function providers(string $type, ?array $filter = null): array + { + $requestedProviders = $filter ?? array_keys($this->registeredProviders[$type] ?? []); + $result = []; + + foreach ($requestedProviders as $identifier) { + $provider = $this->resolve($type, $identifier); + if ($provider !== null) { + $result[$identifier] = $provider; + } + } + + return $result; + } + +} diff --git a/core/lib/Routing/Route.php b/core/lib/Routing/Route.php new file mode 100644 index 0000000..bfa96e3 --- /dev/null +++ b/core/lib/Routing/Route.php @@ -0,0 +1,29 @@ + Route parameters extracted from path */ + public array $params = []; + + public function __construct( + public readonly string $name, + public readonly string $method, + public readonly string $path, + public readonly bool $authenticated, + public readonly string $className, + public readonly string $classMethodName, + public readonly array $classMethodParameters = [], + ) {} + + public function withParams(array $params): self + { + $clone = clone $this; + $clone->params = $params; + return $clone; + } +} diff --git a/core/lib/Routing/Router.php b/core/lib/Routing/Router.php new file mode 100644 index 0000000..be0e7f9 --- /dev/null +++ b/core/lib/Routing/Router.php @@ -0,0 +1,248 @@ +> */ + private array $routes = []; // [method][path] => Route + private bool $initialized = false; + private string $cacheFile; + + public function __construct( + private readonly LoggerInterface $logger, + private readonly ModuleManager $moduleManager + ) + { + $this->container = Server::runtimeContainer(); + $this->cacheFile = Server::runtimeRootLocation() . '/var/cache/routes.cache.php'; + } + + private function initialize(): void + { + // load cached routes in production + if (Server::environment() === Server::ENVIRONMENT_PROD && file_exists($this->cacheFile)) { + $data = include $this->cacheFile; + if (is_array($data)) { + $this->routes = $data; + $this->initialized = true; + return; + } + } + // otherwise scan for routes + $this->scan(); + $this->initialized = true; + // write cache + $dir = dirname($this->cacheFile); + if (!is_dir($dir)) @mkdir($dir, 0775, true); + file_put_contents($this->cacheFile, 'routes, true) . ';'); + } + + + private function scan(): void + { + // load core controllers + foreach (glob(Server::runtimeRootLocation() . '/core/lib/Controllers/*.php') as $file) { + $this->extract($file); + } + + // load module controllers + foreach ($this->moduleManager->list(true, true) as $module) { + $path = Server::runtimeModuleLocation() . '/' . $module->handle() . '/lib/Controllers'; + if (is_dir($path)) { + foreach (glob($path . '/*.php') as $file) { + $this->extract($file, '/m/' . $module->handle()); + } + } + } + } + + private function extract(string $file, string $routePrefix = ''): void + { + $contents = file_get_contents($file); + if ($contents === false) return; + // extract namespace + if (!preg_match('#namespace\\s+([^;]+);#', $contents, $nsM)) return; + $ns = trim($nsM[1]); + // extract class names + if (!preg_match_all('#class\\s+(\\w+)#', $contents, $cM)) return; + foreach ($cM[1] as $class) { + $fqcn = $ns . '\\' . $class; + try { + if (!class_exists($fqcn)) { + continue; + } + require_once $file; + $reflectionClass = new ReflectionClass($fqcn); + if ($reflectionClass->isAbstract()) continue; + foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) { + $attributes = array_merge( + $reflectionMethod->getAttributes(AnonymousRoute::class), + $reflectionMethod->getAttributes(AuthenticatedRoute::class) + ); + foreach ($attributes as $attribute) { + $route = $attribute->newInstance(); + $httpPath = $routePrefix . $route->path; + foreach ($route->methods as $httpMethod) { + $this->routes[$httpMethod][$httpPath] = new Route( + method: $httpMethod, + path: $httpPath, + name: $route->name, + authenticated: $route instanceof AuthenticatedRoute, + className: $reflectionClass->getName(), + classMethodName: $reflectionMethod->getName(), + classMethodParameters: $reflectionMethod->getParameters(), + ); + } + } + } + } catch (\Throwable $e) { + $this->logger->error('Route collection failed', ['file' => $file, 'error' => $e->getMessage()]); + } + } + } + + /** + * Match a Request to a Route, or return null if no match. + * Supports exact matches and simple {param} patterns. + * Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns + */ + public function match(Request $request): ?Route + { + if (!$this->initialized) { + $this->initialize(); + } + $method = $request->getMethod(); + $path = $request->getPathInfo(); + // Exact match first + if (isset($this->routes[$method][$path])) { + return $this->routes[$method][$path]; + } + // Pattern matching - separate catch-all from specific patterns + $specificPatterns = []; + $catchAllPattern = null; + foreach ($this->routes[$method] ?? [] as $routePath => $routeObj) { + if (str_contains($routePath, '{')) { + // Check if this is a catch-all pattern (e.g., /{path}) + if (preg_match('#^/\{[^/]+\}$#', $routePath)) { + $catchAllPattern = [$routePath, $routeObj]; + } else { + $specificPatterns[] = [$routePath, $routeObj]; + } + } + } + // Try specific patterns first + foreach ($specificPatterns as [$routePath, $routeObj]) { + $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $routePath); + $pattern = '#^' . $pattern . '$#'; + if (preg_match($pattern, $path, $m)) { + $params = []; + foreach ($m as $k => $v) { + if (is_string($k)) { $params[$k] = $v; } + } + return $routeObj->withParams($params); + } + } + // Try catch-all pattern last + if ($catchAllPattern !== null) { + [$routePath, $routeObj] = $catchAllPattern; + $pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>.*)', $routePath); + $pattern = '#^' . $pattern . '$#'; + if (preg_match($pattern, $path, $m)) { + $params = []; + foreach ($m as $k => $v) { + if (is_string($k)) { $params[$k] = $v; } + } + return $routeObj->withParams($params); + } + } + return null; + } + + /** + * Dispatch a matched route meta and return a Response (or null if controller does not return one). + * Performs light argument resolution: Request object, route params, body fields, full body for array params. + */ + public function dispatch(Route $route, Request $request): ?Response + { + // extract controller and method + $routeControllerName = $route->className; + $routeControllerMethod = $route->classMethodName; + $routeControllerParameters = $route->classMethodParameters; + //try { + // instantiate controller + if ($this->container->has($routeControllerName)) { + $instance = $this->container->get($routeControllerName); + } else { + $instance = new $routeControllerName(); + } + try { + $requestParameters = $request->getPayload(); + } catch (\Throwable) { + // ignore payload errors + } + $reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod); + $routeParams = $route->params ?? []; + $callArgs = []; + foreach ($reflectionMethod->getParameters() as $reflectionParameter) { + $reflectionParameterName = $reflectionParameter->getName(); + $reflectionParameterType = $reflectionParameter->getType(); + // if parameter matches request class, use current request + if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), Request::class, true)) { + $callArgs[] = $request; + continue; + } + // if method parameter matches a route path param, use that (highest priority) + if (array_key_exists($reflectionParameterName, $routeParams)) { + $callArgs[] = $routeParams[$reflectionParameterName]; + continue; + } + // if method parameter matches a request param, use that + if ($requestParameters->has($reflectionParameterName)) { + // if parameter is a class implementing JsonDeserializable, call jsonDeserialize on it + if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), JsonDeserializable::class, true)) { + $type = $reflectionParameterType->getName(); + $object = new $type(); + if ($object instanceof JsonDeserializable) { + $object->jsonDeserialize($requestParameters->get($reflectionParameterName)); + $callArgs[] = $object; + continue; + } + } + // otherwise, use the raw value + $callArgs[] = $requestParameters->get($reflectionParameterName); + continue; + } + // if method parameter did not match, but has a default value, use that + if ($reflectionParameter->isDefaultValueAvailable()) { + $callArgs[] = $reflectionParameter->getDefaultValue(); + continue; + } + $callArgs[] = null; + } + $result = $instance->$routeControllerMethod(...$callArgs); + return $result instanceof Response ? $result : null; + //} catch (\Throwable $e) { + // $this->logger->error('Route dispatch failed', [ + // 'controller' => $routeControllerName, + // 'method' => $routeControllerMethod, + // 'error' => $e->getMessage(), + // ]); + // throw $e; + //} + } + +} diff --git a/core/lib/Security/Authentication/AuthenticationRequest.php b/core/lib/Security/Authentication/AuthenticationRequest.php new file mode 100644 index 0000000..040c3e1 --- /dev/null +++ b/core/lib/Security/Authentication/AuthenticationRequest.php @@ -0,0 +1,180 @@ + $allDevices], + ); + } +} diff --git a/core/lib/Security/Authentication/AuthenticationResponse.php b/core/lib/Security/Authentication/AuthenticationResponse.php new file mode 100644 index 0000000..63a8074 --- /dev/null +++ b/core/lib/Security/Authentication/AuthenticationResponse.php @@ -0,0 +1,272 @@ + $identity] : null, + ); + } + + // ========================================================================= + // Status Checks + // ========================================================================= + + public function isSuccess(): bool + { + return $this->status === self::STATUS_SUCCESS; + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isRedirect(): bool + { + return $this->status === self::STATUS_REDIRECT; + } + + public function isFailed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + public function hasTokens(): bool + { + return $this->tokens !== null && !empty($this->tokens); + } + + // ========================================================================= + // Serialization + // ========================================================================= + + /** + * Convert to array for JSON response + */ + public function toArray(): array + { + $result = ['status' => $this->status]; + + if ($this->sessionId !== null) { + $result['session'] = $this->sessionId; + } + + if ($this->sessionState !== null) { + $result['state'] = $this->sessionState; + } + + if ($this->user !== null) { + $result['user'] = $this->user; + } + + if ($this->methods !== null) { + $result['methods'] = $this->methods; + } + + if ($this->challenge !== null) { + $result['challenge'] = $this->challenge; + } + + if ($this->redirectUrl !== null) { + $result['redirect_url'] = $this->redirectUrl; + } + + if ($this->returnUrl !== null) { + $result['return_url'] = $this->returnUrl; + } + + if ($this->errorCode !== null) { + $result['error_code'] = $this->errorCode; + } + + if ($this->errorMessage !== null) { + $result['error'] = $this->errorMessage; + } + + return $result; + } +} diff --git a/core/lib/Security/AuthenticationManager.php b/core/lib/Security/AuthenticationManager.php new file mode 100644 index 0000000..9e5cea1 --- /dev/null +++ b/core/lib/Security/AuthenticationManager.php @@ -0,0 +1,793 @@ +securityCode = $this->tenant->configuration()->security()->code(); + } + + // ========================================================================= + // Main Entry Point + // ========================================================================= + + /** + * Handle an authentication request + */ + public function handle(AuthenticationRequest $request): AuthenticationResponse + { + return match ($request->action) { + AuthenticationRequest::ACTION_START => $this->handleStart(), + AuthenticationRequest::ACTION_IDENTIFY => $this->handleIdentify($request), + AuthenticationRequest::ACTION_VERIFY => $this->handleVerify($request), + AuthenticationRequest::ACTION_CHALLENGE => $this->handleChallenge($request), + AuthenticationRequest::ACTION_REDIRECT => $this->handleRedirect($request), + AuthenticationRequest::ACTION_CALLBACK => $this->handleCallback($request), + AuthenticationRequest::ACTION_STATUS => $this->handleStatus($request), + AuthenticationRequest::ACTION_CANCEL => $this->handleCancel($request), + AuthenticationRequest::ACTION_REFRESH => $this->handleRefresh($request), + AuthenticationRequest::ACTION_LOGOUT => $this->handleLogout($request), + default => AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_REQUEST, + 'Unknown action', + 400 + ), + }; + } + + // ========================================================================= + // Action Handlers + // ========================================================================= + + /** + * Start a new authentication session + */ + private function handleStart(): AuthenticationResponse + { + $methods = $this->methodsConfigured(); + + $session = AuthenticationSession::create( + $this->tenant->identifier(), + AuthenticationSession::STATE_FRESH + ); + + $this->saveSession($session); + + return AuthenticationResponse::started($session->id, $methods); + } + + /** + * Identify user (identity-first flow) + */ + private function handleIdentify(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + // Return all tenant methods to prevent enumeration + // Filter to non-redirect methods since redirects don't need identity first + $methods = $this->methodsConfigured(); + $methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect')); + $require = $this->tenant->configuration()->authentication()->methodsMinimal(); + + // Store identity in session without validating to prevent enumeration + $session->setMethods(array_column($methods, 'id'), $require); + $session->setIdentity($request->identity); + $this->saveSession($session); + + return AuthenticationResponse::identified($session->id, $session->state(), $methods); + } + + /** + * Verify credentials or challenge response + */ + private function handleVerify(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + if (empty($session->userIdentity)) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_SESSION, + 'Identity is required', + 400 + ); + } + + $method = $request->method; + + if (!$session->methodEligible($method)) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_REQUEST, + 'Method not available', + 400 + ); + } + + $provider = $this->providerManager->resolve('authentication', $method); + if (!$provider instanceof AuthenticationProviderInterface) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider not available', + 400 + ); + } + + // Build provider context + $context = $this->buildProviderContext($session, $method); + + // Call appropriate provider method based on provider type + $providerMethod = $provider->method(); + + if ($providerMethod === AuthenticationProviderInterface::METHOD_CREDENTIAL) { + $result = $provider->verify($context, $request->secret); + } elseif ($providerMethod === AuthenticationProviderInterface::METHOD_CHALLENGE) { + $result = $provider->verifyChallenge($context, $request->secret); + } else { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider cannot be used for direct verification', + 400 + ); + } + + // Store any session data from provider + if (!empty($result->sessionData)) { + $session->setMeta("provider:{$method}", $result->sessionData); + } + + if (!$result->isSuccess()) { + $this->saveSession($session); + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + 'Authentication failed. If you haven\'t set up this method, try another option.', + 401 + ); + } + + // Resolve user if not yet set + if ($session->userIdentifier === null) { + $user = $this->userService->fetchByIdentity($session->userIdentity); + if ($user === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_USER_NOT_FOUND, + 'User not found', + 401 + ); + } + $session->userIdentifier = $user->getId(); + } + + // Mark method complete + $session->methodCompleted($method); + $this->saveSession($session); + + // Check if all required factors are complete + if ($session->state() !== AuthenticationSession::STATE_COMPLETE) { + $remainingMethods = $this->methodsConfigured($session->methodsCompleted); + // Filter out redirect methods - they can't be used as secondary factors + $remainingMethods = array_values(array_filter( + $remainingMethods, + fn($m) => $m['method'] !== 'redirect' + )); + return AuthenticationResponse::pending($session->id, $remainingMethods); + } + + // Authentication complete - issue tokens + return $this->completeAuthentication($session); + } + + /** + * Begin a challenge (SMS, email, TOTP preparation) + */ + private function handleChallenge(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + $method = $request->method; + + // Resolve user identifier if needed + if ($session->userIdentifier === null && $session->userIdentity) { + $user = $this->userService->fetchByIdentity($session->userIdentity); + if ($user) { + $session->userIdentifier = $user->getId(); + $this->saveSession($session); + } + } + + $provider = $this->providerManager->resolve('authentication', $method); + if (!$provider instanceof AuthenticationProviderInterface) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider not available', + 400 + ); + } + + $context = $this->buildProviderContext($session, $method); + $result = $provider->beginChallenge($context); + + // Store any session data from provider + if (!empty($result->sessionData)) { + $session->setMeta("provider:{$method}", $result->sessionData); + $this->saveSession($session); + } + + if ($result->isChallenge()) { + return AuthenticationResponse::challenge( + $session->id, + $result->getClientData('challenge', []) + ); + } + + if ($result->isFailed()) { + // Generic error to prevent enumeration + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + 'Authentication failed. If you haven\'t set up this method, try another option.', + 401 + ); + } + + // Unexpected result + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INTERNAL, + 'Unexpected provider response', + 500 + ); + } + + /** + * Begin redirect-based authentication (OIDC/SAML) + */ + private function handleRedirect(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + $method = $request->method; + + $provider = $this->providerManager->resolve('authentication', $method); + if (!$provider instanceof AuthenticationProviderInterface) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider not available', + 400 + ); + } + + if ($provider->method() !== AuthenticationProviderInterface::METHOD_REDIRECT) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider does not support redirect authentication', + 400 + ); + } + + $context = $this->buildProviderContext($session, $method); + $result = $provider->beginRedirect($context, $request->callbackUrl, $request->returnUrl); + + if ($result->isFailed()) { + return AuthenticationResponse::failed( + $result->errorCode ?? AuthenticationResponse::ERROR_INTERNAL, + $result->errorMessage ?? 'Failed to initiate redirect authentication', + 500 + ); + } + + // Store provider session data (state, nonce, etc.) + $session->setMeta("provider:{$method}", $result->sessionData); + $session->setMeta('redirect_method', $method); + $this->saveSession($session); + + return AuthenticationResponse::redirect( + $session->id, + $result->getClientData('redirect_url') + ); + } + + /** + * Complete redirect-based authentication (callback from IdP) + */ + private function handleCallback(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Invalid or expired session', + 401 + ); + } + + $method = $request->method; + $expectedMethod = $session->getMeta('redirect_method'); + + if ($expectedMethod !== $method) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_SESSION, + 'Provider mismatch', + 400 + ); + } + + $provider = $this->providerManager->resolve('authentication', $method); + if (!$provider instanceof AuthenticationProviderInterface) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_PROVIDER, + 'Provider not available', + 400 + ); + } + + $context = $this->buildProviderContext($session, $method); + $result = $provider->completeRedirect($context, $request->params); + + if ($result->isFailed()) { + $this->deleteSession($session->id); + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + $result->errorMessage ?? 'Authentication failed', + 401 + ); + } + + // Find or provision user from external identity + $providerConfig = $this->getProviderConfig($method); + $user = $this->findOrProvisionUser($method, $result->identity, $providerConfig); + + if ($user === null) { + $this->deleteSession($session->id); + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_USER_NOT_FOUND, + 'User not found and auto-provisioning is disabled', + 401 + ); + } + + // Set user in session + $session->userIdentifier = $user->getId(); + $session->userIdentity = $user->getIdentity(); + $session->methodCompleted($method); + + // Check if MFA is required + $require = $this->tenant->configuration()->authentication()->methodsMinimal(); + if ($require > 1) { + $remainingMethods = $this->methodsConfigured([$method]); + // Filter out redirect methods - they can't be used as secondary factors + $remainingMethods = array_values(array_filter( + $remainingMethods, + fn($m) => $m['method'] !== 'redirect' + )); + $session->setMethods(array_column($remainingMethods, 'id'), $require); + $this->saveSession($session); + + return AuthenticationResponse::pending($session->id, $remainingMethods); + } + + // Authentication complete + return $this->completeAuthentication($session); + } + + /** + * Get session status + */ + private function handleStatus(AuthenticationRequest $request): AuthenticationResponse + { + $session = $this->retrieveSession($request->sessionId); + + if ($session === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_SESSION_EXPIRED, + 'Session not found or expired', + 404 + ); + } + + $methods = $this->methodsConfigured($session->methodsCompleted); + + return AuthenticationResponse::status( + $session->id, + $session->state(), + $methods, + $session->userIdentity + ); + } + + /** + * Cancel session + */ + private function handleCancel(AuthenticationRequest $request): AuthenticationResponse + { + if ($request->sessionId) { + $this->deleteSession($request->sessionId); + } + + return AuthenticationResponse::cancelled(); + } + + /** + * Refresh access token + */ + private function handleRefresh(AuthenticationRequest $request): AuthenticationResponse + { + $payload = $this->tokenService->validateToken($request->token, $this->securityCode); + + if (!$payload) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + 'Invalid or expired refresh token', + 401 + ); + } + + if (($payload['type'] ?? null) !== 'refresh') { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_INVALID_CREDENTIALS, + 'Invalid token type', + 401 + ); + } + + $identifier = $payload['identifier'] ?? null; + $userData = $this->userService->fetchByIdentifier($identifier); + + if ($userData === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_USER_NOT_FOUND, + 'User not found', + 401 + ); + } + + $user = new User(); + $user->populate($userData, 'users'); + + $accessToken = $this->tokenService->createToken( + [ + 'tenant' => $this->tenant->identifier(), + 'identifier' => $user->getId(), + 'identity' => $user->getIdentity(), + 'label' => $user->getLabel(), + 'permissions' => $user->getPermissions(), + 'mfa_verified' => true, + ], + $this->securityCode, + 900 + ); + + return AuthenticationResponse::success( + $this->buildUserData($user), + ['access' => $accessToken] + ); + } + + /** + * Logout + */ + private function handleLogout(AuthenticationRequest $request): AuthenticationResponse + { + $allDevices = $request->params['all_devices'] ?? false; + + if ($request->token) { + $payload = $this->tokenService->validateToken($request->token, $this->securityCode); + + if ($payload) { + if ($allDevices && isset($payload['identity'])) { + $this->tokenService->blacklistUserTokensBefore($payload['identity'], time()); + } elseif (isset($payload['jti'], $payload['exp'])) { + $this->tokenService->blacklist($payload['jti'], $payload['exp']); + } + } + } + + return AuthenticationResponse::cancelled(); + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + /** + * Build provider context from session + */ + private function buildProviderContext(AuthenticationSession $session, string $method): ProviderContext + { + return new ProviderContext( + tenantId: $session->tenantIdentifier, + userIdentifier: $session->userIdentifier, + userIdentity: $session->userIdentity, + metadata: $session->getMeta("provider:{$method}") ?? [], + config: $this->getProviderConfig($method), + ); + } + + /** + * Get provider configuration + */ + private function getProviderConfig(string $method): array + { + $providers = $this->tenant->configuration()->authentication()->providers(); + return $providers[$method]['config'] ?? []; + } + + /** + * Complete authentication and issue tokens + */ + private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse + { + $userData = $this->userService->fetchByIdentifier($session->userIdentifier); + + if ($userData === null) { + return AuthenticationResponse::failed( + AuthenticationResponse::ERROR_USER_NOT_FOUND, + 'User not found', + 401 + ); + } + + $user = new User(); + $user->populate($userData, 'users'); + + $tokens = $this->createTokens($user, count($session->methodsCompleted) > 1); + + $this->deleteSession($session->id); + + return AuthenticationResponse::success( + $this->buildUserData($user), + $tokens + ); + } + + /** + * Build user data for response + */ + private function buildUserData(User $user): array + { + return [ + 'identifier' => $user->getId(), + 'identity' => $user->getIdentity(), + 'label' => $user->getLabel(), + 'permissions' => $user->getPermissions(), + ]; + } + + /** + * Get configured authentication methods + */ + private function methodsConfigured(array $methodsCompleted = []): array + { + $tenantProviders = $this->tenant->configuration()->authentication()->providers(); + $methods = []; + + foreach ($tenantProviders as $providerId => $providerConfiguration) { + if (!($providerConfiguration['enabled'] ?? false)) { + continue; + } + + if (in_array($providerId, $methodsCompleted, true)) { + continue; + } + + $provider = $this->providerManager->resolve('authentication', $providerId); + if (!$provider instanceof AuthenticationProviderInterface) { + continue; + } + + $methods[] = [ + 'id' => $providerId, + 'method' => $provider->method(), + 'label' => $providerConfiguration['label'] ?? $provider->label(), + 'icon' => $providerConfiguration['icon'] ?? $provider->icon() ?? null, + ]; + } + + return $methods; + } + + /** + * Create JWT tokens + */ + private function createTokens(User $user, bool $mfaVerified = false): array + { + $payload = [ + 'tenant' => $this->tenant->identifier(), + 'identifier' => $user->getId(), + 'identity' => $user->getIdentity(), + 'label' => $user->getLabel(), + 'permissions' => $user->getPermissions(), + 'mfa_verified' => $mfaVerified, + ]; + + return [ + 'access' => $this->tokenService->createToken($payload, $this->securityCode, 900), + 'refresh' => $this->tokenService->createToken( + [ + 'tenant' => $payload['tenant'], + 'identifier' => $payload['identifier'], + 'identity' => $payload['identity'], + 'type' => 'refresh', + ], + $this->securityCode, + 604800 + ), + ]; + } + + /** + * Find or provision user from external identity + */ + private function findOrProvisionUser( + string $providerId, + array $identity, + array $providerConfig + ): ?User { + $userIdentity = $identity['email'] ?? $identity['identity'] ?? null; + $externalSubject = $identity['subject'] ?? $identity['sub'] ?? null; + $attributes = $identity['attributes'] ?? []; + $attributes['identity'] = $userIdentity; + $attributes['external_subject'] = $externalSubject; + + // Try to find by external subject first + if ($externalSubject) { + $user = $this->provisioningService->findByExternalIdentity($providerId, $externalSubject); + if ($user) { + $this->provisioningService->syncProfile( + $user, + $attributes, + $providerConfig['attribute_map'] ?? [] + ); + return $user; + } + } + + // Try to find by identity + if ($userIdentity) { + $existingUser = $this->userService->fetchByIdentity($userIdentity); + if ($existingUser) { + if ($existingUser->getProvider() === $providerId) { + if ($externalSubject) { + $this->provisioningService->linkExternalIdentity( + $existingUser, + $providerId, + $externalSubject, + $attributes + ); + } + $this->provisioningService->syncProfile( + $existingUser, + $attributes, + $providerConfig['attribute_map'] ?? [] + ); + return $existingUser; + } + return null; + } + } + + // Auto-provision if enabled + if ($this->provisioningService->isAutoProvisioningEnabled($providerId)) { + return $this->provisioningService->provisionUser( + $providerId, + $attributes, + $providerConfig + ); + } + + return null; + } + + // ========================================================================= + // Session Cache Helpers + // ========================================================================= + + /** + * Retrieve authentication session from cache + */ + private function retrieveSession(?string $sessionId): ?AuthenticationSession + { + if (empty($sessionId)) { + return null; + } + + $data = $this->cache->get($sessionId, CacheScope::Tenant, self::CACHE_USAGE); + + if ($data === null) { + return null; + } + + if ($data instanceof AuthenticationSession) { + if ($data->isExpired()) { + $this->deleteSession($sessionId); + return null; + } + return $data; + } + + return null; + } + + /** + * Save authentication session to cache + */ + private function saveSession(AuthenticationSession $session): bool + { + $ttl = $session->expiresAt > 0 ? $session->expiresAt - time() : AuthenticationSession::DEFAULT_TTL; + + return $this->cache->set( + $session->id, + $session, + CacheScope::Tenant, + self::CACHE_USAGE, + max($ttl, 60) + ); + } + + /** + * Delete authentication session from cache + */ + private function deleteSession(string $sessionId): bool + { + return $this->cache->delete($sessionId, CacheScope::Tenant, self::CACHE_USAGE); + } +} diff --git a/core/lib/Server.php b/core/lib/Server.php new file mode 100644 index 0000000..729e2ea --- /dev/null +++ b/core/lib/Server.php @@ -0,0 +1,183 @@ +handle($request); + if ($response instanceof Response) { + $response->send(); + } + } catch (\Throwable $e) { + self::logException($e); + $content = self::debug() + ? '
' . htmlspecialchars((string) $e) . '
' + : 'An error occurred. Please try again later.'; + $response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [ + 'Content-Type' => 'text/html; charset=UTF-8', + ]); + $response->send(); + exit(1); + } + } + + public static function environment(): string { + return self::$kernel->environment(); + } + + public static function debug(): bool { + return self::$kernel->debug(); + } + + public static function runtimeKernel(): Kernel { + return self::$kernel; + } + + public static function runtimeContainer(): Container { + return self::$kernel->container(); + } + + public static function runtimeRootLocation(): string { + return self::$kernel->folderRoot(); + } + + public static function runtimeModuleLocation(): string { + return self::$kernel->folderRoot() . '/modules'; + } + + /** + * Set up global error and exception handlers + */ + protected static function setupErrorHandlers(): void { + // Convert PHP errors to exceptions + set_error_handler(function ($errno, $errstr, $errfile, $errline) { + // Don't throw exception if error reporting is turned off + if (!(error_reporting() & $errno)) { + return false; + } + + $message = sprintf( + "PHP Error [%d]: %s in %s:%d", + $errno, + $errstr, + $errfile, + $errline + ); + + self::logError($message, [ + 'errno' => $errno, + 'file' => $errfile, + 'line' => $errline, + ]); + + // Throw exception for fatal errors + if ($errno === E_ERROR || $errno === E_CORE_ERROR || $errno === E_COMPILE_ERROR || $errno === E_USER_ERROR) { + throw new ErrorException($errstr, 0, $errno, $errfile, $errline); + } + + return true; + }); + + // Handle uncaught exceptions + set_exception_handler(function (Throwable $exception) { + self::logException($exception); + + if (self::debug()) { + echo '
Uncaught Exception: ' . $exception . '
'; + } else { + echo 'An unexpected error occurred. Please try again later.'; + } + + exit(1); + }); + + // Handle fatal errors + register_shutdown_function(function () { + $error = error_get_last(); + if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) { + $message = sprintf( + "Fatal Error [%d]: %s in %s:%d", + $error['type'], + $error['message'], + $error['file'], + $error['line'] + ); + + self::logError($message, $error); + + if (self::debug()) { + echo '
' . $message . '
'; + } else { + echo 'A fatal error occurred. Please try again later.'; + } + } + }); + } + + /** + * Log an error message + */ + protected static function logError(string $message, array $context = []): void { + try { + if (self::$kernel && self::$kernel->container()->has(LoggerInterface::class)) { + $logger = self::$kernel->container()->get(LoggerInterface::class); + $logger->error($message, $context); + } else { + // Fallback to error_log if logger not available + error_log($message . ' ' . json_encode($context)); + } + } catch (Throwable $e) { + // Last resort fallback + error_log('Error logging failed: ' . $e->getMessage()); + error_log($message . ' ' . json_encode($context)); + } + } + + /** + * Log an exception + */ + protected static function logException(Throwable $exception): void { + try { + if (self::$kernel && self::$kernel->container()->has(LoggerInterface::class)) { + $logger = self::$kernel->container()->get(LoggerInterface::class); + $logger->error('Exception caught: ' . $exception->getMessage(), [ + 'exception' => $exception, + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString(), + ]); + } else { + // Fallback to error_log if logger not available + error_log('Exception: ' . $exception->getMessage() . ' in ' . $exception->getFile() . ':' . $exception->getLine()); + error_log($exception->getTraceAsString()); + } + } catch (Throwable $e) { + // Last resort fallback + error_log('Exception logging failed: ' . $e->getMessage()); + error_log('Original exception: ' . $exception->getMessage()); + } + } + +} diff --git a/core/lib/Service/ConfigurationService.php b/core/lib/Service/ConfigurationService.php new file mode 100644 index 0000000..a5ea6b8 --- /dev/null +++ b/core/lib/Service/ConfigurationService.php @@ -0,0 +1,228 @@ +collection = $store->selectCollection(self::TABLE_NAME); + $this->collection->createIndex(['did' => 1, 'path' => 1, 'key' => 1], ['unique' => true]); + } + + /** + * Get a configuration value by path and key + */ + public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]); + if (!$doc) { return $default; } + $value = $doc['value'] ?? ($doc['default'] ?? null); + if ($value === null) { return $default; } + return $this->convertFromDatabase((string)$value, (int)$doc['type']); + } + + /** + * Set a configuration value + */ + public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $type = $this->determineType($value); + $serializedValue = $this->convertToDatabase($value, $type); + $serializedDefault = $default !== null ? $this->convertToDatabase($default, $type) : null; + $this->collection->updateOne( + ['did' => $tenant, 'path' => $path, 'key' => $key], + ['$set' => [ + 'did' => $tenant, + 'path' => $path, + 'key' => $key, + 'value' => $serializedValue, + 'type' => $type, + 'default' => $serializedDefault, + 'updated_at' => $this->bsonUtcDateTime() + ], '$setOnInsert' => [ 'created_at' => $this->bsonUtcDateTime() ]], + ['upsert' => true] + ); + return true; + } + + /** + * Get all configuration values for a specific path + */ + public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $filter = ['did' => $tenant]; + if ($path !== null) { + if ($subset) { + $filter['$or'] = [ + ['path' => $path], + ['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']] + ]; + } else { + $filter['path'] = $path; + } + } + $cursor = $this->collection->find($filter); + $configurations = []; + foreach ($cursor as $doc) { + $value = $doc['value'] ?? ($doc['default'] ?? null); + $convertedValue = $value !== null ? $this->convertFromDatabase((string)$value, (int)$doc['type']) : null; + $configurations[$doc['path']] = [$doc['key'] => $convertedValue]; + } + return $configurations; + } + + /** + * Delete a configuration value + */ + public function delete(string $path, string $key, ?string $tenant = null): bool + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]); + return true; + } + + /** + * Delete all configuration values for a specific path + */ + public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + $filter = ['did' => $tenant]; + if ($includeSubPaths) { + $filter['$or'] = [ + ['path' => $path], + ['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']] + ]; + } else { + $filter['path'] = $path; + } + $this->collection->deleteMany($filter); + return true; + } + + /** + * Check if a configuration exists + */ + public function exists(string $path, string $key, ?string $tenant = null): bool + { + if ($tenant === null && !$this->tenant->isConfigured()) { + throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); + } elseif ($tenant === null) { + $tenant = $this->tenant->identifier(); + } + + return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0; + } + + /** + * Determine the type of a PHP value + */ + private function determineType(mixed $value): int + { + return match (true) { + is_null($value) => self::TYPE_NULL, + is_bool($value) => self::TYPE_BOOLEAN, + is_int($value) => self::TYPE_INTEGER, + is_float($value) => self::TYPE_FLOAT, + is_array($value) => self::TYPE_ARRAY, + is_string($value) && $this->isJson($value) => self::TYPE_JSON, + default => self::TYPE_STRING + }; + } + + /** + * Convert a PHP value to database format + */ + private function convertToDatabase(mixed $value, int $type): string + { + return match ($type) { + self::TYPE_NULL => '', + self::TYPE_BOOLEAN => $value ? '1' : '0', + self::TYPE_INTEGER => (string)$value, + self::TYPE_FLOAT => (string)$value, + self::TYPE_ARRAY, self::TYPE_JSON => json_encode($value), + default => (string)$value + }; + } + + /** + * Convert a database value to PHP format + */ + private function convertFromDatabase(string $value, int $type): mixed + { + return match ($type) { + self::TYPE_NULL => null, + self::TYPE_BOOLEAN => $value === '1', + self::TYPE_INTEGER => (int)$value, + self::TYPE_FLOAT => (float)$value, + self::TYPE_ARRAY, self::TYPE_JSON => json_decode($value, true), + default => $value + }; + } + + /** + * Check if a string is valid JSON + */ + private function isJson(string $string): bool + { + json_decode($string); + return json_last_error() === JSON_ERROR_NONE; + } + /** + * Create a UTCDateTime for timestamp fields + */ + private function bsonUtcDateTime(): UTCDateTime + { + return UTCDateTime::now(); + } +} diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php new file mode 100644 index 0000000..da872f5 --- /dev/null +++ b/core/lib/Service/FirewallService.php @@ -0,0 +1,630 @@ +eventBus->subscribe( + SecurityEvent::AUTH_FAILURE, + [$this, 'handleAuthFailure'], + 100 // High priority + ); + + // Log all security events asynchronously + $this->eventBus->subscribeAsync( + SecurityEvent::AUTH_FAILURE, + [$this, 'logSecurityEvent'] + ); + $this->eventBus->subscribeAsync( + SecurityEvent::AUTH_SUCCESS, + [$this, 'logSecurityEvent'] + ); + $this->eventBus->subscribeAsync( + SecurityEvent::ACCESS_DENIED, + [$this, 'logSecurityEvent'] + ); + $this->eventBus->subscribeAsync( + SecurityEvent::BRUTE_FORCE_DETECTED, + [$this, 'logSecurityEvent'] + ); + } + + /** + * Check firewall rules for a request + * Returns a Response if blocked, null if allowed + */ + public function authorized(Request $request): bool + { + $ipAddress = $request->getClientIp() ?? '0.0.0.0'; + $deviceFingerprint = $request->headers->get('X-Device-Fingerprint'); + + $result = $this->analyze($ipAddress, $deviceFingerprint); + + if ($result->isBlocked()) { + return false; + } + + return true; + } + + /** + * Check if a request is allowed based on IP and device fingerprint + */ + public function analyze( + string $ipAddress, + ?string $deviceFingerprint = null + ): FirewallAnalyzeResult { + // Check if firewall is enabled for this tenant + if (!$this->isEnabled()) { + return new FirewallAnalyzeResult(true); + } + + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return new FirewallAnalyzeResult(true); + } + + $rules = $this->getActiveRules(); + + // First check for explicit allow rules (whitelist takes precedence) + foreach ($rules as $rule) { + if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) { + continue; + } + + if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) { + return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed'); + } + } + + // Then check for block rules + foreach ($rules as $rule) { + if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) { + continue; + } + + if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) { + $this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule); + return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason()); + } + } + + return new FirewallAnalyzeResult(true); + } + + /** + * Check if a rule matches the request + */ + private function ruleMatchesRequest( + FirewallRuleObject $rule, + string $ipAddress, + ?string $deviceFingerprint + ): bool { + $type = $rule->getType(); + $value = $rule->getValue(); + + return match ($type) { + FirewallRuleObject::TYPE_IP => $ipAddress === $value, + FirewallRuleObject::TYPE_IP_RANGE => IpUtils::checkIp($ipAddress, $value), + FirewallRuleObject::TYPE_DEVICE => $deviceFingerprint !== null && $deviceFingerprint === $value, + default => false, + }; + } + + /** + * Handle authentication failure event + */ + public function handleAuthFailure(SecurityEvent $event): void + { + $ipAddress = $event->getIpAddress(); + $tenantId = $event->getTenantId() ?? $this->tenant->identifier(); + + if (!$ipAddress || !$tenantId) { + return; + } + + // Check for brute force + $windowSeconds = $this->getConfig( + self::CONFIG_FAILURE_WINDOW, + self::DEFAULT_AUTH_FAILURE_WINDOW + ); + $maxFailures = $this->getConfig( + self::CONFIG_MAX_FAILURES, + self::DEFAULT_MAX_AUTH_FAILURES + ); + + $failureCount = $this->store->countRecentFailures( + $tenantId, + $ipAddress, + $windowSeconds + ); + + // Include current failure in count + $failureCount++; + + if ($failureCount >= $maxFailures) { + $this->handleBruteForce($ipAddress, $failureCount, $windowSeconds); + } + } + + /** + * Handle detected brute force attack + */ + private function handleBruteForce( + string $ipAddress, + int $failureCount, + int $windowSeconds + ): void { + // Publish brute force event + $event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds); + $event->setTenantId($this->tenant->identifier()); + $this->eventBus->publish($event); + + // Auto-block the IP + $blockDuration = $this->getConfig( + self::CONFIG_AUTO_BLOCK_DURATION, + self::DEFAULT_AUTO_BLOCK_DURATION + ); + + $this->blockIp( + $ipAddress, + sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds), + null, // System-created + $blockDuration + ); + } + + /** + * Log security event to firewall logs + */ + public function logSecurityEvent(SecurityEvent $event): void + { + $tenantId = $event->getTenantId() ?? $this->tenant->identifier(); + if (!$tenantId) { + return; + } + + $log = new FirewallLogObject(); + $log->setTenantId($tenantId) + ->setIpAddress($event->getIpAddress()) + ->setDeviceFingerprint($event->getDeviceFingerprint()) + ->setUserAgent($event->getUserAgent()) + ->setRequestPath($event->getRequestPath()) + ->setRequestMethod($event->getRequestMethod()) + ->setEventType($this->mapEventToLogType($event->getName())) + ->setResult($this->mapEventToResult($event->getName())) + ->setIdentityId($event->getUserId()) + ->setTimestamp(new \DateTimeImmutable()) + ->setMetadata($event->getData()); + + $this->store->createLog($log); + } + + /** + * Map security event name to log event type + */ + private function mapEventToLogType(string $eventName): string + { + return match ($eventName) { + SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE, + SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK, + SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE, + SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT, + SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH, + SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS, + default => FirewallLogObject::EVENT_ACCESS_CHECK, + }; + } + + /** + * Map security event to result + */ + private function mapEventToResult(string $eventName): string + { + return match ($eventName) { + SecurityEvent::AUTH_SUCCESS, + SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED, + default => FirewallLogObject::RESULT_BLOCKED, + }; + } + + /** + * Publish access denied event + */ + private function publishAccessDenied( + string $ipAddress, + ?string $deviceFingerprint, + FirewallRuleObject $rule + ): void { + $event = SecurityEvent::accessDenied( + $ipAddress, + $deviceFingerprint, + $rule->getId(), + $rule->getReason() + ); + $event->setTenantId($this->tenant->identifier()); + $this->eventBus->publish($event); + } + + // ======================================== + // Rule Management + // ======================================== + + /** + * Block an IP address + */ + public function blockIp( + string $ipAddress, + ?string $reason = null, + ?string $createdBy = null, + ?int $durationSeconds = null + ): FirewallRuleObject { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); + } + + // Check if already blocked + $existing = $this->store->findExactIpRule( + $tenantId, + $ipAddress, + FirewallRuleObject::ACTION_BLOCK + ); + + if ($existing) { + return $existing; + } + + $rule = new FirewallRuleObject(); + $rule->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_IP) + ->setAction(FirewallRuleObject::ACTION_BLOCK) + ->setValue($ipAddress) + ->setReason($reason ?? 'Blocked by administrator') + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + if ($durationSeconds !== null) { + $rule->setExpiresAt( + (new \DateTimeImmutable())->modify("+{$durationSeconds} seconds") + ); + } + + $this->store->depositRule($rule); + $this->clearRulesCache(); + + // Publish event + $event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]); + $event->setIpAddress($ipAddress) + ->setReason($reason) + ->setTenantId($tenantId); + $this->eventBus->publish($event); + + return $rule; + } + + /** + * Allow an IP address (whitelist) + */ + public function allowIp( + string $ipAddress, + ?string $reason = null, + ?string $createdBy = null + ): FirewallRuleObject { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); + } + + $rule = new FirewallRuleObject(); + $rule->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_IP) + ->setAction(FirewallRuleObject::ACTION_ALLOW) + ->setValue($ipAddress) + ->setReason($reason ?? 'Allowed by administrator') + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + $this->store->depositRule($rule); + $this->clearRulesCache(); + + // Publish event + $event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]); + $event->setIpAddress($ipAddress) + ->setReason($reason) + ->setTenantId($tenantId); + $this->eventBus->publish($event); + + return $rule; + } + + /** + * Block an IP range (CIDR notation) + */ + public function blockIpRange( + string $cidr, + ?string $reason = null, + ?string $createdBy = null + ): FirewallRuleObject { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); + } + + $rule = new FirewallRuleObject(); + $rule->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_IP_RANGE) + ->setAction(FirewallRuleObject::ACTION_BLOCK) + ->setValue($cidr) + ->setReason($reason ?? 'Range blocked by administrator') + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + $this->store->depositRule($rule); + $this->clearRulesCache(); + + return $rule; + } + + /** + * Block a device fingerprint + */ + public function blockDevice( + string $fingerprint, + ?string $reason = null, + ?string $createdBy = null, + ?int $durationSeconds = null + ): FirewallRuleObject { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); + } + + $rule = new FirewallRuleObject(); + $rule->setTenantId($tenantId) + ->setType(FirewallRuleObject::TYPE_DEVICE) + ->setAction(FirewallRuleObject::ACTION_BLOCK) + ->setValue($fingerprint) + ->setReason($reason ?? 'Device blocked by administrator') + ->setCreatedBy($createdBy) + ->setCreatedAt(new \DateTimeImmutable()) + ->setEnabled(true); + + if ($durationSeconds !== null) { + $rule->setExpiresAt( + (new \DateTimeImmutable())->modify("+{$durationSeconds} seconds") + ); + } + + $this->store->depositRule($rule); + $this->clearRulesCache(); + + // Publish event + $event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]); + $event->setDeviceFingerprint($fingerprint) + ->setReason($reason) + ->setTenantId($tenantId); + $this->eventBus->publish($event); + + return $rule; + } + + /** + * Remove a rule by ID + */ + public function removeRule(string $ruleId): bool + { + $rule = $this->store->fetchRule($ruleId); + if (!$rule) { + return false; + } + + // Verify tenant ownership + if ($rule->getTenantId() !== $this->tenant->identifier()) { + return false; + } + + $this->store->destroyRule($rule); + $this->clearRulesCache(); + + return true; + } + + /** + * Disable a rule (soft delete) + */ + public function disableRule(string $ruleId): bool + { + $rule = $this->store->fetchRule($ruleId); + if (!$rule) { + return false; + } + + // Verify tenant ownership + if ($rule->getTenantId() !== $this->tenant->identifier()) { + return false; + } + + $rule->setEnabled(false); + $this->store->depositRule($rule); + $this->clearRulesCache(); + + return true; + } + + /** + * Get all rules for current tenant + */ + public function listRules(bool $activeOnly = true): array + { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return []; + } + + return $this->store->listRules($tenantId, $activeOnly); + } + + /** + * Get firewall logs for current tenant + */ + public function getLogs( + ?string $ipAddress = null, + ?string $eventType = null, + ?string $result = null, + int $limit = 100 + ): array { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return []; + } + + return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit); + } + + /** + * Get blocked requests count + */ + public function getBlockedCount(?\DateTimeImmutable $since = null): int + { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return 0; + } + + return $this->store->countBlockedRequests($tenantId, $since); + } + + // ======================================== + // Helpers + // ======================================== + + /** + * Check if firewall is enabled for current tenant + */ + private function isEnabled(): bool + { + return (bool) $this->getConfig(self::CONFIG_ENABLED, true); + } + + /** + * Get configuration value + */ + private function getConfig(string $key, mixed $default = null): mixed + { + $config = $this->tenant->configuration(); + $parts = explode('.', $key); + + foreach ($parts as $part) { + if (!is_array($config) || !array_key_exists($part, $config)) { + return $default; + } + $config = $config[$part]; + } + + return $config; + } + + /** + * Get active rules (cached) + * @return FirewallRuleObject[] + */ + private function getActiveRules(): array + { + if ($this->rulesCache === null) { + $tenantId = $this->tenant->identifier(); + $this->rulesCache = $tenantId + ? $this->store->listRules($tenantId, true) + : []; + } + return $this->rulesCache; + } + + /** + * Clear rules cache + */ + private function clearRulesCache(): void + { + $this->rulesCache = null; + } + + /** + * Cleanup maintenance tasks + */ + public function cleanup(): array + { + $expiredRules = $this->store->cleanupExpiredRules(); + $oldLogs = $this->store->cleanupOldLogs(30); + + return [ + 'expiredRules' => $expiredRules, + 'oldLogs' => $oldLogs, + ]; + } +} + +/** + * Result of a firewall check + */ +class FirewallAnalyzeResult +{ + public function __construct( + public readonly bool $allowed, + public readonly ?string $ruleId = null, + public readonly ?string $reason = null + ) {} + + public function isAllowed(): bool + { + return $this->allowed; + } + + public function isBlocked(): bool + { + return !$this->allowed; + } +} diff --git a/core/lib/Service/SecurityService.php b/core/lib/Service/SecurityService.php new file mode 100644 index 0000000..107b721 --- /dev/null +++ b/core/lib/Service/SecurityService.php @@ -0,0 +1,202 @@ +securityCode = $this->sessionTenant->configuration()->security()->code(); + } + + /** + * Authenticate a request and return the user if valid + * + * @param Request $request The HTTP request to authenticate + * @return User|null The authenticated user, or null if not authenticated + */ + public function authenticate(Request $request): ?User + { + $authorization = $request->headers->get('Authorization'); + $cookieToken = $request->cookies->get('accessToken'); + + // Cookie token takes precedence + if ($cookieToken) { + return $this->authenticateJWT($cookieToken); + } + + if ($authorization) { + if (str_starts_with($authorization, 'Bearer ')) { + $token = substr($authorization, 7); + return $this->authenticateBearer($token); + } + + if (str_starts_with($authorization, 'Basic ')) { + $decoded = base64_decode(substr($authorization, 6) ?: '', true); + if ($decoded !== false) { + [$identity, $secret] = array_pad(explode(':', $decoded, 2), 2, null); + if ($identity !== null && $secret !== null) { + return $this->authenticateBasicHeader($identity, $secret); + } + } + } + } + + return null; + } + + /** + * Authenticate JWT token from cookie or header + */ + public function authenticateJWT(string $token): ?User + { + $payload = $this->tokenService->validateToken($token, $this->securityCode); + + if (!$payload) { + return null; + } + + // Verify user still exists + if ($this->userService->fetchByIdentifier($payload['identifier']) === null) { + return null; + } + + $user = new User(); + $user->populate($payload, 'jwt'); + + return $user; + } + + /** + * Authenticate Bearer token + */ + public function authenticateBearer(string $token): ?User + { + return $this->authenticateJWT($token); + } + + /** + * Authenticate HTTP Basic header (for API access) + * Note: This is for request authentication, not login + */ + private function authenticateBasicHeader(string $identity, string $credentials): ?User + { + // For Basic auth headers, we need to validate against the provider + // This is a simplified flow for API access + $provider = $this->providerRegistry->resolve('default'); + if ($provider === null) { + return null; + } + + $result = $provider->authenticate($identity, $credentials); + if (!$result->isSuccess()) { + return null; + } + + return $this->getUserByIdentity($identity); + } + + // ========================================================================= + // Token Operations (delegated to AuthenticationManager for new flows) + // These are kept for backwards compatibility during transition + // ========================================================================= + + /** + * @deprecated Use AuthenticationManager::createTokens() instead + */ + public function createAccessToken(array $payload): string + { + return $this->tokenService->createToken($payload, $this->securityCode, 900); + } + + /** + * @deprecated Use AuthenticationManager::createTokens() instead + */ + public function createRefreshToken(array $payload): string + { + $refreshPayload = [ + 'tenant' => $payload['tenant'] ?? null, + 'identifier' => $payload['identifier'], + 'identity' => $payload['identity'], + 'type' => 'refresh' + ]; + + return $this->tokenService->createToken($refreshPayload, $this->securityCode, 604800); + } + + /** + * @deprecated Use AuthenticationManager::refreshAccessToken() instead + */ + public function validateRefreshToken(string $refreshToken): ?User + { + $payload = $this->tokenService->validateToken($refreshToken, $this->securityCode); + + if (!$payload) { + return null; + } + + if (!isset($payload['type']) || $payload['type'] !== 'refresh') { + return null; + } + + $identifier = $payload['identifier'] ?? null; + if (!$identifier || $this->providerRegistry->validateUser($identifier) === false) { + return null; + } + + $user = new User(); + $user->populate([ + 'identifier' => $payload['identifier'], + 'identity' => $payload['identity'], + 'tenant' => $payload['tenant'] ?? null, + ], 'jwt'); + + return $user; + } + + /** + * @deprecated Use AuthenticationManager::logout() instead + */ + public function logout(?string $jti = null, ?int $exp = null): void + { + if ($jti !== null) { + $expiresAt = $exp ?? (time() + 86400); + $this->tokenService->blacklist($jti, $expiresAt); + } + } + + /** + * @deprecated Use AuthenticationManager::logoutAll() instead + */ + public function logoutAllDevices(string $identity): void + { + $this->tokenService->blacklistUserTokensBefore($identity, time()); + } + + /** + * Extract token claims (for logout to get jti/exp) + */ + public function extractTokenClaims(string $token): ?array + { + return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false); + } +} diff --git a/core/lib/Service/TenantService.php b/core/lib/Service/TenantService.php new file mode 100644 index 0000000..f8d9a3e --- /dev/null +++ b/core/lib/Service/TenantService.php @@ -0,0 +1,19 @@ +store->fetchByDomain($domain); + } + +} diff --git a/core/lib/Service/TokenService.php b/core/lib/Service/TokenService.php new file mode 100644 index 0000000..ef22868 --- /dev/null +++ b/core/lib/Service/TokenService.php @@ -0,0 +1,309 @@ + 'JWT', + 'alg' => $this->algorithm + ]; + + $payload['iat'] = time(); // Issued at + $payload['exp'] = time() + $expirationTime; // Expiration + + // Add JWT ID for token identification and revocation support + $payload['jti'] = $jti ?? $this->generateJti(); + + $headerEncoded = $this->base64UrlEncode(json_encode($header)); + $payloadEncoded = $this->base64UrlEncode(json_encode($payload)); + + $signature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey); + + return $headerEncoded . '.' . $payloadEncoded . '.' . $signature; + } + + // ========================================================================= + // Token Validation + // ========================================================================= + + /** + * Validate a JWT token and return its payload + * + * @param string $token The JWT token to validate + * @param string $secretKey The secret key for verification + * @param bool $checkBlacklist Whether to check the blacklist (default: true) + * @return array|null The token payload if valid, null otherwise + */ + public function validateToken(string $token, string $secretKey, bool $checkBlacklist = true): ?array + { + $parts = explode('.', $token); + + if (count($parts) !== 3) { + return null; + } + + [$headerEncoded, $payloadEncoded, $signature] = $parts; + + // Decode and validate header first + $header = json_decode($this->base64UrlDecode($headerEncoded), true); + + if (!$header) { + return null; + } + + // SECURITY: Validate algorithm to prevent "none" algorithm and algorithm switching attacks + if (!isset($header['alg']) || !in_array($header['alg'], self::ALLOWED_ALGORITHMS, true)) { + return null; // Reject tokens with unexpected algorithms + } + + // Verify signature using our expected algorithm (not the one in the header) + $expectedSignature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey); + if (!hash_equals($signature, $expectedSignature)) { + return null; + } + + // Decode payload + $payload = json_decode($this->base64UrlDecode($payloadEncoded), true); + + if (!$payload) { + return null; + } + + // Check expiration + if (isset($payload['exp']) && $payload['exp'] < time()) { + return null; // Token expired + } + + // Check blacklist if enabled + if ($checkBlacklist) { + // Check if this specific token has been blacklisted (by jti) + if (isset($payload['jti']) && $this->isBlacklisted($payload['jti'])) { + return null; + } + + // Check if user's tokens have been globally invalidated + if (isset($payload['identity'], $payload['iat'])) { + if ($this->isUserTokenBlacklisted($payload['identity'], $payload['iat'])) { + return null; + } + } + } + + return $payload; + } + + /** + * Refresh a token by creating a new one with fresh timestamps + * + * @param string $token The token to refresh + * @param string $secretKey The secret key + * @return string|null The new token, or null if original was invalid + */ + public function refreshToken(string $token, string $secretKey): ?string + { + $payload = $this->validateToken($token, $secretKey); + + if (!$payload) { + return null; + } + + // Remove old timestamps and jti (new token gets new jti) + unset($payload['iat'], $payload['exp'], $payload['jti']); + + // Create new token with fresh timestamps and new jti + return $this->createToken($payload, $secretKey); + } + + // ========================================================================= + // Token Blacklisting + // ========================================================================= + + /** + * Add a token to the blacklist (revoke it) + * + * @param string $jti The JWT ID to blacklist + * @param int $expiresAt Unix timestamp when the token expires (for cleanup) + */ + public function blacklist(string $jti, int $expiresAt): void + { + $ttl = max($expiresAt - time(), 60); // Minimum 60 seconds + $this->cache->set( + $this->getTokenCacheKey($jti), + $expiresAt, + CacheScope::Tenant, + self::CACHE_USAGE_BLACKLIST, + $ttl + ); + } + + /** + * Check if a token is blacklisted + * + * @param string $jti The JWT ID to check + * @return bool True if blacklisted, false otherwise + */ + public function isBlacklisted(string $jti): bool + { + return $this->cache->has( + $this->getTokenCacheKey($jti), + CacheScope::Tenant, + self::CACHE_USAGE_BLACKLIST + ); + } + + /** + * Remove a token from the blacklist + * + * @param string $jti The JWT ID to remove + */ + public function unblacklist(string $jti): void + { + $this->cache->delete( + $this->getTokenCacheKey($jti), + CacheScope::Tenant, + self::CACHE_USAGE_BLACKLIST + ); + } + + /** + * Blacklist all tokens for a user issued before a timestamp + * Used for "logout all devices" functionality + * + * @param string $identity User identity + * @param int $beforeTimestamp Tokens issued before this time are invalid + */ + public function blacklistUserTokensBefore(string $identity, int $beforeTimestamp): void + { + // Store for 30 days (longer than any token lifetime) + $this->cache->set( + $this->getUserCacheKey($identity), + $beforeTimestamp, + CacheScope::Tenant, + self::CACHE_USAGE_USER_BLACKLIST, + 2592000 // 30 days + ); + } + + /** + * Check if a user's token was issued before the blacklist timestamp + * + * @param string $identity User identity + * @param int $issuedAt Token's iat claim + * @return bool True if token should be rejected + */ + public function isUserTokenBlacklisted(string $identity, int $issuedAt): bool + { + $blacklistBefore = $this->cache->get( + $this->getUserCacheKey($identity), + CacheScope::Tenant, + self::CACHE_USAGE_USER_BLACKLIST + ); + + if ($blacklistBefore === null) { + return false; + } + + return $issuedAt < (int) $blacklistBefore; + } + + /** + * Clear user's "logout all devices" blacklist + * + * @param string $identity User identity + */ + public function clearUserBlacklist(string $identity): void + { + $this->cache->delete( + $this->getUserCacheKey($identity), + CacheScope::Tenant, + self::CACHE_USAGE_USER_BLACKLIST + ); + } + + // ========================================================================= + // Private Helpers + // ========================================================================= + + private function createSignature(string $data, string $secretKey): string + { + $signature = hash_hmac('sha256', $data, $secretKey, true); + return $this->base64UrlEncode($signature); + } + + private function base64UrlEncode(string $data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + private function base64UrlDecode(string $data): string + { + return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT)); + } + + /** + * Generate cache key for token blacklist + */ + private function getTokenCacheKey(string $jti): string + { + return 'jti_' . hash('sha256', $jti); + } + + /** + * Generate cache key for user blacklist + */ + private function getUserCacheKey(string $identity): string + { + return 'user_' . hash('sha256', $identity); + } +} diff --git a/core/lib/Service/UserManagerService.php b/core/lib/Service/UserManagerService.php new file mode 100644 index 0000000..3ee063c --- /dev/null +++ b/core/lib/Service/UserManagerService.php @@ -0,0 +1,137 @@ +providerRegister('default', DefaultIdentityProvider::class); + } + + /** + * Register an authentication provider + */ + public function providerRegister(string $identifier, string $class): void + { + $this->availableIdentityProviders[$identifier] = $class; + } + + public function providerList(?array $filter = null): array + { + $requestedProviders = $filter ? $filter : array_keys($this->availableIdentityProviders); + $result = []; + + foreach ($requestedProviders as $identifier) { + // Check if provider is available + if (!isset($this->availableIdentityProviders[$identifier])) { + continue; + } + + // Check cache first + if (isset($this->cachedIdentityProviders[$identifier])) { + $result[$identifier] = $this->cachedIdentityProviders[$identifier]; + } else { + // Instantiate the provider and cache it + $providerClass = $this->availableIdentityProviders[$identifier]; + + try { + // Server::get automatically detects context from calling object + $providerInstance = Server::runtimeContainer()->get($providerClass); + + // Cache the instance + $this->cachedIdentityProviders[$identifier] = $providerInstance; + $result[$identifier] = $providerInstance; + } catch (\Exception $e) { + // Skip providers that can't be resolved + error_log("Failed to resolve identity provider {$providerClass}: " . $e->getMessage()); + continue; + } + } + } + + return $result; + } + + /** + * Authenticate user against enabled providers + */ + public function authenticate(string $identity, string $credential): User | null + { + // validate identity and credential + if (empty($identity) || empty($credential)) { + return null; + } + + // retrieve user by identity + $user = $this->userService->fetchByIdentity($identity); + + // determine if user has logged in before + if (!$user) { + return null; + } + // determine if user has a identity provider assigned + if ($user->getProvider() === null) { + return null; + } + + $authenticated = $this->authenticateExtant($user->getProvider(), $identity, $credential); + + if ($authenticated) { + return $user; + } + + return null; + + } + + public function authenticateExtant(string $provider, string $identity, string $credential): bool { + // determine if provider is enabled + $providers = $this->providerList([$provider]); + if (empty($providers)) { + return false; + } + + // Get the first (and should be only) provider + $provider = reset($providers); + + // authenticate user against provider + $user = $provider->authenticate($identity, $credential); + + return $user; + } + + public function validate(string $identifier): Bool + { + $data = $this->userService->fetchByIdentifier($identifier); + if (!$data) { + return false; + } + + if ($data['enabled'] !== true) { + return false; + } + + if ($data['tid'] !== $this->tenant->identifier()) { + return false; + } + + return true; + } + +} diff --git a/core/lib/Service/UserProvisioningService.php b/core/lib/Service/UserProvisioningService.php new file mode 100644 index 0000000..bafe57a --- /dev/null +++ b/core/lib/Service/UserProvisioningService.php @@ -0,0 +1,278 @@ +tenant->identifier(); + if (!$tenantId) { + return null; + } + + // Map attributes to user fields + $mappedData = $this->mapAttributes($attributes, $providerConfig['attribute_map'] ?? []); + + // Validate required fields + $identity = $mappedData['identity'] ?? $attributes['identity'] ?? $attributes['email'] ?? null; + if (!$identity) { + return null; + } + + // Generate user ID + $userId = UUID::v4(); + + // Build user data + $userData = [ + 'tid' => $tenantId, + 'uid' => $userId, + 'identity' => $identity, + 'label' => $mappedData['label'] ?? $attributes['label'] ?? $attributes['name'] ?? $identity, + 'enabled' => true, + 'provider' => $providerId, + 'external_subject' => $attributes['external_subject'] ?? null, + 'roles' => $providerConfig['default_roles'] ?? [], + 'profile' => $mappedData['profile'] ?? [], + 'settings' => [], + 'initial_login' => time(), + 'recent_login' => time(), + ]; + + // Create the user + $createdUserId = $this->userStore->create($userData); + if (!$createdUserId) { + return null; + } + + // Link external identity if we have an external subject + if (!empty($attributes['external_subject'])) { + $this->externalIdentityStore->linkIdentity( + $tenantId, + $userId, + $providerId, + $attributes['external_subject'], + $attributes['raw'] ?? $attributes + ); + } + + // Build and return User object + $user = new User(); + $user->populate($userData, 'users'); + + return $user; + } + + /** + * Synchronize user profile with attributes from provider + * Called on each login to keep profile data up to date + * + * @param User $user The existing user + * @param array $attributes Attributes from provider + * @param array $attributeMap Attribute mapping configuration + * @return bool Whether sync was successful + */ + public function syncProfile(User $user, array $attributes, array $attributeMap = []): bool + { + $tenantId = $this->tenant->identifier(); + $userId = $user->getId(); + + if (!$tenantId || !$userId) { + return false; + } + + // Map attributes + $mappedData = $this->mapAttributes($attributes, $attributeMap); + + // Update profile fields if we have mapped profile data + if (!empty($mappedData['profile'])) { + $this->userStore->updateProfile($tenantId, $userId, $mappedData['profile']); + } + + // Update label if provided and different + if (!empty($mappedData['label']) && $mappedData['label'] !== $user->getLabel()) { + $this->userStore->updateLabel($tenantId, $userId, $mappedData['label']); + } + + // Always update last login + $this->userStore->updateLastLogin($tenantId, $userId); + + // Update external identity attributes if applicable + if (!empty($attributes['external_subject'])) { + $this->externalIdentityStore->updateLastLogin( + $tenantId, + $user->getProvider() ?? '', + $attributes['external_subject'] + ); + $this->externalIdentityStore->updateAttributes( + $tenantId, + $user->getProvider() ?? '', + $attributes['external_subject'], + $attributes['raw'] ?? $attributes + ); + } + + return true; + } + + /** + * Link an external identity to an existing user + * + * @param User $user The user to link + * @param string $providerId Provider identifier + * @param string $externalSubject External subject identifier + * @param array $attributes Optional attributes from provider + * @return bool Whether linking was successful + */ + public function linkExternalIdentity(User $user, string $providerId, string $externalSubject, array $attributes = []): bool + { + $tenantId = $this->tenant->identifier(); + $userId = $user->getId(); + + if (!$tenantId || !$userId) { + return false; + } + + return $this->externalIdentityStore->linkIdentity( + $tenantId, + $userId, + $providerId, + $externalSubject, + $attributes + ); + } + + /** + * Find user by external identity + * + * @param string $providerId Provider identifier + * @param string $externalSubject External subject identifier + * @return User|null The user or null if not found + */ + public function findByExternalIdentity(string $providerId, string $externalSubject): ?User + { + $tenantId = $this->tenant->identifier(); + if (!$tenantId) { + return null; + } + + // Look up in external identities table + $externalIdentity = $this->externalIdentityStore->findByExternalSubject( + $tenantId, + $providerId, + $externalSubject + ); + + if (!$externalIdentity) { + return null; + } + + // Fetch the linked user + $userData = $this->userStore->fetchByIdentifier($tenantId, $externalIdentity['uid']); + if (!$userData) { + return null; + } + + $user = new User(); + $user->populate($userData, 'users'); + + return $user; + } + + /** + * Map provider attributes to user fields using attribute map + * + * @param array $attributes Raw attributes from provider + * @param array $attributeMap Mapping configuration {source_attr: target_field} + * @return array Mapped data with 'identity', 'label', 'profile' keys + */ + protected function mapAttributes(array $attributes, array $attributeMap): array + { + $result = [ + 'identity' => null, + 'label' => null, + 'profile' => [], + ]; + + foreach ($attributeMap as $sourceAttr => $targetField) { + // Get source value (supports nested attributes with dot notation) + $value = $this->getNestedValue($attributes, $sourceAttr); + if ($value === null) { + continue; + } + + // Set target value (supports nested targets with dot notation) + if ($targetField === 'identity') { + $result['identity'] = $value; + } elseif ($targetField === 'label') { + $result['label'] = $value; + } elseif (str_starts_with($targetField, 'profile.')) { + $profileField = substr($targetField, 8); + $result['profile'][$profileField] = $value; + } + } + + return $result; + } + + /** + * Get nested value from array using dot notation + * + * @param array $array Source array + * @param string $key Key with optional dot notation (e.g., 'user.email') + * @return mixed|null Value or null if not found + */ + protected function getNestedValue(array $array, string $key): mixed + { + $keys = explode('.', $key); + $value = $array; + + foreach ($keys as $k) { + if (!is_array($value) || !array_key_exists($k, $value)) { + return null; + } + $value = $value[$k]; + } + + return $value; + } + + /** + * Check if auto-provisioning is enabled for a provider + * + * @param string $providerId Provider identifier + * @return bool + */ + public function isAutoProvisioningEnabled(string $providerId): bool + { + $config = $this->tenant->identityProviderConfig($providerId); + return ($config['provisioning'] ?? 'manual') === 'auto'; + } +} diff --git a/core/lib/Service/UserService.php b/core/lib/Service/UserService.php new file mode 100644 index 0000000..43eccbf --- /dev/null +++ b/core/lib/Service/UserService.php @@ -0,0 +1,47 @@ +userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); + if (!$data) { + return null; + } + + $user = new User(); + $user->populate($data, 'users'); + return $user; + } + + public function fetchByIdentifier(string $identifier): array | null + { + return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier); + } + + public function fetchSettings(array $settings = []): array | null + { + return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); + } + + public function storeSettings(array $settings): bool + { + return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); + } + +} diff --git a/core/lib/SessionIdentity.php b/core/lib/SessionIdentity.php new file mode 100644 index 0000000..deb5e3f --- /dev/null +++ b/core/lib/SessionIdentity.php @@ -0,0 +1,64 @@ +identityLock) { + throw new \RuntimeException('Identity is already locked and cannot be changed.'); + } + + $this->identityData = $identity; + $this->identityLock = $lock; + } + + public function identity(): ?User + { + return $this->identityData; + } + + public function identifier(): ?string + { + return $this->identityData?->getId(); + } + + public function label(): ?string + { + return $this->identityData?->getLabel(); + } + + public function mailAddress(): ?string + { + return $this->identityData?->getEmail(); + } + + public function nameFirst(): ?string + { + return $this->identityData?->getFirstName(); + } + + public function nameLast(): ?string + { + return $this->identityData?->getLastName(); + } + + public function permissions(): array + { + $permissions = $this->identityData?->getPermissions() ?? []; + $permissions[] = 'ROLE_USER'; + return array_unique($permissions); + } + + public function hasPermission(string $permission): bool + { + return in_array($permission, $this->permissions()); + } + +} diff --git a/core/lib/SessionTenant.php b/core/lib/SessionTenant.php new file mode 100644 index 0000000..8de4d66 --- /dev/null +++ b/core/lib/SessionTenant.php @@ -0,0 +1,122 @@ +configured) { + return; + } + $service = Server::runtimeContainer()->get(TenantService::class); + $tenant = $service->fetchByDomain($domain); + if ($tenant) { + $this->domain = $domain; + $this->tenant = $tenant; + $this->configured = true; + } else { + $this->domain = null; + $this->tenant = null; + $this->configured = false; + } + } + + /** + * Is the tenant configured + */ + public function configured(): bool + { + return $this->configured; + } + + /** + * Is the tenant enabled + */ + public function enabled(): bool + { + return $this->tenant?->getEnabled() ?? false; + } + + /** + * Current tenant domain + */ + public function domain(): ?string + { + return $this->domain; + } + + /** + * Current tenant identifier + */ + public function identifier(): ?string + { + return $this->tenant?->getIdentifier(); + } + + /** + * Current tenant label + */ + public function label(): ?string + { + return $this->tenant?->getLabel(); + } + + /** + * Current tenant configuration + */ + public function configuration(): TenantConfiguration + { + return $this->tenant?->getConfiguration(); + } + + /** + * Current tenant settings + */ + public function settings(): array + { + return $this->tenant?->getSettings() ?? []; + } + + /** + * Get all identity providers configuration for this tenant + * @return array Map of provider ID to provider config + */ + public function identityProviders(): array + { + return $this->tenant?->getConfiguration()['identity']['providers'] ?? []; + } + + /** + * Get configuration for a specific identity provider + * + * @param string $providerId Provider identifier (e.g., 'default', 'oidc') + * @return array|null Provider configuration or null if not found + */ + public function identityProviderConfig(string $providerId): ?array + { + $providers = $this->identityProviders(); + return $providers[$providerId] ?? null; + } + + /** + * Check if an identity provider is enabled for this tenant + */ + public function isIdentityProviderEnabled(string $providerId): bool + { + $config = $this->identityProviderConfig($providerId); + return $config !== null && ($config['enabled'] ?? false); + } +} diff --git a/core/lib/Stores/ExternalIdentityStore.php b/core/lib/Stores/ExternalIdentityStore.php new file mode 100644 index 0000000..bffcde5 --- /dev/null +++ b/core/lib/Stores/ExternalIdentityStore.php @@ -0,0 +1,224 @@ +store->selectCollection(self::COLLECTION_NAME)->findOne([ + 'tid' => $tenant, + 'provider' => $provider, + 'external_subject' => $externalSubject + ]); + + if (!$entry) { + return null; + } + + return (array)$entry; + } + + /** + * Find all external identities for a user + * + * @param string $tenant Tenant identifier + * @param string $userId Local user identifier + * @return array List of external identity records + */ + public function findByUser(string $tenant, string $userId): array + { + $cursor = $this->store->selectCollection(self::COLLECTION_NAME)->find([ + 'tid' => $tenant, + 'uid' => $userId + ]); + + $result = []; + foreach ($cursor as $entry) { + $result[] = (array)$entry; + } + + return $result; + } + + /** + * Find external identity for a user from a specific provider + * + * @param string $tenant Tenant identifier + * @param string $userId Local user identifier + * @param string $provider Provider identifier + * @return array|null External identity record or null + */ + public function findByUserAndProvider(string $tenant, string $userId, string $provider): ?array + { + $entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([ + 'tid' => $tenant, + 'uid' => $userId, + 'provider' => $provider + ]); + + if (!$entry) { + return null; + } + + return (array)$entry; + } + + /** + * Link an external identity to a local user + * + * @param string $tenant Tenant identifier + * @param string $userId Local user identifier + * @param string $provider Provider identifier + * @param string $externalSubject External subject identifier + * @param array $attributes Optional attributes from provider + * @return bool Whether the operation was successful + */ + public function linkIdentity( + string $tenant, + string $userId, + string $provider, + string $externalSubject, + array $attributes = [] + ): bool { + $now = time(); + + // Use upsert to handle both create and update + $result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne( + [ + 'tid' => $tenant, + 'provider' => $provider, + 'external_subject' => $externalSubject + ], + [ + '$set' => [ + 'uid' => $userId, + 'attributes' => $attributes, + 'last_login' => $now + ], + '$setOnInsert' => [ + 'tid' => $tenant, + 'provider' => $provider, + 'external_subject' => $externalSubject, + 'linked_at' => $now + ] + ], + ['upsert' => true] + ); + + return $result->isAcknowledged(); + } + + /** + * Unlink an external identity from a user + * + * @param string $tenant Tenant identifier + * @param string $userId Local user identifier + * @param string $provider Provider identifier + * @return bool Whether the operation was successful + */ + public function unlinkIdentity(string $tenant, string $userId, string $provider): bool + { + $result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteOne([ + 'tid' => $tenant, + 'uid' => $userId, + 'provider' => $provider + ]); + + return $result->isAcknowledged(); + } + + /** + * Update last login timestamp for an external identity + * + * @param string $tenant Tenant identifier + * @param string $provider Provider identifier + * @param string $externalSubject External subject identifier + * @return bool Whether the operation was successful + */ + public function updateLastLogin(string $tenant, string $provider, string $externalSubject): bool + { + $result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne( + [ + 'tid' => $tenant, + 'provider' => $provider, + 'external_subject' => $externalSubject + ], + ['$set' => ['last_login' => time()]] + ); + + return $result->isAcknowledged(); + } + + /** + * Update cached attributes for an external identity + * + * @param string $tenant Tenant identifier + * @param string $provider Provider identifier + * @param string $externalSubject External subject identifier + * @param array $attributes New attributes to store + * @return bool Whether the operation was successful + */ + public function updateAttributes(string $tenant, string $provider, string $externalSubject, array $attributes): bool + { + $result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne( + [ + 'tid' => $tenant, + 'provider' => $provider, + 'external_subject' => $externalSubject + ], + ['$set' => ['attributes' => $attributes]] + ); + + return $result->isAcknowledged(); + } + + /** + * Delete all external identities for a user (used when deleting user) + * + * @param string $tenant Tenant identifier + * @param string $userId Local user identifier + * @return int Number of deleted records + */ + public function deleteAllForUser(string $tenant, string $userId): int + { + $result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteMany([ + 'tid' => $tenant, + 'uid' => $userId + ]); + + return $result->getDeletedCount(); + } +} diff --git a/core/lib/Stores/FirewallStore.php b/core/lib/Stores/FirewallStore.php new file mode 100644 index 0000000..728bea8 --- /dev/null +++ b/core/lib/Stores/FirewallStore.php @@ -0,0 +1,309 @@ + $tenantId]; + + if ($activeOnly) { + $filter['enabled'] = true; + $filter['$or'] = [ + ['expiresAt' => null], + ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]] + ]; + } + + $cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter); + $list = []; + + foreach ($cursor as $entry) { + $rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry); + $list[] = $rule; + } + + return $list; + } + + /** + * Find rules by IP address + */ + public function findRulesByIp(string $tenantId, string $ipAddress): array + { + $filter = [ + 'tenantId' => $tenantId, + 'type' => ['$in' => [FirewallRuleObject::TYPE_IP, FirewallRuleObject::TYPE_IP_RANGE]], + 'enabled' => true, + '$or' => [ + ['expiresAt' => null], + ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]] + ] + ]; + + $cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter); + $list = []; + + foreach ($cursor as $entry) { + $rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry); + $list[] = $rule; + } + + return $list; + } + + /** + * Find rules by device fingerprint + */ + public function findRulesByDevice(string $tenantId, string $deviceFingerprint): array + { + $filter = [ + 'tenantId' => $tenantId, + 'type' => FirewallRuleObject::TYPE_DEVICE, + 'value' => $deviceFingerprint, + 'enabled' => true, + '$or' => [ + ['expiresAt' => null], + ['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]] + ] + ]; + + $cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter); + $list = []; + + foreach ($cursor as $entry) { + $rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry); + $list[] = $rule; + } + + return $list; + } + + /** + * Fetch a specific rule by ID + */ + public function fetchRule(string $id): ?FirewallRuleObject + { + $entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]); + if (!$entry) { + return null; + } + return (new FirewallRuleObject())->jsonDeserialize((array)$entry); + } + + /** + * Check if exact IP rule exists + */ + public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject + { + $entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([ + 'tenantId' => $tenantId, + 'type' => FirewallRuleObject::TYPE_IP, + 'value' => $ipAddress, + 'action' => $action, + 'enabled' => true, + ]); + + if (!$entry) { + return null; + } + return (new FirewallRuleObject())->jsonDeserialize((array)$entry); + } + + /** + * Create or update a rule + */ + public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject + { + if ($rule->getId()) { + return $this->updateRule($rule); + } else { + return $this->createRule($rule); + } + } + + private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject + { + $data = $rule->jsonSerialize(); + unset($data['id']); // Remove id for insert + + $result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data); + $rule->setId((string)$result->getInsertedId()); + return $rule; + } + + private function updateRule(FirewallRuleObject $rule): ?FirewallRuleObject + { + $id = $rule->getId(); + if (!$id) { + return null; + } + + $data = $rule->jsonSerialize(); + unset($data['id']); + + $this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne( + ['_id' => $id], + ['$set' => $data] + ); + return $rule; + } + + /** + * Delete a rule + */ + public function destroyRule(FirewallRuleObject $rule): void + { + $id = $rule->getId(); + if (!$id) { + return; + } + $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]); + } + + /** + * Delete expired rules + */ + public function cleanupExpiredRules(): int + { + $result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([ + 'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)], + 'expiresAt' => ['$ne' => null] + ]); + + return $result->getDeletedCount(); + } + + // ======================================== + // Log Operations + // ======================================== + + /** + * Log a firewall event + */ + public function createLog(FirewallLogObject $log): FirewallLogObject + { + $data = $log->jsonSerialize(); + unset($data['id']); + + $result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data); + $log->setId((string)$result->getInsertedId()); + return $log; + } + + /** + * Get logs for a tenant with optional filters + */ + public function listLogs( + string $tenantId, + ?string $ipAddress = null, + ?string $eventType = null, + ?string $result = null, + int $limit = 100, + int $offset = 0 + ): array { + $filter = ['tenantId' => $tenantId]; + + if ($ipAddress !== null) { + $filter['ipAddress'] = $ipAddress; + } + if ($eventType !== null) { + $filter['eventType'] = $eventType; + } + if ($result !== null) { + $filter['result'] = $result; + } + + $cursor = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->find( + $filter, + [ + 'sort' => ['timestamp' => -1], + 'limit' => $limit, + 'skip' => $offset + ] + ); + + $list = []; + foreach ($cursor as $entry) { + $log = (new FirewallLogObject())->jsonDeserialize((array)$entry); + $list[] = $log; + } + + return $list; + } + + /** + * Count recent failures from an IP within a time window + */ + public function countRecentFailures( + string $tenantId, + string $ipAddress, + int $windowSeconds = 300 + ): int { + $since = (new \DateTimeImmutable())->modify("-{$windowSeconds} seconds"); + + return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments([ + 'tenantId' => $tenantId, + 'ipAddress' => $ipAddress, + 'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE, + 'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)] + ]); + } + + /** + * Get blocked requests count for dashboard + */ + public function countBlockedRequests( + string $tenantId, + ?\DateTimeImmutable $since = null + ): int { + $filter = [ + 'tenantId' => $tenantId, + 'result' => FirewallLogObject::RESULT_BLOCKED + ]; + + if ($since !== null) { + $filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)]; + } + + return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter); + } + + /** + * Clean up old logs + */ + public function cleanupOldLogs(int $daysToKeep = 30): int + { + $cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days"); + + $result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([ + 'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)] + ]); + + return $result->getDeletedCount(); + } +} diff --git a/core/lib/Stores/TenantStore.php b/core/lib/Stores/TenantStore.php new file mode 100644 index 0000000..a13b2dc --- /dev/null +++ b/core/lib/Stores/TenantStore.php @@ -0,0 +1,75 @@ +dataStore->selectCollection(self::COLLECTION_NAME)->find(); + $list = []; + foreach ($cursor as $entry) { + $entry = (new TenantObject())->jsonDeserialize((array)$entry); + $list[$entry->getId()] = $entry; + } + return $list; + } + + public function fetch(string $identifier): ?TenantObject + { + $entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['identifier' => $identifier]); + if (!$entry) { return null; } + return (new TenantObject())->jsonDeserialize((array)$entry); + } + + public function fetchByDomain(string $domain): ?TenantObject + { + $entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['domains' => $domain]); + if (!$entry) { return null; } + $entity = new TenantObject(); + $entity->jsonDeserialize((array)$entry); + return $entity; + } + + public function deposit(TenantObject $entry): ?TenantObject + { + if ($entry->getId()) { + return $this->update($entry); + } else { + return $this->create($entry); + } + } + + private function create(TenantObject $entry): ?TenantObject + { + $result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize()); + $entry->setId((string)$result->getInsertedId()); + return $entry; + } + + private function update(TenantObject $entry): ?TenantObject + { + $id = $entry->getId(); + if (!$id) { return null; } + $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]); + return $entry; + } + + public function destroy(TenantObject $entry): void + { + $id = $entry->getId(); + if (!$id) { return; } + $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]); + } + +} \ No newline at end of file diff --git a/core/lib/Stores/UserStore.php b/core/lib/Stores/UserStore.php new file mode 100644 index 0000000..1037b71 --- /dev/null +++ b/core/lib/Stores/UserStore.php @@ -0,0 +1,257 @@ + [ + 'tid' => $tenant, + 'identity' => $identity + ] + ], + [ + '$lookup' => [ + 'from' => 'user_roles', + 'localField' => 'roles', // Array field in `users` + 'foreignField' => 'rid', // Scalar field in `user_roles` + 'as' => 'role_details' + ] + ], + // Add flattened, deduplicated permissions while preserving all original user fields + [ + '$addFields' => [ + 'permissions' => [ + '$reduce' => [ + 'input' => [ + '$map' => [ + 'input' => '$role_details', + 'as' => 'r', + 'in' => [ '$ifNull' => ['$$r.permissions', []] ] + ] + ], + 'initialValue' => [], + 'in' => [ '$setUnion' => ['$$value', '$$this'] ] + ] + ] + ] + ], + // Optionally remove expanded role documents from output + [ '$unset' => 'role_details' ] + ]; + + $entry = $this->store->selectCollection('users')->aggregate($pipeline)->toArray()[0] ?? null; + if (!$entry) { return null; } + return (array)$entry; + } + + public function fetchByIdentifier(string $tenant, string $identifier): array | null + { + $entry = $this->store->selectCollection('users')->findOne(['tid' => $tenant, 'uid' => $identifier]); + if (!$entry) { return null; } + return (array)$entry; + } + + /** + * Fetch user settings from the embedded settings field in the user document + * + * @param string $tenant Tenant identifier + * @param string $identifier User identifier + * @param array $settings Optional array of specific setting keys to retrieve + * @return array|null Settings array or null if user not found + */ + public function fetchSettings(string $tenant, string $identifier, array $keys = []): array | null + { + $entry = $this->store->selectCollection('users')->findOne( + ['tid' => $tenant, 'uid' => $identifier], + ['projection' => ['settings' => 1]] + ); + + if (!$entry) { + return null; + } + + $settings = (array)($entry['settings'] ?? []); + + if (empty($keys)) { + return $settings; + } + + // Filter to only requested keys + return array_filter( + $settings, + fn($key) => in_array($key, $keys), + ARRAY_FILTER_USE_KEY + ); + } + + /** + * Store/update user settings in the embedded settings field + * + * @param string $tenant Tenant identifier + * @param string $identifier User identifier + * @param array $settings Key-value pairs to set/update + * @return bool Whether the update was acknowledged + */ + public function storeSettings(string $tenant, string $identifier, array $settings): bool + { + // Build dot-notation update for each setting key + $setFields = []; + foreach ($settings as $key => $value) { + $setFields["settings.$key"] = $value; + } + + $result = $this->store->selectCollection('users')->updateOne( + ['tid' => $tenant, 'uid' => $identifier], + ['$set' => $setFields] + ); + + return $result->isAcknowledged(); + } + + /** + * Remove specific settings from a user + * + * @param string $tenant Tenant identifier + * @param string $identifier User identifier + * @param array $keys Setting keys to remove + * @return bool Whether the update was acknowledged + */ + public function removeSettings(string $tenant, string $identifier, array $keys): bool + { + $unsetFields = []; + foreach ($keys as $key) { + $unsetFields["settings.$key"] = ""; + } + + $result = $this->store->selectCollection('users')->updateOne( + ['tid' => $tenant, 'uid' => $identifier], + ['$unset' => $unsetFields] + ); + + return $result->isAcknowledged(); + } + + /** + * Create a new user + * + * @param array $data User data including tid, uid, identity, label, provider, etc. + * @return string|null The created user's UID or null on failure + */ + public function create(array $data): ?string + { + // Ensure required fields + if (empty($data['tid']) || empty($data['uid']) || empty($data['identity'])) { + return null; + } + + // Set defaults + $data['enabled'] = $data['enabled'] ?? true; + $data['roles'] = $data['roles'] ?? []; + $data['profile'] = $data['profile'] ?? []; + $data['settings'] = $data['settings'] ?? []; + $data['initial_login'] = $data['initial_login'] ?? time(); + $data['recent_login'] = $data['recent_login'] ?? time(); + + $result = $this->store->selectCollection('users')->insertOne($data); + + if ($result->isAcknowledged()) { + return $data['uid']; + } + + return null; + } + + /** + * Update user profile fields + * + * @param string $tenant Tenant identifier + * @param string $identifier User identifier + * @param array $profile Profile data to update + * @return bool Whether the update was acknowledged + */ + public function updateProfile(string $tenant, string $identifier, array $profile): bool + { + $setFields = []; + foreach ($profile as $key => $value) { + $setFields["profile.$key"] = $value; + } + + $result = $this->store->selectCollection('users')->updateOne( + ['tid' => $tenant, 'uid' => $identifier], + ['$set' => $setFields] + ); + + return $result->isAcknowledged(); + } + + /** + * Update user's last login timestamp + * + * @param string $tenant Tenant identifier + * @param string $identifier User identifier + * @return bool Whether the update was acknowledged + */ + public function updateLastLogin(string $tenant, string $identifier): bool + { + $result = $this->store->selectCollection('users')->updateOne( + ['tid' => $tenant, 'uid' => $identifier], + ['$set' => ['recent_login' => time()]] + ); + + return $result->isAcknowledged(); + } + + /** + * Update user's label + * + * @param string $tenant Tenant identifier + * @param string $identifier User identifier + * @param string $label New label + * @return bool Whether the update was acknowledged + */ + public function updateLabel(string $tenant, string $identifier, string $label): bool + { + $result = $this->store->selectCollection('users')->updateOne( + ['tid' => $tenant, 'uid' => $identifier], + ['$set' => ['label' => $label]] + ); + + return $result->isAcknowledged(); + } + + /** + * Find user by external subject (for external identity providers) + * + * @param string $tenant Tenant identifier + * @param string $provider Provider identifier + * @param string $externalSubject External subject identifier + * @return array|null User data or null if not found + */ + public function fetchByExternalSubject(string $tenant, string $provider, string $externalSubject): ?array + { + $entry = $this->store->selectCollection('users')->findOne([ + 'tid' => $tenant, + 'provider' => $provider, + 'external_subject' => $externalSubject + ]); + + if (!$entry) { + return null; + } + + return (array)$entry; + } + +} \ No newline at end of file diff --git a/core/lib/index.php b/core/lib/index.php new file mode 100644 index 0000000..f798d49 --- /dev/null +++ b/core/lib/index.php @@ -0,0 +1,12 @@ +register(); + +Server::run(); \ No newline at end of file diff --git a/core/src/App.vue b/core/src/App.vue new file mode 100644 index 0000000..bf88393 --- /dev/null +++ b/core/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/core/src/assets/images/favicon.svg b/core/src/assets/images/favicon.svg new file mode 100644 index 0000000..7cae276 --- /dev/null +++ b/core/src/assets/images/favicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/core/src/assets/images/maintenance/Error404.png b/core/src/assets/images/maintenance/Error404.png new file mode 100644 index 0000000..494ed12 Binary files /dev/null and b/core/src/assets/images/maintenance/Error404.png differ diff --git a/core/src/assets/images/maintenance/Error500.png b/core/src/assets/images/maintenance/Error500.png new file mode 100644 index 0000000..afc3b4c Binary files /dev/null and b/core/src/assets/images/maintenance/Error500.png differ diff --git a/core/src/assets/images/maintenance/TwoCone.png b/core/src/assets/images/maintenance/TwoCone.png new file mode 100644 index 0000000..e86b965 Binary files /dev/null and b/core/src/assets/images/maintenance/TwoCone.png differ diff --git a/core/src/assets/images/maintenance/coming-soon.png b/core/src/assets/images/maintenance/coming-soon.png new file mode 100644 index 0000000..5d341c9 Binary files /dev/null and b/core/src/assets/images/maintenance/coming-soon.png differ diff --git a/core/src/assets/images/maintenance/under-construction.svg b/core/src/assets/images/maintenance/under-construction.svg new file mode 100644 index 0000000..576f362 --- /dev/null +++ b/core/src/assets/images/maintenance/under-construction.svg @@ -0,0 +1,353 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/src/assets/images/users/avatar-1.png b/core/src/assets/images/users/avatar-1.png new file mode 100644 index 0000000..6a2938a Binary files /dev/null and b/core/src/assets/images/users/avatar-1.png differ diff --git a/core/src/assets/images/users/avatar-2.png b/core/src/assets/images/users/avatar-2.png new file mode 100644 index 0000000..a579106 Binary files /dev/null and b/core/src/assets/images/users/avatar-2.png differ diff --git a/core/src/assets/images/users/avatar-3.png b/core/src/assets/images/users/avatar-3.png new file mode 100644 index 0000000..39fba7d Binary files /dev/null and b/core/src/assets/images/users/avatar-3.png differ diff --git a/core/src/assets/images/users/avatar-4.png b/core/src/assets/images/users/avatar-4.png new file mode 100644 index 0000000..4d5310f Binary files /dev/null and b/core/src/assets/images/users/avatar-4.png differ diff --git a/core/src/assets/images/users/avatar-5.png b/core/src/assets/images/users/avatar-5.png new file mode 100644 index 0000000..4624e95 Binary files /dev/null and b/core/src/assets/images/users/avatar-5.png differ diff --git a/core/src/assets/images/users/avatar-group.png b/core/src/assets/images/users/avatar-group.png new file mode 100644 index 0000000..f7c94c9 Binary files /dev/null and b/core/src/assets/images/users/avatar-group.png differ diff --git a/core/src/components/shared/BaseBreadcrumb.vue b/core/src/components/shared/BaseBreadcrumb.vue new file mode 100644 index 0000000..092d1af --- /dev/null +++ b/core/src/components/shared/BaseBreadcrumb.vue @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/core/src/components/shared/UiParentCard.vue b/core/src/components/shared/UiParentCard.vue new file mode 100644 index 0000000..e890562 --- /dev/null +++ b/core/src/components/shared/UiParentCard.vue @@ -0,0 +1,16 @@ + + + \ No newline at end of file diff --git a/core/src/composables/index.ts b/core/src/composables/index.ts new file mode 100644 index 0000000..1eb6e5d --- /dev/null +++ b/core/src/composables/index.ts @@ -0,0 +1,6 @@ +/** + * Core composables - reusable composition functions + */ + +export { useClipboard } from './useClipboard' +export { usePreferences } from './usePreferences' diff --git a/core/src/composables/useClipboard.ts b/core/src/composables/useClipboard.ts new file mode 100644 index 0000000..905df56 --- /dev/null +++ b/core/src/composables/useClipboard.ts @@ -0,0 +1,45 @@ +import { ref } from 'vue' + +/** + * Composable for clipboard operations with visual feedback + * + * @param timeout - Duration in ms to show success state (default: 2000) + * @returns Object containing copiedKey ref and copyToClipboard function + * + * @example + * ```vue + * + * + * + * ``` + */ +export function useClipboard(timeout = 2000) { + const copiedKey = ref(null) + + const copyToClipboard = async (text: string, key: T): Promise => { + try { + await navigator.clipboard.writeText(text) + copiedKey.value = key + setTimeout(() => { copiedKey.value = null }, timeout) + return true + } catch (err) { + console.error('Failed to copy to clipboard:', err) + return false + } + } + + return { + copiedKey, + copyToClipboard + } +} diff --git a/core/src/composables/usePreferences.ts b/core/src/composables/usePreferences.ts new file mode 100644 index 0000000..d7bc271 --- /dev/null +++ b/core/src/composables/usePreferences.ts @@ -0,0 +1,208 @@ +import { computed, ref } from 'vue'; +import { usePreferencesStore, type PreferencesState } from '@KTXC/stores/preferencesStore'; +import { preferenceService } from '@KTXC/services/preferenceService'; + +/** + * Composable for managing user preferences + * Provides reactive access to preferences with automatic sync to server + */ +export function usePreferences() { + const store = usePreferencesStore(); + const saving = ref(false); + const error = ref(null); + + /** + * Get all preferences + */ + const preferences = computed(() => store.preferences); + + /** + * Get locked preference keys + */ + const locks = computed(() => store.locks); + + /** + * Check if a preference is locked by tenant admin + */ + const isLocked = (key: keyof PreferencesState): boolean => { + return store.isLocked(key); + }; + + /** + * Get a single preference value + */ + const get = (key: K): PreferencesState[K] => { + return store.getPreference(key); + }; + + /** + * Set a single preference and sync to server + */ + const set = async ( + key: K, + value: PreferencesState[K], + syncToServer = true + ): Promise => { + error.value = null; + + // Update local state first + const success = store.setPreference(key, value); + if (!success) { + error.value = `Preference "${key}" is locked by administrator`; + return false; + } + + // Sync to server if requested + if (syncToServer) { + saving.value = true; + try { + const response = await preferenceService.setPreference(key, value); + // Update store with server response to ensure consistency + store.setPreferences(response.effective); + store.setLocks(response.locks); + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to save preference'; + return false; + } finally { + saving.value = false; + } + } + + return true; + }; + + /** + * Update multiple preferences and sync to server + */ + const update = async ( + prefs: Partial, + syncToServer = true + ): Promise<{ success: boolean; rejected: string[] }> => { + error.value = null; + + // Update local state + store.setPreferences(prefs); + + if (syncToServer) { + saving.value = true; + try { + const response = await preferenceService.updatePreferences(prefs); + store.setPreferences(response.effective); + store.setLocks(response.locks); + return { + success: true, + rejected: response.rejectedKeys ?? [], + }; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to save preferences'; + return { success: false, rejected: [] }; + } finally { + saving.value = false; + } + } + + return { success: true, rejected: [] }; + }; + + /** + * Reset preferences to tenant defaults + */ + const reset = async (): Promise => { + error.value = null; + saving.value = true; + + try { + const response = await preferenceService.resetPreferences(); + store.setPreferences(response.effective); + store.setLocks(response.locks); + return true; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to reset preferences'; + return false; + } finally { + saving.value = false; + } + }; + + /** + * Refresh preferences from server + */ + const refresh = async (): Promise => { + error.value = null; + store.setLoading(true); + + try { + const response = await preferenceService.getPreferences(); + store.setPreferences(response.effective); + store.setLocks(response.locks); + return true; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to load preferences'; + return false; + } finally { + store.setLoading(false); + } + }; + + // Individual preference computed refs for convenience + const theme = computed({ + get: () => store.preferences.theme, + set: (value: string) => set('theme', value), + }); + + const language = computed({ + get: () => store.preferences.language, + set: (value: string) => set('language', value), + }); + + const timezone = computed({ + get: () => store.preferences.timezone, + set: (value: string) => set('timezone', value), + }); + + const dateFormat = computed({ + get: () => store.preferences.date_format, + set: (value: string) => set('date_format', value), + }); + + const timeFormat = computed({ + get: () => store.preferences.time_format, + set: (value: string) => set('time_format', value), + }); + + const weekStart = computed({ + get: () => store.preferences.week_start, + set: (value: string) => set('week_start', value), + }); + + const defaultModule = computed({ + get: () => store.preferences.default_module ?? '', + set: (value: string) => set('default_module', value), + }); + + return { + // State + preferences, + locks, + saving, + error, + loading: computed(() => store.loading), + + // Methods + get, + set, + update, + reset, + refresh, + isLocked, + + // Individual preference refs + theme, + language, + timezone, + dateFormat, + timeFormat, + weekStart, + defaultModule, + }; +} diff --git a/core/src/config.ts b/core/src/config.ts new file mode 100644 index 0000000..63d4192 --- /dev/null +++ b/core/src/config.ts @@ -0,0 +1,15 @@ +export type ConfigProps = { + Sidebar_drawer: boolean; + mini_sidebar: boolean; + actTheme: string; + fontTheme: string; +}; + +const config: ConfigProps = { + Sidebar_drawer: true, + mini_sidebar: false, + actTheme: 'light', + fontTheme: 'Public sans' +}; + +export default config; diff --git a/core/src/layouts/blank/BlankLayout.vue b/core/src/layouts/blank/BlankLayout.vue new file mode 100644 index 0000000..d884df5 --- /dev/null +++ b/core/src/layouts/blank/BlankLayout.vue @@ -0,0 +1,9 @@ + + + diff --git a/core/src/layouts/footer/LayoutFooter.vue b/core/src/layouts/footer/LayoutFooter.vue new file mode 100644 index 0000000..4fa684d --- /dev/null +++ b/core/src/layouts/footer/LayoutFooter.vue @@ -0,0 +1,29 @@ + + diff --git a/core/src/layouts/header/LayoutHeader.vue b/core/src/layouts/header/LayoutHeader.vue new file mode 100644 index 0000000..b7b9135 --- /dev/null +++ b/core/src/layouts/header/LayoutHeader.vue @@ -0,0 +1,103 @@ + + + diff --git a/core/src/layouts/header/NotificationDD.vue b/core/src/layouts/header/NotificationDD.vue new file mode 100644 index 0000000..a426ce3 --- /dev/null +++ b/core/src/layouts/header/NotificationDD.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/core/src/layouts/header/SearchBarPanel.vue b/core/src/layouts/header/SearchBarPanel.vue new file mode 100644 index 0000000..6516d7a --- /dev/null +++ b/core/src/layouts/header/SearchBarPanel.vue @@ -0,0 +1,13 @@ + + + diff --git a/core/src/layouts/logo/LogoDark.vue b/core/src/layouts/logo/LogoDark.vue new file mode 100644 index 0000000..f91b360 --- /dev/null +++ b/core/src/layouts/logo/LogoDark.vue @@ -0,0 +1,42 @@ + + diff --git a/core/src/layouts/menus/LayoutSystemMenu.vue b/core/src/layouts/menus/LayoutSystemMenu.vue new file mode 100644 index 0000000..c9afba1 --- /dev/null +++ b/core/src/layouts/menus/LayoutSystemMenu.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue b/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue new file mode 100644 index 0000000..af1dc4c --- /dev/null +++ b/core/src/layouts/menus/LayoutSystemMenuGroupDynamic.vue @@ -0,0 +1,42 @@ + + + + diff --git a/core/src/layouts/menus/LayoutSystemMenuGroupStatic.vue b/core/src/layouts/menus/LayoutSystemMenuGroupStatic.vue new file mode 100644 index 0000000..86518a9 --- /dev/null +++ b/core/src/layouts/menus/LayoutSystemMenuGroupStatic.vue @@ -0,0 +1,15 @@ + + + diff --git a/core/src/layouts/menus/LayoutSystemMenuItem.vue b/core/src/layouts/menus/LayoutSystemMenuItem.vue new file mode 100644 index 0000000..a02ed6f --- /dev/null +++ b/core/src/layouts/menus/LayoutSystemMenuItem.vue @@ -0,0 +1,29 @@ + + + diff --git a/core/src/layouts/menus/LayoutUserMenu.vue b/core/src/layouts/menus/LayoutUserMenu.vue new file mode 100644 index 0000000..6999420 --- /dev/null +++ b/core/src/layouts/menus/LayoutUserMenu.vue @@ -0,0 +1,101 @@ + + + diff --git a/core/src/plugins/vuetify/defaults.ts b/core/src/plugins/vuetify/defaults.ts new file mode 100644 index 0000000..c8bdfda --- /dev/null +++ b/core/src/plugins/vuetify/defaults.ts @@ -0,0 +1,148 @@ +export default { + IconBtn: { + icon: true, + color: 'default', + variant: 'text', + }, + VAlert: { + VBtn: { + color: undefined, + }, + }, + VAvatar: { + // ℹ️ Remove after next release + variant: 'flat', + }, + VBadge: { + // set v-badge default color to primary + color: 'primary', + }, + VBtn: { + // set v-btn default color to primary + color: 'primary', + style: 'text-transform: none;', + }, + VCard: { + rounded: 'lg', + elevation: 2, + }, + VChip: { + elevation: 0, + }, + VMenu: { + offset: '2px', + }, + VPagination: { + density: 'comfortable', + showFirstLastPage: true, + variant: 'tonal', + }, + VTabs: { + // set v-tabs default color to primary + color: 'primary', + VSlideGroup: { + showArrows: true, + }, + }, + VTooltip: { + // set v-tooltip default location to top + location: 'top', + }, + VCheckboxBtn: { + color: 'primary', + }, + VCheckbox: { + // set v-checkbox default color to primary + color: 'primary', + density: 'comfortable', + hideDetails: 'auto', + }, + VRadioGroup: { + color: 'primary', + density: 'comfortable', + hideDetails: 'auto', + }, + VRadio: { + density: 'comfortable', + hideDetails: 'auto', + }, + VSelect: { + variant: 'outlined', + color: 'primary', + hideDetails: 'auto', + density: 'comfortable', + }, + VRangeSlider: { + // set v-range-slider default color to primary + color: 'primary', + thumbLabel: true, + hideDetails: 'auto', + trackSize: 6, + thumbSize: 22, + elevation: 4, + }, + VRating: { + // set v-rating default color to primary + activeColor: 'warning', + color: 'disabled', + }, + VProgressCircular: { + // set v-progress-circular default color to primary + color: 'primary', + }, + VProgressLinear: { + color: 'primary', + }, + VSlider: { + // set v-slider default color to primary + color: 'primary', + trackSize: 6, + hideDetails: 'auto', + thumbSize: 22, + elevation: 4, + }, + VSnackbar: { + VBtn: { + size: 'small', + }, + }, + VTextField: { + variant: 'outlined', + density: 'comfortable', + color: 'primary', + hideDetails: 'auto', + }, + VAutocomplete: { + variant: 'outlined', + color: 'primary', + density: 'comfortable', + hideDetails: 'auto', + }, + VCombobox: { + variant: 'outlined', + color: 'primary', + hideDetails: 'auto', + density: 'comfortable', + }, + VFileInput: { + variant: 'outlined', + color: 'primary', + hideDetails: 'auto', + density: 'comfortable', + }, + VTextarea: { + variant: 'outlined', + color: 'primary', + hideDetails: 'auto', + density: 'comfortable', + }, + VSwitch: { + // set v-switch default color to primary + inset: true, + color: 'primary', + hideDetails: 'auto', + }, + VNavigationDrawer: { + touchless: true, + }, +} diff --git a/core/src/plugins/vuetify/icons.ts b/core/src/plugins/vuetify/icons.ts new file mode 100644 index 0000000..9999b07 --- /dev/null +++ b/core/src/plugins/vuetify/icons.ts @@ -0,0 +1,14 @@ +import type { IconAliases } from 'vuetify' +import { aliases as mdiAliases, mdi } from 'vuetify/iconsets/mdi' + +const aliases: Partial = { + ...mdiAliases, +} + +export const icons = { + defaultSet: 'mdi', + aliases, + sets: { + mdi, + }, +} diff --git a/core/src/plugins/vuetify/index.ts b/core/src/plugins/vuetify/index.ts new file mode 100644 index 0000000..2dcf8b5 --- /dev/null +++ b/core/src/plugins/vuetify/index.ts @@ -0,0 +1,24 @@ +import { createVuetify } from 'vuetify' +import { VBtn } from 'vuetify/components/VBtn' +import * as components from 'vuetify/components' +import * as directives from 'vuetify/directives' +import defaults from './defaults' +import { icons } from './icons' +import { themes } from './theme' + +// Styles +import 'vuetify/styles' + +export default createVuetify({ + components, + directives, + aliases: { + IconBtn: VBtn, + }, + defaults, + icons, + theme: { + defaultTheme: 'light', + themes, + }, +}) diff --git a/core/src/plugins/vuetify/theme.ts b/core/src/plugins/vuetify/theme.ts new file mode 100644 index 0000000..16aec75 --- /dev/null +++ b/core/src/plugins/vuetify/theme.ts @@ -0,0 +1,144 @@ +import type { ThemeDefinition } from 'vuetify' + +export const staticPrimaryColor = '#6366F1' +export const staticPrimaryDarkenColor = '#4F46E5' + +export const themes: Record = { + light: { + dark: false, + colors: { + // Primary brand colors - Modern indigo + 'primary': staticPrimaryColor, + 'on-primary': '#FFFFFF', + 'primary-darken-1': staticPrimaryDarkenColor, + 'primary-lighten-1': '#818CF8', + + // Secondary - Purple accent + 'secondary': '#8B5CF6', + 'secondary-darken-1': '#7C3AED', + 'secondary-lighten-1': '#A78BFA', + 'on-secondary': '#FFFFFF', + + // Semantic colors + 'success': '#10B981', + 'on-success': '#FFFFFF', + 'info': '#3B82F6', + 'on-info': '#FFFFFF', + 'warning': '#F59E0B', + 'on-warning': '#FFFFFF', + 'error': '#EF4444', + 'on-error': '#FFFFFF', + + // Surface & backgrounds + 'background': '#F8FAFC', + 'on-background': '#0F172A', + 'surface': '#FFFFFF', + 'on-surface': '#0F172A', + 'surface-bright': '#FFFFFF', + 'surface-variant': '#F1F5F9', + 'on-surface-variant': '#64748B', + + // Grey scale + 'grey': '#64748B', + 'grey-darken-1': '#475569', + 'grey-lighten-1': '#94A3B8', + 'grey-lighten-2': '#CBD5E1', + 'grey-lighten-3': '#E2E8F0', + 'grey-lighten-4': '#F1F5F9', + 'grey-lighten-5': '#F8FAFC', + + // Component specific + 'perfect-scrollbar-thumb': '#CBD5E1', + 'track-bg': '#F1F5F9', + }, + + variables: { + 'border-color': '#E2E8F0', + 'border-opacity': 0.12, + 'high-emphasis-opacity': 0.87, + 'medium-emphasis-opacity': 0.60, + 'disabled-opacity': 0.38, + 'idle-opacity': 0.04, + 'hover-opacity': 0.04, + 'focus-opacity': 0.12, + 'selected-opacity': 0.08, + 'activated-opacity': 0.12, + 'pressed-opacity': 0.12, + 'dragged-opacity': 0.08, + 'theme-kbd': '#212529', + 'theme-on-kbd': '#FFFFFF', + 'theme-code': '#F5F5F5', + 'theme-on-code': '#000000', + }, + }, + + dark: { + dark: true, + colors: { + // Primary brand colors - Lighter shades for dark mode + 'primary': '#818CF8', + 'on-primary': '#FFFFFF', + 'primary-darken-1': '#6366F1', + 'primary-lighten-1': '#A5B4FC', + + // Secondary - Purple accent + 'secondary': '#A78BFA', + 'secondary-darken-1': '#8B5CF6', + 'secondary-lighten-1': '#C4B5FD', + 'on-secondary': '#FFFFFF', + + // Semantic colors - Adjusted for dark mode + 'success': '#34D399', + 'on-success': '#FFFFFF', + 'info': '#60A5FA', + 'on-info': '#FFFFFF', + 'warning': '#FBBF24', + 'on-warning': '#FFFFFF', + 'error': '#F87171', + 'on-error': '#FFFFFF', + + // Surface & backgrounds - Dark slate palette + 'background': '#0F172A', + 'on-background': '#F1F5F9', + 'surface': '#1E293B', + 'on-surface': '#F1F5F9', + 'surface-bright': '#334155', + 'surface-variant': '#1E293B', + 'on-surface-variant': '#94A3B8', + + // Grey scale + 'grey': '#94A3B8', + 'grey-darken-1': '#CBD5E1', + 'grey-lighten-1': '#64748B', + 'grey-lighten-2': '#475569', + 'grey-lighten-3': '#334155', + 'grey-lighten-4': '#1E293B', + 'grey-lighten-5': '#0F172A', + + // Component specific + 'perfect-scrollbar-thumb': '#475569', + 'track-bg': '#334155', + }, + + variables: { + 'border-color': '#334155', + 'border-opacity': 0.12, + 'high-emphasis-opacity': 0.87, + 'medium-emphasis-opacity': 0.60, + 'disabled-opacity': 0.38, + 'idle-opacity': 0.10, + 'hover-opacity': 0.08, + 'focus-opacity': 0.12, + 'selected-opacity': 0.12, + 'activated-opacity': 0.16, + 'pressed-opacity': 0.14, + 'dragged-opacity': 0.10, + 'theme-kbd': '#212529', + 'theme-on-kbd': '#FFFFFF', + 'theme-code': '#343434', + 'theme-on-code': '#CCCCCC', + }, + }, +} + +export default themes diff --git a/core/src/private.html b/core/src/private.html new file mode 100644 index 0000000..ab6d8fe --- /dev/null +++ b/core/src/private.html @@ -0,0 +1,24 @@ + + + + + + + + + K-Trix + + +
+ + + diff --git a/core/src/private.ts b/core/src/private.ts new file mode 100644 index 0000000..d3fdac9 --- /dev/null +++ b/core/src/private.ts @@ -0,0 +1,67 @@ +import * as Vue from 'vue' +import * as VueRouterLib from 'vue-router' +import * as PiniaLib from 'pinia' +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar' +import { useModuleStore } from '@KTXC/stores/moduleStore' +import { useTenantStore } from '@KTXC/stores/tenantStore' +import { usePreferencesStore } from '@KTXC/stores/preferencesStore' +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper' +import { initializeModules } from '@KTXC/utils/modules' +import App from './App.vue' +import router from './router' +import vuetify from './plugins/vuetify/index' +import '@KTXC/scss/style.scss' + +// google-fonts +import '@fontsource/public-sans/index.css' + +const app = createApp(App) +const pinia = createPinia() +app.use(pinia) +app.use(PerfectScrollbarPlugin) +app.use(vuetify) + +// Note: Router is registered AFTER modules are loaded to prevent premature route matching + +const globalWindow = window as typeof window & { + [key: string]: unknown +} + +globalWindow.Vue = Vue +globalWindow.vue = Vue +globalWindow.VueRouter = VueRouterLib +globalWindow.Pinia = PiniaLib as unknown + +// Bootstrap initial private UI state (modules, tenant, preferences) before mounting +(async () => { + const moduleStore = useModuleStore(); + const tenantStore = useTenantStore(); + + try { + const payload = await fetchWrapper.get('/init'); + moduleStore.init(payload?.modules ?? {}); + tenantStore.init(payload?.tenant ?? null); + + // Initialize registered modules (following reference app's bootstrap pattern) + await initializeModules(app); + + // Add 404 catch-all route AFTER all modules are loaded + // This ensures module routes are registered before the catch-all + router.addRoute({ + name: 'NotFound', + path: '/:pathMatch(.*)*', + component: () => import('@KTXC/views/pages/maintenance/error/Error404Page.vue') + }); + + // Register router AFTER modules are loaded + app.use(router); + + await router.isReady(); + // Home redirect handled by router beforeEnter now + app.mount('#app'); + } catch (e) { + console.error('Bootstrap failed:', e); + } +})(); diff --git a/core/src/public.html b/core/src/public.html new file mode 100644 index 0000000..08307fd --- /dev/null +++ b/core/src/public.html @@ -0,0 +1,14 @@ + + + + + + + + Ktrix Cloud + + +
+ + + diff --git a/core/src/public.ts b/core/src/public.ts new file mode 100644 index 0000000..d7435fd --- /dev/null +++ b/core/src/public.ts @@ -0,0 +1,36 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import { router } from './router'; +import vuetify from './plugins/vuetify/index'; +import '@KTXC/scss/style.scss'; + +// google-fonts +import '@fontsource/public-sans/400.css'; +import '@fontsource/public-sans/500.css'; +import '@fontsource/public-sans/600.css'; +import '@fontsource/public-sans/700.css'; + +// The public app is served when the user has no valid server session. +// Clear any stale identity data from localStorage to ensure the client +// state matches the server's determination that the user is unauthenticated. +//localStorage.removeItem('identityStore.self'); + +const app = createApp(App); +const pinia = createPinia(); +app.use(pinia); +app.use(router); +app.use(vuetify); + +// Wait for router to be ready, then ensure we're on a public route +//router.isReady().then(() => { + // If the current route requires auth, redirect to login + // This handles the case where user navigates to / with an expired session + //const currentRoute = router.currentRoute.value; + //const requiresAuth = currentRoute.matched.some(record => record.meta?.requiresAuth); + //if (requiresAuth || currentRoute.path === '/') { + // router.replace('/login'); + //} +//}); + +app.mount('#app'); diff --git a/core/src/router/index.ts b/core/src/router/index.ts new file mode 100644 index 0000000..05972dd --- /dev/null +++ b/core/src/router/index.ts @@ -0,0 +1,117 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'; +import { useUserStore } from '@KTXC/stores/userStore'; +import { useLayoutStore } from '@KTXC/stores/layoutStore'; +import BlankLayout from '@KTXC/layouts/blank/BlankLayout.vue'; +import PrivateLayout from '@KTXC/views/PrivateLayout.vue'; +import { usePreferencesStore } from '@KTXC/stores/preferencesStore'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; + +const routes: RouteRecordRaw[] = [ + // Public login route + { + name: 'login', + path: '/login', + meta: { requiresAuth: false }, + component: BlankLayout, + children: [ + { + path: '', + component: () => import('@KTXC/views/authentication/LoginPage.vue') + } + ] + }, + // Logout performs action then redirects + { + name: 'logout', + path: '/logout', + meta: { requiresAuth: true }, + component: BlankLayout, + beforeEnter: async () => { + const userStore = useUserStore(); + await userStore.logout(); + return false; + } + }, + // Private area (shell layout). Module routes under /m/{namespace} are added at runtime. + { + name: 'private', + path: '/', + component: PrivateLayout, + meta: { requiresAuth: true }, + children: [ + // Index redirects to the first available module route (if any) + { + name: 'home', + path: '', + meta: { requiresAuth: true }, + component: BlankLayout, + beforeEnter: (to, from, next) => { + const integrationStore = useIntegrationStore(); + const preferences = usePreferencesStore(); + + // Treat preference as a route name (e.g., "samples.overview") + const preferredRouteName = preferences.preferences.default_module; + if (preferredRouteName) { + // If a route with this name exists, go there + try { + // using router variable at runtime: + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const exists = router.getRoutes().some(r => r.name === preferredRouteName); + if (exists) return next({ name: preferredRouteName }); + } catch {} + } + + // Get first available menu item from app_menu + const entries = integrationStore.getPoint('app_menu'); + for (const entry of entries) { + // Check if it's a group with items + if ('items' in entry && entry.items.length > 0) { + const first = entry.items[0]?.to; + if (first) return next(first); + } + // Or a standalone item + if ('to' in entry && entry.to) { + return next(entry.to); + } + } + return next(); + } + } + ] + } +]; + +export const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes +}); + +router.beforeEach(async (to, from, next) => { + const userStore = useUserStore(); + + const authRequired = to.matched.some((record) => record.meta?.requiresAuth); + + if (authRequired && !userStore.user && to.path !== '/login') { + userStore.returnUrl = to.fullPath; + return next('/login'); + } + + if (userStore.user && to.path === '/login') { + const dest = userStore.returnUrl && userStore.returnUrl !== '/' ? userStore.returnUrl : '/'; + return next(dest); + } + + return next(); +}); + +router.beforeEach(() => { + const layoutStore = useLayoutStore(); + layoutStore.isLoading = true; +}); + +router.afterEach(() => { + const layoutStore = useLayoutStore(); + layoutStore.isLoading = false; +}); + +export default router \ No newline at end of file diff --git a/core/src/scss/_override.scss b/core/src/scss/_override.scss new file mode 100644 index 0000000..df5b1d3 --- /dev/null +++ b/core/src/scss/_override.scss @@ -0,0 +1,115 @@ +html { + .bg-success, + .bg-info, + .bg-warning { + color: white !important; + } +} + +.v-row + .v-row { + margin-top: 0px; +} + +.v-divider { + opacity: 1; + border-color: rgb(var(--v-theme-borderLight)); +} + +.v-table > .v-table__wrapper > table > thead > tr > th { + color: inherit; +} + +.border-blue-right { + border-right: 1px solid rgba(var(--v-theme-borderLight), 0.36); +} + +.link-hover { + text-decoration: unset; + &:hover { + text-decoration: underline; + } +} + +.v-selection-control { + flex: unset; +} + +.customizer-btn .icon { + animation: progress-circular-rotate 1.4s linear infinite; + transform-origin: center center; + transition: all 0.2s ease-in-out; +} + +.no-spacer { + .v-list-item__spacer { + display: none !important; + } +} + +@keyframes progress-circular-rotate { + 100% { + transform: rotate(270deg); + } +} + +header { + &.v-toolbar--border { + border-color: rgb(var(--v-theme-borderLight)); + } +} + +.v-toolbar { + &.v-app-bar { + border-bottom: 1px solid rgba(var(--v-theme-borderLight), 0.8); + } +} + +.v-sheet--border { + border: 1px solid rgba(var(--v-theme-borderLight), 0.8); +} + +// table css +.v-table { + &.v-table--hover { + > .v-table__wrapper { + > table { + > tbody { + > tr { + &:hover { + td { + background: rgb(var(--v-theme-gray100)); + } + } + } + } + } + } + } +} + +// accordion page css +.v-expansion-panel { + border: 1px solid rgb(var(--v-theme-borderLight)); + &:not(:first-child) { + margin-top: -1px; + } + .v-expansion-panel-text__wrapper { + border-top: 1px solid rgb(var(--v-theme-borderLight)); + padding: 16px 24px; + } + &.v-expansion-panel--active { + .v-expansion-panel-title--active { + .v-expansion-panel-title__overlay { + background-color: rgb(var(--v-theme-gray100)); + } + } + } +} +.v-expansion-panel--active { + > .v-expansion-panel-title { + min-height: unset; + } +} +.v-expansion-panel--disabled .v-expansion-panel-title { + color: rgba(var(--v-theme-on-surface), 0.15); +} diff --git a/core/src/scss/_variables.scss b/core/src/scss/_variables.scss new file mode 100644 index 0000000..3f2ae89 --- /dev/null +++ b/core/src/scss/_variables.scss @@ -0,0 +1,140 @@ +@use 'sass:math'; +@use 'sass:map'; +@use 'sass:meta'; +@use 'vuetify/lib/styles/tools/functions' as *; + +// This will false all colors which is not necessory for theme +$color-pack: false; + +// Global font size and border radius +$font-size-root: 1rem; +$border-radius-root: 4px; +$body-font-family: 'Public sans', sans-serif !default; +$heading-font-family: $body-font-family !default; +$btn-font-weight: 400 !default; +$btn-letter-spacing: 0 !default; + +// Global Radius as per breakeven point +$rounded: () !default; +$rounded: map-deep-merge( + ( + 0: 0, + 'sm': $border-radius-root * 0.5, + null: $border-radius-root, + 'md': $border-radius-root * 1, + 'lg': $border-radius-root * 2, + 'xl': $border-radius-root * 6, + 'pill': 9999px, + 'circle': 50%, + 'shaped': $border-radius-root * 6 0 + ), + $rounded +); +// Global Typography +$typography: () !default; +$typography: map-deep-merge( + ( + 'h1': ( + 'size': 2.375rem, + 'weight': 600, + 'line-height': 1.21, + 'font-family': inherit + ), + 'h2': ( + 'size': 1.875rem, + 'weight': 600, + 'line-height': 1.27, + 'font-family': inherit + ), + 'h3': ( + 'size': 1.5rem, + 'weight': 600, + 'line-height': 1.33, + 'font-family': inherit + ), + 'h4': ( + 'size': 1.25rem, + 'weight': 600, + 'line-height': 1.4, + 'font-family': inherit + ), + 'h5': ( + 'size': 1rem, + 'weight': 600, + 'line-height': 1.5, + 'font-family': inherit + ), + 'h6': ( + 'size': 0.875rem, + 'weight': 400, + 'line-height': 1.57, + 'font-family': inherit + ), + 'subtitle-1': ( + 'size': 0.875rem, + 'weight': 600, + 'line-height': 1.57, + 'font-family': inherit + ), + 'subtitle-2': ( + 'size': 0.75rem, + 'weight': 500, + 'line-height': 1.66, + 'font-family': inherit + ), + 'body-1': ( + 'size': 0.875rem, + 'weight': 400, + 'line-height': 1.57, + 'font-family': inherit + ), + 'body-2': ( + 'size': 0.75rem, + 'weight': 400, + 'line-height': 1.66, + 'font-family': inherit + ), + 'button': ( + 'size': 0.875rem, + 'weight': 500, + 'font-family': inherit, + 'text-transform': uppercase + ), + 'caption': ( + 'size': 0.75rem, + 'weight': 400, + 'letter-spacing': 0, + 'font-family': inherit + ), + 'overline': ( + 'size': 0.75rem, + 'weight': 500, + 'font-family': inherit, + 'line-height': 1.67, + 'letter-spacing': 0, + 'text-transform': uppercase + ) + ), + $typography +); + +// Custom Variables +// colors +$white: #fff !default; + +// cards +$card-item-spacer-xy: 20px !default; +$card-text-spacer: 20px !default; +$card-title-size: 16px !default; + +// Global Shadow +$box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.08); + +$theme-colors: ( + primary: var(--v-theme-primary), + secondary: var(--v-theme-secondary), + success: var(--v-theme-success), + info: var(--v-theme-info), + warning: var(--v-theme-warning), + error: var(--v-theme-error) +); diff --git a/core/src/scss/components/_VAlert.scss b/core/src/scss/components/_VAlert.scss new file mode 100644 index 0000000..64c8eba --- /dev/null +++ b/core/src/scss/components/_VAlert.scss @@ -0,0 +1,37 @@ +.single-line-alert { + .v-alert__close, + .v-alert__prepend { + align-self: center !important; + } +} + +.v-alert__prepend { + align-self: center; +} + +.v-alert--variant-tonal { + &.with-border { + @each $color, $value in $theme-colors { + &.text-#{$color} { + border: 1px solid rgba(#{$value}, 0.3); + } + } + } +} + +@media (max-width: 500px) { + .single-line-alert { + display: flex; + flex-wrap: wrap; + .v-alert__append { + margin-inline-start: 0px; + } + .v-alert__close { + margin-left: auto; + } + .v-alert__content { + width: 100%; + margin-top: 5px; + } + } +} diff --git a/core/src/scss/components/_VBadge.scss b/core/src/scss/components/_VBadge.scss new file mode 100644 index 0000000..a9871c4 --- /dev/null +++ b/core/src/scss/components/_VBadge.scss @@ -0,0 +1,11 @@ +.v-badge__badge { + min-width: 16px; + height: 16px; + padding: 4px; +} +.v-badge--dot { + .v-badge__badge { + height: 8px; + width: 8px; + } +} diff --git a/core/src/scss/components/_VBreadcrumb.scss b/core/src/scss/components/_VBreadcrumb.scss new file mode 100644 index 0000000..f1ebcff --- /dev/null +++ b/core/src/scss/components/_VBreadcrumb.scss @@ -0,0 +1,32 @@ +.v-breadcrumbs-item--link { + color: rgb(var(--v-theme-lightText)); +} +.v-breadcrumbs { + .v-breadcrumbs-item--disabled { + --v-disabled-opacity: 1; + .v-breadcrumbs-item--link { + color: rgb(var(--v-theme-darkText)); + } + } + .v-breadcrumbs-divider { + color: rgb(var(--v-theme-lightText)); + } +} + +.breadcrumb-with-title { + .v-toolbar__content { + height: unset !important; + padding: 20px 0; + } + .v-breadcrumbs__prepend { + svg { + vertical-align: -3px; + } + } +} + +.breadcrumb-height { + .v-toolbar__content { + height: unset !important; + } +} diff --git a/core/src/scss/components/_VButtons.scss b/core/src/scss/components/_VButtons.scss new file mode 100644 index 0000000..c4ef964 --- /dev/null +++ b/core/src/scss/components/_VButtons.scss @@ -0,0 +1,68 @@ +// +// Light Buttons +// + +.v-btn { + &.bg-lightprimary { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-primary)) !important; + color: $white !important; + } + } + &.bg-lightsecondary { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-secondary)) !important; + color: $white !important; + } + } + &.text-facebook { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-facebook)) !important; + color: $white !important; + } + } + &.text-twitter { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-twitter)) !important; + color: $white !important; + } + } + &.text-linkedin { + &:hover, + &:active, + &:focus { + background-color: rgb(var(--v-theme-linkedin)) !important; + color: $white !important; + } + } +} + +.v-btn { + text-transform: capitalize; + letter-spacing: $btn-letter-spacing; + font-weight: 400; +} +.v-btn--icon.v-btn--density-default { + width: calc(var(--v-btn-height) + 6px); + height: calc(var(--v-btn-height) + 6px); +} + +.v-btn-group .v-btn { + height: inherit !important; +} + +.v-btn-group { + border-color: rgba(var(--v-border-color), 1); +} + +.v-btn-group--divided .v-btn:not(:last-child) { + border-inline-end-color: rgba(var(--v-border-color), 1); +} diff --git a/core/src/scss/components/_VCard.scss b/core/src/scss/components/_VCard.scss new file mode 100644 index 0000000..b0e30b7 --- /dev/null +++ b/core/src/scss/components/_VCard.scss @@ -0,0 +1,40 @@ +// Outline Card +.v-card--variant-outlined { + border-color: rgba(var(--v-theme-borderLight), 1); + .v-divider { + border-color: rgba(var(--v-theme-borderLight), 0.8); + } +} + +.v-card-text { + padding: $card-text-spacer; +} + +.v-card-actions { + padding: 14px $card-text-spacer 14px; +} + +.v-card { + overflow: visible; + .v-card-title { + &.text-h6 { + font-weight: 600; + line-height: 1.57; + } + } +} + +.v-card-item { + padding: $card-item-spacer-xy; +} + +.v-card-subtitle { + font-size: 0.75rem; +} + +.title-card { + .v-card-text { + background-color: rgb(var(--v-theme-background)); + border: 1px solid rgba(var(--v-theme-borderLight), 1); + } +} diff --git a/core/src/scss/components/_VField.scss b/core/src/scss/components/_VField.scss new file mode 100644 index 0000000..97352ac --- /dev/null +++ b/core/src/scss/components/_VField.scss @@ -0,0 +1,9 @@ +.v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr, +.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start { + border-radius: $border-radius-root 0 0 $border-radius-root; +} + +.v-field--variant-outlined .v-field__outline__end.v-locale--is-ltr, +.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__end { + border-radius: 0 $border-radius-root $border-radius-root 0; +} diff --git a/core/src/scss/components/_VInput.scss b/core/src/scss/components/_VInput.scss new file mode 100644 index 0000000..976f726 --- /dev/null +++ b/core/src/scss/components/_VInput.scss @@ -0,0 +1,55 @@ +.v-input--density-default:not(.v-autocomplete--multiple), +.v-field--variant-solo, +.v-field--variant-filled { + --v-input-control-height: 39px; + --v-input-padding-top: 2px; + input.v-field__input { + padding-bottom: 2px; + } + .v-field__input { + padding-bottom: 2px; + } + textarea { + padding-top: 11px; + } +} +.v-input--density-default { + .v-field__input { + min-height: 41px; + } +} +.v-field--variant-outlined { + &.v-field--focused { + .v-field__outline { + --v-field-border-width: 1px; + } + } +} +.v-input { + .v-input__details { + padding-inline: 0; + } +} + +.v-input--density-comfortable { + --v-input-control-height: 56px; + --v-input-padding-top: 17px; +} +.v-label { + font-size: 0.875rem; + --v-medium-emphasis-opacity: 0.8; +} +.v-switch .v-label, +.v-checkbox .v-label { + opacity: 1; +} + +textarea.v-field__input { + font-size: 14px; +} + +.textarea-input { + .v-label { + top: 15px; + } +} diff --git a/core/src/scss/components/_VList.scss b/core/src/scss/components/_VList.scss new file mode 100644 index 0000000..0e6cd11 --- /dev/null +++ b/core/src/scss/components/_VList.scss @@ -0,0 +1,47 @@ +.v-list-item { + &.v-list-item--border { + border-color: rgb(var(--v-border-color)); + border-width: 0 0 1px 0; + &:last-child { + border-width: 0; + } + } + &.v-list-item--variant-tonal { + background: rgb(var(--v-theme-gray100)); + .v-list-item__underlay { + background: transparent; + } + } + &:last-child { + .v-list-item__content { + .v-divider--inset { + display: none; + } + } + } +} +.v-list { + &[aria-busy='true'] { + cursor: context-menu; + } +} +.v-list-group__items { + .v-list-item { + padding-inline-start: 40px !important; + } +} + +.v-list-item__content { + .v-divider--inset:not(.v-divider--vertical) { + max-width: 100%; + margin-inline-start: 0; + } +} + +.v-list--border { + .v-list-item { + + .v-list-item { + border-top: 1px solid rgb(var(--v-theme-borderLight)); + } + } +} diff --git a/core/src/scss/components/_VNavigationDrawer.scss b/core/src/scss/components/_VNavigationDrawer.scss new file mode 100644 index 0000000..9994ae9 --- /dev/null +++ b/core/src/scss/components/_VNavigationDrawer.scss @@ -0,0 +1,3 @@ +.v-navigation-drawer__scrim.fade-transition-leave-to { + display: none; +} diff --git a/core/src/scss/components/_VShadow.scss b/core/src/scss/components/_VShadow.scss new file mode 100644 index 0000000..647b098 --- /dev/null +++ b/core/src/scss/components/_VShadow.scss @@ -0,0 +1,20 @@ +.elevation-24 { + box-shadow: $box-shadow !important; +} + +.v-menu { + > .v-overlay__content { + > .v-sheet { + box-shadow: $box-shadow; + } + } +} + +@each $color, $value in $theme-colors { + .#{$color}-shadow { + box-shadow: 0 14px 12px rgba(#{$value}, 0.2); + &:hover { + box-shadow: none; + } + } +} diff --git a/core/src/scss/components/_VTextField.scss b/core/src/scss/components/_VTextField.scss new file mode 100644 index 0000000..153b10c --- /dev/null +++ b/core/src/scss/components/_VTextField.scss @@ -0,0 +1,18 @@ +.v-text-field input { + font-size: 0.875rem; +} + +.v-field__outline { + color: rgb(var(--v-theme-inputBorder)); +} +.inputWithbg { + .v-field--variant-outlined { + background-color: rgba(0, 0, 0, 0.025); + } +} + +.v-select { + .v-field { + font-size: 0.875rem; + } +} diff --git a/core/src/scss/components/_VTextarea.scss b/core/src/scss/components/_VTextarea.scss new file mode 100644 index 0000000..b611c41 --- /dev/null +++ b/core/src/scss/components/_VTextarea.scss @@ -0,0 +1,7 @@ +.v-textarea input { + font-size: 0.875rem; + font-weight: 500; + &::placeholder { + color: rgba(0, 0, 0, 0.38); + } +} diff --git a/core/src/scss/layout/_container.scss b/core/src/scss/layout/_container.scss new file mode 100644 index 0000000..b42a0c5 --- /dev/null +++ b/core/src/scss/layout/_container.scss @@ -0,0 +1,146 @@ +html { + overflow-y: auto; +} +.horizontalLayout { + .page-wrapper { + .v-container { + padding-top: 20px; + } + } +} +.spacer { + padding: 100px 0; + @media (max-width: 1264px) { + padding: 72px 0; + } +} +@media (max-width: 800px) { + .spacer { + padding: 40px 0; + } +} + +.cursor-pointer { + cursor: pointer; +} +.page-wrapper { + background: rgb(var(--v-theme-containerBg)); + display: flex; + flex-direction: column; + height: calc(100vh - 60px); + overflow: hidden; + + .page-content-container { + flex: 1; + overflow: hidden; + min-height: 0; + display: flex; + flex-direction: column; + padding: 15px; + @media (max-width: 1550px) { + max-width: 100%; + } + @media (min-width: 768px) { + padding-inline: 40px; + } + } + + .page-footer-container { + flex-shrink: 0; + } + + .v-container { + padding: 15px; + @media (max-width: 1550px) { + max-width: 100%; + } + @media (min-width: 768px) { + padding-inline: 40px; + } + } +} +.maxWidth { + max-width: 1200px; + margin: 0 auto; +} +$sizes: ( + 'display-1': 44px, + 'display-2': 40px, + 'display-3': 30px, + 'h1': 36px, + 'h2': 30px, + 'h3': 21px, + 'h4': 18px, + 'h5': 16px, + 'h6': 14px, + 'text-8': 8px, + 'text-10': 10px, + 'text-13': 13px, + 'text-18': 18px, + 'text-20': 20px, + 'text-24': 24px, + 'body-text-1': 10px +); + +@each $pixel, $size in $sizes { + .#{$pixel} { + font-size: $size; + line-height: $size + 10; + } +} + +.customizer-btn { + .icon { + animation: progress-circular-rotate 1.4s linear infinite; + transform-origin: center center; + transition: all 0.2s ease-in-out; + } +} +.fixed-width { + max-width: 1300px; +} +.ga-2 { + gap: 8px; +} + +// font family +body { + font-family: 'Public Sans', sans-serif; + .Roboto { + font-family: 'Roboto', sans-serif !important; + } + + .Poppins { + font-family: 'Poppins', sans-serif !important; + } + + .Inter { + font-family: 'Inter', sans-serif !important; + } + + .Public { + font-family: 'Public sans', sans-serif !important; + } +} + +@keyframes slideY { + 0%, + 50%, + 100% { + transform: translateY(0px); + } + 25% { + transform: translateY(-10px); + } + 75% { + transform: translateY(10px); + } +} + +.link { + color: rgb(var(--v-theme-lightText)); + text-decoration: none; + &:hover { + color: rgb(var(--v-theme-primary)); + } +} diff --git a/core/src/scss/layout/_footer.scss b/core/src/scss/layout/_footer.scss new file mode 100644 index 0000000..67f478d --- /dev/null +++ b/core/src/scss/layout/_footer.scss @@ -0,0 +1,25 @@ +.v-footer { + background: rgb(var(--v-theme-containerbg)); + padding: 24px 16px 0px; + margin-top: auto; + position: unset; + a { + text-decoration: unset; + &:hover { + text-decoration: underline; + } + } +} + +@media (max-width: 475px) { + .footer { + text-align: center; + .v-col-6 { + flex: 0 0 100%; + max-width: 100%; + &.text-right { + text-align: center !important; + } + } + } +} diff --git a/core/src/scss/layout/_sidebar.scss b/core/src/scss/layout/_sidebar.scss new file mode 100644 index 0000000..e9af48c --- /dev/null +++ b/core/src/scss/layout/_sidebar.scss @@ -0,0 +1,165 @@ +/*This is for the logo*/ +.leftSidebar { + border: 0px; + box-shadow: none !important; + border-right: 1px solid rgba(var(--v-theme-borderLight), 0.8); + .logo { + padding-left: 7px; + } +} +/*This is for the Vertical sidebar*/ +.scrollnavbar { + height: calc(100vh - 110px); + .smallCap { + padding: 0px 0 0 20px !important; + } + .v-list { + color: rgb(var(--v-theme-lightText)); + padding: 0; + .v-list-item--one-line { + &.v-list-item--active { + border-right: 2px solid rgb(var(--v-theme-primary)); + } + } + .v-list-group { + .v-list-item--one-line { + &.v-list-item--active.v-list-item--link { + border-right: 2px solid rgb(var(--v-theme-primary)); + } + &.v-list-item--active.v-list-group__header { + border-right: none; + background: transparent; + } + } + .v-list-group__items { + .v-list-item--link, + .v-list-item { + .v-list-item__prepend { + margin-inline-end: 1px; + } + } + } + } + .v-list-item--variant-plain, + .v-list-item--variant-outlined, + .v-list-item--variant-text, + .v-list-item--variant-tonal { + color: rgb(var(--v-theme-darkText)); + } + } + /*General Menu css*/ + .v-list-group__items .v-list-item, + .v-list-item { + border-radius: 0; + padding-inline-start: calc(20px + var(--indent-padding) / 2) !important; + .v-list-item__prepend { + margin-inline-end: 13px; + } + .v-list-item__append { + font-size: 0.875rem; + .v-icon { + margin-inline-start: 13px; + } + > .v-icon { + --v-medium-emphasis-opacity: 0.8; + } + } + .v-list-item-title { + font-size: 0.875rem; + color: rgb(var(--v-theme-darkText)); + } + &.v-list-item--active { + .v-list-item-title { + color: rgb(var(--v-theme-primary)); + } + } + } + /*This is for the dropdown*/ + .v-list { + .v-list-item--active { + .v-list-item-title { + font-weight: 500; + } + } + .sidebarchip .v-icon { + margin-inline-start: -3px; + } + .v-list-group { + .v-list-item:focus-visible > .v-list-item__overlay { + opacity: 0; + } + } + > .v-list-group { + position: relative; + > .v-list-item--active, + > .v-list-item:hover { + background: rgb(var(--v-theme-primary), 0.05); + } + } + } +} +.v-navigation-drawer--rail { + .scrollnavbar .v-list .v-list-group__items, + .hide-menu { + opacity: 0; + } + .scrollnavbar { + .v-list-item { + .v-list-item__prepend { + margin-left: 8px; + .anticon { + svg { + width: 20px; + height: 20px; + } + } + } + } + .v-list-group__items .v-list-item, + .v-list-item { + padding-inline-start: calc(12px + var(--indent-padding) / 2) !important; + } + .ExtraBox { + display: none; + } + } + .sidebar-user { + margin-left: -6px; + } + .leftPadding { + margin-left: 0px; + } + &.leftSidebar { + .v-list-subheader { + display: none; + } + .v-navigation-drawer__content { + .pa-5 { + padding-left: 10px !important; + .logo { + padding-left: 0; + } + } + } + } +} +@media only screen and (min-width: 1170px) { + .mini-sidebar { + .logo { + width: 40px; + overflow: hidden; + } + .leftSidebar:hover { + box-shadow: $box-shadow !important; + } + .v-navigation-drawer--expand-on-hover:hover { + .logo { + width: 100%; + } + .v-list .v-list-group__items, + .hide-menu { + opacity: 1; + } + } + } +} diff --git a/core/src/scss/layout/_topbar.scss b/core/src/scss/layout/_topbar.scss new file mode 100644 index 0000000..6d150f7 --- /dev/null +++ b/core/src/scss/layout/_topbar.scss @@ -0,0 +1,41 @@ +.profileBtn { + height: 44px !important; + margin: 0 20px 0 10px !important; + padding: 0 6px; + .v-avatar { + width: 32px; + height: 32px; + img { + width: 32px; + height: 32px; + } + } +} + +@media (max-width: 600px) { + .profileBtn { + min-width: 42px; + margin: 0 12px 0 0 !important; + .v-avatar { + width: 24px; + height: 24px; + img { + width: 24px; + height: 24px; + } + } + } +} + +@media (max-width: 460px) { + .notification-dropdown { + width: 332px !important; + .v-list-item__content { + .d-inline-flex { + .text-caption { + min-width: 50px; + } + } + } + } +} diff --git a/core/src/scss/style.scss b/core/src/scss/style.scss new file mode 100644 index 0000000..7d95dbc --- /dev/null +++ b/core/src/scss/style.scss @@ -0,0 +1,9 @@ +@import './variables'; +@import 'vuetify/styles/main.sass'; +@import './override'; +@import './layout/container'; +@import './layout/sidebar'; +@import './layout/footer'; +@import './layout/topbar'; + +@import 'vue3-perfect-scrollbar/style.css'; diff --git a/core/src/services/authenticationService.ts b/core/src/services/authenticationService.ts new file mode 100644 index 0000000..c5a51e5 --- /dev/null +++ b/core/src/services/authenticationService.ts @@ -0,0 +1,100 @@ +import type { ChallengeResponse, IdentifyResponse, RedirectResponse, SessionStatus, StartResponse, VerifyResponse } from '@KTXC/types/authenticationTypes'; +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'; + +export const authenticationService = { + /** + * Initialize authentication - get session and available methods + */ + async start(): Promise { + return fetchWrapper.get('/auth/start', undefined, { skipLogoutOnError: true }); + }, + + /** + * Identify user - stores identity in session for identity-first flow + * Returns tenant-wide methods (no user-specific filtering to prevent enumeration) + * + * @param session - Session ID from start + * @param identity - User identity (email/username) + */ + async identify(session: string, identity: string): Promise { + return fetchWrapper.post('/auth/identify', { + session, + identity, + }, { skipLogoutOnError: true }); + }, + + /** + * Verify a factor (primary or secondary) + * + * @param session - Session ID from init + * @param method - Provider/method ID (e.g., 'default', 'totp') + * @param response - User's response (password, code, etc.) + * @param identity - User identity for credential-based auth (email/username) + */ + async verify( + session: string, + method: string, + response: string, + identity?: string + ): Promise { + return fetchWrapper.post('/auth/verify', { + session, + method, + response, + ...(identity && { identity }), + }, { autoRetry: false, skipLogoutOnError: true }); + }, + + /** + * Begin redirect-based authentication (OIDC/SAML) + */ + async beginRedirect( + session: string, + method: string, + returnUrl: string = '/' + ): Promise { + return fetchWrapper.post('/auth/redirect', { + session, + method, + return_url: returnUrl, + }, { skipLogoutOnError: true }); + }, + + /** + * Start a challenge for methods that require it (SMS, email, TOTP) + */ + async beginChallenge(session: string, method: string): Promise { + return fetchWrapper.post('/auth/challenge', { + session, + method, + }, { skipLogoutOnError: true }); + }, + + /** + * Get current session status + */ + async getStatus(session: string): Promise { + return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`, undefined, { skipLogoutOnError: true }); + }, + + /** + * Cancel authentication session + */ + async cancelSession(session: string): Promise { + await fetchWrapper.delete(`/auth/session?session=${encodeURIComponent(session)}`); + }, + + /** + * Refresh access token + */ + async refresh(): Promise { + await fetchWrapper.post('/auth/refresh', {}); + }, + + /** + * Logout + */ + async logout(): Promise { + await fetchWrapper.post('/auth/logout', {}); + }, +}; diff --git a/core/src/services/preferenceService.ts b/core/src/services/preferenceService.ts new file mode 100644 index 0000000..a84cee1 --- /dev/null +++ b/core/src/services/preferenceService.ts @@ -0,0 +1,82 @@ +import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'; +import type { PreferencesState } from '@KTXC/stores/preferencesStore'; + +export interface PreferenceResponse { + effective: PreferencesState; + tenant: PreferencesState; + user: Partial; + locks: string[]; + savedKeys?: string[]; + rejectedKeys?: string[]; +} + +export interface TenantPreferenceResponse { + preferences: PreferencesState; + locks: string[]; + defaults: PreferencesState; +} + +export const preferenceService = { + /** + * Get effective preferences for current user + * Returns merged preferences with tenant defaults and user overrides resolved + */ + async getPreferences(): Promise { + return await fetchWrapper.get('/preferences'); + }, + + /** + * Update multiple user preferences at once + * Locked preferences will be rejected and returned in rejectedKeys + */ + async updatePreferences(preferences: Partial): Promise { + return await fetchWrapper.put('/preferences', preferences); + }, + + /** + * Update a single user preference + */ + async setPreference( + key: K, + value: PreferencesState[K] + ): Promise { + return await fetchWrapper.put(`/preferences/${key}`, { value }); + }, + + /** + * Reset all user preferences to tenant defaults + */ + async resetPreferences(): Promise { + return await fetchWrapper.post('/preferences/reset', {}); + }, + + // ============ Admin/Tenant Management ============ + + /** + * Get tenant preferences (admin only) + * Returns tenant defaults and locks + */ + async getTenantPreferences(): Promise { + return await fetchWrapper.get('/preferences/tenant'); + }, + + /** + * Update tenant preferences (admin only) + * @param preferences - The tenant-wide default preferences + * @param locks - Array of preference keys that users cannot override + */ + async updateTenantPreferences( + preferences: Partial, + locks: string[] = [] + ): Promise { + return await fetchWrapper.put('/preferences/tenant', { preferences, locks }); + }, + + /** + * Update tenant preference locks (admin only) + * @param locks - Array of preference keys that users cannot override + */ + async updateTenantLocks(locks: string[]): Promise { + return await fetchWrapper.put('/preferences/tenant/locks', { locks }); + }, +}; diff --git a/core/src/shims-vue.d.ts b/core/src/shims-vue.d.ts new file mode 100644 index 0000000..64c3fd9 --- /dev/null +++ b/core/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/core/src/stores/integrationStore.ts b/core/src/stores/integrationStore.ts new file mode 100644 index 0000000..f2d3c59 --- /dev/null +++ b/core/src/stores/integrationStore.ts @@ -0,0 +1,183 @@ +import { defineStore } from 'pinia'; +import type { + IntegrationPointType, + IntegrationPoint, + IntegrationEntry, + IntegrationItem, + IntegrationGroup +} from '@KTXC/types/integrationTypes'; + +export const useIntegrationStore = defineStore('integrationStore', { + state: () => ({ + points: new Map(), + }), + + actions: { + // Ensure an integration point exists + ensurePoint(pointType: IntegrationPointType): void { + if (!this.points.has(pointType)) { + this.points.set(pointType, { items: new Map() }); + } + }, + + // Register a single item to an integration point + registerItem(pointType: IntegrationPointType, item: IntegrationItem): void { + this.ensurePoint(pointType); + const point = this.points.get(pointType)!; + point.items.set(item.id, item); + }, + + // Register a group to an integration point + registerGroup(pointType: IntegrationPointType, group: IntegrationGroup): void { + this.ensurePoint(pointType); + const point = this.points.get(pointType)!; + point.items.set(group.id, group); + }, + + // Bulk register from module integrations + registerModuleIntegrations( + moduleHandle: string, + integrations: Record + ): void { + for (const [pointType, entries] of Object.entries(integrations)) { + if (!entries || !Array.isArray(entries)) continue; + + entries.forEach((entry: any) => { + const prefixedEntry = this.prefixEntry(moduleHandle, entry); + + if (entry.type === 'group' || ('items' in entry && Array.isArray(entry.items))) { + this.registerGroup(pointType as IntegrationPointType, prefixedEntry as IntegrationGroup); + } else { + this.registerItem(pointType as IntegrationPointType, prefixedEntry as IntegrationItem); + } + }); + } + }, + + // Prefix IDs and paths with module handle + prefixEntry(moduleHandle: string, entry: any): IntegrationEntry { + const prefixed: any = { + ...entry, + id: entry.id ? `${moduleHandle}.${entry.id}` : `${moduleHandle}.${this.randomID()}`, + moduleHandle, + }; + + // Remove 'type' field as it's only used for module-side disambiguation + delete prefixed.type; + + // Prefix internal paths + if (entry.path) { + prefixed.to = `/m/${moduleHandle}${entry.path}`; + delete prefixed.path; + } else if (entry.to && entry.toType !== 'external') { + prefixed.to = `/m/${moduleHandle}${entry.to}`; + } + + // Recursively prefix items in groups + if (entry.items && Array.isArray(entry.items)) { + prefixed.items = entry.items.map((item: any) => this.prefixEntry(moduleHandle, item)); + } + + return prefixed; + }, + + // Update a specific item (useful for badges, visibility, etc.) + updateItem( + pointType: IntegrationPointType, + itemId: string, + updates: Partial + ): void { + const point = this.points.get(pointType); + if (!point) return; + + const item = point.items.get(itemId); + if (item && !('items' in item)) { + point.items.set(itemId, { ...item, ...updates }); + } + }, + + // Update badge for notification icons + updateBadge( + pointType: IntegrationPointType, + itemId: string, + badge: string | number | null, + badgeColor?: string + ): void { + this.updateItem(pointType, itemId, { badge, badgeColor }); + }, + + // Toggle visibility + setVisibility( + pointType: IntegrationPointType, + itemId: string, + visible: boolean + ): void { + this.updateItem(pointType, itemId, { visible }); + }, + + // Remove an item + unregisterItem(pointType: IntegrationPointType, itemId: string): void { + const point = this.points.get(pointType); + if (point) { + point.items.delete(itemId); + } + }, + + // Remove all items from a module + unregisterModule(moduleHandle: string): void { + this.points.forEach((point) => { + point.items.forEach((entry, id) => { + if (entry.moduleHandle === moduleHandle) { + point.items.delete(id); + } + }); + }); + }, + + reset(): void { + this.points.clear(); + }, + + randomID(length = 8): string { + return Math.random().toString(36).substring(2, 2 + length); + }, + }, + + getters: { + // Get all entries for an integration point, sorted by priority + getPoint: (state) => (pointType: IntegrationPointType): IntegrationEntry[] => { + const point = state.points.get(pointType); + if (!point) return []; + + return Array.from(point.items.values()) + .filter(entry => entry.visible !== false) + .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + }, + + // Get items only (no groups) + getItems: (state) => (pointType: IntegrationPointType): IntegrationItem[] => { + const point = state.points.get(pointType); + if (!point) return []; + + return Array.from(point.items.values()) + .filter((entry): entry is IntegrationItem => !('items' in entry) && entry.visible !== false) + .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + }, + + // Get groups only + getGroups: (state) => (pointType: IntegrationPointType): IntegrationGroup[] => { + const point = state.points.get(pointType); + if (!point) return []; + + return Array.from(point.items.values()) + .filter((entry): entry is IntegrationGroup => 'items' in entry && entry.visible !== false) + .sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100)); + }, + + // Get a specific item by ID + getItemById: (state) => (pointType: IntegrationPointType, itemId: string): IntegrationEntry | undefined => { + const point = state.points.get(pointType); + return point?.items.get(itemId); + }, + }, +}); diff --git a/core/src/stores/layoutStore.ts b/core/src/stores/layoutStore.ts new file mode 100644 index 0000000..adf457e --- /dev/null +++ b/core/src/stores/layoutStore.ts @@ -0,0 +1,62 @@ +import { ref } from 'vue'; +import { defineStore } from 'pinia'; +import config from '@KTXC/config'; + +export type MenuMode = 'apps' | 'settings'; + +export const useLayoutStore = defineStore('layout', () => { + // Loading state + const isLoading = ref(false); + + // Sidebar state + const sidebarDrawer = ref(config.Sidebar_drawer); + const miniSidebar = ref(config.mini_sidebar); + const menuMode = ref('apps'); + + // Theme state + const theme = ref(config.actTheme); + const font = ref(config.fontTheme); + + // Actions + function toggleSidebarDrawer() { + sidebarDrawer.value = !sidebarDrawer.value; + } + + function setMiniSidebar(value: boolean) { + miniSidebar.value = value; + } + + function setTheme(value: string) { + theme.value = value; + } + + function setFont(value: string) { + font.value = value; + } + + function setMenuMode(value: MenuMode) { + menuMode.value = value; + } + + function toggleMenuMode() { + menuMode.value = menuMode.value === 'apps' ? 'settings' : 'apps'; + } + + return { + // State + isLoading, + sidebarDrawer, + miniSidebar, + menuMode, + theme, + font, + + // Actions + toggleSidebarDrawer, + setMiniSidebar, + setMenuMode, + toggleMenuMode, + setTheme, + setFont + }; +}); diff --git a/core/src/stores/moduleStore.ts b/core/src/stores/moduleStore.ts new file mode 100644 index 0000000..47d52f5 --- /dev/null +++ b/core/src/stores/moduleStore.ts @@ -0,0 +1,28 @@ +import { defineStore } from 'pinia'; +import type { ModuleCollection, ModuleObject } from '@KTXC/types/moduleTypes'; + +export const useModuleStore = defineStore('moduleStore', { + state: () => ({ + modules: {} as ModuleCollection, + }), + actions: { + init(data: ModuleCollection) { + this.modules = data ?? {}; + }, + markBooted(ns: string) { + const targetNs = String(ns).toLowerCase(); + Object.keys(this.modules).forEach((key) => { + const mod = this.modules[key] as ModuleObject; + if (!mod) return; + if (String(mod.namespace || mod.handle).toLowerCase() === targetNs) { + mod.booted = true; + } + }); + }, + reset() { + this.modules = {}; + }, + }, + getters: { + }, +}); diff --git a/core/src/stores/preferencesStore.ts b/core/src/stores/preferencesStore.ts new file mode 100644 index 0000000..e81e313 --- /dev/null +++ b/core/src/stores/preferencesStore.ts @@ -0,0 +1,114 @@ +import { defineStore } from 'pinia'; + +export interface PreferencesState { + theme: string; + language: string; + timezone: string; + date_format: string; + time_format: string; + week_start: string; + default_module?: string; // preferred module handle like "dashboard" +} + +const defaults: PreferencesState = { + theme: 'light', + language: 'en', + timezone: 'UTC', + date_format: 'Y-m-d', + time_format: 'H:i', + week_start: 'Monday', + default_module: '' +}; + +export const usePreferencesStore = defineStore('preferencesStore', { + state: () => ({ + preferences: { ...defaults } as PreferencesState, + locks: [] as string[], // preference keys locked by tenant admin + loading: false, + error: null as string | null, + }), + getters: { + /** + * Check if a specific preference is locked by tenant admin + */ + isLocked: (state) => (key: string): boolean => { + return state.locks.includes(key); + }, + /** + * Get a single preference value + */ + getPreference: (state) => (key: K): PreferencesState[K] => { + return state.preferences[key]; + }, + }, + actions: { + /** + * Initialize preferences from server data (called on app bootstrap) + */ + init(prefs: Partial | undefined, locks?: string[]) { + this.preferences = { ...defaults, ...(prefs ?? {}) }; + this.locks = locks ?? []; + }, + /** + * Set multiple preferences at once (local state only) + */ + setPreferences(prefs: Partial) { + // Filter out locked preferences + const unlocked: Partial = {}; + for (const [key, value] of Object.entries(prefs)) { + if (!this.locks.includes(key)) { + (unlocked as Record)[key] = value; + } + } + this.preferences = { ...this.preferences, ...unlocked }; + }, + /** + * Set a single preference (local state only) + */ + setPreference(key: K, value: PreferencesState[K]) { + if (this.locks.includes(key)) { + console.warn(`Preference "${key}" is locked by administrator`); + return false; + } + this.preferences[key] = value; + return true; + }, + /** + * Set default module preference + */ + setDefaultModule(handle: string) { + if (this.locks.includes('default_module')) { + console.warn('Default module preference is locked by administrator'); + return false; + } + this.preferences.default_module = handle || ''; + return true; + }, + /** + * Update locks from server response + */ + setLocks(locks: string[]) { + this.locks = locks ?? []; + }, + /** + * Reset preferences to defaults (local state only) + */ + reset() { + this.preferences = { ...defaults }; + this.locks = []; + this.error = null; + }, + /** + * Set loading state + */ + setLoading(loading: boolean) { + this.loading = loading; + }, + /** + * Set error state + */ + setError(error: string | null) { + this.error = error; + }, + }, +}); diff --git a/core/src/stores/tenantStore.ts b/core/src/stores/tenantStore.ts new file mode 100644 index 0000000..4c96706 --- /dev/null +++ b/core/src/stores/tenantStore.ts @@ -0,0 +1,27 @@ +import { defineStore } from 'pinia'; + +export interface TenantState { + id: string | null; + domain: string | null; + label: string | null; +} + +export const useTenantStore = defineStore('tenantStore', { + state: () => ({ + tenant: null as TenantState | null, + }), + actions: { + init(tenant: Partial | null) { + this.tenant = tenant + ? { + id: tenant.id ?? null, + domain: tenant.domain ?? null, + label: tenant.label ?? null, + } + : null; + }, + reset() { + this.tenant = null; + }, + }, +}); diff --git a/core/src/stores/userStore.ts b/core/src/stores/userStore.ts new file mode 100644 index 0000000..7d963a0 --- /dev/null +++ b/core/src/stores/userStore.ts @@ -0,0 +1,69 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import { router } from '@KTXC/router'; +import { authenticationService } from '@KTXC/services/authenticationService'; +import type { AuthenticatedUser } from '@KTXC/types/authenticationTypes'; + +const STORAGE_KEY = 'userStore.user'; + +export const useUserStore = defineStore('userStore', () => { + // Load user from localStorage on init + const user = ref( + localStorage.getItem(STORAGE_KEY) + ? (JSON.parse(localStorage.getItem(STORAGE_KEY)!) as AuthenticatedUser) + : null + ); + const returnUrl = ref(null); + + /** + * Set user after successful authentication + */ + function setUser(authUser: AuthenticatedUser): void { + user.value = authUser; + localStorage.setItem(STORAGE_KEY, JSON.stringify(authUser)); + } + + /** + * Clear user state (on logout or auth failure) + */ + function clearUser(): void { + user.value = null; + localStorage.removeItem(STORAGE_KEY); + } + + /** + * Logout and redirect to login + */ + async function logout(): Promise { + try { + await authenticationService.logout(); + } catch (error) { + console.warn('Logout request failed, clearing local state:', error); + } finally { + clearUser(); + router.push('/login'); + } + } + + /** + * Refresh access token + */ + async function refreshToken(): Promise { + try { + await authenticationService.refresh(); + return true; + } catch (error) { + await logout(); + return false; + } + } + + return { + user, + returnUrl, + setUser, + clearUser, + logout, + refreshToken, + }; +}); diff --git a/core/src/types/authenticationTypes.ts b/core/src/types/authenticationTypes.ts new file mode 100644 index 0000000..066cd58 --- /dev/null +++ b/core/src/types/authenticationTypes.ts @@ -0,0 +1,86 @@ +/** + * Authentication Method from provider + */ +export interface AuthenticationMethod { + id: string; + method: 'credential' | 'redirect' | 'challenge'; + label: string; + icon?: string; +} + +/** + * Authenticated user info + */ +export interface AuthenticatedUser { + identifier: string; + identity: string; + label: string; + permissions?: string[]; +} + +/** + * Start response from /auth/start + */ +export interface StartResponse { + status: 'success'; + session: string; + methods: AuthenticationMethod[]; +} + +/** + * Identify response from /auth/identify + */ +export interface IdentifyResponse { + status: 'success'; + session: string; + state: string; + methods: AuthenticationMethod[]; +} + +/** + * Verify response from /auth/verify + */ +export interface VerifyResponse { + status: 'success' | 'pending'; + user?: AuthenticatedUser; + session?: string; + methods?: AuthenticationMethod[]; + message?: string; + error?: string; + error_code?: string; +} + +/** + * Redirect response from /auth/redirect + */ +export interface RedirectResponse { + status: 'redirect'; + redirect_url: string; +} + +/** + * Challenge response from /auth/challenge + */ +export interface ChallengeResponse { + status: 'challenge'; + session: string; + challenge?: { + type?: string; + message?: string; + digits?: number; + [key: string]: unknown; + }; +} + +/** + * Session status from /auth/status + */ +export interface SessionStatus { + status: 'success'; + session: string; + state: string; + methods: AuthenticationMethod[]; + user?: { + identity?: string; + }; +} \ No newline at end of file diff --git a/core/src/types/env.d.ts b/core/src/types/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/core/src/types/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/core/src/types/integrationTypes.ts b/core/src/types/integrationTypes.ts new file mode 100644 index 0000000..fbdf0c3 --- /dev/null +++ b/core/src/types/integrationTypes.ts @@ -0,0 +1,45 @@ +// Integration point types - extensible via string +export type IntegrationPointType = + | 'app_menu' // Main applications menu (calendar, contacts, etc.) + | 'admin_settings_menu' // Admin-only settings menu + | 'user_settings_menu' // User's personal settings menu + | 'profile_menu' // Top-right profile dropdown (quick actions) + | string; // Allow custom integration points + +export type IntegrationGroupStyle = 'none' | 'static' | 'dynamic' | null; + +export interface IntegrationItem { + id: string; + moduleHandle: string; + priority?: number; + label?: string; + caption?: string; + icon?: string; + to?: string; + toType?: 'internal' | 'external'; + component?: () => Promise; + badge?: string | number | null; + badgeColor?: string; + visible?: boolean; + disabled?: boolean; + meta?: Record; +} + +export interface IntegrationGroup { + id: string; + moduleHandle: string; + priority?: number; + label?: string; + caption?: string; + icon?: string; + style?: IntegrationGroupStyle; + items: IntegrationItem[]; + visible?: boolean; + meta?: Record; +} + +export type IntegrationEntry = IntegrationItem | IntegrationGroup; + +export interface IntegrationPoint { + items: Map; +} \ No newline at end of file diff --git a/core/src/types/layouts/layoutSystemMenu.ts b/core/src/types/layouts/layoutSystemMenu.ts new file mode 100644 index 0000000..9ea723f --- /dev/null +++ b/core/src/types/layouts/layoutSystemMenu.ts @@ -0,0 +1,30 @@ +export interface LayoutSystemMenuItem { + header?: string; + title?: string; + icon?: object; + to?: string | object; + href?: string; + target?: string; + divider?: boolean; + children?: LayoutSystemMenuItem[]; + badgeContent?: string | number; + badgeColor?: string; + badgeVariant?: string; + badgeIcon?: string; + chip?: string | number; + chipColor?: string; + chipVariant?: string; + chipIcon?: string; + disabled?: boolean; + type?: string; + subCaption?: string; + meta?: { [key: string]: any }; +} + +export interface LayoutSystemMenuGroup { + handle: string; + style: string; + label: string; + icon: object; + items: LayoutSystemMenuItem[]; +} \ No newline at end of file diff --git a/core/src/types/moduleTypes.ts b/core/src/types/moduleTypes.ts new file mode 100644 index 0000000..991da3a --- /dev/null +++ b/core/src/types/moduleTypes.ts @@ -0,0 +1,51 @@ +import type { IntegrationGroupStyle } from './integrationTypes'; + +export interface ModuleObject { + handle: string; + namespace: string; + version: string; + label: string; + author?: string; + description?: string; + boot?: string; // relative path like "js/Dashboard.js" + booted?: boolean; // set true once the module plugin is loaded +} + +export type ModuleCollection = Record; + +// Module-side integration types (before prefixing by the loader) +export interface ModuleIntegrationItem { + id: string; + type?: 'item'; + label: string; + caption?: string; + icon?: string; + path?: string; + to?: string; + toType?: 'internal' | 'external'; + priority?: number; + component?: () => Promise; + visible?: boolean; + disabled?: boolean; + meta?: Record; +} + +export interface ModuleIntegrationGroup { + id: string; + type: 'group'; + label?: string; + caption?: string; + icon?: string; + style?: IntegrationGroupStyle; + priority?: number; + items: ModuleIntegrationItem[]; + meta?: Record; +} + +export type ModuleIntegrationEntry = ModuleIntegrationItem | ModuleIntegrationGroup; + +export interface ModuleIntegrations { + system_menu?: ModuleIntegrationEntry[]; + user_menu?: ModuleIntegrationEntry[]; + [key: string]: ModuleIntegrationEntry[] | undefined; +} diff --git a/core/src/types/user/Identity.ts b/core/src/types/user/Identity.ts new file mode 100644 index 0000000..34fb860 --- /dev/null +++ b/core/src/types/user/Identity.ts @@ -0,0 +1,7 @@ + +export interface Identity { + identifier: string; + identity: string; + label: string; + email: string; +} diff --git a/core/src/utils/helpers/fetch-wrapper-core.ts b/core/src/utils/helpers/fetch-wrapper-core.ts new file mode 100644 index 0000000..578219d --- /dev/null +++ b/core/src/utils/helpers/fetch-wrapper-core.ts @@ -0,0 +1,161 @@ +/** + * Core fetch wrapper - reusable across modules + * Does not depend on stores to avoid bundling issues in library builds + */ + +export interface FetchWrapperOptions { + /** + * Optional callback to handle logout on auth failure + * If not provided, only logs error without redirecting + */ + onLogout?: () => void | Promise; + /** + * Enable automatic retry of failed requests after token refresh + * @default true + */ + autoRetry?: boolean; +} + +// Mutex to prevent multiple simultaneous refresh attempts +class RefreshMutex { + private promise: Promise | null = null; + + async acquire(): Promise { + if (this.promise) { + return this.promise; + } + + this.promise = this.performRefresh(); + const result = await this.promise; + this.promise = null; + return result; + } + + private async performRefresh(): Promise { + try { + const response = await fetch('/security/refresh', { + method: 'POST', + credentials: 'include' + }); + + return response.ok; + } catch (error) { + console.error('Token refresh failed:', error); + return false; + } + } +} + +const tokenRefreshMutex = new RefreshMutex(); + +export interface RequestCallOptions { + /** + * Override autoRetry for this specific request + * @default true + */ + autoRetry?: boolean; + /** + * Skip calling onLogout callback on 401/403 errors + * Useful for authentication endpoints where 401 means invalid credentials, not session expiry + * @default false + */ + skipLogoutOnError?: boolean; +} + +export function createFetchWrapper(options: FetchWrapperOptions = {}) { + const { autoRetry: defaultAutoRetry = true } = options; + + return { + get: request('GET', options, defaultAutoRetry), + post: request('POST', options, defaultAutoRetry), + put: request('PUT', options, defaultAutoRetry), + delete: request('DELETE', options, defaultAutoRetry) + }; +} + +interface RequestOptions { + method: string; + headers: Record; + body?: string; + credentials: 'include'; +} + +function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) { + return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise => { + const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry; + + const requestOptions: RequestOptions = { + method, + headers: getHeaders(url), + credentials: 'include' + }; + + if (body) { + requestOptions.headers['Content-Type'] = 'application/json'; + requestOptions.body = JSON.stringify(body); + } + + try { + const response = await fetch(url, requestOptions); + + if (response.status === 401 && autoRetry) { + // Try to refresh the token + const refreshSuccess = await tokenRefreshMutex.acquire(); + + if (refreshSuccess) { + // Retry the original request with the new token + const retryResponse = await fetch(url, requestOptions); + return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError); + } + } + + return handleResponse(response, options, callOptions?.skipLogoutOnError); + } catch (error) { + console.error('API error:', error); + throw error; + } + }; +} + +function getHeaders(_url: string): Record { + const headers: Record = {}; + + // Add CSRF token if available + const csrfToken = getCsrfTokenFromCookie(); + if (csrfToken) { + headers['X-CSRF-TOKEN'] = csrfToken; + } + + return headers; +} + +function getCsrfTokenFromCookie(): string | null { + if (typeof document === 'undefined') return null; + + const csrfCookie = document.cookie + .split('; ') + .find(row => row.startsWith('X-CSRF-TOKEN=')); + + return csrfCookie ? csrfCookie.split('=')[1] : null; +} + +async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise { + const text = await response.text(); + const data = text && JSON.parse(text); + + if (!response.ok) { + if ([401, 403].includes(response.status) && !skipLogoutOnError) { + // Call logout callback if provided + if (options.onLogout) { + await options.onLogout(); + } else { + console.error('Authentication failed. Please log in again.'); + } + } + + const error: string = (data && data.message) || response.statusText; + throw new Error(error); + } + + return data; +} diff --git a/core/src/utils/helpers/fetch-wrapper.ts b/core/src/utils/helpers/fetch-wrapper.ts new file mode 100644 index 0000000..4e92c39 --- /dev/null +++ b/core/src/utils/helpers/fetch-wrapper.ts @@ -0,0 +1,10 @@ +import { useUserStore } from '@KTXC/stores/userStore'; +import { createFetchWrapper } from './fetch-wrapper-core'; + +// Create fetch wrapper with user store logout callback +export const fetchWrapper = createFetchWrapper({ + onLogout: () => { + const { logout } = useUserStore(); + logout(); + } +}); diff --git a/core/src/utils/helpers/shared.ts b/core/src/utils/helpers/shared.ts new file mode 100644 index 0000000..1889e05 --- /dev/null +++ b/core/src/utils/helpers/shared.ts @@ -0,0 +1,6 @@ +/** + * Shared utilities entry point for external modules + * This file is built separately and exposed via import map + */ + +export { createFetchWrapper, type FetchWrapperOptions } from './fetch-wrapper-core'; diff --git a/core/src/utils/modules.ts b/core/src/utils/modules.ts new file mode 100644 index 0000000..bcb2cf1 --- /dev/null +++ b/core/src/utils/modules.ts @@ -0,0 +1,103 @@ +import type { App } from 'vue'; +import { router } from '@KTXC/router'; +import { useModuleStore } from '@KTXC/stores/moduleStore'; +import { useIntegrationStore } from '@KTXC/stores/integrationStore'; + +function installModuleCSS(moduleHandle: string, cssPaths: string | string[]): void { + const cssFiles = Array.isArray(cssPaths) ? cssPaths : [cssPaths]; + cssFiles.forEach((cssFile: string) => { + const cssPath = `/modules/${moduleHandle}/${cssFile}`; + const existingLink = document.querySelector(`link[href="${cssPath}"]`); + if (!existingLink) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = cssPath; + link.onload = () => { + console.log(`Module Loader - Loaded CSS for ${moduleHandle}: ${cssFile}`); + }; + link.onerror = () => { + console.error(`Module Loader - Failed to load CSS for ${moduleHandle}: ${cssPath}`); + }; + document.head.appendChild(link); + } + }); +} + +function installModuleRoutes(moduleHandle: string, routes: any[]): void { + routes.forEach((route: any) => { + // Prefix route name with module handle for safety + const prefixedRoute = { + ...route, + path: `/m/${moduleHandle}${route.path}` + }; + // Prefix the route name if it exists + if (route.name) { + prefixedRoute.name = `${moduleHandle}.${route.name}`; + } + // Recursively prefix child route names + if (route.children && Array.isArray(route.children)) { + prefixedRoute.children = route.children.map((child: any) => ({ + ...child, + name: child.name ? `${moduleHandle}.${child.name}` : undefined + })); + } + + router.addRoute('private', prefixedRoute); + }); +} + +function installModuleIntegrations( + moduleHandle: string, + integrations: Record +): void { + const integrationStore = useIntegrationStore(); + integrationStore.registerModuleIntegrations(moduleHandle, integrations); +} + +export async function initializeModules(app: App): Promise { + const moduleStore = useModuleStore(); + + // First, dynamically load modules based on moduleStore boot paths + const availableModules = moduleStore.modules; + const loadPromises: Promise[] = []; + + for (const [moduleId, moduleInfo] of Object.entries(availableModules)) { + if (moduleInfo.handle && moduleInfo.boot && !moduleInfo.booted) { + const moduleHandle = moduleInfo.handle; + const moduleUrl = `/modules/${moduleInfo.handle}/${moduleInfo.boot}`; + console.log(`Module Loader - Loading ${moduleInfo.handle} from ${moduleUrl}`); + + const loadPromise = import(/* @vite-ignore */ moduleUrl) + .then((module) => { + // Load CSS if module explicitly exports css path(s) + if (module.css) { + installModuleCSS(moduleInfo.handle, module.css); + } + // install module + console.log(`Module Loader - Installing ${moduleInfo.handle}`); + if (module.default && typeof module.default.install === 'function') { + app.use(module.default); + } + // prefix routes with /m/{moduleHandle} + console.log(`Module Loader - Installing Routes ${moduleInfo.handle}`); + if (module.routes) { + installModuleRoutes(moduleHandle, module.routes); + } + // register integrations + console.log(`Module Loader - Installing Integrations ${moduleInfo.handle}`); + if (module.integrations) { + installModuleIntegrations(moduleHandle, module.integrations); + } + }) + .catch((error) => { + console.error(`Failed to load module ${moduleId} from ${moduleUrl}:`, error); + }); + loadPromises.push(loadPromise); + } else if (!moduleInfo.boot) { + console.warn(`No boot path specified for module: ${moduleId}`); + } + } + + // Wait for all dynamic loading to complete + await Promise.all(loadPromises); +} \ No newline at end of file diff --git a/core/src/views/PrivateLayout.vue b/core/src/views/PrivateLayout.vue new file mode 100644 index 0000000..d9022da --- /dev/null +++ b/core/src/views/PrivateLayout.vue @@ -0,0 +1,21 @@ + + + diff --git a/core/src/views/PublicLayout.vue b/core/src/views/PublicLayout.vue new file mode 100644 index 0000000..b174a3e --- /dev/null +++ b/core/src/views/PublicLayout.vue @@ -0,0 +1,26 @@ + + + diff --git a/core/src/views/authentication/AuthFooter.vue b/core/src/views/authentication/AuthFooter.vue new file mode 100644 index 0000000..72c9440 --- /dev/null +++ b/core/src/views/authentication/AuthFooter.vue @@ -0,0 +1,38 @@ + + diff --git a/core/src/views/authentication/AuthLogin.vue b/core/src/views/authentication/AuthLogin.vue new file mode 100644 index 0000000..5d6600d --- /dev/null +++ b/core/src/views/authentication/AuthLogin.vue @@ -0,0 +1,586 @@ + + + + + diff --git a/core/src/views/authentication/LoginPage.vue b/core/src/views/authentication/LoginPage.vue new file mode 100644 index 0000000..27db103 --- /dev/null +++ b/core/src/views/authentication/LoginPage.vue @@ -0,0 +1,53 @@ + + + + diff --git a/core/src/views/pages/maintenance/error/Error404Page.vue b/core/src/views/pages/maintenance/error/Error404Page.vue new file mode 100644 index 0000000..f0a0324 --- /dev/null +++ b/core/src/views/pages/maintenance/error/Error404Page.vue @@ -0,0 +1,50 @@ + + + + diff --git a/core/src/views/pages/maintenance/error/Error500Page.vue b/core/src/views/pages/maintenance/error/Error500Page.vue new file mode 100644 index 0000000..8667a54 --- /dev/null +++ b/core/src/views/pages/maintenance/error/Error500Page.vue @@ -0,0 +1,28 @@ + + + + diff --git a/deploy/nginx/vhost-dev.conf b/deploy/nginx/vhost-dev.conf new file mode 100644 index 0000000..841db4b --- /dev/null +++ b/deploy/nginx/vhost-dev.conf @@ -0,0 +1,125 @@ +# HTTP to HTTPS redirect +server { + listen *:80; + listen [::]:80; + server_name ktrix; + return 301 https://$server_name$request_uri; +} + +server { + listen *:443 ssl http2; + listen [::]:443 ssl http2; + #listen *:443 quic reuseport; + #listen [::]:443 quic reuseport; + + #http2 on; + + server_name ktrix; + + ### SSL Configuration ### + + # SSL Certificates + ssl_certificate /etc/ssl/certs/localhost.crt; + ssl_certificate_key /etc/ssl/private/localhost.key; + + # SSL Protocols and Ciphers + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_dhparam /etc/ssl/certs/dhparam.pem; # openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048 + + # SSL Sessions + ssl_session_timeout 60m; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + ### Logging Configuration ### + error_log /var/log/nginx/ktrix-error.log; + access_log /var/log/nginx/ktrix-access.log; + + ### Upload Configuration ### + client_max_body_size 1024M; + + ### Site Configuration ### + root /var/www/ktrix/main/public; + index index.html; + + # Serve index.html for root path only + #location = / { + # try_files /index.html =404; + #} + + # Serve module static assets directly from module folders + # URL: /modules//static/... -> FS: /var/www/ktrix/main/modules//static/... + # Note: Linux is case-sensitive; ensure URL module casing matches folder name + location ~ ^/modules/([^/]+)/static/(.*)$ { + alias /var/www/ktrix/main/modules/$1/static/$2; + expires 10m; + add_header Cache-Control "public, immutable"; + access_log on; + types { + text/css css; + application/javascript js; + application/javascript mjs; + image/svg+xml svg; + image/gif gif; + image/png png; + image/jpeg jpg; + image/jpeg jpeg; + image/x-icon ico; + font/woff woff; + font/woff2 woff2; + font/ttf ttf; + application/vnd.ms-fontobject eot; + application/json map; + } + } + + # Handle asset files (css, js, images, etc.) - serve directly if they exist + location ~* \.(css|js|mjs|svg|gif|png|jpg|jpeg|ico|woff|woff2|ttf|eot|map)$ { + try_files $uri =404; + expires 1m; + add_header Cache-Control "public, immutable"; + types { + text/css css; + application/javascript js; + application/javascript mjs; + image/svg+xml svg; + image/gif gif; + image/png png; + image/jpeg jpg; + image/jpeg jpeg; + image/x-icon ico; + font/woff woff; + font/woff2 woff2; + font/ttf ttf; + application/vnd.ms-fontobject eot; + application/json map; + } + } + + # All other URLs should be handled by index.php + location / { + try_files $uri @php; + } + + # Named location for PHP handling + location @php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root/index.php; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param REQUEST_URI $uri?$args; + fastcgi_pass fpm; + } + + # return 404 for all other php files not matching the front controller + # this prevents access to other php files you don't want to be accessible. + location ~ \.php$ { + return 404; + } + + # Optional: Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; +} diff --git a/deploy/nginx/vhost.conf b/deploy/nginx/vhost.conf new file mode 100644 index 0000000..cebfaef --- /dev/null +++ b/deploy/nginx/vhost.conf @@ -0,0 +1,83 @@ +# HTTP to HTTPS redirect +server { + listen *:80; + listen [::]:80; + server_name ktrix; + return 301 https://$server_name$request_uri; +} + +server { + listen *:443 ssl http2; + listen [::]:443 ssl http2; + #listen *:443 quic reuseport; + #listen [::]:443 quic reuseport; + + #http2 on; + + server_name ktrix; + + ### SSL Configuration ### + + # SSL Certificates + ssl_certificate /etc/ssl/certs/localhost.crt; + ssl_certificate_key /etc/ssl/private/localhost.key; + + # SSL Protocols and Ciphers + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_dhparam /etc/ssl/certs/dhparam.pem; # openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048 + + # SSL Sessions + ssl_session_timeout 60m; + ssl_session_cache shared:SSL:10m; + ssl_session_tickets off; + + ### Logging Configuration ### + error_log /var/log/nginx/ktrix-error.log; + access_log /var/log/nginx/ktrix-access.log; + + ### Upload Configuration ### + client_max_body_size 1024M; + + ### Site Configuration ### + root /var/www/ktrix/main/public; + index index.html; + + # Serve index.html for root path only + location = / { + try_files /index.html =404; + } + + # Handle asset files (css, js, images, etc.) - serve directly if they exist + location ~* \.(css|js|svg|gif|png|jpg|jpeg|ico|woff|woff2|ttf|eot|map)$ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # All other URLs should be handled by index.php + location / { + try_files $uri @php; + } + + # Named location for PHP handling + location @php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME $realpath_root/index.php; + fastcgi_param DOCUMENT_ROOT $realpath_root; + fastcgi_param SCRIPT_NAME /index.php; + fastcgi_param REQUEST_URI $uri?$args; + fastcgi_pass fpm; + } + + # return 404 for all other php files not matching the front controller + # this prevents access to other php files you don't want to be accessible. + location ~ \.php$ { + return 404; + } + + # Optional: Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; +} diff --git a/deploy/supervisor/mail-daemon.conf b/deploy/supervisor/mail-daemon.conf new file mode 100644 index 0000000..3542a76 --- /dev/null +++ b/deploy/supervisor/mail-daemon.conf @@ -0,0 +1,22 @@ +[program:ktrix-mail-daemon] +command=/usr/bin/php /var/www/ktrix/main/bin/console mail:queue:daemon +directory=/var/www/ktrix/main +user=www-data +numprocs=1 +autostart=true +autorestart=true +startsecs=5 +startretries=3 +exitcodes=0 +stopsignal=TERM +stopwaitsecs=30 +stopasgroup=true +killasgroup=true +redirect_stderr=true +stdout_logfile=/var/www/ktrix/main/var/log/mail-daemon.log +stdout_logfile_maxbytes=10MB +stdout_logfile_backups=5 +environment=PHP_INI_SCAN_DIR="/etc/php/8.2/cli/conf.d" + +; Process name for easier identification +process_name=%(program_name)s_%(process_num)02d diff --git a/deploy/systemd/mail-daemon.service b/deploy/systemd/mail-daemon.service new file mode 100644 index 0000000..a2028ab --- /dev/null +++ b/deploy/systemd/mail-daemon.service @@ -0,0 +1,30 @@ +[Unit] +Description=Ktrix Mail Queue Daemon +After=network.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/var/www/ktrix/main +ExecStart=/usr/bin/php bin/console mail:queue:daemon +ExecReload=/bin/kill -HUP $MAINPID +Restart=always +RestartSec=5 +StandardOutput=append:/var/www/ktrix/main/var/log/mail-daemon.log +StandardError=append:/var/www/ktrix/main/var/log/mail-daemon.log + +# Process management +KillMode=process +KillSignal=SIGTERM +TimeoutStopSec=30 + +# Security hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/www/ktrix/main/storage +ReadWritePaths=/var/www/ktrix/main/var + +[Install] +WantedBy=multi-user.target diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..7ed7a96 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,18 @@ +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import pluginVue from 'eslint-plugin-vue'; + +export default [ + { + languageOptions: { + globals: globals.browser, + parserOptions: { + parser: '@typescript-eslint/parser' + } + } + }, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs['flat/essential'] +]; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6136297 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5221 @@ +{ + "name": "ktrix", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ktrix", + "version": "0.0.1", + "license": "AGPL-3.0-or-later", + "dependencies": { + "@fontsource/inter": "5.1.0", + "@fontsource/poppins": "5.1.0", + "@fontsource/public-sans": "5.1.1", + "@fontsource/roboto": "5.1.0", + "@tsconfig/node20": "20.1.4", + "@typescript-eslint/parser": "^8.18.2", + "@vue/compiler-sfc": "^3.5.16", + "pinia": "2.3.0", + "vee-validate": "^4.15.1", + "vite-plugin-vuetify": "2.0.4", + "vue": "3.5.13", + "vue-router": "4.5.0", + "vue3-perfect-scrollbar": "2.0.0", + "vuetify": "3.7.6" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/node": "22.10.2", + "@vitejs/plugin-vue": "5.2.1", + "@vue/eslint-config-prettier": "10.1.0", + "@vue/tsconfig": "0.7.0", + "eslint": "9.17.0", + "eslint-plugin-vue": "9.32.0", + "prettier": "3.4.2", + "sass": "1.77.1", + "sass-loader": "16.0.4", + "typescript": "5.7.2", + "typescript-eslint": "^8.18.2", + "vite": "6.0.6", + "vite-plugin-static-copy": "^3.1.2", + "vue-cli-plugin-vuetify": "2.5.8", + "vue-tsc": "^2.2.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", + "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.1.0.tgz", + "integrity": "sha512-zKZR3kf1G0noIes1frLfOHP5EXVVm0M7sV/l9f/AaYf+M/DId35FO4LkigWjqWYjTJZGgplhdv4cB+ssvCqr5A==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/poppins": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.1.0.tgz", + "integrity": "sha512-tpLXlnNi2fwQjiipvuj4uNFHCdoLA8izRsKdoexZuEzjx0r/g1aKLf4ta6lFgF7L+/+AFdmaXFlUwwvmDzYH+g==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/public-sans": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource/public-sans/-/public-sans-5.1.1.tgz", + "integrity": "sha512-BEJEc9kpLBowHLqeOlex1lMJPZ/6mzKn3ArhbvWY9dvMcjSqH7jzJyTN44j0H78FTOrVjIW1g4A8nDdbx8VV5Q==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/roboto": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.1.0.tgz", + "integrity": "sha512-cFRRC1s6RqPygeZ8Uw/acwVHqih8Czjt6Q0MwoUoDe9U3m4dH1HmNDRBZyqlMSFwgNAUKgFImncKdmDHyKpwdg==", + "license": "Apache-2.0" + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz", + "integrity": "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz", + "integrity": "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz", + "integrity": "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz", + "integrity": "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz", + "integrity": "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz", + "integrity": "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz", + "integrity": "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz", + "integrity": "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz", + "integrity": "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz", + "integrity": "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz", + "integrity": "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz", + "integrity": "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz", + "integrity": "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz", + "integrity": "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz", + "integrity": "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz", + "integrity": "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz", + "integrity": "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz", + "integrity": "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz", + "integrity": "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz", + "integrity": "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz", + "integrity": "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tsconfig/node20": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.4.tgz", + "integrity": "sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==", + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.0.tgz", + "integrity": "sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/type-utils": "8.44.0", + "@typescript-eslint/utils": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.44.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.0.tgz", + "integrity": "sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.44.0.tgz", + "integrity": "sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.44.0", + "@typescript-eslint/types": "^8.44.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.44.0.tgz", + "integrity": "sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.0.tgz", + "integrity": "sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.44.0.tgz", + "integrity": "sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/utils": "8.44.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.44.0.tgz", + "integrity": "sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.0.tgz", + "integrity": "sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.44.0", + "@typescript-eslint/tsconfig-utils": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/visitor-keys": "8.44.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.44.0.tgz", + "integrity": "sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.44.0", + "@typescript-eslint/types": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.0.tgz", + "integrity": "sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==", + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.44.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz", + "integrity": "sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", + "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/shared": "3.5.21", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", + "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", + "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/compiler-core": "3.5.21", + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.18", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", + "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz", + "integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.7", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz", + "integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.1.0.tgz", + "integrity": "sha512-J6wV91y2pXc0Phha01k0WOHBTPsoSTf4xlmMjoKaeSxBpAdsgTppGF5RZRdOHM7OA74zAXD+VLANrtYXpiPKkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/reactivity/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-core/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/runtime-dom/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "vue": "3.5.13" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/@vue/shared": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", + "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.7.0.tgz", + "integrity": "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vuetify/loader-shared": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@vuetify/loader-shared/-/loader-shared-2.1.1.tgz", + "integrity": "sha512-jSZTzTYaoiv8iwonFCVZQ0YYX/M+Uyl4ng+C4egMJT0Hcmh9gIxJL89qfZICDeo3g0IhqrvipW2FFKKRDMtVcA==", + "license": "MIT", + "dependencies": { + "upath": "^2.0.1" + }, + "peerDependencies": { + "vue": "^3.0.0", + "vuetify": "^3.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/birpc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz", + "integrity": "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" + }, + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.222", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.17.0", + "@eslint/plugin-kit": "^0.2.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.32.0.tgz", + "integrity": "sha512-b/Y05HYmnB/32wqVcjxjHZzNpwxj1onBOvqW89W+V+XNG1dRuaFbNd3vT9CLbr2LXjEoq+3vn8DanWf7XU22Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "globals": "^13.24.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "vue-eslint-parser": "^9.4.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-vue/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-vue/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-what": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/null-loader": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/null-loader/-/null-loader-4.0.1.tgz", + "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/perfect-scrollbar": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.5.6.tgz", + "integrity": "sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.0.tgz", + "integrity": "sha512-ohZj3jla0LL0OH5PlLTDMzqKiVw2XARmC1XYLdLWIPBMdhDW/123ZWr4zVAhtJm+aoSkFa13pYXskAvAscIkhQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dev": true, + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.50.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.2.tgz", + "integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.50.2", + "@rollup/rollup-android-arm64": "4.50.2", + "@rollup/rollup-darwin-arm64": "4.50.2", + "@rollup/rollup-darwin-x64": "4.50.2", + "@rollup/rollup-freebsd-arm64": "4.50.2", + "@rollup/rollup-freebsd-x64": "4.50.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.50.2", + "@rollup/rollup-linux-arm-musleabihf": "4.50.2", + "@rollup/rollup-linux-arm64-gnu": "4.50.2", + "@rollup/rollup-linux-arm64-musl": "4.50.2", + "@rollup/rollup-linux-loong64-gnu": "4.50.2", + "@rollup/rollup-linux-ppc64-gnu": "4.50.2", + "@rollup/rollup-linux-riscv64-gnu": "4.50.2", + "@rollup/rollup-linux-riscv64-musl": "4.50.2", + "@rollup/rollup-linux-s390x-gnu": "4.50.2", + "@rollup/rollup-linux-x64-gnu": "4.50.2", + "@rollup/rollup-linux-x64-musl": "4.50.2", + "@rollup/rollup-openharmony-arm64": "4.50.2", + "@rollup/rollup-win32-arm64-msvc": "4.50.2", + "@rollup/rollup-win32-ia32-msvc": "4.50.2", + "@rollup/rollup-win32-x64-msvc": "4.50.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.77.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.1.tgz", + "integrity": "sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-loader": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", + "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superjson": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", + "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", + "license": "MIT", + "dependencies": { + "copy-anything": "^3.0.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", + "devOptional": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", + "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.44.0.tgz", + "integrity": "sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.44.0", + "@typescript-eslint/parser": "8.44.0", + "@typescript-eslint/typescript-estree": "8.44.0", + "@typescript-eslint/utils": "8.44.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz", + "integrity": "sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==", + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vee-validate": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.15.1.tgz", + "integrity": "sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.5.2", + "type-fest": "^4.8.3" + }, + "peerDependencies": { + "vue": "^3.4.26" + } + }, + "node_modules/vee-validate/node_modules/@vue/devtools-api": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz", + "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.7" + } + }, + "node_modules/vite": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.6.tgz", + "integrity": "sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.4.49", + "rollup": "^4.23.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-static-copy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.2.tgz", + "integrity": "sha512-aVmYOzptLVOI2b1jL+cmkF7O6uhRv1u5fvOkQgbohWZp2CbR22kn9ZqkCUIt9umKF7UhdbsEpshn1rf4720QFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "fs-extra": "^11.3.0", + "p-map": "^7.0.3", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.14" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/vite-plugin-vuetify": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vite-plugin-vuetify/-/vite-plugin-vuetify-2.0.4.tgz", + "integrity": "sha512-A4cliYUoP/u4AWSRVRvAPKgpgR987Pss7LpFa7s1GvOe8WjgDq92Rt3eVXrvgxGCWvZsPKziVqfHHdCMqeDhfw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vuetify/loader-shared": "^2.0.3", + "debug": "^4.3.3", + "upath": "^2.0.1" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": ">=5", + "vue": "^3.0.0", + "vuetify": "^3.0.0" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-cli-plugin-vuetify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/vue-cli-plugin-vuetify/-/vue-cli-plugin-vuetify-2.5.8.tgz", + "integrity": "sha512-uqi0/URJETJBbWlQHD1l0pnY7JN8Ytu+AL1fw50HFlGByPa8/xx+mq19GkFXA9FcwFT01IqEc/TkxMPugchomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "null-loader": "^4.0.1", + "semver": "^7.1.2", + "shelljs": "^0.8.3" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "sass-loader": { + "optional": true + }, + "vuetify-loader": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", + "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "eslint-scope": "^7.1.1", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.1", + "esquery": "^1.4.0", + "lodash": "^4.17.21", + "semver": "^7.3.6" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-router": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz", + "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vue/node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/vue/node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/vue/node_modules/@vue/compiler-sfc": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" + } + }, + "node_modules/vue/node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/vue/node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==", + "license": "MIT" + }, + "node_modules/vue3-perfect-scrollbar": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/vue3-perfect-scrollbar/-/vue3-perfect-scrollbar-2.0.0.tgz", + "integrity": "sha512-nSWVcRyViCgt0Pe3RhU3w/BllLcFSrEzYOGlRBjSyhVmiZlERHHziffW+9P8L0IMEWouC5t+uYrgNJGSAElqMA==", + "dependencies": { + "perfect-scrollbar": "^1.5.5" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vuetify": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.6.tgz", + "integrity": "sha512-lol0Va5HtMIqZfjccSD5DLv5v31R/asJXzc6s7ULy51PHr1DjXxWylZejhq0kVpMGW64MiV1FmA/p8eYQfOWfQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.20 || >=14.13" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/johnleider" + }, + "peerDependencies": { + "typescript": ">=4.7", + "vite-plugin-vuetify": ">=1.0.0", + "vue": "^3.3.0", + "webpack-plugin-vuetify": ">=2.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite-plugin-vuetify": { + "optional": true + }, + "webpack-plugin-vuetify": { + "optional": true + } + } + }, + "node_modules/watchpack": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", + "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.3", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.2", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.11", + "watchpack": "^2.4.1", + "webpack-sources": "^3.3.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", + "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4317b2a --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "ktrix", + "version": "0.0.1", + "private": false, + "license": "AGPL-3.0-or-later", + "author": "Sebastian Krupinski", + "type": "module", + "scripts": { + "build": "vite build --mode production", + "dev": "vite build --mode development", + "watch": "vite build --mode development --watch", + "typecheck": "vue-tsc --noEmit", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", + "build:modules": "for dir in modules/*/; do if [ -f \"$dir/package.json\" ]; then echo \"Building $dir\" && npm run build --prefix \"$dir\"; fi; done", + "build:all": "npm run build && npm run build:modules", + "dev:modules": "for dir in modules/*/; do if [ -f \"$dir/package.json\" ]; then echo \"Building $dir\" && npm run dev --prefix \"$dir\"; fi; done", + "dev:all": "npm run dev && npm run dev:modules" + }, + "dependencies": { + "@fontsource/inter": "5.1.0", + "@fontsource/poppins": "5.1.0", + "@fontsource/public-sans": "5.1.1", + "@fontsource/roboto": "5.1.0", + "@tsconfig/node20": "20.1.4", + "@typescript-eslint/parser": "^8.18.2", + "@vue/compiler-sfc": "^3.5.16", + "pinia": "2.3.0", + "vee-validate": "^4.15.1", + "vite-plugin-vuetify": "2.0.4", + "vue": "3.5.13", + "vue-router": "4.5.0", + "vue3-perfect-scrollbar": "2.0.0", + "vuetify": "3.7.6" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/node": "22.10.2", + "@vitejs/plugin-vue": "5.2.1", + "@vue/eslint-config-prettier": "10.1.0", + "@vue/tsconfig": "0.7.0", + "eslint": "9.17.0", + "eslint-plugin-vue": "9.32.0", + "prettier": "3.4.2", + "sass": "1.77.1", + "sass-loader": "16.0.4", + "typescript": "5.7.2", + "typescript-eslint": "^8.18.2", + "vite": "6.0.6", + "vite-plugin-static-copy": "^3.1.2", + "vue-cli-plugin-vuetify": "2.5.8", + "vue-tsc": "^2.2.10" + } +} diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 0000000..f9937ba --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + tests/php + + + + + + src + + + + trigger_deprecation + + + + + + diff --git a/scripts/generate-vendor-shims.ts b/scripts/generate-vendor-shims.ts new file mode 100644 index 0000000..4c48694 --- /dev/null +++ b/scripts/generate-vendor-shims.ts @@ -0,0 +1,95 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const OUTPUT_DIR = path.resolve(__dirname, '../public/vendor'); + +interface LibraryDefinition { + packageName: string; + globalName: string; + outputFile: string; +} + +interface WriteShimOptions { + outputDir: string; + silent: boolean; +} + +type ShimSource = Pick; + +const libraries: LibraryDefinition[] = [ + { packageName: 'vue', globalName: 'Vue', outputFile: 'vue.mjs' }, + { packageName: 'vue-router', globalName: 'VueRouter', outputFile: 'vue-router.mjs' }, + { packageName: 'pinia', globalName: 'Pinia', outputFile: 'pinia.mjs' }, +]; + +const formatLines = (globalName: string, exports: readonly string[]): string => { + const lines: string[] = []; + lines.push(`const ${globalName} = window.${globalName};`); + lines.push(`if (!${globalName}) {`); + lines.push(` throw new Error('${globalName} runtime is not available on window.');`); + lines.push('}'); + lines.push(`export default ${globalName};`); + + for (const name of exports) { + lines.push(`export const ${name} = ${globalName}.${name};`); + } + + lines.push(''); + return lines.join('\n'); +}; + +const generateShim = ({ packageName, globalName }: ShimSource): string => { + const mod = require(packageName) as Record; + const exportNames = Object.keys(mod) + .filter((key) => key !== 'default') + .sort(); + + return formatLines(globalName, exportNames); +}; + +const writeShim = async ( + { packageName, globalName, outputFile }: LibraryDefinition, + { outputDir, silent }: WriteShimOptions, +): Promise => { + const content = generateShim({ packageName, globalName }); + await writeFile(path.join(outputDir, outputFile), content, 'utf8'); + + if (!silent) { + console.log(`Generated ${outputFile}`); + } +}; + +export interface GenerateVendorShimsOptions { + outputDir?: string; + silent?: boolean; +} + +export type GenerateVendorShims = (options?: GenerateVendorShimsOptions) => Promise; + +export const generateVendorShims: GenerateVendorShims = async (options = {}) => { + const { outputDir = OUTPUT_DIR, silent = false } = options; + + await mkdir(outputDir, { recursive: true }); + + await Promise.all(libraries.map((library) => writeShim(library, { outputDir, silent }))); + + if (!silent) { + console.log(`[generate-vendor-shims] Vendor shims updated in ${outputDir}`); + } +}; + +const isCliExecution = Boolean(process.argv[1] && path.resolve(process.argv[1]) === __filename); + +if (isCliExecution) { + void generateVendorShims().catch((error) => { + console.error('[generate-vendor-shims] Failed to generate shims', error); + process.exitCode = 1; + }); +} diff --git a/shared/lib/Blob/MimeTypes.php b/shared/lib/Blob/MimeTypes.php new file mode 100644 index 0000000..c7c759f --- /dev/null +++ b/shared/lib/Blob/MimeTypes.php @@ -0,0 +1,219 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Blob; + +/** + * MimeTypes - MIME type and format resolution utility + * + * Provides bidirectional mapping between MIME types and file format identifiers. + */ +class MimeTypes { + + /** Default MIME type for unknown/binary content */ + public const MIME_BINARY = 'application/octet-stream'; + + /** Default format for unknown/binary content */ + public const FORMAT_BINARY = 'binary'; + + /** + * MIME type to format mapping + */ + private const MIME_TO_FORMAT = [ + // Images + 'image/jpeg' => 'jpeg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + 'image/bmp' => 'bmp', + 'image/x-ms-bmp' => 'bmp', + 'image/tiff' => 'tiff', + 'image/x-icon' => 'ico', + 'image/vnd.microsoft.icon' => 'ico', + 'image/svg+xml' => 'svg', + 'image/heic' => 'heic', + 'image/heif' => 'heif', + 'image/avif' => 'avif', + + // Documents + 'application/pdf' => 'pdf', + 'application/rtf' => 'rtf', + 'text/rtf' => 'rtf', + 'application/msword' => 'doc', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', + 'application/vnd.ms-excel' => 'xls', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', + 'application/vnd.ms-powerpoint' => 'ppt', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', + 'application/vnd.oasis.opendocument.text' => 'odt', + 'application/vnd.oasis.opendocument.spreadsheet' => 'ods', + 'application/vnd.oasis.opendocument.presentation' => 'odp', + + // Archives + 'application/zip' => 'zip', + 'application/x-zip-compressed' => 'zip', + 'application/gzip' => 'gzip', + 'application/x-gzip' => 'gzip', + 'application/x-bzip2' => 'bzip2', + 'application/x-xz' => 'xz', + 'application/x-rar-compressed' => 'rar', + 'application/vnd.rar' => 'rar', + 'application/x-7z-compressed' => '7z', + 'application/x-tar' => 'tar', + + // Audio + 'audio/mpeg' => 'mp3', + 'audio/mp3' => 'mp3', + 'audio/ogg' => 'ogg', + 'audio/flac' => 'flac', + 'audio/x-flac' => 'flac', + 'audio/wav' => 'wav', + 'audio/x-wav' => 'wav', + 'audio/aac' => 'aac', + 'audio/mp4' => 'm4a', + 'audio/x-m4a' => 'm4a', + 'audio/webm' => 'webm', + + // Video + 'video/mp4' => 'mp4', + 'video/webm' => 'webm', + 'video/x-msvideo' => 'avi', + 'video/mpeg' => 'mpeg', + 'video/quicktime' => 'mov', + 'video/x-matroska' => 'mkv', + 'video/x-flv' => 'flv', + 'video/3gpp' => '3gp', + + // Fonts + 'font/woff' => 'woff', + 'font/woff2' => 'woff2', + 'font/ttf' => 'ttf', + 'font/otf' => 'otf', + 'application/font-woff' => 'woff', + 'application/font-woff2' => 'woff2', + 'application/x-font-ttf' => 'ttf', + 'application/x-font-otf' => 'otf', + + // Text/Code + 'text/plain' => 'text', + 'text/html' => 'html', + 'text/css' => 'css', + 'text/csv' => 'csv', + 'text/xml' => 'xml', + 'application/xml' => 'xml', + 'application/json' => 'json', + 'application/javascript' => 'js', + 'text/javascript' => 'js', + 'application/x-httpd-php' => 'php', + 'text/x-php' => 'php', + 'text/markdown' => 'md', + 'text/x-python' => 'py', + 'application/x-python-code' => 'py', + + // Other + 'application/epub+zip' => 'epub', + 'application/x-sqlite3' => 'sqlite', + 'application/wasm' => 'wasm', + 'application/octet-stream' => 'binary', + ]; + + /** Cached reverse mapping (format -> mime) */ + private static ?array $formatToMime = null; + + /** + * Get format from MIME type + * + * @param string $mime MIME type + * @return string|null Format or null if not found + */ + public static function toFormat(string $mime): ?string { + return self::MIME_TO_FORMAT[$mime] ?? null; + } + + /** + * Get MIME type from format + * + * @param string $format Format identifier + * @return string|null MIME type or null if not found + */ + public static function toMime(string $format): ?string { + if (self::$formatToMime === null) { + self::$formatToMime = []; + foreach (self::MIME_TO_FORMAT as $mime => $fmt) { + // Keep first occurrence (most canonical MIME type) + if (!isset(self::$formatToMime[$fmt])) { + self::$formatToMime[$fmt] = $mime; + } + } + } + return self::$formatToMime[$format] ?? null; + } + + /** + * Extract format from MIME type string (with fallback parsing) + * + * @param string $mime MIME type + * @return string|null Format or null + */ + public static function parseFormat(string $mime): ?string { + // Check direct mapping first + if (isset(self::MIME_TO_FORMAT[$mime])) { + return self::MIME_TO_FORMAT[$mime]; + } + + // Try to extract from MIME subtype (e.g., "image/jpeg" -> "jpeg") + $parts = explode('/', $mime, 2); + if (count($parts) === 2) { + $subtype = $parts[1]; + // Remove x- prefix and any parameters + $subtype = preg_replace('/^x-/', '', $subtype); + $subtype = explode(';', $subtype)[0]; + $subtype = explode('+', $subtype)[0]; + + if (strlen($subtype) > 0 && strlen($subtype) <= 10) { + return strtolower($subtype); + } + } + + return null; + } + + /** + * Check if MIME type is known + * + * @param string $mime MIME type + * @return bool + */ + public static function isKnownMime(string $mime): bool { + return isset(self::MIME_TO_FORMAT[$mime]); + } + + /** + * Check if format is known + * + * @param string $format Format identifier + * @return bool + */ + public static function isKnownFormat(string $format): bool { + if (self::$formatToMime === null) { + self::toMime($format); // Initialize cache + } + return isset(self::$formatToMime[$format]); + } + + /** + * Get all known MIME types + * + * @return array MIME type to format mapping + */ + public static function all(): array { + return self::MIME_TO_FORMAT; + } + +} diff --git a/shared/lib/Blob/Signature.php b/shared/lib/Blob/Signature.php new file mode 100644 index 0000000..497e267 --- /dev/null +++ b/shared/lib/Blob/Signature.php @@ -0,0 +1,230 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Blob; + +use finfo; + +/** + * Signature - Analyzes binary content to determine MIME type and format + * + * This utility only requires the first bytes of a file to detect its format, + * making it compatible with streams, chunked uploads, and remote storage backends like S3. + * + * Uses PHP's built-in finfo extension (libmagic) for reliable detection with + * fallback to custom magic byte detection if finfo is unavailable. + */ +class Signature { + + /** Minimum bytes needed for reliable detection */ + public const HEADER_SIZE = 256; + + /** + * Fallback magic byte signatures for when finfo is unavailable + */ + private const SIGNATURES = [ + ['offset' => 0, 'bytes' => 'FFD8FF', 'format' => 'jpeg'], + ['offset' => 0, 'bytes' => '89504E470D0A1A0A', 'format' => 'png'], + ['offset' => 0, 'bytes' => '47494638', 'format' => 'gif'], + ['offset' => 0, 'bytes' => '25504446', 'format' => 'pdf'], + ['offset' => 0, 'bytes' => '504B0304', 'format' => 'zip'], + ['offset' => 0, 'bytes' => '1F8B08', 'format' => 'gzip'], + ['offset' => 4, 'bytes' => '66747970', 'format' => 'mp4'], + ['offset' => 0, 'bytes' => '494433', 'format' => 'mp3'], + ['offset' => 0, 'bytes' => 'FFFB', 'format' => 'mp3'], + ['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP + ]; + + /** Cached finfo instance */ + private static ?finfo $finfo = null; + + /** + * Detect both MIME type and format from content bytes in a single operation + * + * @param string $headerBytes First bytes of the file content (256 recommended) + * @return array{mime: string, format: string} Array with 'mime' and 'format' keys + */ + public static function detect(string $headerBytes): array { + if (strlen($headerBytes) === 0) { + return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY]; + } + + $mime = null; + $format = null; + + // Try finfo first (most reliable) + if (extension_loaded('fileinfo')) { + $mime = self::detectMimeType($headerBytes); + if ($mime !== null) { + // Get format from MIME + $format = MimeTypes::toFormat($mime); + if ($format === null && $mime !== MimeTypes::MIME_BINARY) { + $format = MimeTypes::parseFormat($mime); + } + } + } + + // Fallback to magic bytes if format not determined + if ($format === null) { + $format = self::detectFromMagicBytes($headerBytes); + } + + // Ensure MIME type is set + if ($mime === null || $mime === MimeTypes::MIME_BINARY) { + $mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY; + } + + return ['mime' => $mime, 'format' => $format]; + } + + /** + * Detect both MIME type and format from a stream in a single operation + * + * @param resource $stream File stream + * @return array{mime: string, format: string} Array with 'mime' and 'format' keys + */ + public static function detectFromStream($stream): array { + $position = ftell($stream); + $headerBytes = fread($stream, self::HEADER_SIZE); + fseek($stream, $position); + + if ($headerBytes === false || $headerBytes === '') { + return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY]; + } + + return self::detect($headerBytes); + } + + /** + * Detect file format from content bytes + * + * @param string $headerBytes First bytes of the file content (256 recommended) + * @return string Detected format (e.g., 'jpeg', 'png', 'pdf') or 'binary' if unknown + */ + public static function detectFormat(string $headerBytes): string { + return self::detect($headerBytes)['format']; + } + + /** + * Detect MIME type from content bytes using finfo + * + * @param string $headerBytes Content bytes + * @return string|null MIME type or null on failure + */ + public static function detectMimeType(string $headerBytes): ?string { + if (!extension_loaded('fileinfo')) { + return null; + } + + if (self::$finfo === null) { + self::$finfo = new finfo(FILEINFO_MIME_TYPE); + } + + $mime = self::$finfo->buffer($headerBytes); + return $mime !== false ? $mime : null; + } + + /** + * Detect file format from a stream + * + * Reads the header bytes, detects format, and rewinds the stream. + * + * @param resource $stream File stream + * @return string Detected format + */ + public static function detectFormatFromStream($stream): string { + $position = ftell($stream); + $headerBytes = fread($stream, self::HEADER_SIZE); + fseek($stream, $position); + + if ($headerBytes === false || $headerBytes === '') { + return MimeTypes::FORMAT_BINARY; + } + + return self::detectFormat($headerBytes); + } + + /** + * Detect MIME type from a stream + * + * @param resource $stream File stream + * @return string|null MIME type or null + */ + public static function detectMimeTypeFromStream($stream): ?string { + $position = ftell($stream); + $headerBytes = fread($stream, self::HEADER_SIZE); + fseek($stream, $position); + + if ($headerBytes === false || $headerBytes === '') { + return null; + } + + return self::detectMimeType($headerBytes); + } + + /** + * Fallback detection using magic bytes + * + * @param string $headerBytes Content bytes + * @return string Detected format or 'binary' + */ + private static function detectFromMagicBytes(string $headerBytes): string { + $headerHex = strtoupper(bin2hex($headerBytes)); + + foreach (self::SIGNATURES as $sig) { + $offset = $sig['offset'] * 2; + $sigBytes = strtoupper($sig['bytes']); + $sigLength = strlen($sigBytes); + + if (strlen($headerHex) < $offset + $sigLength) { + continue; + } + + $slice = substr($headerHex, $offset, $sigLength); + if ($slice === $sigBytes) { + return $sig['format']; + } + } + + // Check if likely text + if (self::isLikelyText($headerBytes)) { + return 'text'; + } + + return MimeTypes::FORMAT_BINARY; + } + + /** + * Check if content appears to be text + * + * @param string $bytes Content bytes + * @return bool + */ + private static function isLikelyText(string $bytes): bool { + // Check for UTF-8 BOM + if (str_starts_with($bytes, "\xEF\xBB\xBF")) { + return true; + } + + $length = min(strlen($bytes), 256); + $printableCount = 0; + + for ($i = 0; $i < $length; $i++) { + $byte = ord($bytes[$i]); + if (($byte >= 32 && $byte <= 126) || $byte === 9 || $byte === 10 || $byte === 13) { + $printableCount++; + } elseif ($byte >= 128 && $byte <= 247) { + $printableCount++; // UTF-8 bytes + } + } + + return ($printableCount / $length) > 0.9; + } + +} diff --git a/shared/lib/Cache/BlobCacheInterface.php b/shared/lib/Cache/BlobCacheInterface.php new file mode 100644 index 0000000..b408d18 --- /dev/null +++ b/shared/lib/Cache/BlobCacheInterface.php @@ -0,0 +1,123 @@ + 'global', + self::Tenant => $tenantId ? "tenant/{$tenantId}" : 'tenant/_unknown', + self::User => $tenantId && $userId + ? "user/{$tenantId}/{$userId}" + : "user/_unknown/_unknown", + }; + } + + /** + * Validate that required identifiers are provided for this scope + */ + public function validate(?string $tenantId, ?string $userId): bool + { + return match ($this) { + self::Global => true, + self::Tenant => $tenantId !== null, + self::User => $tenantId !== null && $userId !== null, + }; + } +} diff --git a/shared/lib/Cache/EphemeralCacheInterface.php b/shared/lib/Cache/EphemeralCacheInterface.php new file mode 100644 index 0000000..271de30 --- /dev/null +++ b/shared/lib/Cache/EphemeralCacheInterface.php @@ -0,0 +1,57 @@ + $tags Tags for grouping/invalidation + * @param int|null $ttl Time-to-live in seconds (null = default, 0 = indefinite) + * @return bool True if stored successfully + */ + public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?int $ttl = null): bool; + + /** + * Invalidate all entries with a specific tag + * + * @param string $tag Tag to invalidate + * @param CacheScope $scope Cache scope level + * @param string $usage Usage/bucket name + * @return int Number of entries invalidated + */ + public function invalidateByTag(string $tag, CacheScope $scope, string $usage): int; + + /** + * Get the version/timestamp of a cached entry + * + * @param string $key Cache key + * @param CacheScope $scope Cache scope level + * @param string $usage Usage/bucket name + * @return int|null Timestamp when entry was cached, or null if not found + */ + public function getVersion(string $key, CacheScope $scope, string $usage): ?int; + + /** + * Check if an entry is stale based on a reference timestamp + * + * @param string $key Cache key + * @param CacheScope $scope Cache scope level + * @param string $usage Usage/bucket name + * @param int $reference Reference timestamp to compare against + * @return bool True if entry is older than reference (or doesn't exist) + */ + public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool; +} diff --git a/shared/lib/Cache/Store/FileBlobCache.php b/shared/lib/Cache/Store/FileBlobCache.php new file mode 100644 index 0000000..0ad4da3 --- /dev/null +++ b/shared/lib/Cache/Store/FileBlobCache.php @@ -0,0 +1,412 @@ +basePath = rtrim($projectDir, '/') . '/storage'; + } + + /** + * Set the tenant context for scoped operations + */ + public function setTenantContext(?string $tenantId): void + { + $this->tenantId = $tenantId; + } + + /** + * Set the user context for scoped operations + */ + public function setUserContext(?string $userId): void + { + $this->userId = $userId; + } + + /** + * @inheritDoc + */ + public function get(string $key, CacheScope $scope, string $usage): ?string + { + $path = $this->buildPath($key, $scope, $usage); + + if (!$this->isValid($path)) { + return null; + } + + $content = @file_get_contents($path); + + return $content !== false ? $content : null; + } + + /** + * @inheritDoc + */ + public function getStream(string $key, CacheScope $scope, string $usage) + { + $path = $this->buildPath($key, $scope, $usage); + + if (!$this->isValid($path)) { + return null; + } + + $handle = @fopen($path, 'rb'); + + return $handle !== false ? $handle : null; + } + + /** + * @inheritDoc + */ + public function set(string $key, string $data, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool + { + $path = $this->buildPath($key, $scope, $usage); + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + // Write data file + $tempPath = $path . '.tmp.' . getmypid(); + + if (file_put_contents($tempPath, $data, LOCK_EX) === false) { + return false; + } + + chmod($tempPath, 0600); + + if (!rename($tempPath, $path)) { + @unlink($tempPath); + return false; + } + + // Write metadata + $this->writeMetadata($path, [ + 'mimeType' => $mimeType, + 'size' => strlen($data), + 'createdAt' => time(), + 'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null, + ]); + + return true; + } + + /** + * @inheritDoc + */ + public function putStream(string $key, $stream, CacheScope $scope, string $usage, ?string $mimeType = null, ?int $ttl = null): bool + { + $path = $this->buildPath($key, $scope, $usage); + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + // Write data file from stream + $tempPath = $path . '.tmp.' . getmypid(); + $dest = @fopen($tempPath, 'wb'); + + if ($dest === false) { + return false; + } + + $size = 0; + while (!feof($stream)) { + $chunk = fread($stream, 8192); + if ($chunk === false) { + fclose($dest); + @unlink($tempPath); + return false; + } + $written = fwrite($dest, $chunk); + if ($written === false) { + fclose($dest); + @unlink($tempPath); + return false; + } + $size += $written; + } + + fclose($dest); + chmod($tempPath, 0600); + + if (!rename($tempPath, $path)) { + @unlink($tempPath); + return false; + } + + // Write metadata + $this->writeMetadata($path, [ + 'mimeType' => $mimeType, + 'size' => $size, + 'createdAt' => time(), + 'expiresAt' => $ttl !== null && $ttl > 0 ? time() + $ttl : null, + ]); + + return true; + } + + /** + * @inheritDoc + */ + public function has(string $key, CacheScope $scope, string $usage): bool + { + $path = $this->buildPath($key, $scope, $usage); + + return $this->isValid($path); + } + + /** + * @inheritDoc + */ + public function delete(string $key, CacheScope $scope, string $usage): bool + { + $path = $this->buildPath($key, $scope, $usage); + $metaPath = $path . '.meta'; + + $result = true; + + if (file_exists($path)) { + $result = @unlink($path); + } + + if (file_exists($metaPath)) { + @unlink($metaPath); + } + + return $result; + } + + /** + * @inheritDoc + */ + public function getPath(string $key, CacheScope $scope, string $usage): ?string + { + $path = $this->buildPath($key, $scope, $usage); + + if (!$this->isValid($path)) { + return null; + } + + return $path; + } + + /** + * @inheritDoc + */ + public function getMetadata(string $key, CacheScope $scope, string $usage): ?array + { + $path = $this->buildPath($key, $scope, $usage); + + if (!file_exists($path)) { + return null; + } + + return $this->readMetadata($path); + } + + /** + * @inheritDoc + */ + public function clear(CacheScope $scope, string $usage): int + { + $dir = $this->buildDir($scope, $usage); + + if (!is_dir($dir)) { + return 0; + } + + $count = 0; + $files = glob($dir . '/*'); + + foreach ($files as $file) { + if (is_file($file) && !str_ends_with($file, '.meta')) { + if (@unlink($file)) { + $count++; + // Also remove metadata file + @unlink($file . '.meta'); + } + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function cleanup(): int + { + $count = 0; + $now = time(); + + // Scan all tenant directories + $tenantDirs = glob($this->basePath . '/*', GLOB_ONLYDIR); + + foreach ($tenantDirs as $tenantDir) { + $files = $this->findBlobFiles($tenantDir); + + foreach ($files as $file) { + $meta = $this->readMetadata($file); + + if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < $now) { + if (@unlink($file)) { + @unlink($file . '.meta'); + $count++; + } + } + } + } + + return $count; + } + + /** + * Check if a blob is valid (exists and not expired) + */ + private function isValid(string $path): bool + { + if (!file_exists($path)) { + return false; + } + + $meta = $this->readMetadata($path); + + if ($meta !== null && $meta['expiresAt'] !== null && $meta['expiresAt'] < time()) { + @unlink($path); + @unlink($path . '.meta'); + return false; + } + + return true; + } + + /** + * Build the full path for a blob + */ + private function buildPath(string $key, CacheScope $scope, string $usage): string + { + $dir = $this->buildDir($scope, $usage); + $hash = $this->hashKey($key); + + return $dir . '/' . $hash; + } + + /** + * Build the directory path for a scope/usage combination + */ + private function buildDir(CacheScope $scope, string $usage): string + { + $usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage); + + return match ($scope) { + CacheScope::Global => $this->basePath . '/_global/cache/' . $usage, + CacheScope::Tenant => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/cache/' . $usage, + CacheScope::User => $this->basePath . '/' . ($this->tenantId ?? '_unknown') . '/' . ($this->userId ?? '_unknown') . '/cache/' . $usage, + }; + } + + /** + * Hash a cache key for filesystem safety + */ + private function hashKey(string $key): string + { + // Extract extension if present in key + $ext = ''; + if (preg_match('/\.([a-zA-Z0-9]{2,5})$/', $key, $matches)) { + $ext = '.' . strtolower($matches[1]); + } + + $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32)); + $hash = substr(hash('sha256', $key), 0, 16); + + return $safe . '_' . $hash . $ext; + } + + /** + * Read metadata for a blob + */ + private function readMetadata(string $path): ?array + { + $metaPath = $path . '.meta'; + + if (!file_exists($metaPath)) { + // Return basic metadata from file stats + if (file_exists($path)) { + $stat = stat($path); + return [ + 'mimeType' => null, + 'size' => $stat['size'] ?? 0, + 'createdAt' => $stat['ctime'] ?? time(), + 'expiresAt' => null, + ]; + } + return null; + } + + $content = @file_get_contents($metaPath); + + if ($content === false) { + return null; + } + + $meta = @unserialize($content); + + return is_array($meta) ? $meta : null; + } + + /** + * Write metadata for a blob + */ + private function writeMetadata(string $path, array $metadata): bool + { + $metaPath = $path . '.meta'; + + return file_put_contents($metaPath, serialize($metadata), LOCK_EX) !== false; + } + + /** + * Recursively find all blob files in a directory + */ + private function findBlobFiles(string $dir): array + { + $files = []; + + $cacheDirs = glob($dir . '/cache/*', GLOB_ONLYDIR) ?: []; + $cacheDirs = array_merge($cacheDirs, glob($dir . '/*/cache/*', GLOB_ONLYDIR) ?: []); + + foreach ($cacheDirs as $cacheDir) { + $blobFiles = glob($cacheDir . '/*'); + foreach ($blobFiles as $file) { + if (is_file($file) && !str_ends_with($file, '.meta')) { + $files[] = $file; + } + } + } + + return $files; + } +} diff --git a/shared/lib/Cache/Store/FileEphemeralCache.php b/shared/lib/Cache/Store/FileEphemeralCache.php new file mode 100644 index 0000000..aab51df --- /dev/null +++ b/shared/lib/Cache/Store/FileEphemeralCache.php @@ -0,0 +1,346 @@ +basePath = rtrim($projectDir, '/') . '/var/cache'; + } + + /** + * Set the tenant context for scoped operations + */ + public function setTenantContext(?string $tenantId): void + { + $this->tenantId = $tenantId; + } + + /** + * Set the user context for scoped operations + */ + public function setUserContext(?string $userId): void + { + $this->userId = $userId; + } + + /** + * @inheritDoc + */ + public function get(string $key, CacheScope $scope, string $usage): mixed + { + $path = $this->buildPath($key, $scope, $usage); + + if (!file_exists($path)) { + return null; + } + + $entry = $this->readEntry($path); + + if ($entry === null) { + return null; + } + + // Check expiration + if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) { + @unlink($path); + return null; + } + + return $entry['value']; + } + + /** + * @inheritDoc + */ + public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool + { + $path = $this->buildPath($key, $scope, $usage); + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + $ttl = $ttl ?? self::DEFAULT_TTL; + $entry = [ + 'key' => $key, + 'value' => $value, + 'createdAt' => time(), + 'expiresAt' => $ttl > 0 ? time() + $ttl : 0, + ]; + + return $this->writeEntry($path, $entry); + } + + /** + * @inheritDoc + */ + public function has(string $key, CacheScope $scope, string $usage): bool + { + return $this->get($key, $scope, $usage) !== null; + } + + /** + * @inheritDoc + */ + public function delete(string $key, CacheScope $scope, string $usage): bool + { + $path = $this->buildPath($key, $scope, $usage); + + if (file_exists($path)) { + return @unlink($path); + } + + return true; + } + + /** + * @inheritDoc + */ + public function clear(CacheScope $scope, string $usage): int + { + $dir = $this->buildDir($scope, $usage); + + if (!is_dir($dir)) { + return 0; + } + + $count = 0; + $files = glob($dir . '/*.cache'); + + foreach ($files as $file) { + if (@unlink($file)) { + $count++; + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function cleanup(): int + { + $count = 0; + $now = time(); + + // Scan all scope directories + $scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR); + + foreach ($scopeDirs as $scopeDir) { + $files = $this->findCacheFiles($scopeDir); + + foreach ($files as $file) { + $entry = $this->readEntry($file); + + if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) { + if (@unlink($file)) { + $count++; + } + } + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function remember(string $key, callable $callback, CacheScope $scope, string $usage, ?int $ttl = null): mixed + { + $value = $this->get($key, $scope, $usage); + + if ($value !== null) { + return $value; + } + + $value = $callback(); + $this->set($key, $value, $scope, $usage, $ttl); + + return $value; + } + + /** + * @inheritDoc + */ + public function increment(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false + { + $path = $this->buildPath($key, $scope, $usage); + + // Use file locking for atomic increment + $handle = @fopen($path, 'c+'); + if ($handle === false) { + // File doesn't exist, create with initial value + $dir = dirname($path); + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + $handle = @fopen($path, 'c+'); + if ($handle === false) { + return false; + } + } + + if (!flock($handle, LOCK_EX)) { + fclose($handle); + return false; + } + + try { + $content = stream_get_contents($handle); + $entry = $content ? @unserialize($content) : null; + + if ($entry === null || ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time())) { + // Initialize new entry + $newValue = $amount; + $entry = [ + 'key' => $key, + 'value' => $newValue, + 'createdAt' => time(), + 'expiresAt' => time() + self::DEFAULT_TTL, + ]; + } else { + $newValue = (int)$entry['value'] + $amount; + $entry['value'] = $newValue; + } + + ftruncate($handle, 0); + rewind($handle); + fwrite($handle, serialize($entry)); + + return $newValue; + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } + + /** + * @inheritDoc + */ + public function decrement(string $key, CacheScope $scope, string $usage, int $amount = 1): int|false + { + return $this->increment($key, $scope, $usage, -$amount); + } + + /** + * Build the full path for a cache entry + */ + private function buildPath(string $key, CacheScope $scope, string $usage): string + { + $dir = $this->buildDir($scope, $usage); + $hash = $this->hashKey($key); + + return $dir . '/' . $hash . '.cache'; + } + + /** + * Build the directory path for a scope/usage combination + */ + private function buildDir(CacheScope $scope, string $usage): string + { + $prefix = $scope->buildPrefix($this->tenantId, $this->userId); + $usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage); + + return $this->basePath . '/' . $prefix . '/' . $usage; + } + + /** + * Hash a cache key for filesystem safety + */ + private function hashKey(string $key): string + { + // Use a prefix of the original key for debugging + hash for uniqueness + $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32)); + $hash = substr(hash('sha256', $key), 0, 16); + + return $safe . '_' . $hash; + } + + /** + * Read and unserialize a cache entry + */ + private function readEntry(string $path): ?array + { + $content = @file_get_contents($path); + + if ($content === false) { + return null; + } + + $entry = @unserialize($content); + + if (!is_array($entry) || !isset($entry['value'])) { + return null; + } + + return $entry; + } + + /** + * Serialize and write a cache entry atomically + */ + private function writeEntry(string $path, array $entry): bool + { + $content = serialize($entry); + $tempPath = $path . '.tmp.' . getmypid(); + + if (file_put_contents($tempPath, $content, LOCK_EX) === false) { + return false; + } + + chmod($tempPath, 0600); + + if (!rename($tempPath, $path)) { + @unlink($tempPath); + return false; + } + + return true; + } + + /** + * Recursively find all cache files in a directory + */ + private function findCacheFiles(string $dir): array + { + $files = []; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'cache') { + $files[] = $file->getPathname(); + } + } + + return $files; + } +} diff --git a/shared/lib/Cache/Store/FilePersistentCache.php b/shared/lib/Cache/Store/FilePersistentCache.php new file mode 100644 index 0000000..25b780d --- /dev/null +++ b/shared/lib/Cache/Store/FilePersistentCache.php @@ -0,0 +1,433 @@ +basePath = rtrim($projectDir, '/') . '/var/cache'; + } + + /** + * Set the tenant context for scoped operations + */ + public function setTenantContext(?string $tenantId): void + { + $this->tenantId = $tenantId; + } + + /** + * Set the user context for scoped operations + */ + public function setUserContext(?string $userId): void + { + $this->userId = $userId; + } + + /** + * @inheritDoc + */ + public function get(string $key, CacheScope $scope, string $usage): mixed + { + $path = $this->buildPath($key, $scope, $usage); + + if (!file_exists($path)) { + return null; + } + + $entry = $this->readEntry($path); + + if ($entry === null) { + return null; + } + + // Check expiration (0 = never expires) + if ($entry['expiresAt'] > 0 && $entry['expiresAt'] < time()) { + @unlink($path); + $this->removeFromTagIndex($key, $scope, $usage, $entry['tags'] ?? []); + return null; + } + + return $entry['value']; + } + + /** + * @inheritDoc + */ + public function set(string $key, mixed $value, CacheScope $scope, string $usage, ?int $ttl = null): bool + { + return $this->setWithTags($key, $value, $scope, $usage, [], $ttl); + } + + /** + * @inheritDoc + */ + public function setWithTags(string $key, mixed $value, CacheScope $scope, string $usage, array $tags, ?int $ttl = null): bool + { + $path = $this->buildPath($key, $scope, $usage); + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + // Remove from old tags if entry exists + $existingEntry = $this->readEntry($path); + if ($existingEntry !== null && !empty($existingEntry['tags'])) { + $this->removeFromTagIndex($key, $scope, $usage, $existingEntry['tags']); + } + + $ttl = $ttl ?? self::DEFAULT_TTL; + $entry = [ + 'key' => $key, + 'value' => $value, + 'tags' => $tags, + 'createdAt' => time(), + 'expiresAt' => $ttl > 0 ? time() + $ttl : 0, + ]; + + $result = $this->writeEntry($path, $entry); + + if ($result && !empty($tags)) { + $this->addToTagIndex($key, $scope, $usage, $tags); + } + + return $result; + } + + /** + * @inheritDoc + */ + public function has(string $key, CacheScope $scope, string $usage): bool + { + return $this->get($key, $scope, $usage) !== null; + } + + /** + * @inheritDoc + */ + public function delete(string $key, CacheScope $scope, string $usage): bool + { + $path = $this->buildPath($key, $scope, $usage); + + if (file_exists($path)) { + $entry = $this->readEntry($path); + if ($entry !== null && !empty($entry['tags'])) { + $this->removeFromTagIndex($key, $scope, $usage, $entry['tags']); + } + return @unlink($path); + } + + return true; + } + + /** + * @inheritDoc + */ + public function clear(CacheScope $scope, string $usage): int + { + $dir = $this->buildDir($scope, $usage); + + if (!is_dir($dir)) { + return 0; + } + + $count = 0; + $files = glob($dir . '/*.cache'); + + foreach ($files as $file) { + if (@unlink($file)) { + $count++; + } + } + + // Clear tag index + $tagIndexPath = $dir . '/.tags'; + if (file_exists($tagIndexPath)) { + @unlink($tagIndexPath); + } + + return $count; + } + + /** + * @inheritDoc + */ + public function cleanup(): int + { + $count = 0; + $now = time(); + + // Scan all scope directories + $scopeDirs = glob($this->basePath . '/*', GLOB_ONLYDIR); + + foreach ($scopeDirs as $scopeDir) { + $files = $this->findCacheFiles($scopeDir); + + foreach ($files as $file) { + $entry = $this->readEntry($file); + + if ($entry !== null && $entry['expiresAt'] > 0 && $entry['expiresAt'] < $now) { + if (@unlink($file)) { + $count++; + } + } + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function invalidateByTag(string $tag, CacheScope $scope, string $usage): int + { + $tagIndex = $this->readTagIndex($scope, $usage); + + if (!isset($tagIndex[$tag])) { + return 0; + } + + $count = 0; + $keys = $tagIndex[$tag]; + + foreach ($keys as $key) { + if ($this->delete($key, $scope, $usage)) { + $count++; + } + } + + return $count; + } + + /** + * @inheritDoc + */ + public function getVersion(string $key, CacheScope $scope, string $usage): ?int + { + $path = $this->buildPath($key, $scope, $usage); + + if (!file_exists($path)) { + return null; + } + + $entry = $this->readEntry($path); + + return $entry['createdAt'] ?? null; + } + + /** + * @inheritDoc + */ + public function isStale(string $key, CacheScope $scope, string $usage, int $reference): bool + { + $version = $this->getVersion($key, $scope, $usage); + + if ($version === null) { + return true; + } + + return $version < $reference; + } + + /** + * Build the full path for a cache entry + */ + private function buildPath(string $key, CacheScope $scope, string $usage): string + { + $dir = $this->buildDir($scope, $usage); + $hash = $this->hashKey($key); + + return $dir . '/' . $hash . '.cache'; + } + + /** + * Build the directory path for a scope/usage combination + */ + private function buildDir(CacheScope $scope, string $usage): string + { + $prefix = $scope->buildPrefix($this->tenantId, $this->userId); + $usage = preg_replace('/[^a-zA-Z0-9_-]/', '_', $usage); + + return $this->basePath . '/' . $prefix . '/' . $usage; + } + + /** + * Hash a cache key for filesystem safety + */ + private function hashKey(string $key): string + { + $safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($key, 0, 32)); + $hash = substr(hash('sha256', $key), 0, 16); + + return $safe . '_' . $hash; + } + + /** + * Read and unserialize a cache entry + */ + private function readEntry(string $path): ?array + { + $content = @file_get_contents($path); + + if ($content === false) { + return null; + } + + $entry = @unserialize($content); + + if (!is_array($entry) || !isset($entry['value'])) { + return null; + } + + return $entry; + } + + /** + * Serialize and write a cache entry atomically + */ + private function writeEntry(string $path, array $entry): bool + { + $content = serialize($entry); + $tempPath = $path . '.tmp.' . getmypid(); + + if (file_put_contents($tempPath, $content, LOCK_EX) === false) { + return false; + } + + chmod($tempPath, 0600); + + if (!rename($tempPath, $path)) { + @unlink($tempPath); + return false; + } + + return true; + } + + /** + * Read the tag index for a usage bucket + */ + private function readTagIndex(CacheScope $scope, string $usage): array + { + $path = $this->buildDir($scope, $usage) . '/.tags'; + + if (!file_exists($path)) { + return []; + } + + $content = @file_get_contents($path); + + if ($content === false) { + return []; + } + + $index = @unserialize($content); + + return is_array($index) ? $index : []; + } + + /** + * Write the tag index for a usage bucket + */ + private function writeTagIndex(CacheScope $scope, string $usage, array $index): bool + { + $path = $this->buildDir($scope, $usage) . '/.tags'; + $dir = dirname($path); + + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true) && !is_dir($dir)) { + return false; + } + } + + return file_put_contents($path, serialize($index), LOCK_EX) !== false; + } + + /** + * Add a key to the tag index + */ + private function addToTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void + { + $index = $this->readTagIndex($scope, $usage); + + foreach ($tags as $tag) { + if (!isset($index[$tag])) { + $index[$tag] = []; + } + if (!in_array($key, $index[$tag], true)) { + $index[$tag][] = $key; + } + } + + $this->writeTagIndex($scope, $usage, $index); + } + + /** + * Remove a key from the tag index + */ + private function removeFromTagIndex(string $key, CacheScope $scope, string $usage, array $tags): void + { + $index = $this->readTagIndex($scope, $usage); + $changed = false; + + foreach ($tags as $tag) { + if (isset($index[$tag])) { + $pos = array_search($key, $index[$tag], true); + if ($pos !== false) { + unset($index[$tag][$pos]); + $index[$tag] = array_values($index[$tag]); + $changed = true; + + if (empty($index[$tag])) { + unset($index[$tag]); + } + } + } + } + + if ($changed) { + $this->writeTagIndex($scope, $usage, $index); + } + } + + /** + * Recursively find all cache files in a directory + */ + private function findCacheFiles(string $dir): array + { + $files = []; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'cache') { + $files[] = $file->getPathname(); + } + } + + return $files; + } +} diff --git a/shared/lib/Chrono/Collection/CollectionContent.php b/shared/lib/Chrono/Collection/CollectionContent.php new file mode 100644 index 0000000..9e0a403 --- /dev/null +++ b/shared/lib/Chrono/Collection/CollectionContent.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use JsonSerializable; + +enum CollectionContent: string implements JsonSerializable { + + case Event = 'event'; + case Task = 'task'; + case Journal = 'journal'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Chrono/Collection/CollectionPermissions.php b/shared/lib/Chrono/Collection/CollectionPermissions.php new file mode 100644 index 0000000..8c11f26 --- /dev/null +++ b/shared/lib/Chrono/Collection/CollectionPermissions.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use JsonSerializable; + +enum CollectionPermissions: string implements JsonSerializable { + + case View = 'view'; + case Create = 'create'; + case Modify = 'modify'; + case Destroy = 'destroy'; + case Share = 'share'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Chrono/Collection/CollectionRoles.php b/shared/lib/Chrono/Collection/CollectionRoles.php new file mode 100644 index 0000000..602c2c5 --- /dev/null +++ b/shared/lib/Chrono/Collection/CollectionRoles.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use JsonSerializable; + +enum CollectionRoles: string implements JsonSerializable { + + case System = 'system'; + case Individual = 'individual'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Chrono/Collection/ICollectionBase.php b/shared/lib/Chrono/Collection/ICollectionBase.php new file mode 100644 index 0000000..cb5e438 --- /dev/null +++ b/shared/lib/Chrono/Collection/ICollectionBase.php @@ -0,0 +1,160 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use DateTimeImmutable; +use JsonSerializable; + +interface ICollectionBase extends JsonSerializable { + + public const JSON_TYPE = 'chrono.collection'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_SERVICE = 'service'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_DESCRIPTION = 'description'; + public const JSON_PROPERTY_PRIORITY = 'priority'; + public const JSON_PROPERTY_VISIBILITY = 'visibility'; + public const JSON_PROPERTY_COLOR = 'color'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + public const JSON_PROPERTY_PERMISSIONS = 'permissions'; + public const JSON_PROPERTY_ROLES = 'roles'; + public const JSON_PROPERTY_CONTENTS = 'contents'; + + /** + * Unique identifier of the service this collection belongs to + * + * @since 2025.05.01 + */ + public function in(): string|int|null; + + /** + * Unique arbitrary text string identifying this collection (e.g. 1 or collection1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the creation date of this collection + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date of this collection + */ + public function modified(): ?DateTimeImmutable; + + /** + * Lists all supported attributes + * + * @since 2025.05.01 + * + * @return array> + */ + public function attributes(): array; + + /** + * Gets the signature of this collection + * + * @since 2025.05.01 + */ + public function signature(): ?string; + + /** + * Gets the role(s) of this collection + * + * @since 2025.05.01 + */ + public function roles(): array; + + /** + * Checks if this collection supports the given role + * + * @since 2025.05.01 + */ + public function role(CollectionRoles $value): bool; + + /** + * Gets the content types of this collection + * + * @since 2025.05.01 + */ + public function contents(): array; + + /** + * Checks if this collection contains the given content type + * + * @since 2025.05.01 + */ + public function contains(CollectionContent $value): bool; + + /** + * Gets the active status of this collection + * + * @since 2025.05.01 + */ + public function getEnabled(): bool; + + /** + * Gets the permissions of this collection + * + * @since 2025.05.01 + */ + public function getPermissions(): array; + + /** + * Checks if this collection has the given permission + * + * @since 2025.05.01 + */ + public function hasPermission(CollectionPermissions $permission): bool; + + /** + * Gets the human friendly name of this collection (e.g. Personal Calendar) + * + * @since 2025.05.01 + */ + public function getLabel(): ?string; + + /** + * Gets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function getDescription(): ?string; + + /** + * Gets the priority of this collection + * + * @since 2025.05.01 + */ + public function getPriority(): ?int; + + /** + * Gets the visibility of this collection + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool; + + /** + * Gets the color of this collection + * + * @since 2025.05.01 + */ + public function getColor(): ?string; + +} diff --git a/shared/lib/Chrono/Collection/ICollectionMutable.php b/shared/lib/Chrono/Collection/ICollectionMutable.php new file mode 100644 index 0000000..167f02c --- /dev/null +++ b/shared/lib/Chrono/Collection/ICollectionMutable.php @@ -0,0 +1,58 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Collection; + +use KTXF\Json\JsonDeserializable; + +interface ICollectionMutable extends ICollectionBase, JsonDeserializable { + + /** + * Sets the active status of this collection + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self; + + /** + * Sets the human friendly name of this collection (e.g. Personal Calendar) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self; + + /** + * Sets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function setDescription(?string $value): self; + + /** + * Sets the priority of this collection + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): self; + + /** + * Sets the visibility of this collection + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): self; + + /** + * Sets the color of this collection + * + * @since 2025.05.01 + */ + public function setColor(?string $value): self; + +} diff --git a/shared/lib/Chrono/Entity/EntityPermissions.php b/shared/lib/Chrono/Entity/EntityPermissions.php new file mode 100644 index 0000000..fd77959 --- /dev/null +++ b/shared/lib/Chrono/Entity/EntityPermissions.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Entity; + +use JsonSerializable; + +enum EntityPermissions: string implements JsonSerializable { + + case View = 'view'; + case Modify = 'modify'; + case Delete = 'delete'; + case Share = 'share'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Chrono/Entity/IEntityBase.php b/shared/lib/Chrono/Entity/IEntityBase.php new file mode 100644 index 0000000..d3cf50c --- /dev/null +++ b/shared/lib/Chrono/Entity/IEntityBase.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Entity; + +use DateTimeImmutable; + +interface IEntityBase extends \JsonSerializable { + + public const JSON_TYPE = 'chrono.entity'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_DATA = 'data'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + + /** + * Unique arbitrary text string identifying the collection this entity belongs to (e.g. 1 or Collection1 or anything else) + * + * @since 2025.05.01 + */ + public function in(): string|int; + + /** + * Unique arbitrary text string identifying this entity (e.g. 1 or Entity or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the creation date of this entity + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date of this entity + */ + public function modified(): ?DateTimeImmutable; + + /** + * Gets the signature of this entity + * + * @since 2025.05.01 + */ + public function signature(): ?string; + + /** + * Gets the priority of this entity + * + * @since 2025.05.01 + */ + public function getPriority(): ?int; + + /** + * Gets the visibility of this entity + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool; + + /** + * Gets the color of this entity + * + * @since 2025.05.01 + */ + public function getColor(): ?string; + + /** + * Gets the object data (event, task, or journal). + * + * @since 2025.05.01 + */ + public function getDataObject(): object|null; + + /** + * Gets the raw data as an associative array or JSON string. + * + * @since 2025.05.01 + * + * @return array|string|null + */ + public function getDataJson(): array|string|null; + +} diff --git a/shared/lib/Chrono/Entity/IEntityMutable.php b/shared/lib/Chrono/Entity/IEntityMutable.php new file mode 100644 index 0000000..244d313 --- /dev/null +++ b/shared/lib/Chrono/Entity/IEntityMutable.php @@ -0,0 +1,51 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Entity; + +use KTXF\Json\JsonDeserializable; + +interface IEntityMutable extends IEntityBase, JsonDeserializable { + + /** + * Sets the priority of this entity + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): static; + + /** + * Sets the visibility of this entity + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): static; + + /** + * Sets the color of this entity + * + * @since 2025.05.01 + */ + public function setColor(?string $value): static; + + /** + * Sets the object as a class instance. + * + * @since 2025.05.01 + */ + public function setDataObject(object $value): static; + + /** + * Sets the object data from a json string + * + * @since 2025.05.01 + */ + public function setDataJson(array|string $value): static; + +} diff --git a/shared/lib/Chrono/Event/EventAvailabilityTypes.php b/shared/lib/Chrono/Event/EventAvailabilityTypes.php new file mode 100644 index 0000000..8a5d6f7 --- /dev/null +++ b/shared/lib/Chrono/Event/EventAvailabilityTypes.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventAvailabilityTypes: string { + case Free = 'free'; + case Busy = 'busy'; +} diff --git a/shared/lib/Chrono/Event/EventCommonObject.php b/shared/lib/Chrono/Event/EventCommonObject.php new file mode 100644 index 0000000..0260e30 --- /dev/null +++ b/shared/lib/Chrono/Event/EventCommonObject.php @@ -0,0 +1,50 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateInterval; +use DateTime; +use DateTimeImmutable; +use DateTimeZone; +use KTXF\Json\JsonSerializableObject; + +class EventCommonObject extends JsonSerializableObject { + + public int|null $sequence = null; + public DateTimeZone|null $timeZone = null; + public DateTime|DateTimeImmutable|null $startsOn = null; + public DateTimeZone|null $startsTZ = null; + public DateTime|DateTimeImmutable|null $endsOn = null; + public DateTimeZone|null $endsTZ = null; + public DateInterval|null $duration = null; + public bool|null $timeless = false; + public string|null $label = null; + public string|null $description = null; + public EventLocationPhysicalCollection $locationsPhysical; + public EventLocationVirtualCollection $locationsVirtual; + public EventAvailabilityTypes|null $availability = null; + public EventSensitivityTypes|null $sensitivity = null; + public int|null $priority = null; + public string|null $color = null; + public EventTagCollection $tags; + public EventOrganizerObject $organizer; + public EventParticipantCollection $participants; + public EventNotificationCollection $notifications; + + public function __construct() { + $this->participants = new EventParticipantCollection(); + $this->locationsPhysical = new EventLocationPhysicalCollection(); + $this->locationsVirtual = new EventLocationVirtualCollection(); + $this->notifications = new EventNotificationCollection(); + $this->organizer = new EventOrganizerObject(); + $this->tags = new EventTagCollection(); + } + +} diff --git a/shared/lib/Chrono/Event/EventLocationPhysicalCollection.php b/shared/lib/Chrono/Event/EventLocationPhysicalCollection.php new file mode 100644 index 0000000..59a9db8 --- /dev/null +++ b/shared/lib/Chrono/Event/EventLocationPhysicalCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventLocationPhysicalCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventLocationPhysicalObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventLocationPhysicalObject.php b/shared/lib/Chrono/Event/EventLocationPhysicalObject.php new file mode 100644 index 0000000..b6d1167 --- /dev/null +++ b/shared/lib/Chrono/Event/EventLocationPhysicalObject.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableObject; + +class EventLocationPhysicalObject extends JsonSerializableObject { + + public string|null $identifier = null; + public string|null $label = null; + public string|null $description = null; + public string|null $relation = null; // e.g. start, end of event + public string|null $timeZone = null; + +} diff --git a/shared/lib/Chrono/Event/EventLocationVirtualCollection.php b/shared/lib/Chrono/Event/EventLocationVirtualCollection.php new file mode 100644 index 0000000..7565648 --- /dev/null +++ b/shared/lib/Chrono/Event/EventLocationVirtualCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventLocationVirtualCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventLocationVirtualObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventLocationVirtualObject.php b/shared/lib/Chrono/Event/EventLocationVirtualObject.php new file mode 100644 index 0000000..9fe7624 --- /dev/null +++ b/shared/lib/Chrono/Event/EventLocationVirtualObject.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableObject; + +class EventLocationVirtualObject extends JsonSerializableObject { + + public string|null $identifier = null; + public string|null $label = null; + public string|null $description = null; + public string|null $relation = null; + public string|null $location = null; + +} diff --git a/shared/lib/Chrono/Event/EventMutationCollection.php b/shared/lib/Chrono/Event/EventMutationCollection.php new file mode 100644 index 0000000..4d581b7 --- /dev/null +++ b/shared/lib/Chrono/Event/EventMutationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventMutationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventMutationObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventMutationObject.php b/shared/lib/Chrono/Event/EventMutationObject.php new file mode 100644 index 0000000..6cf7c32 --- /dev/null +++ b/shared/lib/Chrono/Event/EventMutationObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateTime; +use DateTimeImmutable; + +class EventMutationObject extends EventCommonObject { + + public DateTime|DateTimeImmutable|null $mutationId = null; + public string|null $mutationTz = null; + public bool|null $mutationExclusion = null; + +} diff --git a/shared/lib/Chrono/Event/EventNotificationAnchorTypes.php b/shared/lib/Chrono/Event/EventNotificationAnchorTypes.php new file mode 100644 index 0000000..f4dab63 --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationAnchorTypes.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventNotificationAnchorTypes: string { + case Start = 'start'; + case End = 'end'; +} diff --git a/shared/lib/Chrono/Event/EventNotificationCollection.php b/shared/lib/Chrono/Event/EventNotificationCollection.php new file mode 100644 index 0000000..817923e --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventNotificationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventNotificationObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventNotificationObject.php b/shared/lib/Chrono/Event/EventNotificationObject.php new file mode 100644 index 0000000..0a5eb8f --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationObject.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateInterval; +use DateTime; +use DateTimeImmutable; +use KTXF\Json\JsonSerializableObject; + +class EventNotificationObject extends JsonSerializableObject { + public string|null $identifier = null; + public EventNotificationTypes|null $Type = null; + public EventNotificationPatterns|null $Pattern = null; + public DateTime|DateTimeImmutable|null $When = null; + public EventNotificationAnchorTypes|null $Anchor = null; + public DateInterval|null $Offset = null; +} diff --git a/shared/lib/Chrono/Event/EventNotificationPatterns.php b/shared/lib/Chrono/Event/EventNotificationPatterns.php new file mode 100644 index 0000000..1d35632 --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationPatterns.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventNotificationPatterns: string { + case Absolute = 'absolute'; + case Relative = 'relative'; + case Unknown = 'unknown'; +} diff --git a/shared/lib/Chrono/Event/EventNotificationTypes.php b/shared/lib/Chrono/Event/EventNotificationTypes.php new file mode 100644 index 0000000..4ec49a7 --- /dev/null +++ b/shared/lib/Chrono/Event/EventNotificationTypes.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventNotificationTypes: string { + case Visual = 'visual'; + case Audible = 'audible'; + case Email = 'email'; +} diff --git a/shared/lib/Chrono/Event/EventObject.php b/shared/lib/Chrono/Event/EventObject.php new file mode 100644 index 0000000..3e1f206 --- /dev/null +++ b/shared/lib/Chrono/Event/EventObject.php @@ -0,0 +1,31 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateTimeInterface; + +class EventObject extends EventCommonObject { + + // Meta Information + public string $type = 'event'; + public int $version = 1; + public string|null $urid = null; + public ?DateTimeInterface $created = null; + public ?DateTimeInterface $modified = null; + + public EventOccurrenceObject|null $pattern = null; + public EventMutationCollection $mutations; + + public function __construct() { + parent::__construct(); + $this->mutations = new EventMutationCollection(); + } + +} diff --git a/shared/lib/Chrono/Event/EventOccurrenceObject.php b/shared/lib/Chrono/Event/EventOccurrenceObject.php new file mode 100644 index 0000000..e367a9f --- /dev/null +++ b/shared/lib/Chrono/Event/EventOccurrenceObject.php @@ -0,0 +1,36 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use DateTime; +use DateTimeImmutable; +use KTXF\Json\JsonSerializableObject; + +class EventOccurrenceObject extends JsonSerializableObject { + public EventOccurrencePatternTypes|null $pattern = null; // Pattern - Absolute / Relative + public EventOccurrencePrecisionTypes|null $precision = null; // Time Interval + public int|null $interval = null; // Time Interval - Every 2 Days / Every 4 Weeks / Every 1 Year + public int|null $iterations = null; // Number of recurrence + public DateTime|DateTimeImmutable|null $concludes = null; // Date to stop recurrence + public String|null $scale = null; // calendar system in which this recurrence rule operates + public array $onDayOfWeek = []; + public array $onDayOfMonth = []; + public array $onDayOfYear = []; + public array $onWeekOfMonth = []; + public array $onWeekOfYear = []; + public array $onMonthOfYear = []; + public array $onHour = []; + public array $onMinute = []; + public array $onSecond = []; + public array $onPosition = []; + + public function __construct() { + } +} diff --git a/shared/lib/Chrono/Event/EventOccurrencePatternTypes.php b/shared/lib/Chrono/Event/EventOccurrencePatternTypes.php new file mode 100644 index 0000000..05e4dc5 --- /dev/null +++ b/shared/lib/Chrono/Event/EventOccurrencePatternTypes.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventOccurrencePatternTypes: string { + case Absolute = 'absolute'; + case Relative = 'relative'; +} diff --git a/shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php b/shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php new file mode 100644 index 0000000..3ccf3a4 --- /dev/null +++ b/shared/lib/Chrono/Event/EventOccurrencePrecisionTypes.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventOccurrencePrecisionTypes: string { + case Yearly = 'yearly'; + case Monthly = 'monthly'; + case Weekly = 'weekly'; + case Daily = 'daily'; + case Hourly = 'hourly'; + case Minutely = 'minutely'; + case Secondly = 'secondly'; +} diff --git a/shared/lib/Chrono/Event/EventOrganizerObject.php b/shared/lib/Chrono/Event/EventOrganizerObject.php new file mode 100644 index 0000000..12f965b --- /dev/null +++ b/shared/lib/Chrono/Event/EventOrganizerObject.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableObject; + +class EventOrganizerObject extends JsonSerializableObject { + + public EventParticipantRealm|null $realm = null; // E - external, I - internal + public string|null $address = null; + public string|null $name = null; + +} diff --git a/shared/lib/Chrono/Event/EventParticipantCollection.php b/shared/lib/Chrono/Event/EventParticipantCollection.php new file mode 100644 index 0000000..c5b9031 --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventParticipantCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventParticipantObject::class, 'string'); + } + +} diff --git a/shared/lib/Chrono/Event/EventParticipantObject.php b/shared/lib/Chrono/Event/EventParticipantObject.php new file mode 100644 index 0000000..353f83f --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantObject.php @@ -0,0 +1,31 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableObject; + +class EventParticipantObject extends JsonSerializableObject { + + public string|null $identifier = null; + public EventParticipantRealm|null $realm = null; // E - external, I - internal + public string|null $name = null; + public string|null $description = null; + public string|null $language = null; + public string|null $address = null; + public EventParticipantTypes|null $type = null; + public EventParticipantStatusTypes|null $status = null; + public string|null $comment = null; + public EventParticipantRoleCollection $roles; + + public function __construct() { + $this->roles = new EventParticipantRoleCollection(); + } + +} diff --git a/shared/lib/Chrono/Event/EventParticipantRealm.php b/shared/lib/Chrono/Event/EventParticipantRealm.php new file mode 100644 index 0000000..0c8a1ac --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantRealm.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventParticipantRealm: string { + case Internal = 'I'; + case External = 'E'; +} diff --git a/shared/lib/Chrono/Event/EventParticipantRoleCollection.php b/shared/lib/Chrono/Event/EventParticipantRoleCollection.php new file mode 100644 index 0000000..2405108 --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantRoleCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventParticipantRoleCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, EventParticipantRoleTypes::class); + } + +} diff --git a/shared/lib/Chrono/Event/EventParticipantRoleTypes.php b/shared/lib/Chrono/Event/EventParticipantRoleTypes.php new file mode 100644 index 0000000..7d3e70c --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantRoleTypes.php @@ -0,0 +1,19 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventParticipantRoleTypes: string { + case Owner = 'owner'; + case Chair = 'chair'; + case Attendee = 'attendee'; + case Optional = 'optional'; + case Informational = 'informational'; + case Contact = 'contact'; +} diff --git a/shared/lib/Chrono/Event/EventParticipantStatusTypes.php b/shared/lib/Chrono/Event/EventParticipantStatusTypes.php new file mode 100644 index 0000000..8925c9c --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantStatusTypes.php @@ -0,0 +1,18 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventParticipantStatusTypes: string { + case None = 'none'; + case Accepted = 'accepted'; + case Declined = 'declined'; + case Tentative = 'tentative'; + case Delegated = 'delegated'; +} diff --git a/shared/lib/Chrono/Event/EventParticipantTypes.php b/shared/lib/Chrono/Event/EventParticipantTypes.php new file mode 100644 index 0000000..58e66d9 --- /dev/null +++ b/shared/lib/Chrono/Event/EventParticipantTypes.php @@ -0,0 +1,18 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventParticipantTypes: string { + case Unknown = 'unknown'; + case Individual = 'individual'; + case Group = 'group'; + case Resource = 'resource'; + case Location = 'location'; +} diff --git a/shared/lib/Chrono/Event/EventSensitivityTypes.php b/shared/lib/Chrono/Event/EventSensitivityTypes.php new file mode 100644 index 0000000..55ca876 --- /dev/null +++ b/shared/lib/Chrono/Event/EventSensitivityTypes.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +enum EventSensitivityTypes: string { + case Public = 'public'; + case Private = 'private'; + case Secret = 'secret'; +} diff --git a/shared/lib/Chrono/Event/EventTagCollection.php b/shared/lib/Chrono/Event/EventTagCollection.php new file mode 100644 index 0000000..d1ebcd4 --- /dev/null +++ b/shared/lib/Chrono/Event/EventTagCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Event; + +use KTXF\Json\JsonSerializableCollection; + +class EventTagCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, 'string'); + } + +} diff --git a/shared/lib/Chrono/Provider/IProviderBase.php b/shared/lib/Chrono/Provider/IProviderBase.php new file mode 100644 index 0000000..d65d3d9 --- /dev/null +++ b/shared/lib/Chrono/Provider/IProviderBase.php @@ -0,0 +1,96 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Provider; + +use JsonSerializable; +use KTXF\Chrono\Service\IServiceBase; + +interface IProviderBase extends JsonSerializable { + + public const CAPABILITY_SERVICE_LIST = 'ServiceList'; + public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch'; + public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant'; + + public const JSON_TYPE = 'chrono.provider'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + + /** + * Confirms if specific capability is supported (e.g. 'ServiceList') + * + * @since 2025.05.01 + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else) + * + * @since 2025.05.01 + */ + public function id(): string; + + /** + * The localized human friendly name of this provider (e.g. System Calendar Provider) + * + * @since 2025.05.01 + */ + public function label(): string; + + /** + * Retrieve collection of services for a specific user + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param array $filter filter criteria + * + * @return array collection of service objects + */ + public function serviceList(string $tenantId, string $userId, array $filter): array; + + /** + * Determine if any services are configured for a specific user + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param int|string ...$identifiers variadic collection of service identifiers + * + * @return array collection of service identifiers with boolean values indicating if the service is available + */ + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array; + + /** + * Retrieve a service with a specific identifier + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $identifier service identifier + * + * @return IServiceBase|null returns service object or null if non found + */ + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase; + +} diff --git a/shared/lib/Chrono/Provider/IProviderServiceMutate.php b/shared/lib/Chrono/Provider/IProviderServiceMutate.php new file mode 100644 index 0000000..6a90e27 --- /dev/null +++ b/shared/lib/Chrono/Provider/IProviderServiceMutate.php @@ -0,0 +1,50 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Provider; + +use KTXF\Json\JsonDeserializable; +use KTXF\Chrono\Service\IServiceBase; + +interface IProviderServiceMutate extends JsonDeserializable { + + public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh'; + public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate'; + public const CAPABILITY_SERVICE_UPDATE = 'ServiceUpdate'; + public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy'; + + /** + * construct and new blank service instance + * + * @since 2025.05.01 + */ + public function serviceFresh(string $uid = ''): IServiceBase; + + /** + * create a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceCreate(string $uid, IServiceBase $service): string; + + /** + * modify a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceModify(string $uid, IServiceBase $service): string; + + /** + * delete a service configuration for a specific user + * + * @since 2025.05.01 + */ + public function serviceDestroy(string $uid, IServiceBase $service): bool; + +} diff --git a/shared/lib/Chrono/Service/IServiceBase.php b/shared/lib/Chrono/Service/IServiceBase.php new file mode 100644 index 0000000..424fd4f --- /dev/null +++ b/shared/lib/Chrono/Service/IServiceBase.php @@ -0,0 +1,197 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Service; + +use JsonSerializable; +use KTXF\Chrono\Collection\ICollectionBase; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; + +interface IServiceBase extends JsonSerializable { + + public const CAPABILITY_COLLECTION_LIST = 'CollectionList'; + public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter'; + public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort'; + public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant'; + public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch'; + + public const CAPABILITY_ENTITY_LIST = 'EntityList'; + public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter'; + public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort'; + public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange'; + public const CAPABILITY_ENTITY_DELTA = 'EntityDelta'; + public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant'; + public const CAPABILITY_ENTITY_FETCH = 'EntityFetch'; + + public const CAPABILITY_FILTER_ANY = '*'; + public const CAPABILITY_FILTER_ID = 'id'; + public const CAPABILITY_FILTER_URID = 'urid'; + public const CAPABILITY_FILTER_LABEL = 'label'; + public const CAPABILITY_FILTER_DESCRIPTION = 'description'; + + public const CAPABILITY_SORT_ID = 'id'; + public const CAPABILITY_SORT_URID = 'urid'; + public const CAPABILITY_SORT_LABEL = 'label'; + public const CAPABILITY_SORT_PRIORITY = 'priority'; + + public const CAPABILITY_RANGE_TALLY = 'tally'; + public const CAPABILITY_RANGE_TALLY_ABSOLUTE = 'absolute'; + public const CAPABILITY_RANGE_TALLY_RELATIVE = 'relative'; + public const CAPABILITY_RANGE_DATE = 'date'; + + public const JSON_TYPE = 'chrono.service'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + + /** + * Confirms if specific capability is supported + * + * @since 2025.05.01 + * + * @param string $value required ability e.g. 'EntityList' + * + * @return bool + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * Unique identifier of the provider this service belongs to + * + * @since 2025.05.01 + */ + public function in(): string; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the localized human friendly name of this service (e.g. ACME Company Calendar Service) + * + * @since 2025.05.01 + */ + public function getLabel(): string; + + /** + * Gets the active status of this service + * + * @since 2025.05.01 + */ + public function getEnabled(): bool; + + /** + * Retrieve collection of collections for this service + * + * @since 2025.05.01 + */ + public function collectionList(?IFilter $filter = null, ?ISort $sort = null): array; + + /** + * Retrieve filter object for collection list + * + * @since 2025.05.01 + */ + public function collectionListFilter(): IFilter; + + /** + * Retrieve sort object for collection list + * + * @since 2025.05.01 + */ + public function collectionListSort(): ISort; + + /** + * Determine if a collection exists + * + * @since 2025.05.01 + */ + public function collectionExtant(string|int $identifier): bool; + + /** + * Retrieve a specific collection + * + * @since 2025.05.01 + */ + public function collectionFetch(string|int $identifier): ?ICollectionBase; + + /** + * Retrieve collection of entities from a specific collection + * + * @since 2025.05.01 + */ + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $elements = null): array; + + /** + * Retrieve filter object for entity list + * + * @since 2025.05.01 + */ + public function entityListFilter(): IFilter; + + /** + * Retrieve sort object for entity list + * + * @since 2025.05.01 + */ + public function entityListSort(): ISort; + + /** + * Retrieve range object for entity list + * + * @since 2025.05.01 + */ + public function entityListRange(RangeType $type): IRange; + + /** + * Retrieve collection of entities that have changed since a given signature + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param string $signature signature to compare against + * @param string $detail level of detail to return (ids, full, etc) + * + * @return array collection of entities or entity identifiers + */ + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): array; + + /** + * Determine if entities exist in a specific collection + * + * @since 2025.05.01 + */ + public function entityExtant(string|int $collection, string|int ...$identifiers): array; + + /** + * Retrieve specific entities from a specific collection + * + * @since 2025.05.01 + */ + public function entityFetch(string|int $collection, string|int ...$identifiers): array; + +} diff --git a/shared/lib/Chrono/Service/IServiceCollectionMutable.php b/shared/lib/Chrono/Service/IServiceCollectionMutable.php new file mode 100644 index 0000000..78f6413 --- /dev/null +++ b/shared/lib/Chrono/Service/IServiceCollectionMutable.php @@ -0,0 +1,79 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Service; + +use KTXF\Chrono\Collection\ICollectionBase; +use KTXF\Chrono\Collection\ICollectionMutable; + +interface IServiceCollectionMutable extends IServiceBase { + + public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate'; + public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify'; + public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy'; + public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove'; + + /** + * Creates a new, empty collection object + * + * @since 2025.05.01 + * + * @return ICollectionMutable + */ + public function collectionFresh(): ICollectionMutable; + + /** + * Creates a new collection at the specified location + * + * @since 2025.05.01 + * + * @param string $location The parent collection to create this collection in, or empty string for root + * @param ICollectionMutable $collection The collection to create + * @param array $options Additional options for the collection creation + * + * @return ICollectionBase + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionCreate(string|int $location, ICollectionMutable $collection, array $options): ICollectionBase; + + /** + * Modifies an existing collection + * + * @since 2025.05.01 + * + * @param string $identifier The ID of the collection to modify + * @param ICollectionMutable $collection The collection with modifications + * + * @return ICollectionBase + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionModify(string|int $identifier, ICollectionMutable $collection): ICollectionBase; + + /** + * Destroys an existing collection + * + * @since 2025.05.01 + * + * @param string $identifier The ID of the collection to destroy + * + * @return bool + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionDestroy(string|int $identifier): bool; + +} diff --git a/shared/lib/Chrono/Service/IServiceEntityMutable.php b/shared/lib/Chrono/Service/IServiceEntityMutable.php new file mode 100644 index 0000000..ebe4575 --- /dev/null +++ b/shared/lib/Chrono/Service/IServiceEntityMutable.php @@ -0,0 +1,81 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Service; + +use KTXF\Chrono\Entity\IEntityMutable; + +interface IServiceEntityMutable extends IServiceBase { + + public const CAPABILITY_ENTITY_CREATE = 'EntityCreate'; + public const CAPABILITY_ENTITY_MODIFY = 'EntityModify'; + public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy'; + public const CAPABILITY_ENTITY_COPY = 'EntityCopy'; + public const CAPABILITY_ENTITY_MOVE = 'EntityMove'; + + /** + * Creates a fresh entity of the specified type + * + * @since 2025.05.01 + * + * @return IEntityMutable + */ + public function entityFresh(): IEntityMutable; + + /** + * Creates a new entity in the specified collection + * + * @since 2025.05.01 + * + * @param string $collection The collection to create this entity in + * @param IEntityMutable $entity The entity to create + * @param array $options Additional options for the entity creation + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityCreate(string|int $collection, IEntityMutable $entity, array $options): IEntityMutable; + + /** + * Modifies an existing entity in the specified collection + * + * @since 2025.05.01 + * + * @param string $collection The collection containing the entity to modify + * @param string $identifier The ID of the entity to modify + * @param IEntityMutable $entity The entity with modifications + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityModify(string|int $collection, string|int $identifier, IEntityMutable $entity): IEntityMutable; + + /** + * Destroys an existing entity in the specified collection + * + * @since 2025.05.01 + * + * @param string $collection The collection containing the entity to destroy + * @param string $identifier The ID of the entity to destroy + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityDestroy(string|int $collection, string|int $identifier): IEntityMutable; + +} diff --git a/shared/lib/Chrono/Service/IServiceMutable.php b/shared/lib/Chrono/Service/IServiceMutable.php new file mode 100644 index 0000000..02ece43 --- /dev/null +++ b/shared/lib/Chrono/Service/IServiceMutable.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Chrono\Service; + +use KTXF\Json\JsonDeserializable; + +interface IServiceMutable extends IServiceBase, JsonDeserializable { + + /** + * Sets the localized human friendly name of this service (e.g. ACME Company Calendar Service) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self; + + /** + * Sets the active status of this service + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self; + +} diff --git a/shared/lib/Controller/ControllerAbstract.php b/shared/lib/Controller/ControllerAbstract.php new file mode 100644 index 0000000..53840eb --- /dev/null +++ b/shared/lib/Controller/ControllerAbstract.php @@ -0,0 +1,8 @@ +data = $data; + $this->timestamp = microtime(true); + } + + /** + * Get the event name + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get a data value by key + */ + public function get(string $key, mixed $default = null): mixed + { + return $this->data[$key] ?? $default; + } + + /** + * Set a data value + */ + public function set(string $key, mixed $value): self + { + $this->data[$key] = $value; + return $this; + } + + /** + * Check if a data key exists + */ + public function has(string $key): bool + { + return array_key_exists($key, $this->data); + } + + /** + * Get all data + */ + public function getData(): array + { + return $this->data; + } + + /** + * Alias for getData() for backward compatibility + */ + public function all(): array + { + return $this->data; + } + + /** + * Get the event timestamp + */ + public function getTimestamp(): float + { + return $this->timestamp; + } + + /** + * Stop event propagation to subsequent listeners + */ + public function stopPropagation(): void + { + $this->propagationStopped = true; + } + + /** + * Check if propagation is stopped + */ + public function isPropagationStopped(): bool + { + return $this->propagationStopped; + } + + /** + * Get tenant ID for multi-tenant context + */ + public function getTenantId(): ?string + { + return $this->tenantId; + } + + /** + * Set tenant ID for multi-tenant context + */ + public function setTenantId(?string $tenantId): self + { + $this->tenantId = $tenantId; + return $this; + } + + /** + * Get identity ID (user who triggered the event) + */ + public function getIdentityId(): ?string + { + return $this->identityId; + } + + /** + * Set identity ID + */ + public function setIdentityId(?string $identityId): self + { + $this->identityId = $identityId; + return $this; + } + + /** + * Convert event to array for serialization/logging + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'data' => $this->data, + 'timestamp' => $this->timestamp, + 'tenantId' => $this->tenantId, + 'identityId' => $this->identityId, + ]; + } +} \ No newline at end of file diff --git a/shared/lib/Event/EventBus.php b/shared/lib/Event/EventBus.php new file mode 100644 index 0000000..750a2c7 --- /dev/null +++ b/shared/lib/Event/EventBus.php @@ -0,0 +1,186 @@ +> */ + private array $listeners = []; + + /** @var array> */ + private array $asyncListeners = []; + + /** @var Event[] */ + private array $deferredEvents = []; + + /** + * Subscribe to an event with optional priority + * Higher priority listeners are called first + */ + public function subscribe(string $eventName, callable $listener, int $priority = 0): self + { + $this->listeners[$eventName][] = [ + 'callback' => $listener, + 'priority' => $priority, + ]; + + // Sort by priority (higher first) + usort( + $this->listeners[$eventName], + fn($a, $b) => $b['priority'] <=> $a['priority'] + ); + + return $this; + } + + /** + * Subscribe to an event for async/deferred processing + * These handlers run at the end of the request cycle + */ + public function subscribeAsync(string $eventName, callable $listener): self + { + $this->asyncListeners[$eventName][] = $listener; + return $this; + } + + /** + * Unsubscribe a listener from an event + */ + public function unsubscribe(string $eventName, callable $listener): self + { + if (isset($this->listeners[$eventName])) { + $this->listeners[$eventName] = array_filter( + $this->listeners[$eventName], + fn($item) => $item['callback'] !== $listener + ); + } + + if (isset($this->asyncListeners[$eventName])) { + $this->asyncListeners[$eventName] = array_filter( + $this->asyncListeners[$eventName], + fn($item) => $item !== $listener + ); + } + + return $this; + } + + /** + * Publish an event to all subscribers + */ + public function publish(Event $event): self + { + $eventName = $event->getName(); + + // Execute synchronous listeners + if (isset($this->listeners[$eventName])) { + foreach ($this->listeners[$eventName] as $listenerData) { + if ($event->isPropagationStopped()) { + break; + } + + try { + call_user_func($listenerData['callback'], $event); + } catch (\Throwable $e) { + // Log error but don't break the chain + error_log(sprintf( + 'Event listener error for %s: %s', + $eventName, + $e->getMessage() + )); + } + } + } + + // Queue for async processing if there are async listeners + if (isset($this->asyncListeners[$eventName]) && !empty($this->asyncListeners[$eventName])) { + $this->deferredEvents[] = $event; + } + + return $this; + } + + /** + * Process deferred/async events + * Call this at the end of the request cycle + */ + public function processDeferred(): int + { + $processed = 0; + + foreach ($this->deferredEvents as $event) { + $eventName = $event->getName(); + + if (!isset($this->asyncListeners[$eventName])) { + continue; + } + + foreach ($this->asyncListeners[$eventName] as $listener) { + try { + call_user_func($listener, $event); + $processed++; + } catch (\Throwable $e) { + // Log but don't fail - these are non-critical + error_log(sprintf( + 'Async event handler error for %s: %s', + $eventName, + $e->getMessage() + )); + } + } + } + + $this->deferredEvents = []; + + return $processed; + } + + /** + * Check if an event has any listeners + */ + public function hasListeners(string $eventName): bool + { + return !empty($this->listeners[$eventName]) || !empty($this->asyncListeners[$eventName]); + } + + /** + * Get count of listeners for an event + */ + public function getListenerCount(string $eventName): int + { + $sync = isset($this->listeners[$eventName]) ? count($this->listeners[$eventName]) : 0; + $async = isset($this->asyncListeners[$eventName]) ? count($this->asyncListeners[$eventName]) : 0; + + return $sync + $async; + } + + /** + * Get count of pending deferred events + */ + public function getDeferredCount(): int + { + return count($this->deferredEvents); + } + + /** + * Clear all listeners (useful for testing) + */ + public function clear(): self + { + $this->listeners = []; + $this->asyncListeners = []; + $this->deferredEvents = []; + + return $this; + } +} diff --git a/shared/lib/Event/SecurityEvent.php b/shared/lib/Event/SecurityEvent.php new file mode 100644 index 0000000..82e1eba --- /dev/null +++ b/shared/lib/Event/SecurityEvent.php @@ -0,0 +1,303 @@ +ipAddress = $ipAddress; + $event->deviceFingerprint = $deviceFingerprint; + + // Set default severity based on event type + $event->severity = self::getSeverityForEvent($name); + + return $event; + } + + /** + * Create an authentication failure event + */ + public static function authFailure( + string $ipAddress, + ?string $deviceFingerprint = null, + ?string $userId = null, + ?string $reason = null + ): self { + $event = self::create(self::AUTH_FAILURE, $ipAddress, $deviceFingerprint, [ + 'userId' => $userId, + 'reason' => $reason, + ]); + $event->userId = $userId; + $event->reason = $reason; + return $event; + } + + /** + * Create an authentication success event + */ + public static function authSuccess( + string $ipAddress, + ?string $deviceFingerprint = null, + string $userId = null + ): self { + $event = self::create(self::AUTH_SUCCESS, $ipAddress, $deviceFingerprint, [ + 'userId' => $userId, + ]); + $event->userId = $userId; + return $event; + } + + /** + * Create a brute force detection event + */ + public static function bruteForceDetected( + string $ipAddress, + int $failureCount, + int $windowSeconds + ): self { + $event = self::create(self::BRUTE_FORCE_DETECTED, $ipAddress, null, [ + 'failureCount' => $failureCount, + 'windowSeconds' => $windowSeconds, + ]); + $event->reason = sprintf( + '%d failed attempts in %d seconds', + $failureCount, + $windowSeconds + ); + return $event; + } + + /** + * Create a rate limit exceeded event + */ + public static function rateLimitExceeded( + string $ipAddress, + int $requestCount, + int $windowSeconds, + ?string $endpoint = null + ): self { + $event = self::create(self::RATE_LIMIT_EXCEEDED, $ipAddress, null, [ + 'requestCount' => $requestCount, + 'windowSeconds' => $windowSeconds, + 'endpoint' => $endpoint, + ]); + $event->requestPath = $endpoint; + $event->reason = sprintf( + '%d requests in %d seconds', + $requestCount, + $windowSeconds + ); + return $event; + } + + /** + * Create an access denied event + */ + public static function accessDenied( + string $ipAddress, + ?string $deviceFingerprint = null, + ?string $ruleId = null, + ?string $reason = null + ): self { + $event = self::create(self::ACCESS_DENIED, $ipAddress, $deviceFingerprint, [ + 'ruleId' => $ruleId, + 'reason' => $reason, + ]); + $event->reason = $reason; + return $event; + } + + /** + * Get default severity for event types + */ + private static function getSeverityForEvent(string $eventName): int + { + return match ($eventName) { + self::AUTH_SUCCESS, + self::ACCESS_GRANTED, + self::TOKEN_REFRESH => self::SEVERITY_INFO, + + self::AUTH_FAILURE, + self::ACCESS_DENIED, + self::AUTH_LOGOUT, + self::TOKEN_REVOKED => self::SEVERITY_WARNING, + + self::RATE_LIMIT_EXCEEDED, + self::SUSPICIOUS_ACTIVITY => self::SEVERITY_ERROR, + + self::BRUTE_FORCE_DETECTED, + self::IP_BLOCKED, + self::DEVICE_BLOCKED => self::SEVERITY_CRITICAL, + + default => self::SEVERITY_INFO, + }; + } + + // Getters and setters + + public function getIpAddress(): ?string + { + return $this->ipAddress; + } + + public function setIpAddress(?string $ipAddress): self + { + $this->ipAddress = $ipAddress; + return $this; + } + + public function getDeviceFingerprint(): ?string + { + return $this->deviceFingerprint; + } + + public function setDeviceFingerprint(?string $deviceFingerprint): self + { + $this->deviceFingerprint = $deviceFingerprint; + return $this; + } + + public function getUserAgent(): ?string + { + return $this->userAgent; + } + + public function setUserAgent(?string $userAgent): self + { + $this->userAgent = $userAgent; + return $this; + } + + public function getRequestPath(): ?string + { + return $this->requestPath; + } + + public function setRequestPath(?string $requestPath): self + { + $this->requestPath = $requestPath; + return $this; + } + + public function getRequestMethod(): ?string + { + return $this->requestMethod; + } + + public function setRequestMethod(?string $requestMethod): self + { + $this->requestMethod = $requestMethod; + return $this; + } + + public function getUserId(): ?string + { + return $this->userId; + } + + public function setUserId(?string $userId): self + { + $this->userId = $userId; + return $this; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function setReason(?string $reason): self + { + $this->reason = $reason; + return $this; + } + + public function getSeverity(): int + { + return $this->severity; + } + + public function setSeverity(int $severity): self + { + $this->severity = $severity; + return $this; + } + + public function getSeverityLabel(): string + { + return match ($this->severity) { + self::SEVERITY_DEBUG => 'DEBUG', + self::SEVERITY_INFO => 'INFO', + self::SEVERITY_WARNING => 'WARNING', + self::SEVERITY_ERROR => 'ERROR', + self::SEVERITY_CRITICAL => 'CRITICAL', + default => 'UNKNOWN', + }; + } + + /** + * Override toArray to include security-specific fields + */ + public function toArray(): array + { + return array_merge(parent::toArray(), [ + 'ipAddress' => $this->ipAddress, + 'deviceFingerprint' => $this->deviceFingerprint, + 'userAgent' => $this->userAgent, + 'requestPath' => $this->requestPath, + 'requestMethod' => $this->requestMethod, + 'userId' => $this->userId, + 'reason' => $this->reason, + 'severity' => $this->severity, + 'severityLabel' => $this->getSeverityLabel(), + ]); + } +} diff --git a/shared/lib/Exception/BaseException.php b/shared/lib/Exception/BaseException.php new file mode 100644 index 0000000..2ad830f --- /dev/null +++ b/shared/lib/Exception/BaseException.php @@ -0,0 +1,175 @@ +message = $message; + $this->code = $code; + $this->previous = $previous; + + // Capture backtrace; first element is this constructor call site + $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); + if (!empty($bt)) { + $first = $bt[0]; + $this->file = $first['file'] ?? 'unknown'; + $this->line = $first['line'] ?? 0; + } + // Exclude current frame for readability + $this->trace = array_slice($bt, 1); + } + + /** + * Clone the exception + * Tries to clone the Exception, which results in Fatal error. + * @link https://php.net/manual/en/exception.clone.php + * @return void + */ + public function __clone(): void + { + // Mimic internal Exception behavior: cloning not allowed. + trigger_error('Trying to clone an uncloneable object of class ' . static::class, E_USER_ERROR); + } + + /** + * String representation of the exception + * @link https://php.net/manual/en/exception.tostring.php + * @return string the string representation of the exception. + */ + public function __toString(): string + { + return sprintf( + "%s: %s in %s:%d\nStack trace:\n%s", + static::class, + $this->getMessage(), + $this->getFile(), + $this->getLine(), + $this->getTraceAsString() + ); + } + + public function __wakeup(): void + { + // On wakeup we don't have original trace; reset trace to empty + $this->trace = []; + } + + /** + * Gets the Exception message + * @link https://php.net/manual/en/exception.getmessage.php + * @return string the Exception message as a string. + */ + final public function getMessage(): string + { + return $this->message; + } + + /** + * Gets the Exception code + * @link https://php.net/manual/en/exception.getcode.php + * @return mixed|int the exception code as integer in + * Exception but possibly as other type in + * Exception descendants (for example as + * string in PDOException). + */ + final public function getCode(): int + { + return $this->code; + } + + /** + * Gets the file in which the exception occurred + * @link https://php.net/manual/en/exception.getfile.php + * @return string the filename in which the exception was created. + */ + final public function getFile(): string + { + return $this->file; + } + + /** + * Gets the line in which the exception occurred + * @link https://php.net/manual/en/exception.getline.php + * @return int the line number where the exception was created. + */ + final public function getLine(): int + { + return $this->line; + } + + /** + * Gets the stack trace + * @link https://php.net/manual/en/exception.gettrace.php + * @return array the Exception stack trace as an array. + */ + final public function getTrace(): array + { + return $this->trace; + } + + /** + * Returns previous Exception + * @link https://php.net/manual/en/exception.getprevious.php + * @return null|Throwable Returns the previous {@see Throwable} if available, or NULL otherwise. + * or null otherwise. + */ + final public function getPrevious(): ?Throwable + { + return $this->previous; + } + + /** + * Gets the stack trace as a string + * @link https://php.net/manual/en/exception.gettraceasstring.php + * @return string the Exception stack trace as a string. + */ + final public function getTraceAsString(): string + { + $lines = []; + foreach ($this->trace as $i => $frame) { + $file = $frame['file'] ?? '[internal function]'; + $line = $frame['line'] ?? 0; + $func = $frame['function'] ?? ''; + $class = $frame['class'] ?? ''; + $type = $frame['type'] ?? ''; + $lines[] = sprintf('#%d %s(%s): %s%s%s()', $i, $file, $line, $class, $type, $func); + } + $lines[] = sprintf('#%d {main}', count($lines)); + return implode("\n", $lines); + } + +} \ No newline at end of file diff --git a/shared/lib/Exception/RuntimeException.php b/shared/lib/Exception/RuntimeException.php new file mode 100644 index 0000000..87bc568 --- /dev/null +++ b/shared/lib/Exception/RuntimeException.php @@ -0,0 +1,9 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +use DateTimeImmutable; + +interface INodeBase extends \JsonSerializable { + + public const JSON_TYPE = 'files.node'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_CREATED_ON = 'createdOn'; + public const JSON_PROPERTY_CREATED_BY = 'createdBy'; + public const JSON_PROPERTY_MODIFIED_ON = 'modifiedOn'; + public const JSON_PROPERTY_MODIFIED_BY = 'modifiedBy'; + public const JSON_PROPERTY_OWNER = 'owner'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + public const JSON_PROPERTY_LABEL = 'label'; + + /** + * Unique identifier of the parent node (folder) this node belongs to + * + * @since 2025.11.01 + */ + public function in(): string|int|null; + + /** + * Unique identifier of this node + * + * @since 2025.11.01 + */ + public function id(): string|int; + + /** + * Node type (collection or entity) + * + * @since 2025.11.01 + */ + public function type(): NodeType; + + /** + * Creator user ID + * + * @since 2025.11.01 + */ + public function createdBy(): string|null; + + /** + * Creation timestamp + * + * @since 2025.11.01 + */ + public function createdOn(): DateTimeImmutable|null; + + /** + * Last modifier user ID + * + * @since 2025.11.01 + */ + public function modifiedBy(): string|null; + + /** + * Last modification timestamp + * + * @since 2025.11.01 + */ + public function modifiedOn(): DateTimeImmutable|null; + + /** + * Signature/etag for sync and caching + * + * @since 2025.11.01 + */ + public function signature(): string|null; + + /** + * Check if this node is a collection (folder) + * + * @since 2025.11.01 + */ + public function isCollection(): bool; + + /** + * Check if this node is an entity (file) + * + * @since 2025.11.01 + */ + public function isEntity(): bool; + + /** + * Human-readable name/label of this node + * + * @since 2025.11.01 + */ + public function getLabel(): string|null; + +} diff --git a/shared/lib/Files/Node/INodeCollectionBase.php b/shared/lib/Files/Node/INodeCollectionBase.php new file mode 100644 index 0000000..b69a8cf --- /dev/null +++ b/shared/lib/Files/Node/INodeCollectionBase.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +/** + * Interface for collection (folder) nodes + * + * Collections are containers that can hold other nodes (both collections and entities). + * They inherit common properties from INodeBase and add collection-specific properties. + */ +interface INodeCollectionBase extends INodeBase { + + public const JSON_TYPE = 'files.collection'; + +} diff --git a/shared/lib/Files/Node/INodeCollectionMutable.php b/shared/lib/Files/Node/INodeCollectionMutable.php new file mode 100644 index 0000000..e1e44fc --- /dev/null +++ b/shared/lib/Files/Node/INodeCollectionMutable.php @@ -0,0 +1,35 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +/** + * Interface for mutable collection (folder) nodes + */ +interface INodeCollectionMutable extends INodeCollectionBase { + + /** + * Deserialize from JSON data + * + * @since 2025.11.01 + * + * @param array|string $data JSON data to deserialize + * + * @return static + */ + public function jsonDeserialize(array|string $data): static; + + /** + * Sets the human-readable name/label of this collection + * + * @since 2025.11.01 + */ + public function setLabel(string $value): static; + +} diff --git a/shared/lib/Files/Node/INodeEntityBase.php b/shared/lib/Files/Node/INodeEntityBase.php new file mode 100644 index 0000000..d6bac3d --- /dev/null +++ b/shared/lib/Files/Node/INodeEntityBase.php @@ -0,0 +1,54 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +/** + * Interface for entity (file) nodes + * + * Entities are leaf nodes that contain actual file data. + * They inherit common properties from INodeBase and add file-specific properties. + */ +interface INodeEntityBase extends INodeBase { + + public const JSON_TYPE = 'files.entity'; + public const JSON_PROPERTY_SIZE = 'size'; + public const JSON_PROPERTY_MIME = 'mime'; + public const JSON_PROPERTY_FORMAT = 'format'; + public const JSON_PROPERTY_ENCODING = 'encoding'; + + /** + * File size in bytes + * + * @since 2025.11.01 + */ + public function size(): int; + + /** + * MIME type of the file (e.g., 'application/pdf', 'image/png') + * + * @since 2025.11.01 + */ + public function getMime(): string|null; + + /** + * File format/extension (e.g., 'pdf', 'png', 'txt') + * + * @since 2025.11.01 + */ + public function getFormat(): string|null; + + /** + * Character encoding (e.g., 'utf-8') + * + * @since 2025.11.01 + */ + public function getEncoding(): string|null; + +} diff --git a/shared/lib/Files/Node/INodeEntityMutable.php b/shared/lib/Files/Node/INodeEntityMutable.php new file mode 100644 index 0000000..520bd24 --- /dev/null +++ b/shared/lib/Files/Node/INodeEntityMutable.php @@ -0,0 +1,56 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +/** + * Interface for mutable entity (file) nodes + */ +interface INodeEntityMutable extends INodeEntityBase { + + /** + * Deserialize from JSON data + * + * @since 2025.11.01 + * + * @param array|string $data JSON data to deserialize + * + * @return static + */ + public function jsonDeserialize(array|string $data): static; + + /** + * Sets the human-readable name/label of this entity + * + * @since 2025.11.01 + */ + public function setLabel(string $value): static; + + /** + * Sets the MIME type of the file + * + * @since 2025.11.01 + */ + public function setMime(string $value): static; + + /** + * Sets the file format/extension + * + * @since 2025.11.01 + */ + public function setFormat(string $value): static; + + /** + * Sets the character encoding + * + * @since 2025.11.01 + */ + public function setEncoding(string $value): static; + +} diff --git a/shared/lib/Files/Node/NodeType.php b/shared/lib/Files/Node/NodeType.php new file mode 100644 index 0000000..b09ba5c --- /dev/null +++ b/shared/lib/Files/Node/NodeType.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Node; + +use JsonSerializable; + +enum NodeType: string implements JsonSerializable { + + case Collection = 'C'; + case Entity = 'E'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Files/Provider/IProviderBase.php b/shared/lib/Files/Provider/IProviderBase.php new file mode 100644 index 0000000..b1313fd --- /dev/null +++ b/shared/lib/Files/Provider/IProviderBase.php @@ -0,0 +1,96 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Provider; + +use JsonSerializable; +use KTXF\Files\Service\IServiceBase; + +interface IProviderBase extends JsonSerializable { + + public const CAPABILITY_SERVICE_LIST = 'ServiceList'; + public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch'; + public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant'; + + public const JSON_TYPE = 'files.provider'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + + /** + * Confirms if specific capability is supported (e.g. 'ServiceList') + * + * @since 2025.11.01 + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.11.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else) + * + * @since 2025.11.01 + */ + public function id(): string; + + /** + * The localized human friendly name of this provider (e.g. System File Provider) + * + * @since 2025.11.01 + */ + public function label(): string; + + /** + * Retrieve collection of services for a specific user + * + * @since 2025.11.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param array $filter filter criteria + * + * @return array collection of service objects + */ + public function serviceList(string $tenantId, string $userId, array $filter): array; + + /** + * Determine if any services are configured for a specific user + * + * @since 2025.11.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param int|string ...$identifiers variadic collection of service identifiers + * + * @return array collection of service identifiers with boolean values indicating if the service is available + */ + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array; + + /** + * Retrieve a service with a specific identifier + * + * @since 2025.11.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $identifier service identifier + * + * @return IServiceBase|null returns service object or null if non found + */ + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase; + +} diff --git a/shared/lib/Files/Service/IServiceBase.php b/shared/lib/Files/Service/IServiceBase.php new file mode 100644 index 0000000..92143e7 --- /dev/null +++ b/shared/lib/Files/Service/IServiceBase.php @@ -0,0 +1,333 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Service; + +use JsonSerializable; +use KTXF\Files\Node\INodeBase; +use KTXF\Files\Node\INodeCollectionBase; +use KTXF\Files\Node\INodeEntityBase; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; + +interface IServiceBase extends JsonSerializable { + + // Collection Capabilities + public const CAPABILITY_COLLECTION_LIST = 'CollectionList'; + public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter'; + public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort'; + public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant'; + public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch'; + + // Entity Capabilities + public const CAPABILITY_ENTITY_LIST = 'EntityList'; + public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter'; + public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort'; + public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange'; + public const CAPABILITY_ENTITY_DELTA = 'EntityDelta'; + public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant'; + public const CAPABILITY_ENTITY_FETCH = 'EntityFetch'; + public const CAPABILITY_ENTITY_READ = 'EntityRead'; + public const CAPABILITY_ENTITY_READ_STREAM = 'EntityReadStream'; + public const CAPABILITY_ENTITY_READ_CHUNK = 'EntityReadChunk'; + + // Node Capabilities (recursive/unified) + public const CAPABILITY_NODE_LIST = 'NodeList'; + public const CAPABILITY_NODE_LIST_FILTER = 'NodeListFilter'; + public const CAPABILITY_NODE_LIST_SORT = 'NodeListSort'; + public const CAPABILITY_NODE_LIST_RANGE = 'NodeListRange'; + public const CAPABILITY_NODE_DELTA = 'NodeDelta'; + + // JSON Constants + public const JSON_TYPE = 'files.service'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + + /** + * Confirms if specific capability is supported + * + * @since 2025.11.01 + * + * @param string $value required ability e.g. 'EntityList' + * + * @return bool + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.11.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * Unique identifier of the provider this service belongs to + * + * @since 2025.11.01 + */ + public function in(): string; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else) + * + * @since 2025.11.01 + */ + public function id(): string|int; + + /** + * Gets the localized human friendly name of this service (e.g. ACME Company File Service) + * + * @since 2025.11.01 + */ + public function getLabel(): string; + + /** + * Gets the active status of this service + * + * @since 2025.11.01 + */ + public function getEnabled(): bool; + + // ==================== Collection Methods ==================== + + /** + * List of accessible collections at a specific location + * + * @since 2025.11.01 + * + * @param string|int|null $location Parent collection identifier, null for root + * + * @return array + */ + public function collectionList(string|int|null $location = null, ?IFilter $filter = null, ?ISort $sort = null): array; + + /** + * Fresh filter for collection list + * + * @since 2025.11.01 + */ + public function collectionListFilter(): IFilter; + + /** + * Fresh sort for collection list + * + * @since 2025.11.01 + */ + public function collectionListSort(): ISort; + + /** + * Confirms if specific collection exists + * + * @since 2025.11.01 + * + * @param string|int|null $identifier Collection identifier + */ + public function collectionExtant(string|int|null $identifier): bool; + + /** + * Fetches details about a specific collection + * + * @since 2025.11.01 + * + * @param string|int|null $identifier Collection identifier + */ + public function collectionFetch(string|int|null $identifier): ?INodeCollectionBase; + + // ==================== Entity Methods ==================== + + /** + * Lists all entities in a specific collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * + * @return array + */ + public function entityList(string|int|null $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array; + + /** + * Fresh filter for entity list + * + * @since 2025.11.01 + */ + public function entityListFilter(): IFilter; + + /** + * Fresh sort for entity list + * + * @since 2025.11.01 + */ + public function entityListSort(): ISort; + + /** + * Fresh range for entity list + * + * @since 2025.11.01 + */ + public function entityListRange(RangeType $type): IRange; + + /** + * Lists all changes from a specific signature + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string $signature Sync token signature + * @param string $detail Detail level: ids | meta | full + * + * @return array{ + * added: array, + * updated: array, + * deleted: array, + * signature: string + * } + */ + public function entityDelta(string|int|null $collection, string $signature, string $detail = 'ids'): array; + + /** + * Confirms if specific entities exist in a collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int ...$identifiers Entity identifiers + * + * @return array + */ + public function entityExtant(string|int|null $collection, string|int ...$identifiers): array; + + /** + * Fetches details about specific entities in a collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int ...$identifiers Entity identifiers + * + * @return array + */ + public function entityFetch(string|int|null $collection, string|int ...$identifiers): array; + + /** + * Reads the entire content of an entity as a string + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * + * @return string|null File content or null if not found + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityRead(string|int|null $collection, string|int $identifier): ?string; + + /** + * Opens a stream to read the content of an entity + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * + * @return resource|null Stream resource or null if not found + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityReadStream(string|int|null $collection, string|int $identifier); + + /** + * Reads a chunk of content from an entity + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param int $offset Starting byte position (0-indexed) + * @param int $length Number of bytes to read + * + * @return string|null Chunk content or null if not found + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityReadChunk(string|int|null $collection, string|int $identifier, int $offset, int $length): ?string; + + // ==================== Node Methods (Recursive/Unified) ==================== + + /** + * Lists all nodes (collections and entities) at a location, optionally recursive + * Returns a flat list with parent references via in() + * + * @since 2025.11.01 + * + * @param string|int|null $location Starting location, null for root + * @param bool $recursive Whether to list recursively + * + * @return array + */ + public function nodeList(string|int|null $location = null, bool $recursive = false, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): array; + + /** + * Fresh filter for node list + * + * @since 2025.11.01 + */ + public function nodeListFilter(): IFilter; + + /** + * Fresh sort for node list + * + * @since 2025.11.01 + */ + public function nodeListSort(): ISort; + + /** + * Fresh range for node list + * + * @since 2025.11.01 + */ + public function nodeListRange(RangeType $type): IRange; + + /** + * Lists all node changes from a specific signature, optionally recursive + * Returns flat list with parent references + * + * @since 2025.11.01 + * + * @param string|int|null $location Starting location, null for root + * @param string $signature Sync token signature + * @param bool $recursive Whether to include recursive changes + * @param string $detail Detail level: ids | meta | full + * + * @return array{ + * added: array|array, + * updated: array|array, + * deleted: array, + * signature: string + * } + */ + public function nodeDelta(string|int|null $location, string $signature, bool $recursive = false, string $detail = 'ids'): array; + +} diff --git a/shared/lib/Files/Service/IServiceCollectionMutable.php b/shared/lib/Files/Service/IServiceCollectionMutable.php new file mode 100644 index 0000000..6b0cf4e --- /dev/null +++ b/shared/lib/Files/Service/IServiceCollectionMutable.php @@ -0,0 +1,100 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Service; + +use KTXF\Files\Node\INodeCollectionBase; +use KTXF\Files\Node\INodeCollectionMutable; + +interface IServiceCollectionMutable extends IServiceBase { + + public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate'; + public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify'; + public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy'; + public const CAPABILITY_COLLECTION_COPY = 'CollectionCopy'; + public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove'; + + /** + * Creates a new, empty collection node + * + * @since 2025.11.01 + */ + public function collectionFresh(): INodeCollectionMutable; + + /** + * Creates a new collection at the specified location + * + * @since 2025.11.01 + * + * @param string|int|null $location Parent collection, null for root + * @param INodeCollectionMutable $collection The collection to create + * @param array $options Additional options + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionCreate(string|int|null $location, INodeCollectionMutable $collection, array $options = []): INodeCollectionBase; + + /** + * Modifies an existing collection + * + * @since 2025.11.01 + * + * @param string|int $identifier Collection identifier + * @param INodeCollectionMutable $collection The collection with modifications + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionModify(string|int $identifier, INodeCollectionMutable $collection): INodeCollectionBase; + + /** + * Destroys an existing collection + * + * @since 2025.11.01 + * + * @param string|int $identifier Collection identifier + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionDestroy(string|int $identifier): bool; + + /** + * Copies an existing collection to a new location + * + * @since 2025.11.01 + * + * @param string|int $identifier Collection identifier + * @param string|int|null $location Destination parent collection, null for root + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionCopy(string|int $identifier, string|int|null $location): INodeCollectionBase; + + /** + * Moves an existing collection to a new location + * + * @since 2025.11.01 + * + * @param string|int $identifier Collection identifier + * @param string|int|null $location Destination parent collection, null for root + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionMove(string|int $identifier, string|int|null $location): INodeCollectionBase; + +} diff --git a/shared/lib/Files/Service/IServiceEntityMutable.php b/shared/lib/Files/Service/IServiceEntityMutable.php new file mode 100644 index 0000000..e505335 --- /dev/null +++ b/shared/lib/Files/Service/IServiceEntityMutable.php @@ -0,0 +1,158 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Service; + +use KTXF\Files\Node\INodeEntityBase; +use KTXF\Files\Node\INodeEntityMutable; + +interface IServiceEntityMutable extends IServiceBase { + + public const CAPABILITY_ENTITY_CREATE = 'EntityCreate'; + public const CAPABILITY_ENTITY_MODIFY = 'EntityModify'; + public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy'; + public const CAPABILITY_ENTITY_COPY = 'EntityCopy'; + public const CAPABILITY_ENTITY_MOVE = 'EntityMove'; + public const CAPABILITY_ENTITY_WRITE = 'EntityWrite'; + public const CAPABILITY_ENTITY_WRITE_STREAM = 'EntityWriteStream'; + public const CAPABILITY_ENTITY_WRITE_CHUNK = 'EntityWriteChunk'; + + /** + * Creates a new, empty entity node + * + * @since 2025.11.01 + */ + public function entityFresh(): INodeEntityMutable; + + /** + * Creates a new entity in the specified collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier, null for root + * @param INodeEntityMutable $entity The entity to create + * @param array $options Additional options + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityCreate(string|int|null $collection, INodeEntityMutable $entity, array $options = []): INodeEntityBase; + + /** + * Modifies an existing entity in the specified collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param INodeEntityMutable $entity The entity with modifications + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityModify(string|int|null $collection, string|int $identifier, INodeEntityMutable $entity): INodeEntityBase; + + /** + * Destroys an existing entity in the specified collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityDestroy(string|int|null $collection, string|int $identifier): bool; + + /** + * Copies an existing entity to a new collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Source collection identifier + * @param string|int $identifier Entity identifier + * @param string|int|null $destination Destination collection identifier, null for root + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityCopy(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase; + + /** + * Moves an existing entity to a new collection + * + * @since 2025.11.01 + * + * @param string|int|null $collection Source collection identifier + * @param string|int $identifier Entity identifier + * @param string|int|null $destination Destination collection identifier, null for root + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityMove(string|int|null $collection, string|int $identifier, string|int|null $destination): INodeEntityBase; + + /** + * Writes the entire content of an entity from a string + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param string $data Content to write + * + * @return int Number of bytes written + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityWrite(string|int|null $collection, string|int $identifier, string $data): int; + + /** + * Opens a stream to write the content of an entity + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * + * @return resource Stream resource for writing + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityWriteStream(string|int|null $collection, string|int $identifier); + + /** + * Writes a chunk of content to an entity at a specific position + * + * @since 2025.11.01 + * + * @param string|int|null $collection Collection identifier + * @param string|int $identifier Entity identifier + * @param int $offset Starting byte position (0-indexed) + * @param string $data Chunk content to write + * + * @return int Number of bytes written + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityWriteChunk(string|int|null $collection, string|int $identifier, int $offset, string $data): int; + +} diff --git a/shared/lib/Files/Service/IServiceMutable.php b/shared/lib/Files/Service/IServiceMutable.php new file mode 100644 index 0000000..43fba83 --- /dev/null +++ b/shared/lib/Files/Service/IServiceMutable.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Files\Service; + +use KTXF\Json\JsonDeserializable; + +interface IServiceMutable extends IServiceBase, JsonDeserializable { + + /** + * Sets the localized human friendly name of this service + * + * @since 2025.11.01 + */ + public function setLabel(string $value): static; + + /** + * Sets the active status of this service + * + * @since 2025.11.01 + */ + public function setEnabled(bool $value): static; + +} diff --git a/shared/lib/IpUtils.php b/shared/lib/IpUtils.php new file mode 100644 index 0000000..df12766 --- /dev/null +++ b/shared/lib/IpUtils.php @@ -0,0 +1,275 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace KTXF; + +/** + * Http utility functions. + * + * @author Fabien Potencier + */ +class IpUtils +{ + public const PRIVATE_SUBNETS = [ + '127.0.0.0/8', // RFC1700 (Loopback) + '10.0.0.0/8', // RFC1918 + '192.168.0.0/16', // RFC1918 + '172.16.0.0/12', // RFC1918 + '169.254.0.0/16', // RFC3927 + '0.0.0.0/8', // RFC5735 + '240.0.0.0/4', // RFC1112 + '::1/128', // Loopback + 'fc00::/7', // Unique Local Address + 'fe80::/10', // Link Local Address + '::ffff:0:0/96', // IPv4 translations + '::/128', // Unspecified address + ]; + + private static array $checkedIps = []; + + /** + * This class should not be instantiated. + */ + private function __construct() + { + } + + /** + * Checks if an IPv4 or IPv6 address is contained in the list of given IPs or subnets. + * + * @param string|array $ips List of IPs or subnets (can be a string if only a single one) + */ + public static function checkIp(string $requestIp, string|array $ips): bool + { + if (!\is_array($ips)) { + $ips = [$ips]; + } + + $method = substr_count($requestIp, ':') > 1 ? 'checkIp6' : 'checkIp4'; + + foreach ($ips as $ip) { + if (self::$method($requestIp, $ip)) { + return true; + } + } + + return false; + } + + /** + * Compares two IPv4 addresses. + * In case a subnet is given, it checks if it contains the request IP. + * + * @param string $ip IPv4 address or subnet in CIDR notation + * + * @return bool Whether the request IP matches the IP, or whether the request IP is within the CIDR subnet + */ + public static function checkIp4(string $requestIp, string $ip): bool + { + $cacheKey = $requestIp.'-'.$ip.'-v4'; + if (null !== $cacheValue = self::getCacheResult($cacheKey)) { + return $cacheValue; + } + + if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) { + return self::setCacheResult($cacheKey, false); + } + + if (str_contains($ip, '/')) { + [$address, $netmask] = explode('/', $ip, 2); + + if ('0' === $netmask) { + return self::setCacheResult($cacheKey, false !== filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)); + } + + if ($netmask < 0 || $netmask > 32) { + return self::setCacheResult($cacheKey, false); + } + } else { + $address = $ip; + $netmask = 32; + } + + if (false === ip2long($address)) { + return self::setCacheResult($cacheKey, false); + } + + return self::setCacheResult($cacheKey, 0 === substr_compare(\sprintf('%032b', ip2long($requestIp)), \sprintf('%032b', ip2long($address)), 0, $netmask)); + } + + /** + * Compares two IPv6 addresses. + * In case a subnet is given, it checks if it contains the request IP. + * + * @author David Soria Parra + * + * @see https://github.com/dsp/v6tools + * + * @param string $ip IPv6 address or subnet in CIDR notation + * + * @throws \RuntimeException When IPV6 support is not enabled + */ + public static function checkIp6(string $requestIp, string $ip): bool + { + $cacheKey = $requestIp.'-'.$ip.'-v6'; + if (null !== $cacheValue = self::getCacheResult($cacheKey)) { + return $cacheValue; + } + + if (!((\extension_loaded('sockets') && \defined('AF_INET6')) || @inet_pton('::1'))) { + throw new \RuntimeException('Unable to check Ipv6. Check that PHP was not compiled with option "disable-ipv6".'); + } + + // Check to see if we were given a IP4 $requestIp or $ip by mistake + if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { + return self::setCacheResult($cacheKey, false); + } + + if (str_contains($ip, '/')) { + [$address, $netmask] = explode('/', $ip, 2); + + if (!filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { + return self::setCacheResult($cacheKey, false); + } + + if ('0' === $netmask) { + return (bool) unpack('n*', @inet_pton($address)); + } + + if ($netmask < 1 || $netmask > 128) { + return self::setCacheResult($cacheKey, false); + } + } else { + if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { + return self::setCacheResult($cacheKey, false); + } + + $address = $ip; + $netmask = 128; + } + + $bytesAddr = unpack('n*', @inet_pton($address)); + $bytesTest = unpack('n*', @inet_pton($requestIp)); + + if (!$bytesAddr || !$bytesTest) { + return self::setCacheResult($cacheKey, false); + } + + for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) { + $left = $netmask - 16 * ($i - 1); + $left = ($left <= 16) ? $left : 16; + $mask = ~(0xFFFF >> $left) & 0xFFFF; + if (($bytesAddr[$i] & $mask) != ($bytesTest[$i] & $mask)) { + return self::setCacheResult($cacheKey, false); + } + } + + return self::setCacheResult($cacheKey, true); + } + + /** + * Anonymizes an IP/IPv6. + * + * Removes the last bytes of IPv4 and IPv6 addresses (1 byte for IPv4 and 8 bytes for IPv6 by default). + * + * @param int<0, 4> $v4Bytes + * @param int<0, 16> $v6Bytes + */ + public static function anonymize(string $ip/* , int $v4Bytes = 1, int $v6Bytes = 8 */): string + { + $v4Bytes = 1 < \func_num_args() ? func_get_arg(1) : 1; + $v6Bytes = 2 < \func_num_args() ? func_get_arg(2) : 8; + + if ($v4Bytes < 0 || $v6Bytes < 0) { + throw new \InvalidArgumentException('Cannot anonymize less than 0 bytes.'); + } + + if ($v4Bytes > 4 || $v6Bytes > 16) { + throw new \InvalidArgumentException('Cannot anonymize more than 4 bytes for IPv4 and 16 bytes for IPv6.'); + } + + /** + * If the IP contains a % symbol, then it is a local-link address with scoping according to RFC 4007 + * In that case, we only care about the part before the % symbol, as the following functions, can only work with + * the IP address itself. As the scope can leak information (containing interface name), we do not want to + * include it in our anonymized IP data. + */ + if (str_contains($ip, '%')) { + $ip = substr($ip, 0, strpos($ip, '%')); + } + + $wrappedIPv6 = false; + if (str_starts_with($ip, '[') && str_ends_with($ip, ']')) { + $wrappedIPv6 = true; + $ip = substr($ip, 1, -1); + } + + $mappedIpV4MaskGenerator = function (string $mask, int $bytesToAnonymize) { + $mask .= str_repeat('ff', 4 - $bytesToAnonymize); + $mask .= str_repeat('00', $bytesToAnonymize); + + return '::'.implode(':', str_split($mask, 4)); + }; + + $packedAddress = inet_pton($ip); + if (4 === \strlen($packedAddress)) { + $mask = rtrim(str_repeat('255.', 4 - $v4Bytes).str_repeat('0.', $v4Bytes), '.'); + } elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff:ffff'))) { + $mask = $mappedIpV4MaskGenerator('ffff', $v4Bytes); + } elseif ($ip === inet_ntop($packedAddress & inet_pton('::ffff:ffff'))) { + $mask = $mappedIpV4MaskGenerator('', $v4Bytes); + } else { + $mask = str_repeat('ff', 16 - $v6Bytes).str_repeat('00', $v6Bytes); + $mask = implode(':', str_split($mask, 4)); + } + $ip = inet_ntop($packedAddress & inet_pton($mask)); + + if ($wrappedIPv6) { + $ip = '['.$ip.']'; + } + + return $ip; + } + + /** + * Checks if an IPv4 or IPv6 address is contained in the list of private IP subnets. + */ + public static function isPrivateIp(string $requestIp): bool + { + return self::checkIp($requestIp, self::PRIVATE_SUBNETS); + } + + private static function getCacheResult(string $cacheKey): ?bool + { + if (isset(self::$checkedIps[$cacheKey])) { + // Move the item last in cache (LRU) + $value = self::$checkedIps[$cacheKey]; + unset(self::$checkedIps[$cacheKey]); + self::$checkedIps[$cacheKey] = $value; + + return self::$checkedIps[$cacheKey]; + } + + return null; + } + + private static function setCacheResult(string $cacheKey, bool $result): bool + { + if (1000 < \count(self::$checkedIps)) { + // stop memory leak if there are many keys + self::$checkedIps = \array_slice(self::$checkedIps, 500, null, true); + } + + return self::$checkedIps[$cacheKey] = $result; + } +} diff --git a/shared/lib/Json/JsonDeserializable.php b/shared/lib/Json/JsonDeserializable.php new file mode 100644 index 0000000..e07af2a --- /dev/null +++ b/shared/lib/Json/JsonDeserializable.php @@ -0,0 +1,11 @@ +getArrayCopy(); + } + + public function jsonDeserialize(array|string $data): static { + + if (is_string($data)) { + $data = json_decode($data, true); + } + + $this->exchangeArray([]); + + if (in_array($this->typeValue, $this->primitiveTypes)) { + if ($this->associative) { + foreach ($data as $key => $value) { + $this[$key] = $value; + } + } else { + foreach ($data as $value) { + $this[] = $value; + } + } + } + + if (!in_array($this->typeValue, $this->primitiveTypes) && class_exists($this->typeValue)) { + $reflection = new \ReflectionClass($this->typeValue); + if ($reflection->implementsInterface(JsonDeserializable::class)) { + if ($this->associative) { + foreach ($data as $key => $value) { + $instance = $reflection->newInstance(); + /** @var JsonDeserializable $instance */ + $this[$key] = $instance->jsonDeserialize($value); + } + } else { + foreach ($data as $value) { + $instance = $reflection->newInstance(); + /** @var JsonDeserializable $instance */ + $this[] = $instance->jsonDeserialize($value); + } + } + } + } + + return $this; + } + +} \ No newline at end of file diff --git a/shared/lib/Json/JsonSerializableObject.php b/shared/lib/Json/JsonSerializableObject.php new file mode 100644 index 0000000..b198dd0 --- /dev/null +++ b/shared/lib/Json/JsonSerializableObject.php @@ -0,0 +1,165 @@ +serializableProperties)) { + $vars = array_filter($vars, function($key) { + return in_array($key, $this->serializableProperties); + }, ARRAY_FILTER_USE_KEY); + } + + // Process each property for special types + foreach ($vars as $key => $value) { + // Skip internal control properties + if (in_array($key, $this->nonSerializableProperties)) { + unset($vars[$key]); + continue; + } + + // Handle DateTimeInterface (DateTime/DateTimeImmutable) + if ($value instanceof DateTimeInterface) { + $vars[$key] = $value->format($this->dateTimeFormat); + } + // Handle DateTimeZone + elseif ($value instanceof DateTimeZone) { + $vars[$key] = $value->getName(); + } + // Handle DateInterval + elseif ($value instanceof DateInterval) { + $vars[$key] = $this->fromDateInterval($value); + } + // Handle backed enums + elseif ($value instanceof \BackedEnum) { + $vars[$key] = $value->value; + } + // Handle JsonSerializable objects + elseif ($value instanceof JsonSerializable) { + $vars[$key] = $value->jsonSerialize(); + } + } + + return $vars; + } + + public function jsonDeserialize(array|string $data): static { + + if (is_string($data)) { + $data = json_decode($data, true); + } + + foreach ($data as $key => $value) { + if (property_exists($this, $key)) { + // Skip internal control properties + if (in_array($key, $this->nonSerializableProperties)) { + continue; + } + + // Check if property should be deserialized (if serializableProperties is set) + if (!empty($this->serializableProperties) && !in_array($key, $this->serializableProperties)) { + continue; + } + + $type = gettype($this->$key); + + // Handle JsonDeserializable objects + if ($type === 'object' && $this->$key instanceof JsonDeserializable) { + $this->$key = $this->$key->jsonDeserialize($value); + } + // Handle DateTimeInterface (DateTime/DateTimeImmutable) + elseif ($type === 'object' && $this->$key instanceof DateTimeInterface) { + $this->$key = new \DateTimeImmutable($value); + } + // Handle DateTimeZone + elseif ($type === 'object' && $this->$key instanceof DateTimeZone) { + $this->$key = new DateTimeZone($value); + } + // Handle DateInterval + elseif ($type === 'object' && $this->$key instanceof DateInterval) { + $this->$key = $this->toDateInterval($value); + } + // Handle backed enums + elseif ($type === 'object' && $this->$key instanceof \BackedEnum) { + $enumClass = get_class($this->$key); + $this->$key = $enumClass::from($value); + } + // Handle regular values + else { + $this->$key = $value; + } + } + } + + return $this; + } + + protected function fromDateInterval(DateInterval $interval): string { + $spec = ''; + + // Handle negative intervals + if ($interval->invert === 1) { + $spec = '-'; + } + + $spec .= 'P'; + + if ($interval->y > 0) $spec .= $interval->y . 'Y'; + if ($interval->m > 0) $spec .= $interval->m . 'M'; + if ($interval->d > 0) $spec .= $interval->d . 'D'; + + $timePart = ''; + if ($interval->h > 0) $timePart .= $interval->h . 'H'; + if ($interval->i > 0) $timePart .= $interval->i . 'M'; + if ($interval->s > 0) $timePart .= $interval->s . 'S'; + + if (!empty($timePart)) { + $spec .= 'T' . $timePart; + } + + // Handle edge case of zero duration + if ($spec === 'P' || $spec === '-P') { + $spec = 'PT0S'; + } + + return $spec; + } + + protected function toDateInterval(string $value): DateInterval { + $isNegative = false; + + // Check for negative interval + if (str_starts_with($value, '-')) { + $isNegative = true; + $value = substr($value, 1); + } + + // Create the interval + $interval = new DateInterval($value); + + // Set invert property for negative intervals + if ($isNegative) { + $interval->invert = 1; + } + + return $interval; + } + +} \ No newline at end of file diff --git a/shared/lib/Mail/Collection/CollectionRoles.php b/shared/lib/Mail/Collection/CollectionRoles.php new file mode 100644 index 0000000..28e9fec --- /dev/null +++ b/shared/lib/Mail/Collection/CollectionRoles.php @@ -0,0 +1,37 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use JsonSerializable; + +/** + * Mail Collection Roles + * + * Standard mailbox/folder roles for mail collections. + * + * @since 2025.05.01 + */ +enum CollectionRoles: string implements JsonSerializable { + + case Inbox = 'inbox'; + case Drafts = 'drafts'; + case Sent = 'sent'; + case Trash = 'trash'; + case Junk = 'junk'; + case Archive = 'archive'; + case Outbox = 'outbox'; + case Queue = 'queue'; + case Custom = 'custom'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Mail/Collection/ICollectionBase.php b/shared/lib/Mail/Collection/ICollectionBase.php new file mode 100644 index 0000000..c3d3f7d --- /dev/null +++ b/shared/lib/Mail/Collection/ICollectionBase.php @@ -0,0 +1,117 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Collection; + +use DateTimeImmutable; +use JsonSerializable; + +/** + * Mail Collection Base Interface + * + * Represents a mailbox/folder in a mail service. + * For future use with full mail providers (IMAP, JMAP, etc.) + * + * @since 2025.05.01 + */ +interface ICollectionBase extends JsonSerializable { + + public const JSON_TYPE = 'mail.collection'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_SERVICE = 'service'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_ROLE = 'role'; + public const JSON_PROPERTY_TOTAL = 'total'; + public const JSON_PROPERTY_UNREAD = 'unread'; + + /** + * Gets the parent collection identifier (null for root) + * + * @since 2025.05.01 + * + * @return string|int|null + */ + public function in(): string|int|null; + + /** + * Gets the collection identifier + * + * @since 2025.05.01 + * + * @return string|int + */ + public function id(): string|int; + + /** + * Gets the collection label/name + * + * @since 2025.05.01 + * + * @return string + */ + public function getLabel(): string; + + /** + * Gets the collection role + * + * @since 2025.05.01 + * + * @return CollectionRoles + */ + public function getRole(): CollectionRoles; + + /** + * Gets the total message count + * + * @since 2025.05.01 + * + * @return int|null + */ + public function getTotal(): ?int; + + /** + * Gets the unread message count + * + * @since 2025.05.01 + * + * @return int|null + */ + public function getUnread(): ?int; + + /** + * Gets the collection signature/sync token + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getSignature(): ?string; + + /** + * Gets the creation date + * + * @since 2025.05.01 + * + * @return DateTimeImmutable|null + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date + * + * @since 2025.05.01 + * + * @return DateTimeImmutable|null + */ + public function modified(): ?DateTimeImmutable; + +} diff --git a/shared/lib/Mail/Entity/Address.php b/shared/lib/Mail/Entity/Address.php new file mode 100644 index 0000000..047a4af --- /dev/null +++ b/shared/lib/Mail/Entity/Address.php @@ -0,0 +1,141 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +/** + * Mail Address Implementation + * + * Concrete implementation of IAddress for email addresses. + * + * @since 2025.05.01 + */ +class Address implements IAddress { + + /** + * @param string $address Email address + * @param string|null $name Display name + */ + public function __construct( + private string $address = '', + private ?string $name = null, + ) {} + + /** + * Creates an Address from a formatted string + * + * @since 2025.05.01 + * + * @param string $value Formatted as "Name
" or just "address" + * + * @return self + */ + public static function fromString(string $value): self { + $value = trim($value); + + // Match "Name
" format + if (preg_match('/^(.+?)\s*<([^>]+)>$/', $value, $matches)) { + return new self(trim($matches[2]), trim($matches[1], ' "\'')); + } + + // Match "
" format + if (preg_match('/^<([^>]+)>$/', $value, $matches)) { + return new self(trim($matches[1])); + } + + // Assume plain address + return new self($value); + } + + /** + * Creates an Address from an array + * + * @since 2025.05.01 + * + * @param array $data Array with 'address' and optional 'name' keys + * + * @return self + */ + public static function fromArray(array $data): self { + return new self( + $data[self::JSON_PROPERTY_ADDRESS] ?? $data['address'] ?? '', + $data[self::JSON_PROPERTY_NAME] ?? $data['name'] ?? null, + ); + } + + /** + * @inheritDoc + */ + public function getAddress(): string { + return $this->address; + } + + /** + * Sets the email address + * + * @since 2025.05.01 + * + * @param string $address + * + * @return self + */ + public function setAddress(string $address): self { + $this->address = $address; + return $this; + } + + /** + * @inheritDoc + */ + public function getName(): ?string { + return $this->name; + } + + /** + * Sets the display name + * + * @since 2025.05.01 + * + * @param string|null $name + * + * @return self + */ + public function setName(?string $name): self { + $this->name = $name; + return $this; + } + + /** + * @inheritDoc + */ + public function toString(): string { + if ($this->name !== null && $this->name !== '') { + return sprintf('"%s" <%s>', $this->name, $this->address); + } + return $this->address; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return array_filter([ + self::JSON_PROPERTY_ADDRESS => $this->address, + self::JSON_PROPERTY_NAME => $this->name, + ], fn($v) => $v !== null && $v !== ''); + } + + /** + * String representation + */ + public function __toString(): string { + return $this->toString(); + } + +} diff --git a/shared/lib/Mail/Entity/Attachment.php b/shared/lib/Mail/Entity/Attachment.php new file mode 100644 index 0000000..93caac5 --- /dev/null +++ b/shared/lib/Mail/Entity/Attachment.php @@ -0,0 +1,195 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +/** + * Mail Attachment Implementation + * + * Concrete implementation of IAttachment for mail attachments. + * + * @since 2025.05.01 + */ +class Attachment implements IAttachment { + + /** + * @param string $name File name + * @param string $mimeType MIME type + * @param string $content Binary content + * @param string|null $id Attachment ID + * @param int|null $size Size in bytes + * @param string|null $contentId Content-ID for inline attachments + * @param bool $inline Whether inline attachment + */ + public function __construct( + private string $name, + private string $mimeType, + private string $content, + private ?string $id = null, + private ?int $size = null, + private ?string $contentId = null, + private bool $inline = false, + ) { + if ($this->size === null) { + $this->size = strlen($this->content); + } + } + + /** + * Creates an attachment from a file path + * + * @since 2025.05.01 + * + * @param string $path File path + * @param string|null $name Override file name + * @param string|null $mimeType Override MIME type + * + * @return self + */ + public static function fromFile(string $path, ?string $name = null, ?string $mimeType = null): self { + $content = file_get_contents($path); + $name = $name ?? basename($path); + $mimeType = $mimeType ?? mime_content_type($path) ?: 'application/octet-stream'; + + return new self($name, $mimeType, $content); + } + + /** + * Creates an attachment from base64 encoded content + * + * @since 2025.05.01 + * + * @param string $name File name + * @param string $mimeType MIME type + * @param string $base64Content Base64 encoded content + * + * @return self + */ + public static function fromBase64(string $name, string $mimeType, string $base64Content): self { + return new self($name, $mimeType, base64_decode($base64Content)); + } + + /** + * Creates an inline attachment for embedding in HTML + * + * @since 2025.05.01 + * + * @param string $name File name + * @param string $mimeType MIME type + * @param string $content Binary content + * @param string $contentId Content-ID (without cid: prefix) + * + * @return self + */ + public static function inline(string $name, string $mimeType, string $content, string $contentId): self { + return new self($name, $mimeType, $content, null, null, $contentId, true); + } + + /** + * Creates from array data + * + * @since 2025.05.01 + * + * @param array $data + * + * @return self + */ + public static function fromArray(array $data): self { + $content = $data['content'] ?? ''; + if (isset($data['contentBase64'])) { + $content = base64_decode($data['contentBase64']); + } + + return new self( + $data[self::JSON_PROPERTY_NAME] ?? $data['name'] ?? '', + $data[self::JSON_PROPERTY_MIME_TYPE] ?? $data['mimeType'] ?? 'application/octet-stream', + $content, + $data[self::JSON_PROPERTY_ID] ?? $data['id'] ?? null, + $data[self::JSON_PROPERTY_SIZE] ?? $data['size'] ?? null, + $data[self::JSON_PROPERTY_CONTENT_ID] ?? $data['contentId'] ?? null, + $data[self::JSON_PROPERTY_INLINE] ?? $data['inline'] ?? false, + ); + } + + /** + * @inheritDoc + */ + public function getId(): ?string { + return $this->id; + } + + /** + * @inheritDoc + */ + public function getName(): string { + return $this->name; + } + + /** + * @inheritDoc + */ + public function getMimeType(): string { + return $this->mimeType; + } + + /** + * @inheritDoc + */ + public function getSize(): ?int { + return $this->size; + } + + /** + * @inheritDoc + */ + public function getContentId(): ?string { + return $this->contentId; + } + + /** + * @inheritDoc + */ + public function isInline(): bool { + return $this->inline; + } + + /** + * @inheritDoc + */ + public function getContent(): string { + return $this->content; + } + + /** + * Gets the content as base64 encoded string + * + * @since 2025.05.01 + * + * @return string + */ + public function getContentBase64(): string { + return base64_encode($this->content); + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return array_filter([ + self::JSON_PROPERTY_ID => $this->id, + self::JSON_PROPERTY_NAME => $this->name, + self::JSON_PROPERTY_MIME_TYPE => $this->mimeType, + self::JSON_PROPERTY_SIZE => $this->size, + self::JSON_PROPERTY_CONTENT_ID => $this->contentId, + self::JSON_PROPERTY_INLINE => $this->inline ?: null, + 'contentBase64' => $this->getContentBase64(), + ], fn($v) => $v !== null); + } + +} diff --git a/shared/lib/Mail/Entity/IAddress.php b/shared/lib/Mail/Entity/IAddress.php new file mode 100644 index 0000000..57755df --- /dev/null +++ b/shared/lib/Mail/Entity/IAddress.php @@ -0,0 +1,53 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use JsonSerializable; + +/** + * Mail Address Interface + * + * Represents an email address with optional display name. + * + * @since 2025.05.01 + */ +interface IAddress extends JsonSerializable { + + public const JSON_PROPERTY_ADDRESS = 'address'; + public const JSON_PROPERTY_NAME = 'name'; + + /** + * Gets the email address + * + * @since 2025.05.01 + * + * @return string Email address (e.g., "user@example.com") + */ + public function getAddress(): string; + + /** + * Gets the display name + * + * @since 2025.05.01 + * + * @return string|null Display name (e.g., "John Doe") or null + */ + public function getName(): ?string; + + /** + * Gets the formatted address string + * + * @since 2025.05.01 + * + * @return string Formatted as "Name
" or just "address" if no name + */ + public function toString(): string; + +} diff --git a/shared/lib/Mail/Entity/IAttachment.php b/shared/lib/Mail/Entity/IAttachment.php new file mode 100644 index 0000000..92a44a1 --- /dev/null +++ b/shared/lib/Mail/Entity/IAttachment.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use JsonSerializable; + +/** + * Mail Attachment Interface + * + * Represents a file attachment on a mail message. + * + * @since 2025.05.01 + */ +interface IAttachment extends JsonSerializable { + + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_NAME = 'name'; + public const JSON_PROPERTY_MIME_TYPE = 'mimeType'; + public const JSON_PROPERTY_SIZE = 'size'; + public const JSON_PROPERTY_CONTENT_ID = 'contentId'; + public const JSON_PROPERTY_INLINE = 'inline'; + + /** + * Gets the attachment identifier + * + * @since 2025.05.01 + * + * @return string|null Attachment ID or null for new attachments + */ + public function getId(): ?string; + + /** + * Gets the file name + * + * @since 2025.05.01 + * + * @return string File name (e.g., "document.pdf") + */ + public function getName(): string; + + /** + * Gets the MIME type + * + * @since 2025.05.01 + * + * @return string MIME type (e.g., "application/pdf") + */ + public function getMimeType(): string; + + /** + * Gets the file size in bytes + * + * @since 2025.05.01 + * + * @return int|null Size in bytes or null if unknown + */ + public function getSize(): ?int; + + /** + * Gets the Content-ID for inline attachments + * + * @since 2025.05.01 + * + * @return string|null Content-ID for referencing in HTML body (e.g., "cid:image1") + */ + public function getContentId(): ?string; + + /** + * Checks if this is an inline attachment (embedded in body) + * + * @since 2025.05.01 + * + * @return bool True if inline, false if regular attachment + */ + public function isInline(): bool; + + /** + * Gets the attachment content + * + * @since 2025.05.01 + * + * @return string Binary content of the attachment + */ + public function getContent(): string; + +} diff --git a/shared/lib/Mail/Entity/IMessageBase.php b/shared/lib/Mail/Entity/IMessageBase.php new file mode 100644 index 0000000..235d425 --- /dev/null +++ b/shared/lib/Mail/Entity/IMessageBase.php @@ -0,0 +1,176 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use DateTimeImmutable; +use JsonSerializable; + +/** + * Mail Message Base Interface + * + * Read-only interface for mail message entities. + * + * @since 2025.05.01 + */ +interface IMessageBase extends JsonSerializable { + + public const JSON_TYPE = 'mail.message'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_SUBJECT = 'subject'; + public const JSON_PROPERTY_FROM = 'from'; + public const JSON_PROPERTY_REPLY_TO = 'replyTo'; + public const JSON_PROPERTY_TO = 'to'; + public const JSON_PROPERTY_CC = 'cc'; + public const JSON_PROPERTY_BCC = 'bcc'; + public const JSON_PROPERTY_DATE = 'date'; + public const JSON_PROPERTY_BODY_TEXT = 'bodyText'; + public const JSON_PROPERTY_BODY_HTML = 'bodyHtml'; + public const JSON_PROPERTY_ATTACHMENTS = 'attachments'; + public const JSON_PROPERTY_HEADERS = 'headers'; + + /** + * Gets the message identifier + * + * @since 2025.05.01 + * + * @return string|null Message ID or null for unsent messages + */ + public function getId(): ?string; + + /** + * Gets the message subject + * + * @since 2025.05.01 + * + * @return string + */ + public function getSubject(): string; + + /** + * Gets the sender address + * + * @since 2025.05.01 + * + * @return IAddress|null + */ + public function getFrom(): ?IAddress; + + /** + * Gets the reply-to address + * + * @since 2025.05.01 + * + * @return IAddress|null + */ + public function getReplyTo(): ?IAddress; + + /** + * Gets the primary recipients (To) + * + * @since 2025.05.01 + * + * @return array + */ + public function getTo(): array; + + /** + * Gets the carbon copy recipients (CC) + * + * @since 2025.05.01 + * + * @return array + */ + public function getCc(): array; + + /** + * Gets the blind carbon copy recipients (BCC) + * + * @since 2025.05.01 + * + * @return array + */ + public function getBcc(): array; + + /** + * Gets the message date + * + * @since 2025.05.01 + * + * @return DateTimeImmutable|null + */ + public function getDate(): ?DateTimeImmutable; + + /** + * Gets the plain text body + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getBodyText(): ?string; + + /** + * Gets the HTML body + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getBodyHtml(): ?string; + + /** + * Gets the attachments + * + * @since 2025.05.01 + * + * @return array + */ + public function getAttachments(): array; + + /** + * Gets custom headers + * + * @since 2025.05.01 + * + * @return array Header name => value + */ + public function getHeaders(): array; + + /** + * Gets a specific header value + * + * @since 2025.05.01 + * + * @param string $name Header name + * + * @return string|null Header value or null if not set + */ + public function getHeader(string $name): ?string; + + /** + * Checks if the message has any recipients + * + * @since 2025.05.01 + * + * @return bool True if To, CC, or BCC has at least one recipient + */ + public function hasRecipients(): bool; + + /** + * Checks if the message has any body content + * + * @since 2025.05.01 + * + * @return bool True if text or HTML body is set + */ + public function hasBody(): bool; + +} diff --git a/shared/lib/Mail/Entity/IMessageMutable.php b/shared/lib/Mail/Entity/IMessageMutable.php new file mode 100644 index 0000000..7be4b3a --- /dev/null +++ b/shared/lib/Mail/Entity/IMessageMutable.php @@ -0,0 +1,211 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use DateTimeImmutable; + +/** + * Mail Message Mutable Interface + * + * Interface for composing and modifying mail messages with fluent setters. + * + * @since 2025.05.01 + */ +interface IMessageMutable extends IMessageBase { + + /** + * Sets the message identifier + * + * @since 2025.05.01 + * + * @param string|null $id + * + * @return self + */ + public function setId(?string $id): self; + + /** + * Sets the message subject + * + * @since 2025.05.01 + * + * @param string $subject + * + * @return self + */ + public function setSubject(string $subject): self; + + /** + * Sets the sender address + * + * @since 2025.05.01 + * + * @param IAddress|null $from + * + * @return self + */ + public function setFrom(?IAddress $from): self; + + /** + * Sets the reply-to address + * + * @since 2025.05.01 + * + * @param IAddress|null $replyTo + * + * @return self + */ + public function setReplyTo(?IAddress $replyTo): self; + + /** + * Sets the primary recipients (To) + * + * @since 2025.05.01 + * + * @param array $to + * + * @return self + */ + public function setTo(array $to): self; + + /** + * Adds a primary recipient (To) + * + * @since 2025.05.01 + * + * @param IAddress $address + * + * @return self + */ + public function addTo(IAddress $address): self; + + /** + * Sets the carbon copy recipients (CC) + * + * @since 2025.05.01 + * + * @param array $cc + * + * @return self + */ + public function setCc(array $cc): self; + + /** + * Adds a carbon copy recipient (CC) + * + * @since 2025.05.01 + * + * @param IAddress $address + * + * @return self + */ + public function addCc(IAddress $address): self; + + /** + * Sets the blind carbon copy recipients (BCC) + * + * @since 2025.05.01 + * + * @param array $bcc + * + * @return self + */ + public function setBcc(array $bcc): self; + + /** + * Adds a blind carbon copy recipient (BCC) + * + * @since 2025.05.01 + * + * @param IAddress $address + * + * @return self + */ + public function addBcc(IAddress $address): self; + + /** + * Sets the message date + * + * @since 2025.05.01 + * + * @param DateTimeImmutable|null $date + * + * @return self + */ + public function setDate(?DateTimeImmutable $date): self; + + /** + * Sets the plain text body + * + * @since 2025.05.01 + * + * @param string|null $text + * + * @return self + */ + public function setBodyText(?string $text): self; + + /** + * Sets the HTML body + * + * @since 2025.05.01 + * + * @param string|null $html + * + * @return self + */ + public function setBodyHtml(?string $html): self; + + /** + * Sets the attachments + * + * @since 2025.05.01 + * + * @param array $attachments + * + * @return self + */ + public function setAttachments(array $attachments): self; + + /** + * Adds an attachment + * + * @since 2025.05.01 + * + * @param IAttachment $attachment + * + * @return self + */ + public function addAttachment(IAttachment $attachment): self; + + /** + * Sets custom headers + * + * @since 2025.05.01 + * + * @param array $headers Header name => value + * + * @return self + */ + public function setHeaders(array $headers): self; + + /** + * Sets a specific header value + * + * @since 2025.05.01 + * + * @param string $name Header name + * @param string $value Header value + * + * @return self + */ + public function setHeader(string $name, string $value): self; + +} diff --git a/shared/lib/Mail/Entity/Message.php b/shared/lib/Mail/Entity/Message.php new file mode 100644 index 0000000..e2136a5 --- /dev/null +++ b/shared/lib/Mail/Entity/Message.php @@ -0,0 +1,383 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Entity; + +use DateTimeImmutable; + +/** + * Mail Message Implementation + * + * Concrete implementation of IMessageMutable for composing mail messages. + * + * @since 2025.05.01 + */ +class Message implements IMessageMutable { + + private ?string $id = null; + private string $subject = ''; + private ?IAddress $from = null; + private ?IAddress $replyTo = null; + /** @var array */ + private array $to = []; + /** @var array */ + private array $cc = []; + /** @var array */ + private array $bcc = []; + private ?DateTimeImmutable $date = null; + private ?string $bodyText = null; + private ?string $bodyHtml = null; + /** @var array */ + private array $attachments = []; + /** @var array */ + private array $headers = []; + + /** + * Creates a message from array data + * + * @since 2025.05.01 + * + * @param array $data + * + * @return self + */ + public static function fromArray(array $data): self { + $message = new self(); + + if (isset($data[self::JSON_PROPERTY_ID])) { + $message->setId($data[self::JSON_PROPERTY_ID]); + } + if (isset($data[self::JSON_PROPERTY_SUBJECT])) { + $message->setSubject($data[self::JSON_PROPERTY_SUBJECT]); + } + if (isset($data[self::JSON_PROPERTY_FROM])) { + $message->setFrom(Address::fromArray($data[self::JSON_PROPERTY_FROM])); + } + if (isset($data[self::JSON_PROPERTY_REPLY_TO])) { + $message->setReplyTo(Address::fromArray($data[self::JSON_PROPERTY_REPLY_TO])); + } + if (isset($data[self::JSON_PROPERTY_TO])) { + $message->setTo(array_map(fn($a) => Address::fromArray($a), $data[self::JSON_PROPERTY_TO])); + } + if (isset($data[self::JSON_PROPERTY_CC])) { + $message->setCc(array_map(fn($a) => Address::fromArray($a), $data[self::JSON_PROPERTY_CC])); + } + if (isset($data[self::JSON_PROPERTY_BCC])) { + $message->setBcc(array_map(fn($a) => Address::fromArray($a), $data[self::JSON_PROPERTY_BCC])); + } + if (isset($data[self::JSON_PROPERTY_DATE])) { + $message->setDate(new DateTimeImmutable($data[self::JSON_PROPERTY_DATE])); + } + if (isset($data[self::JSON_PROPERTY_BODY_TEXT])) { + $message->setBodyText($data[self::JSON_PROPERTY_BODY_TEXT]); + } + if (isset($data[self::JSON_PROPERTY_BODY_HTML])) { + $message->setBodyHtml($data[self::JSON_PROPERTY_BODY_HTML]); + } + if (isset($data[self::JSON_PROPERTY_ATTACHMENTS])) { + $message->setAttachments(array_map(fn($a) => Attachment::fromArray($a), $data[self::JSON_PROPERTY_ATTACHMENTS])); + } + if (isset($data[self::JSON_PROPERTY_HEADERS])) { + $message->setHeaders($data[self::JSON_PROPERTY_HEADERS]); + } + + return $message; + } + + /** + * @inheritDoc + */ + public function getId(): ?string { + return $this->id; + } + + /** + * @inheritDoc + */ + public function setId(?string $id): self { + $this->id = $id; + return $this; + } + + /** + * @inheritDoc + */ + public function getSubject(): string { + return $this->subject; + } + + /** + * @inheritDoc + */ + public function setSubject(string $subject): self { + $this->subject = $subject; + return $this; + } + + /** + * @inheritDoc + */ + public function getFrom(): ?IAddress { + return $this->from; + } + + /** + * @inheritDoc + */ + public function setFrom(?IAddress $from): self { + $this->from = $from; + return $this; + } + + /** + * @inheritDoc + */ + public function getReplyTo(): ?IAddress { + return $this->replyTo; + } + + /** + * @inheritDoc + */ + public function setReplyTo(?IAddress $replyTo): self { + $this->replyTo = $replyTo; + return $this; + } + + /** + * @inheritDoc + */ + public function getTo(): array { + return $this->to; + } + + /** + * @inheritDoc + */ + public function setTo(array $to): self { + $this->to = $to; + return $this; + } + + /** + * @inheritDoc + */ + public function addTo(IAddress $address): self { + $this->to[] = $address; + return $this; + } + + /** + * @inheritDoc + */ + public function getCc(): array { + return $this->cc; + } + + /** + * @inheritDoc + */ + public function setCc(array $cc): self { + $this->cc = $cc; + return $this; + } + + /** + * @inheritDoc + */ + public function addCc(IAddress $address): self { + $this->cc[] = $address; + return $this; + } + + /** + * @inheritDoc + */ + public function getBcc(): array { + return $this->bcc; + } + + /** + * @inheritDoc + */ + public function setBcc(array $bcc): self { + $this->bcc = $bcc; + return $this; + } + + /** + * @inheritDoc + */ + public function addBcc(IAddress $address): self { + $this->bcc[] = $address; + return $this; + } + + /** + * @inheritDoc + */ + public function getDate(): ?DateTimeImmutable { + return $this->date; + } + + /** + * @inheritDoc + */ + public function setDate(?DateTimeImmutable $date): self { + $this->date = $date; + return $this; + } + + /** + * @inheritDoc + */ + public function getBodyText(): ?string { + return $this->bodyText; + } + + /** + * @inheritDoc + */ + public function setBodyText(?string $text): self { + $this->bodyText = $text; + return $this; + } + + /** + * @inheritDoc + */ + public function getBodyHtml(): ?string { + return $this->bodyHtml; + } + + /** + * @inheritDoc + */ + public function setBodyHtml(?string $html): self { + $this->bodyHtml = $html; + return $this; + } + + /** + * @inheritDoc + */ + public function getAttachments(): array { + return $this->attachments; + } + + /** + * @inheritDoc + */ + public function setAttachments(array $attachments): self { + $this->attachments = $attachments; + return $this; + } + + /** + * @inheritDoc + */ + public function addAttachment(IAttachment $attachment): self { + $this->attachments[] = $attachment; + return $this; + } + + /** + * @inheritDoc + */ + public function getHeaders(): array { + return $this->headers; + } + + /** + * @inheritDoc + */ + public function getHeader(string $name): ?string { + return $this->headers[$name] ?? null; + } + + /** + * @inheritDoc + */ + public function setHeaders(array $headers): self { + $this->headers = $headers; + return $this; + } + + /** + * @inheritDoc + */ + public function setHeader(string $name, string $value): self { + $this->headers[$name] = $value; + return $this; + } + + /** + * @inheritDoc + */ + public function hasRecipients(): bool { + return !empty($this->to) || !empty($this->cc) || !empty($this->bcc); + } + + /** + * @inheritDoc + */ + public function hasBody(): bool { + return ($this->bodyText !== null && $this->bodyText !== '') + || ($this->bodyHtml !== null && $this->bodyHtml !== ''); + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + $data = [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + ]; + + if ($this->id !== null) { + $data[self::JSON_PROPERTY_ID] = $this->id; + } + + $data[self::JSON_PROPERTY_SUBJECT] = $this->subject; + + if ($this->from !== null) { + $data[self::JSON_PROPERTY_FROM] = $this->from; + } + if ($this->replyTo !== null) { + $data[self::JSON_PROPERTY_REPLY_TO] = $this->replyTo; + } + if (!empty($this->to)) { + $data[self::JSON_PROPERTY_TO] = $this->to; + } + if (!empty($this->cc)) { + $data[self::JSON_PROPERTY_CC] = $this->cc; + } + if (!empty($this->bcc)) { + $data[self::JSON_PROPERTY_BCC] = $this->bcc; + } + if ($this->date !== null) { + $data[self::JSON_PROPERTY_DATE] = $this->date->format('c'); + } + if ($this->bodyText !== null) { + $data[self::JSON_PROPERTY_BODY_TEXT] = $this->bodyText; + } + if ($this->bodyHtml !== null) { + $data[self::JSON_PROPERTY_BODY_HTML] = $this->bodyHtml; + } + if (!empty($this->attachments)) { + $data[self::JSON_PROPERTY_ATTACHMENTS] = $this->attachments; + } + if (!empty($this->headers)) { + $data[self::JSON_PROPERTY_HEADERS] = $this->headers; + } + + return $data; + } + +} diff --git a/shared/lib/Mail/Exception/SendException.php b/shared/lib/Mail/Exception/SendException.php new file mode 100644 index 0000000..d670d87 --- /dev/null +++ b/shared/lib/Mail/Exception/SendException.php @@ -0,0 +1,68 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Exception; + +use Exception; + +/** + * Mail Send Exception + * + * Exception thrown when mail delivery fails. + * + * @since 2025.05.01 + */ +class SendException extends Exception { + + /** + * @param string $message Error message + * @param int $code Error code + * @param Exception|null $previous Previous exception + * @param string|null $recipient Specific recipient that failed (if applicable) + * @param bool $permanent Whether this is a permanent failure (no retry) + */ + public function __construct( + string $message, + int $code = 0, + ?Exception $previous = null, + public readonly ?string $recipient = null, + public readonly bool $permanent = false, + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Creates a permanent failure exception (no retry) + * + * @since 2025.05.01 + * + * @param string $message + * @param string|null $recipient + * + * @return self + */ + public static function permanent(string $message, ?string $recipient = null): self { + return new self($message, 0, null, $recipient, true); + } + + /** + * Creates a temporary failure exception (will retry) + * + * @since 2025.05.01 + * + * @param string $message + * @param Exception|null $previous + * + * @return self + */ + public static function temporary(string $message, ?Exception $previous = null): self { + return new self($message, 0, $previous, null, false); + } + +} diff --git a/shared/lib/Mail/Provider/IProviderBase.php b/shared/lib/Mail/Provider/IProviderBase.php new file mode 100644 index 0000000..8b83b65 --- /dev/null +++ b/shared/lib/Mail/Provider/IProviderBase.php @@ -0,0 +1,132 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Provider; + +use JsonSerializable; +use KTXF\Mail\Selector\ServiceSelector; +use KTXF\Mail\Service\IServiceBase; + +/** + * Mail Provider Base Interface + * + * Core interface for mail providers with context-aware service discovery. + * + * @since 2025.05.01 + */ +interface IProviderBase extends JsonSerializable { + + public const CAPABILITY_SERVICE_LIST = 'ServiceList'; + public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch'; + public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant'; + public const CAPABILITY_SERVICE_FIND_BY_ADDRESS = 'ServiceFindByAddress'; + + public const JSON_TYPE = 'mail.provider'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + + /** + * Confirms if a specific capability is supported + * + * @since 2025.05.01 + * + * @param string $value Required capability e.g. 'ServiceList' + * + * @return bool + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * Gets the unique identifier for this provider + * + * @since 2025.05.01 + * + * @return string Provider ID (e.g., 'smtp', 'imap', 'jmap') + */ + public function id(): string; + + /** + * Gets the localized human-friendly name of this provider + * + * @since 2025.05.01 + * + * @return string Provider label (e.g., 'SMTP Mail Provider') + */ + public function label(): string; + + /** + * Lists services for a tenant, optionally filtered by user context + * + * When userId is null, returns only System-scoped services. + * When userId is provided, returns System-scoped services plus + * User-scoped services owned by that user. + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context (null = system only) + * @param ServiceSelector|null $selector Optional filter criteria + * + * @return array + */ + public function serviceList(string $tenantId, ?string $userId = null, ?ServiceSelector $selector = null): array; + + /** + * Checks if specific services exist + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string|int ...$identifiers Service identifiers to check + * + * @return array Identifier => exists + */ + public function serviceExtant(string $tenantId, ?string $userId, string|int ...$identifiers): array; + + /** + * Fetches a specific service by identifier + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string|int $identifier Service identifier + * + * @return IServiceBase|null + */ + public function serviceFetch(string $tenantId, ?string $userId, string|int $identifier): ?IServiceBase; + + /** + * Finds a service that handles a specific email address + * + * Searches within the appropriate scope based on userId context. + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for context + * @param string $address Email address to find service for + * + * @return IServiceBase|null Service handling the address, or null + */ + public function serviceFindByAddress(string $tenantId, ?string $userId, string $address): ?IServiceBase; + +} diff --git a/shared/lib/Mail/Provider/IProviderServiceMutate.php b/shared/lib/Mail/Provider/IProviderServiceMutate.php new file mode 100644 index 0000000..aeff98a --- /dev/null +++ b/shared/lib/Mail/Provider/IProviderServiceMutate.php @@ -0,0 +1,77 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Provider; + +use KTXF\Json\JsonDeserializable; +use KTXF\Mail\Service\IServiceBase; + +/** + * Mail Provider Service Mutate Interface + * + * Optional interface for providers that support service CRUD operations. + * + * @since 2025.05.01 + */ +interface IProviderServiceMutate extends JsonDeserializable { + + public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh'; + public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate'; + public const CAPABILITY_SERVICE_MODIFY = 'ServiceModify'; + public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy'; + + /** + * Creates a new blank service instance for configuration + * + * @since 2025.05.01 + * + * @return IServiceBase Fresh service object + */ + public function serviceFresh(): IServiceBase; + + /** + * Creates a new service configuration + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId Owner user ID (null for system services) + * @param IServiceBase $service Service configuration to create + * + * @return string|int Created service identifier + */ + public function serviceCreate(string $tenantId, ?string $userId, IServiceBase $service): string|int; + + /** + * Modifies an existing service configuration + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for authorization context + * @param IServiceBase $service Service configuration to update + * + * @return string|int Updated service identifier + */ + public function serviceModify(string $tenantId, ?string $userId, IServiceBase $service): string|int; + + /** + * Destroys a service configuration + * + * @since 2025.05.01 + * + * @param string $tenantId Tenant identifier + * @param string|null $userId User identifier for authorization context + * @param IServiceBase $service Service to destroy + * + * @return bool True if destroyed, false if not found + */ + public function serviceDestroy(string $tenantId, ?string $userId, IServiceBase $service): bool; + +} diff --git a/shared/lib/Mail/Queue/SendOptions.php b/shared/lib/Mail/Queue/SendOptions.php new file mode 100644 index 0000000..d346687 --- /dev/null +++ b/shared/lib/Mail/Queue/SendOptions.php @@ -0,0 +1,81 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Queue; + +use JsonSerializable; + +/** + * Mail Send Options + * + * Configuration options for message delivery behavior. + * + * @since 2025.05.01 + */ +class SendOptions implements JsonSerializable { + + /** + * @param bool $immediate Send immediately bypassing queue (for 2FA, etc.) + * @param int $priority Queue priority (-100 to 100, higher = sooner) + * @param int $retryCount Maximum retry attempts on failure + * @param int|null $delaySeconds Delay before first send attempt + */ + public function __construct( + public readonly bool $immediate = false, + public readonly int $priority = 0, + public readonly int $retryCount = 3, + public readonly ?int $delaySeconds = null, + ) {} + + /** + * Creates options for immediate delivery (bypasses queue) + * + * @since 2025.05.01 + * + * @return self + */ + public static function immediate(): self { + return new self(immediate: true); + } + + /** + * Creates options for high-priority queued delivery + * + * @since 2025.05.01 + * + * @return self + */ + public static function highPriority(): self { + return new self(priority: 50); + } + + /** + * Creates options for low-priority queued delivery (bulk mail) + * + * @since 2025.05.01 + * + * @return self + */ + public static function lowPriority(): self { + return new self(priority: -50); + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return [ + 'immediate' => $this->immediate, + 'priority' => $this->priority, + 'retryCount' => $this->retryCount, + 'delaySeconds' => $this->delaySeconds, + ]; + } + +} diff --git a/shared/lib/Mail/Selector/ServiceSelector.php b/shared/lib/Mail/Selector/ServiceSelector.php new file mode 100644 index 0000000..66071c3 --- /dev/null +++ b/shared/lib/Mail/Selector/ServiceSelector.php @@ -0,0 +1,183 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Selector; + +use JsonSerializable; +use KTXF\Mail\Service\ServiceScope; + +/** + * Mail Service Selector + * + * Filter criteria for selecting mail services from providers. + * + * @since 2025.05.01 + */ +class ServiceSelector implements JsonSerializable { + + private ?ServiceScope $scope = null; + private ?string $owner = null; + private ?string $address = null; + private ?array $capabilities = null; + private ?bool $enabled = null; + + /** + * Filter by service scope + * + * @since 2025.05.01 + * + * @param ServiceScope|null $scope + * + * @return self + */ + public function setScope(?ServiceScope $scope): self { + $this->scope = $scope; + return $this; + } + + /** + * Gets the scope filter + * + * @since 2025.05.01 + * + * @return ServiceScope|null + */ + public function getScope(): ?ServiceScope { + return $this->scope; + } + + /** + * Filter by owner user ID + * + * @since 2025.05.01 + * + * @param string|null $owner + * + * @return self + */ + public function setOwner(?string $owner): self { + $this->owner = $owner; + return $this; + } + + /** + * Gets the owner filter + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getOwner(): ?string { + return $this->owner; + } + + /** + * Filter by email address (matches primary or secondary) + * + * @since 2025.05.01 + * + * @param string|null $address + * + * @return self + */ + public function setAddress(?string $address): self { + $this->address = $address; + return $this; + } + + /** + * Gets the address filter + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getAddress(): ?string { + return $this->address; + } + + /** + * Filter by required capabilities + * + * @since 2025.05.01 + * + * @param array|null $capabilities + * + * @return self + */ + public function setCapabilities(?array $capabilities): self { + $this->capabilities = $capabilities; + return $this; + } + + /** + * Gets the capabilities filter + * + * @since 2025.05.01 + * + * @return array|null + */ + public function getCapabilities(): ?array { + return $this->capabilities; + } + + /** + * Filter by enabled status + * + * @since 2025.05.01 + * + * @param bool|null $enabled + * + * @return self + */ + public function setEnabled(?bool $enabled): self { + $this->enabled = $enabled; + return $this; + } + + /** + * Gets the enabled filter + * + * @since 2025.05.01 + * + * @return bool|null + */ + public function getEnabled(): ?bool { + return $this->enabled; + } + + /** + * Checks if any filter criteria are set + * + * @since 2025.05.01 + * + * @return bool + */ + public function hasFilters(): bool { + return $this->scope !== null + || $this->owner !== null + || $this->address !== null + || $this->capabilities !== null + || $this->enabled !== null; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return array_filter([ + 'scope' => $this->scope?->value, + 'owner' => $this->owner, + 'address' => $this->address, + 'capabilities' => $this->capabilities, + 'enabled' => $this->enabled, + ], fn($v) => $v !== null); + } + +} diff --git a/shared/lib/Mail/Service/IServiceBase.php b/shared/lib/Mail/Service/IServiceBase.php new file mode 100644 index 0000000..8d7cdfb --- /dev/null +++ b/shared/lib/Mail/Service/IServiceBase.php @@ -0,0 +1,139 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use JsonSerializable; +use KTXF\Mail\Entity\IAddress; + +/** + * Mail Service Base Interface + * + * Core interface for mail services providing identity, addressing, and capability information. + * + * @since 2025.05.01 + */ +interface IServiceBase extends JsonSerializable { + + public const JSON_TYPE = 'mail.service'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_SCOPE = 'scope'; + public const JSON_PROPERTY_OWNER = 'owner'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + public const JSON_PROPERTY_PRIMARY_ADDRESS = 'primaryAddress'; + public const JSON_PROPERTY_SECONDARY_ADDRESSES = 'secondaryAddresses'; + + /** + * Confirms if a specific capability is supported + * + * @since 2025.05.01 + * + * @param string $value Required capability e.g. 'Send' + * + * @return bool + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * Gets the unique identifier of the provider this service belongs to + * + * @since 2025.05.01 + * + * @return string + */ + public function in(): string; + + /** + * Gets the unique identifier for this service + * + * @since 2025.05.01 + * + * @return string|int + */ + public function id(): string|int; + + /** + * Gets the localized human-friendly name of this service + * + * @since 2025.05.01 + * + * @return string + */ + public function getLabel(): string; + + /** + * Gets the scope of this service (System or User) + * + * @since 2025.05.01 + * + * @return ServiceScope + */ + public function getScope(): ServiceScope; + + /** + * Gets the owner user ID for User-scoped services + * + * @since 2025.05.01 + * + * @return string|null User ID or null for System scope + */ + public function getOwner(): ?string; + + /** + * Gets whether this service is enabled + * + * @since 2025.05.01 + * + * @return bool + */ + public function getEnabled(): bool; + + /** + * Gets the primary mailing address for this service + * + * @since 2025.05.01 + * + * @return IAddress + */ + public function getPrimaryAddress(): IAddress; + + /** + * Gets the secondary mailing addresses (aliases) for this service + * + * @since 2025.05.01 + * + * @return array + */ + public function getSecondaryAddresses(): array; + + /** + * Checks if this service handles a specific email address + * + * @since 2025.05.01 + * + * @param string $address Email address to check + * + * @return bool True if address matches primary or any secondary address + */ + public function handlesAddress(string $address): bool; + +} diff --git a/shared/lib/Mail/Service/IServiceIdentity.php b/shared/lib/Mail/Service/IServiceIdentity.php new file mode 100644 index 0000000..1fd951b --- /dev/null +++ b/shared/lib/Mail/Service/IServiceIdentity.php @@ -0,0 +1,36 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use JsonSerializable; + +/** + * Mail Service Identity Interface + * + * Base interface for authentication credentials used by mail services. + * + * @since 2025.05.01 + */ +interface IServiceIdentity extends JsonSerializable { + + public const TYPE_BASIC = 'basic'; + public const TYPE_OAUTH = 'oauth'; + public const TYPE_APIKEY = 'apikey'; + + /** + * Gets the identity/authentication type + * + * @since 2025.05.01 + * + * @return string One of: 'basic', 'oauth', 'apikey' + */ + public function getType(): string; + +} diff --git a/shared/lib/Mail/Service/IServiceIdentityApiKey.php b/shared/lib/Mail/Service/IServiceIdentityApiKey.php new file mode 100644 index 0000000..9d098f6 --- /dev/null +++ b/shared/lib/Mail/Service/IServiceIdentityApiKey.php @@ -0,0 +1,39 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +/** + * Mail Service API Key Identity Interface + * + * API key authentication for transactional mail services (SendGrid, Mailgun, etc.) + * + * @since 2025.05.01 + */ +interface IServiceIdentityApiKey extends IServiceIdentity { + + /** + * Gets the API key + * + * @since 2025.05.01 + * + * @return string + */ + public function getApiKey(): string; + + /** + * Gets the optional API key identifier/name + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getApiKeyId(): ?string; + +} diff --git a/shared/lib/Mail/Service/IServiceIdentityBasic.php b/shared/lib/Mail/Service/IServiceIdentityBasic.php new file mode 100644 index 0000000..bd55412 --- /dev/null +++ b/shared/lib/Mail/Service/IServiceIdentityBasic.php @@ -0,0 +1,39 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +/** + * Mail Service Basic Authentication Identity Interface + * + * Username/password authentication for traditional mail services. + * + * @since 2025.05.01 + */ +interface IServiceIdentityBasic extends IServiceIdentity { + + /** + * Gets the username/login identifier + * + * @since 2025.05.01 + * + * @return string + */ + public function getUsername(): string; + + /** + * Gets the password/secret + * + * @since 2025.05.01 + * + * @return string + */ + public function getPassword(): string; + +} diff --git a/shared/lib/Mail/Service/IServiceIdentityOAuth.php b/shared/lib/Mail/Service/IServiceIdentityOAuth.php new file mode 100644 index 0000000..cf090c6 --- /dev/null +++ b/shared/lib/Mail/Service/IServiceIdentityOAuth.php @@ -0,0 +1,68 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use DateTimeImmutable; + +/** + * Mail Service OAuth Identity Interface + * + * OAuth2 token-based authentication for modern mail APIs. + * + * @since 2025.05.01 + */ +interface IServiceIdentityOAuth extends IServiceIdentity { + + /** + * Gets the OAuth access token + * + * @since 2025.05.01 + * + * @return string + */ + public function getAccessToken(): string; + + /** + * Gets the OAuth refresh token + * + * @since 2025.05.01 + * + * @return string|null + */ + public function getRefreshToken(): ?string; + + /** + * Gets the token expiration time + * + * @since 2025.05.01 + * + * @return DateTimeImmutable|null + */ + public function getExpiresAt(): ?DateTimeImmutable; + + /** + * Gets the granted OAuth scopes + * + * @since 2025.05.01 + * + * @return array + */ + public function getScopes(): array; + + /** + * Checks if the access token has expired + * + * @since 2025.05.01 + * + * @return bool + */ + public function isExpired(): bool; + +} diff --git a/shared/lib/Mail/Service/IServiceLocation.php b/shared/lib/Mail/Service/IServiceLocation.php new file mode 100644 index 0000000..d3b04fb --- /dev/null +++ b/shared/lib/Mail/Service/IServiceLocation.php @@ -0,0 +1,105 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use JsonSerializable; + +/** + * Mail Service Location Interface + * + * Unified interface supporting both URI-based (API services) and socket-based + * (traditional IMAP/SMTP) connection configurations. + * + * @since 2025.05.01 + */ +interface IServiceLocation extends JsonSerializable { + + public const TYPE_URI = 'uri'; + public const TYPE_SOCKET_SINGLE = 'socket-single'; + public const TYPE_SOCKET_SPLIT = 'socket-split'; + + public const SECURITY_NONE = 'none'; + public const SECURITY_SSL = 'ssl'; + public const SECURITY_TLS = 'tls'; + public const SECURITY_STARTTLS = 'starttls'; + + /** + * Gets the location type + * + * @since 2025.05.01 + * + * @return string One of: 'uri', 'socket-single', 'socket-split' + */ + public function getType(): string; + + /** + * Gets the URI for API-based services (JMAP, EWS, Graph API, HTTP relay) + * + * @since 2025.05.01 + * + * @return string|null URI or null if not URI-based + */ + public function getUri(): ?string; + + /** + * Gets the inbound/primary host for socket-based services + * + * @since 2025.05.01 + * + * @return string|null Hostname or null if URI-based + */ + public function getInboundHost(): ?string; + + /** + * Gets the inbound/primary port for socket-based services + * + * @since 2025.05.01 + * + * @return int|null Port number or null if URI-based + */ + public function getInboundPort(): ?int; + + /** + * Gets the inbound/primary security mode + * + * @since 2025.05.01 + * + * @return string|null One of: 'none', 'ssl', 'tls', 'starttls' + */ + public function getInboundSecurity(): ?string; + + /** + * Gets the outbound host for split-socket services (e.g., SMTP separate from IMAP) + * + * @since 2025.05.01 + * + * @return string|null Hostname or null if not split-socket + */ + public function getOutboundHost(): ?string; + + /** + * Gets the outbound port for split-socket services + * + * @since 2025.05.01 + * + * @return int|null Port number or null if not split-socket + */ + public function getOutboundPort(): ?int; + + /** + * Gets the outbound security mode for split-socket services + * + * @since 2025.05.01 + * + * @return string|null One of: 'none', 'ssl', 'tls', 'starttls' + */ + public function getOutboundSecurity(): ?string; + +} diff --git a/shared/lib/Mail/Service/IServiceSend.php b/shared/lib/Mail/Service/IServiceSend.php new file mode 100644 index 0000000..940c0bc --- /dev/null +++ b/shared/lib/Mail/Service/IServiceSend.php @@ -0,0 +1,49 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use KTXF\Mail\Entity\IMessageMutable; +use KTXF\Mail\Exception\SendException; + +/** + * Mail Service Send Interface + * + * Interface for mail services capable of sending outbound messages. + * This is the minimum capability for an outbound-only mail service. + * + * @since 2025.05.01 + */ +interface IServiceSend extends IServiceBase { + + public const CAPABILITY_SEND = 'Send'; + + /** + * Creates a new fresh message object + * + * @since 2025.05.01 + * + * @return IMessageMutable Fresh message object for composing + */ + public function messageFresh(): IMessageMutable; + + /** + * Sends an outbound message + * + * @since 2025.05.01 + * + * @param IMessageMutable $message Message to send + * + * @return string Message ID assigned by the transport + * + * @throws SendException On delivery failure + */ + public function messageSend(IMessageMutable $message): string; + +} diff --git a/shared/lib/Mail/Service/ServiceIdentityApiKey.php b/shared/lib/Mail/Service/ServiceIdentityApiKey.php new file mode 100644 index 0000000..970ba23 --- /dev/null +++ b/shared/lib/Mail/Service/ServiceIdentityApiKey.php @@ -0,0 +1,78 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +/** + * Mail Service API Key Identity Implementation + * + * API key authentication for transactional mail services. + * + * @since 2025.05.01 + */ +class ServiceIdentityApiKey implements IServiceIdentityApiKey { + + /** + * @param string $apiKey API key + * @param string|null $apiKeyId Optional API key identifier + */ + public function __construct( + private string $apiKey, + private ?string $apiKeyId = null, + ) {} + + /** + * Creates from array data + * + * @since 2025.05.01 + * + * @param array $data + * + * @return self + */ + public static function fromArray(array $data): self { + return new self( + $data['apiKey'] ?? '', + $data['apiKeyId'] ?? null, + ); + } + + /** + * @inheritDoc + */ + public function getType(): string { + return self::TYPE_APIKEY; + } + + /** + * @inheritDoc + */ + public function getApiKey(): string { + return $this->apiKey; + } + + /** + * @inheritDoc + */ + public function getApiKeyId(): ?string { + return $this->apiKeyId; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return array_filter([ + 'type' => self::TYPE_APIKEY, + 'apiKeyId' => $this->apiKeyId, + // API key intentionally omitted from serialization for security + ], fn($v) => $v !== null); + } + +} diff --git a/shared/lib/Mail/Service/ServiceIdentityBasic.php b/shared/lib/Mail/Service/ServiceIdentityBasic.php new file mode 100644 index 0000000..a301955 --- /dev/null +++ b/shared/lib/Mail/Service/ServiceIdentityBasic.php @@ -0,0 +1,78 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +/** + * Mail Service Basic Identity Implementation + * + * Username/password authentication credentials. + * + * @since 2025.05.01 + */ +class ServiceIdentityBasic implements IServiceIdentityBasic { + + /** + * @param string $username Login username + * @param string $password Login password + */ + public function __construct( + private string $username, + private string $password, + ) {} + + /** + * Creates from array data + * + * @since 2025.05.01 + * + * @param array $data + * + * @return self + */ + public static function fromArray(array $data): self { + return new self( + $data['username'] ?? '', + $data['password'] ?? '', + ); + } + + /** + * @inheritDoc + */ + public function getType(): string { + return self::TYPE_BASIC; + } + + /** + * @inheritDoc + */ + public function getUsername(): string { + return $this->username; + } + + /** + * @inheritDoc + */ + public function getPassword(): string { + return $this->password; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return [ + 'type' => self::TYPE_BASIC, + 'username' => $this->username, + // Password intentionally omitted from serialization for security + ]; + } + +} diff --git a/shared/lib/Mail/Service/ServiceLocation.php b/shared/lib/Mail/Service/ServiceLocation.php new file mode 100644 index 0000000..8d40791 --- /dev/null +++ b/shared/lib/Mail/Service/ServiceLocation.php @@ -0,0 +1,211 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +/** + * Mail Service Location Implementation + * + * Unified implementation supporting URI-based and socket-based connections. + * + * @since 2025.05.01 + */ +class ServiceLocation implements IServiceLocation { + + /** + * @param string $type Location type (uri, socket-single, socket-split) + * @param string|null $uri URI for API-based services + * @param string|null $inboundHost Inbound/primary host + * @param int|null $inboundPort Inbound/primary port + * @param string|null $inboundSecurity Inbound security (none, ssl, tls, starttls) + * @param string|null $outboundHost Outbound host (for split-socket) + * @param int|null $outboundPort Outbound port (for split-socket) + * @param string|null $outboundSecurity Outbound security (for split-socket) + */ + public function __construct( + private string $type, + private ?string $uri = null, + private ?string $inboundHost = null, + private ?int $inboundPort = null, + private ?string $inboundSecurity = null, + private ?string $outboundHost = null, + private ?int $outboundPort = null, + private ?string $outboundSecurity = null, + ) {} + + /** + * Creates a URI-based location (for API services) + * + * @since 2025.05.01 + * + * @param string $uri + * + * @return self + */ + public static function uri(string $uri): self { + return new self(self::TYPE_URI, $uri); + } + + /** + * Creates a single-socket location (e.g., SMTP only) + * + * @since 2025.05.01 + * + * @param string $host + * @param int $port + * @param string $security + * + * @return self + */ + public static function socket(string $host, int $port, string $security = self::SECURITY_TLS): self { + return new self( + self::TYPE_SOCKET_SINGLE, + null, + $host, + $port, + $security + ); + } + + /** + * Creates a split-socket location (IMAP + SMTP) + * + * @since 2025.05.01 + * + * @param string $inboundHost IMAP host + * @param int $inboundPort IMAP port + * @param string $inboundSecurity IMAP security + * @param string $outboundHost SMTP host + * @param int $outboundPort SMTP port + * @param string $outboundSecurity SMTP security + * + * @return self + */ + public static function splitSocket( + string $inboundHost, + int $inboundPort, + string $inboundSecurity, + string $outboundHost, + int $outboundPort, + string $outboundSecurity + ): self { + return new self( + self::TYPE_SOCKET_SPLIT, + null, + $inboundHost, + $inboundPort, + $inboundSecurity, + $outboundHost, + $outboundPort, + $outboundSecurity + ); + } + + /** + * Creates from array data + * + * @since 2025.05.01 + * + * @param array $data + * + * @return self + */ + public static function fromArray(array $data): self { + return new self( + $data['type'] ?? self::TYPE_SOCKET_SINGLE, + $data['uri'] ?? null, + $data['inboundHost'] ?? $data['host'] ?? null, + $data['inboundPort'] ?? $data['port'] ?? null, + $data['inboundSecurity'] ?? $data['security'] ?? null, + $data['outboundHost'] ?? null, + $data['outboundPort'] ?? null, + $data['outboundSecurity'] ?? null, + ); + } + + /** + * @inheritDoc + */ + public function getType(): string { + return $this->type; + } + + /** + * @inheritDoc + */ + public function getUri(): ?string { + return $this->uri; + } + + /** + * @inheritDoc + */ + public function getInboundHost(): ?string { + return $this->inboundHost; + } + + /** + * @inheritDoc + */ + public function getInboundPort(): ?int { + return $this->inboundPort; + } + + /** + * @inheritDoc + */ + public function getInboundSecurity(): ?string { + return $this->inboundSecurity; + } + + /** + * @inheritDoc + */ + public function getOutboundHost(): ?string { + return $this->outboundHost ?? $this->inboundHost; + } + + /** + * @inheritDoc + */ + public function getOutboundPort(): ?int { + return $this->outboundPort ?? $this->inboundPort; + } + + /** + * @inheritDoc + */ + public function getOutboundSecurity(): ?string { + return $this->outboundSecurity ?? $this->inboundSecurity; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + $data = ['type' => $this->type]; + + if ($this->type === self::TYPE_URI) { + $data['uri'] = $this->uri; + } else { + $data['inboundHost'] = $this->inboundHost; + $data['inboundPort'] = $this->inboundPort; + $data['inboundSecurity'] = $this->inboundSecurity; + + if ($this->type === self::TYPE_SOCKET_SPLIT) { + $data['outboundHost'] = $this->outboundHost; + $data['outboundPort'] = $this->outboundPort; + $data['outboundSecurity'] = $this->outboundSecurity; + } + } + + return array_filter($data, fn($v) => $v !== null); + } + +} diff --git a/shared/lib/Mail/Service/ServiceScope.php b/shared/lib/Mail/Service/ServiceScope.php new file mode 100644 index 0000000..bfdbff3 --- /dev/null +++ b/shared/lib/Mail/Service/ServiceScope.php @@ -0,0 +1,37 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Mail\Service; + +use JsonSerializable; + +/** + * Defines the scope/context of a mail service + * + * @since 2025.05.01 + */ +enum ServiceScope: string implements JsonSerializable { + + /** + * System-level service for tenant-wide communications + * (e.g., notifications@, reports@, noreply@) + */ + case System = 'system'; + + /** + * User-level service for personal mail accounts + * (e.g., user's own IMAP/SMTP accounts) + */ + case User = 'user'; + + public function jsonSerialize(): string { + return $this->value; + } + +} diff --git a/shared/lib/Module/ModuleInstanceAbstract.php b/shared/lib/Module/ModuleInstanceAbstract.php new file mode 100644 index 0000000..4d3907d --- /dev/null +++ b/shared/lib/Module/ModuleInstanceAbstract.php @@ -0,0 +1,49 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use JsonSerializable; + +enum CollectionContent: string implements JsonSerializable { + + case Individual = 'individual'; + case Organization = 'organization'; + case Group = 'group'; + + public function jsonSerialize(): string { + return $this->value; + } + +} \ No newline at end of file diff --git a/shared/lib/People/Collection/CollectionPermissions.php b/shared/lib/People/Collection/CollectionPermissions.php new file mode 100644 index 0000000..3136ca7 --- /dev/null +++ b/shared/lib/People/Collection/CollectionPermissions.php @@ -0,0 +1,26 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use JsonSerializable; + +enum CollectionPermissions: string implements JsonSerializable { + + case View = 'view'; + case Create = 'create'; + case Modify = 'modify'; + case Destroy = 'destroy'; + case Share = 'share'; + + public function jsonSerialize(): string { + return $this->value; + } + +} \ No newline at end of file diff --git a/shared/lib/People/Collection/CollectionRoles.php b/shared/lib/People/Collection/CollectionRoles.php new file mode 100644 index 0000000..7a6bbbb --- /dev/null +++ b/shared/lib/People/Collection/CollectionRoles.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use JsonSerializable; + +enum CollectionRoles: string implements JsonSerializable { + + case System = 'system'; + case Individual = 'individual'; + case Recent = 'recent'; + + public function jsonSerialize(): string { + return $this->value; + } + +} \ No newline at end of file diff --git a/shared/lib/People/Collection/ICollectionBase.php b/shared/lib/People/Collection/ICollectionBase.php new file mode 100644 index 0000000..cc1617e --- /dev/null +++ b/shared/lib/People/Collection/ICollectionBase.php @@ -0,0 +1,160 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use DateTimeImmutable; +use JsonSerializable; + +interface ICollectionBase extends JsonSerializable { + + public const JSON_TYPE = 'people.collection'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_SERVICE = 'service'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_DESCRIPTION = 'description'; + public const JSON_PROPERTY_PRIORITY = 'priority'; + public const JSON_PROPERTY_VISIBILITY = 'visibility'; + public const JSON_PROPERTY_COLOR = 'color'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + public const JSON_PROPERTY_PERMISSIONS = 'permissions'; + public const JSON_PROPERTY_ROLES = 'roles'; + public const JSON_PROPERTY_CONTENTS = 'contents'; + + /** + * Unique identifier of the service this collection belongs to + * + * @since 2025.05.01 + */ + public function in(): string|int|null; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or collection1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the creation date of this collection + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date of this collection + */ + public function modified(): ?DateTimeImmutable; + + /** + * Lists all supported attributes + * + * @since 2025.05.01 + * + * @return array> + */ + public function attributes(): array; + + /** + * Gets the signature of this collection + * + * @since 2025.05.01 + */ + public function signature(): ?string; + + /** + * Gets the role(s) of this collection + * + * @since 2025.05.01 + */ + public function roles(): array; + + /** + * Checks if this collection supports the given role + * + * @since 2025.05.01 + */ + public function role(CollectionRoles $value): bool; + + /** + * Gets the content types of this collection + * + * @since 2025.05.01 + */ + public function contents(): array; + + /** + * Checks if this collection contains the given content type + * + * @since 2025.05.01 + */ + public function contains(CollectionContent $value): bool; + + /** + * Gets the active status of this collection + * + * @since 2025.05.01 + */ + public function getEnabled(): bool; + + /** + * Gets the active status of this collection + * + * @since 2025.05.01 + */ + public function getPermissions(): array; + + /** + * Checks if this collection has the given permission + * + * @since 2025.05.01 + */ + public function hasPermission(CollectionPermissions $permission): bool; + + /** + * Gets the human friendly name of this collection (e.g. Personal Contacts) + * + * @since 2025.05.01 + */ + public function getLabel(): ?string; + + /** + * Gets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function getDescription(): ?string; + + /** + * Gets the priority of this collection + * + * @since 2025.05.01 + */ + public function getPriority(): ?int; + + /** + * Gets the visibility of this collection + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool; + + /** + * Gets the color of this collection + * + * @since 2025.05.01 + */ + public function getColor(): ?string; + +} diff --git a/shared/lib/People/Collection/ICollectionMutable.php b/shared/lib/People/Collection/ICollectionMutable.php new file mode 100644 index 0000000..610a48b --- /dev/null +++ b/shared/lib/People/Collection/ICollectionMutable.php @@ -0,0 +1,58 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Collection; + +use KTXF\Json\JsonDeserializable; + +interface ICollectionMutable extends ICollectionBase, JsonDeserializable { + + /** + * Sets the active status of this collection + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self; + + /** + * Sets the human friendly name of this collection (e.g. Personal Contacts) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self; + + /** + * Sets the human friendly description of this collection + * + * @since 2025.05.01 + */ + public function setDescription(?string $value): self; + + /** + * Sets the priority of this collection + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): self; + + /** + * Sets the visibility of this collection + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): self; + + /** + * Sets the color of this collection + * + * @since 2025.05.01 + */ + public function setColor(?string $value): self; + +} diff --git a/shared/lib/People/Entity/EntityPermissions.php b/shared/lib/People/Entity/EntityPermissions.php new file mode 100644 index 0000000..71a20a1 --- /dev/null +++ b/shared/lib/People/Entity/EntityPermissions.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity; + +use JsonSerializable; + +enum EntityPermissions: string implements JsonSerializable { + + case View = 'view'; + case Modify = 'modify'; + case Delete = 'delete'; + case Share = 'share'; + + public function jsonSerialize(): string { + return $this->value; + } + +} \ No newline at end of file diff --git a/shared/lib/People/Entity/IEntityBase.php b/shared/lib/People/Entity/IEntityBase.php new file mode 100644 index 0000000..88bdf47 --- /dev/null +++ b/shared/lib/People/Entity/IEntityBase.php @@ -0,0 +1,94 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity; + +use DateTimeImmutable; +use KTXF\People\Entity\Individual\IndividualObject; + +interface IEntityBase extends \JsonSerializable { + + public const JSON_TYPE = 'people.entity'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_IN = 'in'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_DATA = 'data'; + public const JSON_PROPERTY_CREATED = 'created'; + public const JSON_PROPERTY_MODIFIED = 'modified'; + public const JSON_PROPERTY_SIGNATURE = 'signature'; + + /** + * Unique arbitrary text string identifying the collection this entity belongs to (e.g. 1 or Collection1 or anything else) + * + * @since 2025.05.01 + */ + public function in(): string|int; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or Entity or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the creation date of this entity + */ + public function created(): ?DateTimeImmutable; + + /** + * Gets the modification date of this entity + */ + public function modified(): ?DateTimeImmutable; + + /** + * Gets the signature of this entity + * + * @since 2025.05.01 + */ + public function signature(): ?string; + + /** + * Gets the priority of this entity + * + * @since 2025.05.01 + */ + public function getPriority(): ?int; + + /** + * Gets the visibility of this entity + * + * @since 2025.05.01 + */ + public function getVisibility(): ?bool; + + /** + * Gets the color of this entity + * + * @since 2025.05.01 + */ + public function getColor(): ?string; + + /** + * Gets the object as a class instance. + * + * @since 2025.05.01 + */ + public function getDataObject(): IndividualObject|null; + + /** + * Gets the raw data as an associative array or JSON string. + * + * @since 2025.05.01 + * + * @return array|string|null + */ + public function getDataJson(): array|string|null; + +} diff --git a/shared/lib/People/Entity/IEntityMutable.php b/shared/lib/People/Entity/IEntityMutable.php new file mode 100644 index 0000000..bd30af5 --- /dev/null +++ b/shared/lib/People/Entity/IEntityMutable.php @@ -0,0 +1,52 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity; + +use KTXF\Json\JsonDeserializable; +use KTXF\People\Entity\Individual\IndividualObject; + +interface IEntityMutable extends IEntityBase, JsonDeserializable { + + /** + * Sets the priority of this entity + * + * @since 2025.05.01 + */ + public function setPriority(?int $value): static; + + /** + * Sets the visibility of this entity + * + * @since 2025.05.01 + */ + public function setVisibility(?bool $value): static; + + /** + * Sets the color of this entity + * + * @since 2025.05.01 + */ + public function setColor(?string $value): static; + + /** + * Sets the object as a class instance. + * + * @since 2025.05.01 + */ + public function setDataObject(IndividualObject $value): static; + + /** + * Sets the object data from a json string + * + * @since 2025.05.01 + */ + public function setDataJson(array|string $value): static; + +} diff --git a/shared/lib/People/Individual/IndividualAliasCollection.php b/shared/lib/People/Individual/IndividualAliasCollection.php new file mode 100644 index 0000000..cdc0497 --- /dev/null +++ b/shared/lib/People/Individual/IndividualAliasCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualAliasCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualAliasObject::class); + } + +} diff --git a/shared/lib/People/Individual/IndividualAliasObject.php b/shared/lib/People/Individual/IndividualAliasObject.php new file mode 100644 index 0000000..565808f --- /dev/null +++ b/shared/lib/People/Individual/IndividualAliasObject.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualAliasObject extends JsonSerializableObject { + + public string|null $label = null; + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualAnniversaryCollection.php b/shared/lib/People/Individual/IndividualAnniversaryCollection.php new file mode 100644 index 0000000..b6a744d --- /dev/null +++ b/shared/lib/People/Individual/IndividualAnniversaryCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualAnniversaryCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualAnniversaryObject::class); + } + +} diff --git a/shared/lib/People/Individual/IndividualAnniversaryObject.php b/shared/lib/People/Individual/IndividualAnniversaryObject.php new file mode 100644 index 0000000..62d7050 --- /dev/null +++ b/shared/lib/People/Individual/IndividualAnniversaryObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use DateTimeInterface; +use KTXF\Json\JsonSerializableObject; + +class IndividualAnniversaryObject extends JsonSerializableObject { + + public IndividualAnniversaryTypes|null $type = null; + public DateTimeInterface|null $when = null; + public string|null $location = null; + +} diff --git a/shared/lib/People/Individual/IndividualAnniversaryTypes.php b/shared/lib/People/Individual/IndividualAnniversaryTypes.php new file mode 100644 index 0000000..96598dd --- /dev/null +++ b/shared/lib/People/Individual/IndividualAnniversaryTypes.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +enum IndividualAnniversaryTypes: string { + case Birth = 'birth'; + case Death = 'death'; + case Nuptial = 'nuptial'; +} diff --git a/shared/lib/People/Individual/IndividualCryptoCollection.php b/shared/lib/People/Individual/IndividualCryptoCollection.php new file mode 100644 index 0000000..8bea37d --- /dev/null +++ b/shared/lib/People/Individual/IndividualCryptoCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualCryptoCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualCryptoObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualCryptoObject.php b/shared/lib/People/Individual/IndividualCryptoObject.php new file mode 100644 index 0000000..250b4e4 --- /dev/null +++ b/shared/lib/People/Individual/IndividualCryptoObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualCryptoObject extends JsonSerializableObject { + + public string|null $data = null; + public string|null $type = null; + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualEmailCollection.php b/shared/lib/People/Individual/IndividualEmailCollection.php new file mode 100644 index 0000000..1ee18d1 --- /dev/null +++ b/shared/lib/People/Individual/IndividualEmailCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualEmailCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualEmailObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualEmailObject.php b/shared/lib/People/Individual/IndividualEmailObject.php new file mode 100644 index 0000000..e9b9226 --- /dev/null +++ b/shared/lib/People/Individual/IndividualEmailObject.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualEmailObject extends JsonSerializableObject { + + public string|null $address = null; + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualLanguageCollection.php b/shared/lib/People/Individual/IndividualLanguageCollection.php new file mode 100644 index 0000000..ca596ab --- /dev/null +++ b/shared/lib/People/Individual/IndividualLanguageCollection.php @@ -0,0 +1,18 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualLanguageCollection extends JsonSerializableCollection { + public function __construct(array $data = []) { + parent::__construct($data, IndividualLanguageObject::class); + } +} diff --git a/shared/lib/People/Individual/IndividualLanguageObject.php b/shared/lib/People/Individual/IndividualLanguageObject.php new file mode 100644 index 0000000..a1fbc93 --- /dev/null +++ b/shared/lib/People/Individual/IndividualLanguageObject.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualLanguageObject extends JsonSerializableObject { + + public string|null $Data = null; + + public string|null $Id = null; + public int|null $Priority = null; + public string|null $Context = null; + +} diff --git a/shared/lib/People/Individual/IndividualMediaCollection.php b/shared/lib/People/Individual/IndividualMediaCollection.php new file mode 100644 index 0000000..ca49666 --- /dev/null +++ b/shared/lib/People/Individual/IndividualMediaCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualMediaCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualMediaObject::class, 'string'); + } + +} \ No newline at end of file diff --git a/shared/lib/People/Individual/IndividualMediaObject.php b/shared/lib/People/Individual/IndividualMediaObject.php new file mode 100644 index 0000000..0f226dd --- /dev/null +++ b/shared/lib/People/Individual/IndividualMediaObject.php @@ -0,0 +1,24 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualMediaObject extends JsonSerializableObject { + + public string $type = 'Media'; + public string $kind; + public string $uri; + public string|null $mediaType = null; + public array|null $contexts = null; + public int|null $pref = null; + public string|null $label = null; + +} \ No newline at end of file diff --git a/shared/lib/People/Individual/IndividualNameObject.php b/shared/lib/People/Individual/IndividualNameObject.php new file mode 100644 index 0000000..d134abe --- /dev/null +++ b/shared/lib/People/Individual/IndividualNameObject.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualNameObject extends JsonSerializableObject { + + public string|null $family = null; + public string|null $given = null; + public string|null $additional = null; + public string|null $prefix = null; + public string|null $suffix = null; + public string|null $phoneticFamily = null; + public string|null $phoneticGiven = null; + public string|null $phoneticAdditional = null; + public IndividualAliasCollection $aliases; + + public function __construct() { + $this->aliases = new IndividualAliasCollection(); + } + +} diff --git a/shared/lib/People/Individual/IndividualNoteCollection.php b/shared/lib/People/Individual/IndividualNoteCollection.php new file mode 100644 index 0000000..2138aa9 --- /dev/null +++ b/shared/lib/People/Individual/IndividualNoteCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualNoteCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualNoteObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualNoteObject.php b/shared/lib/People/Individual/IndividualNoteObject.php new file mode 100644 index 0000000..efd9c46 --- /dev/null +++ b/shared/lib/People/Individual/IndividualNoteObject.php @@ -0,0 +1,25 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use DateTimeInterface; +use KTXF\Json\JsonSerializableObject; + +class IndividualNoteObject extends JsonSerializableObject { + + public string|null $content = null; + public DateTimeInterface|null $date = null; + public string|null $authorUri = null; + public string|null $authorName = null; + + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualObject.php b/shared/lib/People/Individual/IndividualObject.php new file mode 100644 index 0000000..55b5501 --- /dev/null +++ b/shared/lib/People/Individual/IndividualObject.php @@ -0,0 +1,62 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use DateTimeInterface; +use KTXF\Json\JsonSerializableObject; + +class IndividualObject extends JsonSerializableObject { + + // Meta Information + public string $type = 'individual'; + public int $version = 1; + public string|null $urid = null; + public ?DateTimeInterface $created = null; + public ?DateTimeInterface $modified = null; + // Personal Information + public string|null $label = null; + public IndividualNameObject $names; + public IndividualTitleCollection $titles; + public IndividualAnniversaryCollection $anniversaries; + // Location Information + public IndividualPhysicalLocationCollection $physicalLocations; + // Communication Information + public IndividualPhoneCollection $phones; + public IndividualEmailCollection $emails; + public IndividualVirtualLocationCollection $virtualLocations; + // Media Information + public IndividualMediaCollection $media; + // Organizations Information + public IndividualOrganizationCollection $organizations; + // Organizational Information + public IndividualTagCollection $tags; + public IndividualNoteCollection $notes; + // Localization Information + public string|null $language = null; + public IndividualLanguageCollection $languages; + // Other Information + public IndividualCryptoCollection $crypto; + + public function __construct() { + $this->names = new IndividualNameObject(); + $this->anniversaries = new IndividualAnniversaryCollection(); + $this->phones = new IndividualPhoneCollection(); + $this->emails = new IndividualEmailCollection(); + $this->physicalLocations = new IndividualPhysicalLocationCollection(); + $this->organizations = new IndividualOrganizationCollection(); + $this->titles = new IndividualTitleCollection(); + $this->tags = new IndividualTagCollection(); + $this->notes = new IndividualNoteCollection(); + $this->crypto = new IndividualCryptoCollection(); + $this->virtualLocations = new IndividualVirtualLocationCollection(); + $this->media = new IndividualMediaCollection(); + } + +} diff --git a/shared/lib/People/Individual/IndividualOrganizationCollection.php b/shared/lib/People/Individual/IndividualOrganizationCollection.php new file mode 100644 index 0000000..fc51b67 --- /dev/null +++ b/shared/lib/People/Individual/IndividualOrganizationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualOrganizationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualOrganizationObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualOrganizationObject.php b/shared/lib/People/Individual/IndividualOrganizationObject.php new file mode 100644 index 0000000..f5aaad3 --- /dev/null +++ b/shared/lib/People/Individual/IndividualOrganizationObject.php @@ -0,0 +1,29 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; +use OCA\JMAPC\Objects\BaseStringCollection; + +class IndividualOrganizationObject extends JsonSerializableObject { + + public string|null $Label; + public BaseStringCollection $Units; + + public string|null $sortName = null; + + public string|null $context = null; + public int|null $priority = null; + + public function __construct() { + $this->units = new BaseStringCollection(); + } + +} diff --git a/shared/lib/People/Individual/IndividualPhoneCollection.php b/shared/lib/People/Individual/IndividualPhoneCollection.php new file mode 100644 index 0000000..342059b --- /dev/null +++ b/shared/lib/People/Individual/IndividualPhoneCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualPhoneCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualPhoneObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualPhoneObject.php b/shared/lib/People/Individual/IndividualPhoneObject.php new file mode 100644 index 0000000..8ada8bf --- /dev/null +++ b/shared/lib/People/Individual/IndividualPhoneObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualPhoneObject extends JsonSerializableObject { + + public string|null $number = null; + + public string|null $label = null; + public string|null $context = null; + public int|null $priority = null; +} diff --git a/shared/lib/People/Individual/IndividualPhysicalLocationCollection.php b/shared/lib/People/Individual/IndividualPhysicalLocationCollection.php new file mode 100644 index 0000000..dc3459a --- /dev/null +++ b/shared/lib/People/Individual/IndividualPhysicalLocationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualPhysicalLocationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualPhysicalLocationObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualPhysicalLocationObject.php b/shared/lib/People/Individual/IndividualPhysicalLocationObject.php new file mode 100644 index 0000000..56f3926 --- /dev/null +++ b/shared/lib/People/Individual/IndividualPhysicalLocationObject.php @@ -0,0 +1,31 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualPhysicalLocationObject extends JsonSerializableObject { + + public string|null $box = null; + public string|null $unit = null; + public string|null $street = null; + public string|null $locality = null; + public string|null $region = null; + public string|null $code = null; + public string|null $country = null; + + public string|null $label = null; + public string|null $coordinates = null; + public string|null $timeZone = null; + + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualPronounCollection.php b/shared/lib/People/Individual/IndividualPronounCollection.php new file mode 100644 index 0000000..d7366c8 --- /dev/null +++ b/shared/lib/People/Individual/IndividualPronounCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualPronounCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualPronounObject::class); + } + +} diff --git a/shared/lib/People/Individual/IndividualPronounObject.php b/shared/lib/People/Individual/IndividualPronounObject.php new file mode 100644 index 0000000..c1392ae --- /dev/null +++ b/shared/lib/People/Individual/IndividualPronounObject.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualPronounObject extends JsonSerializableObject { + + public string|null $pronoun = null; + + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualTagCollection.php b/shared/lib/People/Individual/IndividualTagCollection.php new file mode 100644 index 0000000..56e7b08 --- /dev/null +++ b/shared/lib/People/Individual/IndividualTagCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualTagCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualTitleCollection.php b/shared/lib/People/Individual/IndividualTitleCollection.php new file mode 100644 index 0000000..2c0c0cb --- /dev/null +++ b/shared/lib/People/Individual/IndividualTitleCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualTitleCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualTitleObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualTitleObject.php b/shared/lib/People/Individual/IndividualTitleObject.php new file mode 100644 index 0000000..28c75c4 --- /dev/null +++ b/shared/lib/People/Individual/IndividualTitleObject.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualTitleObject extends JsonSerializableObject { + + public IndividualTitleTypes|null $kind = null; + public string|null $label = null; + public string|null $relation = null; + + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Individual/IndividualTitleTypes.php b/shared/lib/People/Individual/IndividualTitleTypes.php new file mode 100644 index 0000000..7690c64 --- /dev/null +++ b/shared/lib/People/Individual/IndividualTitleTypes.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +enum IndividualTitleTypes: string { + case Title = 't'; + case Role = 'r'; +} diff --git a/shared/lib/People/Individual/IndividualVirtualLocationCollection.php b/shared/lib/People/Individual/IndividualVirtualLocationCollection.php new file mode 100644 index 0000000..9df4fc5 --- /dev/null +++ b/shared/lib/People/Individual/IndividualVirtualLocationCollection.php @@ -0,0 +1,20 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableCollection; + +class IndividualVirtualLocationCollection extends JsonSerializableCollection { + + public function __construct(array $data = []) { + parent::__construct($data, IndividualVirtualLocationObject::class, 'string'); + } + +} diff --git a/shared/lib/People/Individual/IndividualVirtualLocationObject.php b/shared/lib/People/Individual/IndividualVirtualLocationObject.php new file mode 100644 index 0000000..76cfa0a --- /dev/null +++ b/shared/lib/People/Individual/IndividualVirtualLocationObject.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Entity\Individual; + +use KTXF\Json\JsonSerializableObject; + +class IndividualVirtualLocationObject extends JsonSerializableObject { + + public string|null $location = null; + + public string|null $label = null; + public string|null $context = null; + public int|null $priority = null; + +} diff --git a/shared/lib/People/Provider/IProviderBase.php b/shared/lib/People/Provider/IProviderBase.php new file mode 100644 index 0000000..5f778af --- /dev/null +++ b/shared/lib/People/Provider/IProviderBase.php @@ -0,0 +1,96 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Provider; + +use JsonSerializable; +use KTXF\People\Service\IServiceBase; + +interface IProviderBase extends JsonSerializable { + + public const CAPABILITY_SERVICE_LIST = 'ServiceList'; + public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch'; + public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant'; + + public const JSON_TYPE = 'people.provider'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + + /** + * Confirms if specific capability is supported (e.g. 'ServiceList') + * + * @since 2025.05.01 + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else) + * + * @since 2025.05.01 + */ + public function id(): string; + + /** + * The localized human friendly name of this provider (e.g. System Contacts Provider) + * + * @since 2025.05.01 + */ + public function label(): string; + + /** + * Retrieve collection of services for a specific user + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param array $filter filter criteria + * + * @return array collection of service objects + */ + public function serviceList(string $tenantId, string $userId, array $filter): array; + + /** + * Determine if any services are configured for a specific user + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param int|string ...$identifiers variadic collection of service identifiers + * + * @return array collection of service identifiers with boolean values indicating if the service is available + */ + public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array; + + /** + * Retrieve a service with a specific identifier + * + * @since 2025.05.01 + * + * @param string $tenantId tenant identifier + * @param string $userId user identifier + * @param string|int $identifier service identifier + * + * @return IServiceBase|null returns service object or null if non found + */ + public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase; + +} diff --git a/shared/lib/People/Provider/IProviderServiceMutate.php b/shared/lib/People/Provider/IProviderServiceMutate.php new file mode 100644 index 0000000..e128c96 --- /dev/null +++ b/shared/lib/People/Provider/IProviderServiceMutate.php @@ -0,0 +1,69 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Provider; + +use KTXF\Json\JsonDeserializable; +use KTXF\People\Service\IServiceBase; + +interface IProviderServiceMutate extends JsonDeserializable { + + public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh'; + public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate'; + public const CAPABILITY_SERVICE_UPDATE = 'ServiceUpdate'; + public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy'; + + /** + * construct and new blank service instance + * + * @since 2025.05.01 + * + * @param string $userId user identifier + * + * @return IServiceBase + */ + public function serviceFresh(string $userId = ''): IServiceBase; + + /** + * create a service configuration for a specific user + * + * @since 2025.05.01 + * + * @param string $userId user identifier + * @param IServiceBase $service service instance + * + * @return string + */ + public function serviceCreate(string $userId, IServiceBase $service): string; + + /** + * modify a service configuration for a specific user + * + * @since 2025.05.01 + * + * @param string $userId user identifier + * @param IServiceBase $service service instance + * + * @return string + */ + public function serviceModify(string $userId, IServiceBase $service): string; + + /** + * delete a service configuration for a specific user + * + * @since 2025.05.01 + * + * @param string $userId user identifier + * @param IServiceBase $service service instance + * + * @return bool + */ + public function serviceDestroy(string $userId, IServiceBase $service): bool; + +} diff --git a/shared/lib/People/Service/IServiceBase.php b/shared/lib/People/Service/IServiceBase.php new file mode 100644 index 0000000..fe8901d --- /dev/null +++ b/shared/lib/People/Service/IServiceBase.php @@ -0,0 +1,221 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Service; + +use JsonSerializable; +use KTXF\People\Collection\ICollectionBase; +use KTXF\Resource\Filter\IFilter; +use KTXF\Resource\Range\IRange; +use KTXF\Resource\Range\RangeType; +use KTXF\Resource\Sort\ISort; + +interface IServiceBase extends JsonSerializable { + + public const CAPABILITY_COLLECTION_LIST = 'CollectionList'; + public const CAPABILITY_COLLECTION_LIST_FILTER = 'CollectionListFilter'; + public const CAPABILITY_COLLECTION_LIST_SORT = 'CollectionListSort'; + public const CAPABILITY_COLLECTION_EXTANT = 'CollectionExtant'; + public const CAPABILITY_COLLECTION_FETCH = 'CollectionFetch'; + + public const CAPABILITY_ENTITY_LIST = 'EntityList'; + public const CAPABILITY_ENTITY_LIST_FILTER = 'EntityListFilter'; + public const CAPABILITY_ENTITY_LIST_SORT = 'EntityListSort'; + public const CAPABILITY_ENTITY_LIST_RANGE = 'EntityListRange'; + public const CAPABILITY_ENTITY_DELTA = 'EntityDelta'; + public const CAPABILITY_ENTITY_EXTANT = 'EntityExtant'; + public const CAPABILITY_ENTITY_FETCH = 'EntityFetch'; + + public const JSON_TYPE = 'people.service'; + public const JSON_PROPERTY_TYPE = '@type'; + public const JSON_PROPERTY_PROVIDER = 'provider'; + public const JSON_PROPERTY_ID = 'id'; + public const JSON_PROPERTY_LABEL = 'label'; + public const JSON_PROPERTY_CAPABILITIES = 'capabilities'; + public const JSON_PROPERTY_ENABLED = 'enabled'; + + /** + * Confirms if specific capability is supported + * + * @since 2025.05.01 + * + * @param string $value required ability e.g. 'EntityList' + * + * @return bool + */ + public function capable(string $value): bool; + + /** + * Lists all supported capabilities + * + * @since 2025.05.01 + * + * @return array + */ + public function capabilities(): array; + + /** + * Unique identifier of the provider this service belongs to + * + * @since 2025.05.01 + */ + public function in(): string; + + /** + * Unique arbitrary text string identifying this service (e.g. 1 or service1 or anything else) + * + * @since 2025.05.01 + */ + public function id(): string|int; + + /** + * Gets the localized human friendly name of this service (e.g. ACME Company Mail Service) + * + * @since 2025.05.01 + */ + public function getLabel(): string; + + /** + * Gets the active status of this service + * + * @since 2025.05.01 + */ + public function getEnabled(): bool; + + /** + * List of accessible collection + * + * @since 2025.05.01 + * + * @return array + */ + public function collectionList(?IFilter $filter = null, ?ISort $sort = null): array; + + /** + * Fresh filter for collection list + * + * @since 2025.05.01 + * + * @return IFilter + */ + public function collectionListFilter(): IFilter; + + /** + * Fresh sort for collection list + * + * @since 2025.05.01 + * + * @return ISort + */ + public function collectionListSort(): ISort; + + /** + * Fetches details about a specific collection + * + * @since 2025.05.01 + * + * @param string|int $id collection identifier + */ + public function collectionExtant(string|int $identifier): bool; + + /** + * Fetches details about a specific collection + * + * @since 2025.05.01 + * + * @param string|int $identifier collection identifier + */ + public function collectionFetch(string|int $identifier): ?ICollectionBase; + + /** + * Lists all entities in a specific collection + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * + * @return array + */ + public function entityList(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $elements = null): array; + + /** + * Fresh filter for entity list + * + * @since 2025.05.01 + * + * @return Filter + */ + public function entityListFilter(): IFilter; + + /** + * Fresh sort for entity list + * + * @since 2025.05.01 + * + * @return ISort + */ + public function entityListSort(): ISort; + + /** + * Fresh range for entity list + * + * @since 2025.05.01 + * + * @param RangeType $type range type + * + * @return IRange + */ + public function entityListRange(RangeType $type): IRange; + + /** + * Lists of all changes from a specific token + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param string $signature token signature + * @param string $detail detail level ids | meta | full + * + * @return array + * + * [ + * 'added' => array, + * 'updated' => array, + * 'deleted' => array, + * 'signature' => string + * ] + * + */ + public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): array; + + /** + * Confirms if specific entity exists in a collection + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param string|int ...$identifiers list of entity identifiers + * + * @return array + */ + public function entityExtant(string|int $collection, string|int ...$identifiers): array; + + /** + * Fetches details about a specific entities in a collection + * + * @since 2025.05.01 + * + * @param string|int $collection collection identifier + * @param string|int ...$identifiers entity identifier + * + * @return array + */ + public function entityFetch(string|int $collection, string|int ...$identifiers): array; + +} diff --git a/shared/lib/People/Service/IServiceCollectionMutable.php b/shared/lib/People/Service/IServiceCollectionMutable.php new file mode 100644 index 0000000..84fe128 --- /dev/null +++ b/shared/lib/People/Service/IServiceCollectionMutable.php @@ -0,0 +1,79 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Service; + +use KTXF\People\Collection\ICollectionBase; +use KTXF\People\Collection\ICollectionMutable; + +interface IServiceCollectionMutable extends IServiceBase { + + public const CAPABILITY_COLLECTION_CREATE = 'CollectionCreate'; + public const CAPABILITY_COLLECTION_MODIFY = 'CollectionModify'; + public const CAPABILITY_COLLECTION_DESTROY = 'CollectionDestroy'; + public const CAPABILITY_COLLECTION_MOVE = 'CollectionMove'; + + /** + * Creates a new, empty collection object + * + * @since 2025.05.01 + * + * @return ICollectionMutable + */ + public function collectionFresh(): ICollectionMutable; + + /** + * Creates a new collection at the specified location + * + * @since 2025.05.01 + * + * @param string|int $location The parent collection to create this collection in, or empty string for root + * @param ICollectionMutable $collection The collection to create + * @param array $options Additional options for the collection creation + * + * @return ICollectionBase + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionCreate(string|int $location, ICollectionMutable $collection, array $options): ICollectionBase; + + /** + * Modifies an existing collection + * + * @since 2025.05.01 + * + * @param string|int $identifier The ID of the collection to modify + * @param ICollectionMutable $collection The collection with modifications + * + * @return ICollectionBase + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionModify(string|int $identifier, ICollectionMutable $collection): ICollectionBase; + + /** + * Destroys an existing collection + * + * @since 2025.05.01 + * + * @param string|int $identifier The ID of the collection to destroy + * + * @return bool + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function collectionDestroy(string|int $identifier): bool; + +} diff --git a/shared/lib/People/Service/IServiceEntityMutable.php b/shared/lib/People/Service/IServiceEntityMutable.php new file mode 100644 index 0000000..b80f840 --- /dev/null +++ b/shared/lib/People/Service/IServiceEntityMutable.php @@ -0,0 +1,82 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Service; + +use KTXF\People\Entity\IEntityBase; +use KTXF\People\Entity\IEntityMutable; + +interface IServiceEntityMutable extends IServiceBase { + + public const CAPABILITY_ENTITY_CREATE = 'EntityCreate'; + public const CAPABILITY_ENTITY_MODIFY = 'EntityModify'; + public const CAPABILITY_ENTITY_DESTROY = 'EntityDestroy'; + public const CAPABILITY_ENTITY_COPY = 'EntityCopy'; + public const CAPABILITY_ENTITY_MOVE = 'EntityMove'; + + /** + * Creates a fresh entity of the specified type + * + * @since 2025.05.01 + * + * @return IEntityMutable + */ + public function entityFresh(): IEntityMutable; + + /** + * Creates a new entity in the specified collection + * + * @since 2025.05.01 + * + * @param string|int $collection The collection to create this entity in + * @param IEntityMutable $entity The entity to create + * @param array $options Additional options for the entity creation + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityCreate(string|int $collection, IEntityMutable $entity, array $options): IEntityMutable; + + /** + * Modifies an existing entity in the specified collection + * + * @since 2025.05.01 + * + * @param string|int $collection The collection containing the entity to modify + * @param string|int $identifier The ID of the entity to modify + * @param IEntityMutable $entity The entity with modifications + * + * @return IEntityMutable + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityModify(string|int $collection, string|int $identifier, IEntityMutable $entity): IEntityMutable; + + /** + * Destroys an existing entity in the specified collection + * + * @since 2025.05.01 + * + * @param string|int $collection The collection containing the entity to destroy + * @param string|int $identifier The ID of the entity to destroy + * + * @return bool + * + * @throws \KTXF\Resource\Exceptions\InvalidArgumentException + * @throws \KTXF\Resource\Exceptions\UnsupportedException + * @throws \KTXF\Resource\Exceptions\UnauthorizedException + */ + public function entityDestroy(string|int $collection, string|int $identifier): IEntityBase; + +} diff --git a/shared/lib/People/Service/IServiceMutable.php b/shared/lib/People/Service/IServiceMutable.php new file mode 100644 index 0000000..f35c8a9 --- /dev/null +++ b/shared/lib/People/Service/IServiceMutable.php @@ -0,0 +1,30 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\People\Service; + +use KTXF\Json\JsonDeserializable; + +interface IServiceMutable extends IServiceBase, JsonDeserializable { + + /** + * Sets the localized human friendly name of this service (e.g. ACME Company Mail Service) + * + * @since 2025.05.01 + */ + public function setLabel(string $value): self; + + /** + * Sets the active status of this service + * + * @since 2025.05.01 + */ + public function setEnabled(bool $value): self; + +} diff --git a/shared/lib/Resource/Exceptions/InvalidParameterException.php b/shared/lib/Resource/Exceptions/InvalidParameterException.php new file mode 100644 index 0000000..ecf90ac --- /dev/null +++ b/shared/lib/Resource/Exceptions/InvalidParameterException.php @@ -0,0 +1,21 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Filter; + +class Filter implements IFilter { + + protected array $attributes = []; + protected array $conditions = []; + + /** + * Constructor + * + * @since 2025.05.01 + * + * @param array $attributes List of attributes that can be used in the filter + */ + public function __construct(array $attributes) { + foreach ($attributes as $id => $value) { + // separate the value into components + [$type, $length, $comparatorDefault, $comparatorSupported] = explode(':', $value); + // validate the components + $this->attributes[$id]['type'] = match ($type) { + 's' => 'string', + 'i' => 'integer', + 'b' => 'boolean', + 'a' => 'array', + default => throw new \InvalidArgumentException("Invalid type '$type' for attribute '$id'."), + }; + $this->attributes[$id]['length'] = (int)$length; + $this->attributes[$id]['comparatorDefault'] = FilterComparisonOperator::from((int)$comparatorDefault); + // comparatorSupported is a bitmask of supported comparators + if ($comparatorSupported !== '0') { + $comparators = FilterComparisonOperator::cases(); + foreach ($comparators as $comparator) { + if (($comparatorSupported & $comparator->value) === $comparator->value) { + $this->attributes[$id]['comparatorSupported'][] = $comparator; + } + } + } else { + $this->attributes[$id]['comparatorSupported'] = []; + } + } + } + + /** + * List of attributes that can be used in the filter + * + * @since 2025.05.01 + * + * @return array + */ + public function attributes(): array { + return $this->attributes; + } + + /** + * List of comparison operators that can be used in the filter + * + * @since 2025.05.01 + */ + public function comparators(): string { + return FilterComparisonOperator::class; + } + + /** + * List of conjunction operators that can be used in the filter + * + * @since 2025.05.01 + */ + public function conjunctions(): string { + return FilterConjunctionOperator::class; + } + + /** + * Define a condition for the filter + * + * @since 2025.05.01 + */ + public function condition(string $attribute, mixed $value, ?FilterComparisonOperator $comparator = null, ?FilterConjunctionOperator $conjunction = null): void { + // check if the attribute is defined in the filter + if (!isset($this->attributes[$attribute])) { + throw new \InvalidArgumentException("Attribute '$attribute' is not defined in the filter"); + } + // check if comparator is valid and supported for the attribute + if ($comparator === null) { + $comparator = $this->attributes[$attribute]['comparatorDefault']; + } + if (!in_array($comparator, $this->attributes[$attribute]['comparatorSupported'], true)) { + throw new \InvalidArgumentException("Comparator '$comparator' is not supported for attribute '$attribute'"); + } + // check if the value type is valid for the attribute + if ($this->attributes[$attribute]['type'] !== gettype($value)) { + throw new \InvalidArgumentException("Value for attribute '$attribute' must be of type '" . $this->attributes[$attribute]['type'] . "'"); + } + // check if the value length is within the defined limit + if ($this->attributes[$attribute]['type'] === 'array' && $this->attributes[$attribute]['length'] <= count($value)) { + throw new \InvalidArgumentException("Value for attribute '$attribute' exceeds the maximum length of " . $this->attributes[$attribute]['length'] . " items"); + } + if ($this->attributes[$attribute]['type'] === 'string' && $this->attributes[$attribute]['length'] <= mb_strlen($value)) { + throw new \InvalidArgumentException("Value for attribute '$attribute' exceeds the maximum length of " . $this->attributes[$attribute]['length'] . " characters"); + } + + $this->conditions[$attribute] = [ + 'attribute' => $attribute, + 'value' => $value, + 'comparator' => $comparator, + 'conjunction' => $conjunction, + ]; + } + + /** + * List of defined conditions + * + * @since 2025.05.01 + * + * @return array + */ + public function conditions(): array { + return $this->conditions; + } + +} diff --git a/shared/lib/Resource/Filter/FilterComparisonOperator.php b/shared/lib/Resource/Filter/FilterComparisonOperator.php new file mode 100644 index 0000000..1e3f4ca --- /dev/null +++ b/shared/lib/Resource/Filter/FilterComparisonOperator.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Filter; + +enum FilterComparisonOperator: int { + case EQ = 1; + case NEQ = 2; + case GT = 4; + case LT = 8; + case GTE = 16; + case LTE = 32; + case IN = 64; + case NIN = 128; + case LIKE = 256; + case NLIKE = 512; +} diff --git a/shared/lib/Resource/Filter/FilterConjunctionOperator.php b/shared/lib/Resource/Filter/FilterConjunctionOperator.php new file mode 100644 index 0000000..2dc4243 --- /dev/null +++ b/shared/lib/Resource/Filter/FilterConjunctionOperator.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Filter; + +enum FilterConjunctionOperator: string { + case NONE = ''; + case AND = 'AND'; + case OR = 'OR'; +} diff --git a/shared/lib/Resource/Filter/IFilter.php b/shared/lib/Resource/Filter/IFilter.php new file mode 100644 index 0000000..7d1b861 --- /dev/null +++ b/shared/lib/Resource/Filter/IFilter.php @@ -0,0 +1,53 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Filter; + +interface IFilter { + + /** + * List of attributes that can be used in the filter + * + * @since 2025.05.01 + * + * @return array + */ + public function attributes(): array; + + /** + * List of comparison operators that can be used in the filter + * + * @since 2025.05.01 + */ + public function comparators(): string; + + /** + * List of conjunction operators that can be used in the filter + * + * @since 2025.05.01 + */ + public function conjunctions(): string; + + /** + * Define a filter condition + * + * @since 2025.05.01 + */ + public function condition(string $property, mixed $value, ?FilterComparisonOperator $comparator = null, ?FilterConjunctionOperator $conjunction = null): void; + + /** + * list of defined conditions + * + * @since 2025.05.01 + * + * @return array + */ + public function conditions(): array; + +} diff --git a/shared/lib/Resource/Provider/ProviderInterface.php b/shared/lib/Resource/Provider/ProviderInterface.php new file mode 100644 index 0000000..66027d5 --- /dev/null +++ b/shared/lib/Resource/Provider/ProviderInterface.php @@ -0,0 +1,44 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +interface IRange { + + /** + * Gets the type of this range + * + * @since 1.0.0 + */ + public function type(): RangeType; + +} diff --git a/shared/lib/Resource/Range/IRangeDate.php b/shared/lib/Resource/Range/IRangeDate.php new file mode 100644 index 0000000..c9b221f --- /dev/null +++ b/shared/lib/Resource/Range/IRangeDate.php @@ -0,0 +1,40 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +use DateTimeInterface; + +interface IRangeDate extends IRange { + + /** + * + * @since 1.0.0 + */ + public function getStart(): DateTimeInterface; + + /** + * + * @since 1.0.0 + */ + public function setStart(DateTimeInterface $value): void; + + /** + * + * @since 1.0.0 + */ + public function getEnd(): DateTimeInterface; + + /** + * + * @since 1.0.0 + */ + public function setEnd(DateTimeInterface $value): void; + +} diff --git a/shared/lib/Resource/Range/IRangeTally.php b/shared/lib/Resource/Range/IRangeTally.php new file mode 100644 index 0000000..b26abfa --- /dev/null +++ b/shared/lib/Resource/Range/IRangeTally.php @@ -0,0 +1,56 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +interface IRangeTally extends IRange { + + /** + * Gets the anchor type of the range + * + * @since 1.0.0 + */ + public function getAnchor(): RangeAnchorType; + + /** + * Sets the anchor type of the range + * + * @since 1.0.0 + */ + public function setAnchor(RangeAnchorType $value): void; + + /** + * Gets the start position of the range + * + * @since 1.0.0 + */ + public function getPosition(): string|int; + + /** + * Sets the start position of the range + * + * @since 1.0.0 + */ + public function setPosition(string|int $value): void; + + /** + * Gets the count of items in the range + * + * @since 1.0.0 + */ + public function getTally(): int; + + /** + * Sets the count of items in the range + * + * @since 1.0.0 + */ + public function setTally(int $value): void; + +} diff --git a/shared/lib/Resource/Range/Range.php b/shared/lib/Resource/Range/Range.php new file mode 100644 index 0000000..49e38ac --- /dev/null +++ b/shared/lib/Resource/Range/Range.php @@ -0,0 +1,23 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +class Range implements IRange { + + /** + * Returns the type of the range + * + * @since 1.0.0 + */ + public function type(): RangeType { + return RangeType::NONE; + } + +} diff --git a/shared/lib/Resource/Range/RangeAnchorType.php b/shared/lib/Resource/Range/RangeAnchorType.php new file mode 100644 index 0000000..c6cb66f --- /dev/null +++ b/shared/lib/Resource/Range/RangeAnchorType.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +enum RangeAnchorType: string { + case RELATIVE = 'relative'; // A relative anchor is used to indicate a starting position based on a record identifier + case ABSOLUTE = 'absolute'; // A absolute anchor is used to indicate a starting position based on record count +} diff --git a/shared/lib/Resource/Range/RangeDate.php b/shared/lib/Resource/Range/RangeDate.php new file mode 100644 index 0000000..069049b --- /dev/null +++ b/shared/lib/Resource/Range/RangeDate.php @@ -0,0 +1,65 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +use DateTime; +use DateTimeInterface; + +class RangeDate extends Range implements IRangeDate { + + protected DateTimeInterface $start; + protected DateTimeInterface $end; + + /** + * Returns the type of the range + * + * @since 1.0.0 + */ + public function type(): RangeType { + return RangeType::DATE; + } + + /** + * Gets the start date of the range + * + * @since 1.0.0 + */ + public function getStart(): DateTimeInterface { + return $this->start; + } + + /** + * Sets the start date of the range + * + * @since 1.0.0 + */ + public function setStart(DateTimeInterface $value): void { + $this->start = $value; + } + + /** + * Gets the end date of the range + * + * @since 1.0.0 + */ + public function getEnd(): DateTimeInterface { + return $this->end; + } + + /** + * Sets the end date of the range + * + * @since 1.0.0 + */ + public function setEnd(DateTimeInterface $value): void { + $this->end = $value; + } + +} diff --git a/shared/lib/Resource/Range/RangeTally.php b/shared/lib/Resource/Range/RangeTally.php new file mode 100644 index 0000000..4ce4e6d --- /dev/null +++ b/shared/lib/Resource/Range/RangeTally.php @@ -0,0 +1,81 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +class RangeTally extends Range implements IRangeTally { + + protected RangeAnchorType $anchor = RangeAnchorType::ABSOLUTE; + protected string|int $position = 0; + protected int $tally = 32; + + /** + * Returns the type of the range + * + * @since 1.0.0 + */ + public function type(): RangeType { + return RangeType::TALLY; + } + + /** + * Gets the anchor type of the range + * + * @since 1.0.0 + */ + public function getAnchor(): RangeAnchorType { + return $this->anchor; + } + + /** + * Sets the anchor type of the range + * + * @since 1.0.0 + */ + public function setAnchor(RangeAnchorType $value): void { + $this->anchor = $value; + } + + /** + * Gets the start position of the range + * + * @since 1.0.0 + */ + public function getPosition(): string|int { + return $this->position; + } + + /** + * Sets the start position of the range + * + * @since 1.0.0 + */ + public function setPosition(string|int $value): void { + $this->position = $value; + } + + /** + * Gets the count of items in the range + * + * @since 1.0.0 + */ + public function getTally(): int { + return $this->tally; + } + + /** + * Sets the count of items in the range + * + * @since 1.0.0 + */ + public function setTally(int $value): void { + $this->tally = $value; + } + +} diff --git a/shared/lib/Resource/Range/RangeType.php b/shared/lib/Resource/Range/RangeType.php new file mode 100644 index 0000000..7d4b6af --- /dev/null +++ b/shared/lib/Resource/Range/RangeType.php @@ -0,0 +1,16 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Range; + +enum RangeType: string { + case NONE = 'none'; + case DATE = 'date'; + case TALLY = 'tally'; +} diff --git a/shared/lib/Resource/Selector/CollectionSelector.php b/shared/lib/Resource/Selector/CollectionSelector.php new file mode 100644 index 0000000..d985f08 --- /dev/null +++ b/shared/lib/Resource/Selector/CollectionSelector.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +/** + * Collection-level selector + */ +class CollectionSelector extends SelectorAbstract { + + protected array $keyTypes = ['string', 'integer']; + protected array $valueTypes = ['boolean', EntitySelector::class]; + protected string $nestedSelector = EntitySelector::class; + protected string $selectorName = 'CollectionSelector'; + +} diff --git a/shared/lib/Resource/Selector/EntitySelector.php b/shared/lib/Resource/Selector/EntitySelector.php new file mode 100644 index 0000000..23ae89d --- /dev/null +++ b/shared/lib/Resource/Selector/EntitySelector.php @@ -0,0 +1,46 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +/** + * Entity-level selector (leaf node) + */ +class EntitySelector extends SelectorAbstract { + + protected array $keyTypes = ['string', 'integer']; + protected array $valueTypes = ['boolean', 'array', 'string', 'integer']; + protected string $selectorName = 'EntitySelector'; + + public function append($value): void { + if (!is_string($value) && !is_int($value)) { + throw new \InvalidArgumentException('EntitySelector values must be string or int'); + } + parent::append($value); + } + + public function offsetSet($key, $value): void { + if ($key !== null && !is_int($key)) { + throw new \InvalidArgumentException('EntitySelector does not support associative keys'); + } + if (!is_string($value) && !is_int($value)) { + throw new \InvalidArgumentException('EntitySelector values must be string or int'); + } + parent::offsetSet($key, $value); + } + + /** + * Get all entity identifiers + * @return array + */ + public function identifiers(): array { + return $this->getArrayCopy(); + } + +} diff --git a/shared/lib/Resource/Selector/SelectorAbstract.php b/shared/lib/Resource/Selector/SelectorAbstract.php new file mode 100644 index 0000000..143566d --- /dev/null +++ b/shared/lib/Resource/Selector/SelectorAbstract.php @@ -0,0 +1,129 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +use JsonSerializable; +use KTXF\Json\JsonDeserializable; + +/** + * Abstract base class for all selector types + * Provides common functionality for hierarchical selectors + */ +abstract class SelectorAbstract extends \ArrayObject implements JsonSerializable, JsonDeserializable { + + protected const TYPE_STRING = 'string'; + protected const TYPE_INT = 'int'; + protected const TYPE_BOOL = 'bool'; + + /** @var array Allowed key types: 'string', 'int' */ + protected array $keyTypes = []; + + /** @var array Allowed scalar value types: 'string', 'int', 'bool' */ + protected array $valueTypes = []; + + /** @var class-string selector class for nested structures */ + protected string $nestedSelector = SelectorAbstract::class; + + /** @var string Human-readable name for this selector type */ + protected string $selectorName = 'Selector'; + + /** + * Serialize to JSON-compatible array + * + * @return array + */ + public function jsonSerialize(): array { + $result = []; + foreach ($this as $key => $value) { + if ($value instanceof JsonSerializable) { + $result[$key] = $value->jsonSerialize(); + } else { + $result[$key] = $value; + } + } + return $result; + } + + /** + * Deserialize from JSON-compatible array + * @param array $data + * @return void + * @throws \InvalidArgumentException + */ + public function jsonDeserialize(array|string $data): static { + if (is_string($data)) { + $data = json_decode($data, true); + } + foreach ($data as $key => $value) { + if ($this->nestedSelector !== null && is_array($value)) { + $selector = new $this->nestedSelector(); + $selector->jsonDeserialize($value); + $this->offsetSet($key, $selector); + } else { + $this->offsetSet($key, $value); + } + } + + return $this; + } + + /** + * Validate if a key is of the correct type for this selector + * + * @param mixed $key + * @return bool + */ + protected function validateKey(mixed $key): bool { + return in_array(gettype($key), $this->keyTypes, true); + } + + /** + * Validate if a value is of the correct type for this selector + * + * @param mixed $value + * @return bool + */ + protected function validateValue(mixed $value): bool { + if ($this->nestedSelector !== null && $value instanceof $this->nestedSelector) { + return true; + } + return in_array(gettype($value), $this->valueTypes, true); + } + + /** + * Override offsetSet to enforce type checking + * + * @param mixed $key + * @param mixed $value + * @return void + * @throws \InvalidArgumentException + */ + #[\Override] + public function offsetSet($key, $value): void { + if (!$this->validateKey($key)) { + throw new \InvalidArgumentException("{$this->selectorName} keys must be one of [" . implode(', ', $this->keyTypes) . "], got " . gettype($key)); + } + + if (!$this->validateValue($value)) { + throw new \InvalidArgumentException("{$this->selectorName} values must be one of [" . implode(', ', $this->valueTypes) . "], got " . gettype($value)); + } + parent::offsetSet($key, $value); + } + + /** + * Get all identifiers (keys or values depending on selector type) + * + * @return array + */ + public function identifiers(): array { + return array_keys($this->getArrayCopy()); + } + +} diff --git a/shared/lib/Resource/Selector/ServiceSelector.php b/shared/lib/Resource/Selector/ServiceSelector.php new file mode 100644 index 0000000..7b0e2e6 --- /dev/null +++ b/shared/lib/Resource/Selector/ServiceSelector.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +/** + * Service-level selector + */ +class ServiceSelector extends SelectorAbstract { + + protected array $keyTypes = ['string', 'int']; + protected array $valueTypes = ['boolean', CollectionSelector::class]; + protected string $nestedSelector = CollectionSelector::class; + protected string $selectorName = 'CollectionSelector'; + +} diff --git a/shared/lib/Resource/Selector/SourceSelector.php b/shared/lib/Resource/Selector/SourceSelector.php new file mode 100644 index 0000000..4c592e4 --- /dev/null +++ b/shared/lib/Resource/Selector/SourceSelector.php @@ -0,0 +1,22 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Selector; + +/** + * Top-level selector for sources + */ +class SourceSelector extends SelectorAbstract { + + protected array $keyTypes = ['string']; + protected array $valueTypes = ['boolean', ServiceSelector::class]; + protected string $nestedSelector = ServiceSelector::class; + protected string $selectorName = 'ProviderSelector'; + +} diff --git a/shared/lib/Resource/Sort/ISort.php b/shared/lib/Resource/Sort/ISort.php new file mode 100644 index 0000000..6af666d --- /dev/null +++ b/shared/lib/Resource/Sort/ISort.php @@ -0,0 +1,42 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Sort; + +interface ISort { + + /** + * List of available attributes + * + * @since 1.0.0 + * + * @return array + */ + public function attributes(): array; + + /** + * Define sort condition + * + * @since 1.0.0 + * + * @param string $attribute attribute name + * @param bool $direction true for ascending, false for descending + */ + public function condition(string $property, bool $direction): void; + + /** + * List of sort conditions + * + * @since 1.0.0 + * + * @return array + */ + public function conditions(): array; + +} diff --git a/shared/lib/Resource/Sort/Sort.php b/shared/lib/Resource/Sort/Sort.php new file mode 100644 index 0000000..f2023b9 --- /dev/null +++ b/shared/lib/Resource/Sort/Sort.php @@ -0,0 +1,57 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Resource\Sort; + +class Sort implements ISort { + + protected array $attributes = []; + protected array $conditions = []; + + public function __construct(array $attributes) { + $this->attributes = $attributes; + } + + /** + * + * @since 1.0.0 + * + * @return array + */ + public function attributes(): array { + return $this->attributes; + } + + /** + * + * @since 1.0.0 + * + * @param string $attribute attribute name + * @param bool $direction true for ascending, false for descending + */ + public function condition(string $attribute, bool $direction): void { + if (isset($this->attributes[$attribute])) { + $this->conditions[$attribute] = [ + 'attribute' => $attribute, + 'direction' => $direction, + ]; + } + } + + /** + * + * @since 1.0.0 + * + * @return array + */ + public function conditions(): array { + return $this->conditions; + } + +} diff --git a/shared/lib/Routing/Attributes/AnonymousRoute.php b/shared/lib/Routing/Attributes/AnonymousRoute.php new file mode 100644 index 0000000..7cb67ba --- /dev/null +++ b/shared/lib/Routing/Attributes/AnonymousRoute.php @@ -0,0 +1,15 @@ +state; + } + + /** + * Check if session has expired + */ + public function isExpired(): bool + { + return time() > $this->expiresAt; + } + + /** + * Check if session is in initial state + */ + public function isFresh(): bool + { + return $this->state === self::STATE_FRESH; + } + + /** + * Check if session has identity but awaiting authentication + */ + public function isIdentified(): bool + { + return $this->state === self::STATE_IDENTIFIED; + } + + /** + * Check if session is in the process of authenticating + */ + public function isAuthenticating(): bool + { + return $this->state === self::STATE_AUTHENTICATING; + } + + /** + * Check if session is complete + */ + public function isComplete(): bool + { + return $this->state === self::STATE_COMPLETE; + } + + /** + * Set user identity (before authentication) + */ + public function setIdentity(string $value): void + { + $this->userIdentity = $value; + $this->state = self::STATE_IDENTIFIED; + } + + public function setMethods(array $methods, int $require = 1): void + { + $this->methodsAvailable = $methods; + $this->methodsRequired = $require; + } + + public function methodEligible(string $method): bool + { + return in_array($method, $this->methodsAvailable, true) + && !in_array($method, $this->methodsCompleted, true); + } + /** + * Mark a method as completed + */ + public function methodCompleted(string $method): void + { + if (!in_array($method, $this->methodsCompleted, true)) { + $this->methodsCompleted[] = $method; + } + + // If we have required factors and all are complete, mark session complete + if (count($this->methodsCompleted) >= $this->methodsRequired) { + $this->state = self::STATE_COMPLETE; + } + } + + /** + * Get methods that still need to be completed + */ + public function methodsRemaining(): array + { + return array_values(array_diff($this->methodsAvailable, $this->methodsCompleted)); + } + + /** + * Promote session after successful primary auth (set user info) + */ + public function setUser(string $userIdentifier, string $userIdentity): void + { + $this->userIdentifier = $userIdentifier; + $this->userIdentity = $userIdentity; + } + + /** + * Get metadata value + */ + public function getMeta(string $key, mixed $default = null): mixed + { + return $this->metadata[$key] ?? $default; + } + + /** + * Set metadata value + */ + public function setMeta(string $key, mixed $value): void + { + $this->metadata[$key] = $value; + } + + /** + * Serialize to array for storage + */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'state' => $this->state, + 'tenant_identifier' => $this->tenantIdentifier, + 'user_identifier' => $this->userIdentifier, + 'user_identity' => $this->userIdentity, + 'methods_required' => $this->methodsRequired, + 'methods_available' => $this->methodsAvailable, + 'methods_completed' => $this->methodsCompleted, + 'metadata' => $this->metadata, + 'created_at' => $this->createdAt, + 'expires_at' => $this->expiresAt, + ]; + } + + /** + * Deserialize from array + */ + public static function fromArray(array $data): self + { + return new self( + id: $data['id'], + state: $data['state'], + tenantIdentifier: $data['tenant_identifier'], + userIdentifier: $data['user_identifier'] ?? null, + userIdentity: $data['user_identity'] ?? null, + methodsRequired: $data['methods_required'] ?? 1, + methodsAvailable: $data['methods_available'] ?? [], + methodsCompleted: $data['methods_completed'] ?? [], + metadata: $data['metadata'] ?? [], + createdAt: $data['created_at'], + expiresAt: $data['expires_at'], + ); + } +} diff --git a/shared/lib/Security/Authentication/ProviderContext.php b/shared/lib/Security/Authentication/ProviderContext.php new file mode 100644 index 0000000..965eacc --- /dev/null +++ b/shared/lib/Security/Authentication/ProviderContext.php @@ -0,0 +1,75 @@ +metadata[$key] ?? $default; + } + + /** + * Get config value + */ + public function getConfig(string $key, mixed $default = null): mixed + { + return $this->config[$key] ?? $default; + } + + /** + * Create context with updated metadata + */ + public function withMetadata(array $metadata): self + { + return new self( + tenantId: $this->tenantId, + userIdentifier: $this->userIdentifier, + userIdentity: $this->userIdentity, + metadata: $metadata, + config: $this->config, + ); + } + + /** + * Create context with user identifier set + */ + public function withUserIdentifier(string $userIdentifier): self + { + return new self( + tenantId: $this->tenantId, + userIdentifier: $userIdentifier, + userIdentity: $this->userIdentity, + metadata: $this->metadata, + config: $this->config, + ); + } +} diff --git a/shared/lib/Security/Authentication/ProviderResult.php b/shared/lib/Security/Authentication/ProviderResult.php new file mode 100644 index 0000000..3ac8f4e --- /dev/null +++ b/shared/lib/Security/Authentication/ProviderResult.php @@ -0,0 +1,156 @@ + $challengeInfo], + sessionData: $sessionData, + ); + } + + /** + * Create a redirect result (for OIDC/SAML) + */ + public static function redirect(string $url, array $sessionData): self + { + return new self( + status: self::REDIRECT, + clientData: ['redirect_url' => $url], + sessionData: $sessionData, + ); + } + + // ========================================================================= + // Status Checks + // ========================================================================= + + public function isSuccess(): bool + { + return $this->status === self::SUCCESS; + } + + public function isFailed(): bool + { + return $this->status === self::FAILED; + } + + public function isChallenge(): bool + { + return $this->status === self::CHALLENGE; + } + + public function isRedirect(): bool + { + return $this->status === self::REDIRECT; + } + + // ========================================================================= + // Data Access + // ========================================================================= + + /** + * Get identity claim + */ + public function getIdentity(string $key, mixed $default = null): mixed + { + return $this->identity[$key] ?? $default; + } + + /** + * Get client data value + */ + public function getClientData(string $key, mixed $default = null): mixed + { + return $this->clientData[$key] ?? $default; + } + + /** + * Get session data value + */ + public function getSessionData(string $key, mixed $default = null): mixed + { + return $this->sessionData[$key] ?? $default; + } +} diff --git a/shared/lib/Security/Crypto.php b/shared/lib/Security/Crypto.php new file mode 100644 index 0000000..ff903ea --- /dev/null +++ b/shared/lib/Security/Crypto.php @@ -0,0 +1,170 @@ +tenantSecret(); + if ($password === null) { + throw new \RuntimeException('Tenant secret unavailable for encryption'); + } + } + + $nonce = random_bytes(self::NONCE_LEN); + $key = hash_hkdf('sha256', $password); + if ($key === false || strlen($key) !== self::KEY_LEN) { + throw new \RuntimeException('Key derivation failed'); + } + + $aes = new AES('gcm'); + $aes->setKey($key); + $aes->setNonce($nonce); + + $encryptedData = $aes->encrypt($data); + if ($encryptedData === false) { + throw new \RuntimeException('Encryption failed'); + } + $tag = $aes->getTag(self::TAG_LEN); + if ($tag === false || strlen($tag) !== self::TAG_LEN) { + throw new \RuntimeException('Authentication tag retrieval failed'); + } + + $nonceLen = strlen($nonce); + $tagLen = strlen($tag); + $dataLen = strlen($encryptedData); + + $header = self::ENCODING_HEADER_TAG + . chr(self::ENCODING_HEADER_VERSION) + . chr(0x00) // flags + . pack('n', $nonceLen) // uint16 BE + . pack('n', $tagLen) // uint16 BE + . pack('N', $dataLen); // uint32 BE + + $binary = $header . $nonce . $tag . $encryptedData; + return bin2hex($binary); + } + + /** + * Decrypt hex-encoded length-prefixed binary envelope. + */ + public function decrypt(string $data, ?string $password = null): string + { + if ($password === null) { + $password = $this->tenantSecret(); + if ($password === null) { + throw new \RuntimeException('Tenant secret unavailable for decryption'); + } + } + + if (!ctype_xdigit($data) || strlen($data) % 2 !== 0) { + throw new \InvalidArgumentException('Invalid data format'); + } + $binary = hex2bin($data); + if ($binary === false || strlen($binary) < self::ENCODING_HEADER_LEN) { + throw new \InvalidArgumentException('Invalid data format'); + } + + if (substr($binary, 0, 4) !== self::ENCODING_HEADER_TAG) { + throw new \InvalidArgumentException('Invalid data format'); + } + + if (ord($binary[4]) !== self::ENCODING_HEADER_VERSION) { + throw new \InvalidArgumentException('Unsupported version'); + } + $flags = ord($binary[5]); // currently unused; reserved for future + $nonceLen = unpack('n', substr($binary, 6, 2))[1]; + $tagLen = unpack('n', substr($binary, 8, 2))[1]; + $dataLen = unpack('N', substr($binary, 10, 4))[1]; + + if (strlen($binary) !== (self::ENCODING_HEADER_LEN + $nonceLen + $tagLen + $dataLen)) { + throw new \InvalidArgumentException('Invalid data format'); + } + + $nonce = substr($binary, 14, $nonceLen); + $tag = substr($binary, 14 + $nonceLen, $tagLen); + $encryptedData = substr($binary, 14 + $nonceLen + $tagLen, $dataLen); + + $key = hash_hkdf('sha256', $password); + if ($key === false || strlen($key) !== self::KEY_LEN) { + throw new \RuntimeException('Key derivation failed'); + } + + $aes = new AES('gcm'); + $aes->setKey($key); + $aes->setNonce($nonce); + $aes->setTag($tag); + + $plainData = $aes->decrypt($encryptedData); + if ($plainData === false) { + throw new \RuntimeException('Decryption failed (auth)'); + } + return $plainData; + } + + private function tenantSecret(): ?string + { + $config = $this->sessionTenant->configuration(); + return $config['security']['code'] ?? null; + } + + // ========================================================================= + // Password Hashing + // ========================================================================= + + /** + * Hash a password using bcrypt + */ + public function hashPassword(string $password): string + { + return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]); + } + + /** + * Verify a password against a hash + */ + public function verifyPassword(string $password, string $hash): bool + { + return password_verify($password, $hash); + } + + /** + * Check if a password hash needs to be rehashed + */ + public function needsRehash(string $hash): bool + { + return password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12]); + } +} diff --git a/shared/lib/Utile/Collection/CollectionAbstract.php b/shared/lib/Utile/Collection/CollectionAbstract.php new file mode 100644 index 0000000..6bd5529 --- /dev/null +++ b/shared/lib/Utile/Collection/CollectionAbstract.php @@ -0,0 +1,136 @@ + + */ +class CollectionAbstract extends \ArrayObject { + + protected const TYPE_STRING = 'string'; + protected const TYPE_INT = 'int'; + protected const TYPE_FLOAT = 'float'; + protected const TYPE_BOOL = 'bool'; + protected const TYPE_ARRAY = 'array'; + protected const TYPE_DATE = 'date'; + + protected bool $associative = false; + protected string $typeKey = 'int'; + protected string $typeValue = 'string'; + + /** + * @param array $data + * @param class-string|string|null $typeValue + * @param class-string|string|null $typeKey + */ + public function __construct(array $data = [], string $typeValue, string|null $typeKey = null) { + // Ensure that all data entries are of the specified type + $this->typeValue = $typeValue; + if ($typeKey !== null) { + $this->typeKey = $typeKey; + $this->associative = true; + } + + foreach ($data as $key => $value) { + if ($this->associative && !$this->validateKey($key)) { + throw new \InvalidArgumentException('Type error: element key ' . $key . ' is not of type ' . $this->typeKey); + } + if (!$this->validateValue($value)) { + throw new \InvalidArgumentException('Type error: element value at index ' . $key . ' is not of type ' . $this->typeValue); + } + } + + if (!$this->associative) { + parent::__construct(array_values($data)); + } else { + parent::__construct($data); + } + } + + private function validateValue($value): bool { + // Check if the value is of the specified type + return match ($this->typeValue) { + self::TYPE_STRING => is_string($value), + self::TYPE_INT, 'integer' => is_int($value), + self::TYPE_FLOAT => is_float($value), + self::TYPE_BOOL, 'boolean' => is_bool($value), + self::TYPE_ARRAY => is_array($value), + self::TYPE_DATE => $value instanceof \DateTimeInterface, + default => $value instanceof $this->typeValue + }; + } + + protected function validateKey($key): bool { + // Check if the key is of the specified type + return match ($this->typeKey) { + self::TYPE_STRING => is_string($key), + default => is_int($key), + }; + } + + public function add($value, string|int|null $key = null): void { + $this->offsetSet($key, $value); + } + + public function remove(string|int $key): void { + $this->offsetUnset($key); + } + + public function extant(string|int $key): bool { + return $this->offsetExists($key); + } + + #[\Override] + public function append(mixed $value): void { + if ($this->associative) { + throw new \LogicException('Cannot append to an associative collection. Use add() or offsetSet() instead.'); + } + // ensure that the value is of the specified type before appending + if (!$this->validateValue($value)) { + throw new \InvalidArgumentException('Type error: value is not of type ' . $this->typeValue); + } + parent::append($value); + } + + #[\Override] + public function offsetSet(mixed $key, mixed $value): void { + if ($this->associative) { + if ($key === null) { + throw new \LogicException('Logic error: Key cannot be null for associative collections'); + } + if (!$this->validateKey($key)) { + throw new \InvalidArgumentException('Type error: key is not of type ' . $this->typeKey); + } + } else { + if ($key !== null) { + throw new \LogicException('Logic error: Key must be null for non-associative collections'); + } + } + if (!$this->validateValue($value)) { + throw new \InvalidArgumentException('Type error: value is not of type ' . $this->typeValue); + } + parent::offsetSet($key, $value); + } + + #[\Override] + public function offsetUnset(mixed $key): void + { + if (!$this->validateKey($key)) { + throw new \InvalidArgumentException('Type error: key is not of type ' . $this->typeKey); + } + parent::offsetUnset($key); + } + + #[\Override] + public function offsetExists(mixed $key): bool + { + if (!$this->validateKey($key)) { + throw new \InvalidArgumentException('Type error: key is not of type ' . $this->typeKey); + } + return parent::offsetExists($key); + } + +} diff --git a/shared/lib/Utile/UUID.php b/shared/lib/Utile/UUID.php new file mode 100644 index 0000000..ce93023 --- /dev/null +++ b/shared/lib/Utile/UUID.php @@ -0,0 +1,54 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXF\Utile; + +/** + * UUID Generator Utility Class + * + * Generates RFC 4122 compliant UUIDs (version 4 - random) + */ +class UUID { + + /** + * Generate a random UUID v4 + * + * @return string UUID in format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + */ + public static function v4(): string { + // Generate 16 random bytes + $bytes = random_bytes(16); + + // Set version to 4 (random) + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + + // Set variant to RFC 4122 + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + + // Format as UUID string + return vsprintf( + '%s%s-%s-%s-%s-%s%s%s', + str_split(bin2hex($bytes), 4) + ); + } + + /** + * Validate a UUID string + * + * @param string $uuid UUID to validate + * @return bool True if valid UUID format + */ + public static function isValid(string $uuid): bool { + return (bool)preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $uuid + ); + } + +} diff --git a/tests/php/bootstrap.php b/tests/php/bootstrap.php new file mode 100644 index 0000000..60aab4c --- /dev/null +++ b/tests/php/bootstrap.php @@ -0,0 +1,7 @@ +jsonSerialize(); + + $this->assertIsArray($serialized); + $this->assertArrayHasKey('publicProperty', $serialized); + $this->assertEquals('test', $serialized['publicProperty']); + $this->assertArrayHasKey('protectedProperty', $serialized); // get_object_vars includes protected + $this->assertEquals('protected', $serialized['protectedProperty']); + $this->assertArrayNotHasKey('privateProperty', $serialized); // but not private + } + + public function testJsonDeserializeSetsProperties(): void + { + $testObject = new class extends JsonSerializableObject { + public string $name = ''; + public int $age = 0; + }; + + $data = [ + 'name' => 'John Doe', + 'age' => 30, + 'nonexistent' => 'ignored' + ]; + + $result = $testObject->jsonDeserialize($data); + + $this->assertSame($testObject, $result); + $this->assertEquals('John Doe', $testObject->name); + $this->assertEquals(30, $testObject->age); + $this->assertObjectNotHasProperty('nonexistent', $testObject); + } + + public function testJsonDeserializeHandlesJsonString(): void + { + $testObject = new class extends JsonSerializableObject { + public string $message = ''; + }; + + $jsonString = '{"message": "Hello World"}'; + $testObject->jsonDeserialize($jsonString); + + $this->assertEquals('Hello World', $testObject->message); + } +} \ No newline at end of file diff --git a/tests/php/shared/People/Entity/Individual/IndividualObjectTest.php b/tests/php/shared/People/Entity/Individual/IndividualObjectTest.php new file mode 100644 index 0000000..6d4cdd6 --- /dev/null +++ b/tests/php/shared/People/Entity/Individual/IndividualObjectTest.php @@ -0,0 +1,109 @@ +urid = 'test-urid-123'; + $individual->label = 'Test Individual'; + $individual->language = 'en'; + + // Set name + $individual->names->First = 'John'; + $individual->names->Last = 'Doe'; + $individual->names->Prefix = 'Mr.'; + + // Add an alias + $alias = new IndividualAliasObject(); + $alias->label = 'Johnny'; + $individual->names->Aliases[] = $alias; + + // Serialize to JSON + $json = json_encode($individual); + + // Verify JSON structure + $this->assertJson($json); + + $data = json_decode($json, true); + $this->assertEquals('individual', $data['type']); + $this->assertEquals(1, $data['version']); + $this->assertEquals('test-urid-123', $data['urid']); + $this->assertEquals('Test Individual', $data['label']); + $this->assertEquals('en', $data['language']); + $this->assertEquals('John', $data['names']['First']); + $this->assertEquals('Doe', $data['names']['Last']); + $this->assertEquals('Mr.', $data['names']['Prefix']); + $this->assertCount(1, $data['names']['Aliases']); + $this->assertEquals('Johnny', $data['names']['Aliases'][0]['label']); + } + + public function testJsonDeserialization(): void + { + $jsonData = [ + 'type' => 'individual', + 'version' => 1, + 'urid' => 'test-urid-456', + 'label' => 'Deserialized Individual', + 'language' => 'fr', + 'names' => [ + 'First' => 'Jane', + 'Last' => 'Smith', + 'Prefix' => 'Ms.', + 'Aliases' => [ + ['label' => 'Janie'] + ] + ] + ]; + + $individual = new IndividualObject(); + $individual->jsonDeserialize($jsonData); + + $this->assertEquals('test-urid-456', $individual->urid); + $this->assertEquals('Deserialized Individual', $individual->label); + $this->assertEquals('fr', $individual->language); + $this->assertEquals('Jane', $individual->names->First); + $this->assertEquals('Smith', $individual->names->Last); + $this->assertEquals('Ms.', $individual->names->Prefix); + $this->assertCount(1, $individual->names->Aliases); + $this->assertEquals('Janie', $individual->names->Aliases[0]->label); + } + + public function testJsonRoundTrip(): void + { + // Create original object + $original = new IndividualObject(); + $original->urid = 'round-trip-urid'; + $original->label = 'Round Trip Test'; + $original->names->First = 'Alice'; + $original->names->Last = 'Wonderland'; + + // Serialize and deserialize + $json = json_encode($original); + $deserialized = new IndividualObject(); + $deserialized->jsonDeserialize($json); + + // Verify round-trip integrity + $this->assertEquals($original->urid, $deserialized->urid); + $this->assertEquals($original->label, $deserialized->label); + $this->assertEquals($original->names->First, $deserialized->names->First); + $this->assertEquals($original->names->Last, $deserialized->names->Last); + + // Verify JSON representations are identical + $originalJson = json_encode($original); + $deserializedJson = json_encode($deserialized); + $this->assertJsonStringEqualsJsonString($originalJson, $deserializedJson); + } +} \ No newline at end of file diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..78c20a9 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "@KTXC/*": ["./core/src/*"] + } + }, + "include": [ + "./core/src/**/*.ts", + "./core/src/**/*.tsx", + "./core/src/**/*.vue"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..82882da --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts", "scripts/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..8106dff --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,108 @@ +import { defineConfig, type PluginOption } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import vuetify from 'vite-plugin-vuetify'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; +import { generateVendorShims } from './scripts/generate-vendor-shims'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const runVendorShimGenerator = async (outputDir: string) => { + await generateVendorShims({ outputDir, silent: true }); +}; + +const generateVendorShimsPlugin = (): PluginOption => { + let outDir = 'dist'; + let rootDir = process.cwd(); + + return { + name: 'generate-vendor-shims', + apply: 'build', + configResolved(config) { + outDir = config.build.outDir; + rootDir = config.root; + }, + async closeBundle() { + try { + const resolvedOutDir = path.isAbsolute(outDir) ? outDir : path.resolve(rootDir, outDir); + const vendorDestination = path.resolve(resolvedOutDir, 'vendor'); + await runVendorShimGenerator(vendorDestination); + } catch (error) { + console.warn('[generate-vendor-shims] Failed to update vendor shims', error); + } + }, + }; +}; + +// https://vite.dev/config/ +export default defineConfig(({ mode }) => ({ + root: path.resolve(__dirname, 'core/src'), + plugins: [ + vue(), + vuetify(), + generateVendorShimsPlugin(), + viteStaticCopy({ + targets: [ + { + src: path.resolve(__dirname, 'core/lib/index.php'), + dest: path.resolve(__dirname, 'public'), + }, + ], + }), + ], + resolve: { + alias: { + '@KTXC': path.resolve(__dirname, 'core/src'), + }, + }, + server: { + fs: { + allow: ['..'], // Allow serving files from one level up to find the project root + }, + host: true, + }, + build: { + outDir: path.resolve(__dirname, 'public'), + emptyOutDir: true, + minify: mode === 'production', + sourcemap: true, + rollupOptions: { + input: { + public: path.resolve(__dirname, 'core/src/public.html'), + private: path.resolve(__dirname, 'core/src/private.html'), + 'shared-utils': path.resolve(__dirname, 'core/src/utils/helpers/shared.ts'), + }, + output: { + // Preserve export names for shared-utils (used via import map by modules) + minifyInternalExports: false, + entryFileNames: (chunkInfo) => { + // Keep shared-utils without hash for stable import map reference + if (chunkInfo.name === 'shared-utils') { + return `js/[name].js`; + } + return `js/[name]-[hash].js`; + }, + chunkFileNames: (chunkInfo) => { + return `js/[name]-[hash].js`; + }, + assetFileNames: (assetInfo) => { + if (assetInfo.name) { + const extType = assetInfo.name.split('.').pop(); + if (extType && /png|jpe?g|svg|gif|tiff|bmp|ico/i.test(extType)) { + return `images/[name]-[hash][extname]`; + } + if (extType && /woff|woff2|eot|ttf|otf/i.test(extType)) { + return `fonts/[name]-[hash][extname]`; + } + if (extType === 'css') { + return `css/[name]-[hash][extname]`; + } + } + return `[name]-[hash][extname]`; + }, + }, + }, + }, +}));