Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f10cdde0c | |||
| 09355d14a4 | |||
| fbc0559a64 | |||
| 625db726f6 | |||
| 8cedd8a18a | |||
| 9fcfe32ea7 | |||
| a0093c9547 | |||
| f39ad1804d | |||
| eaea5f4c83 | |||
| 30a8eddf06 | |||
| 96fbdbab90 | |||
| 545a776aeb | |||
| e137de1183 | |||
| a1413db12f | |||
| f0598412f3 | |||
| 52166feca2 | |||
| 964128e051 | |||
| b3e0c00c87 | |||
| 4f5ea8c442 | |||
| a4c467d54e | |||
| f24e0ae669 | |||
| 673bdc1f7a | |||
| 71ddd3442f | |||
| 0dd735045d | |||
| 5646591c74 | |||
| a5acea72c3 | |||
| 62b416f13e | |||
| c8e6efe203 | |||
| f147ffc5c7 | |||
| 985e4a6450 | |||
| b14bd302a3 | |||
| 5b3e8f6588 | |||
| 84eb0e2c21 | |||
| 3f9c2500d9 | |||
| 2494cef02f | |||
| a5be782c51 | |||
| f4df769b3c | |||
| 688afbe5a1 | |||
| 7c2a8dfbd3 | |||
| e223ae7543 | |||
| a8e29d0305 | |||
| a363a1a4bc |
Generated
+47
-35
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "68916c1b58d9ce06a59f6c58e4a2d226",
|
"content-hash": "e9c9dc399dd43596c3341b7a1beaf27d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "laravel/serializable-closure",
|
"name": "laravel/serializable-closure",
|
||||||
@@ -1366,20 +1366,20 @@
|
|||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
{
|
{
|
||||||
"name": "myclabs/deep-copy",
|
"name": "myclabs/deep-copy",
|
||||||
"version": "1.13.4",
|
"version": "1.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/myclabs/DeepCopy.git",
|
"url": "https://github.com/myclabs/DeepCopy.git",
|
||||||
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
|
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
|
"url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
|
||||||
"reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
|
"reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^7.1 || ^8.0"
|
"php": "^8.0"
|
||||||
},
|
},
|
||||||
"conflict": {
|
"conflict": {
|
||||||
"doctrine/collections": "<1.6.8",
|
"doctrine/collections": "<1.6.8",
|
||||||
@@ -1414,15 +1414,15 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/myclabs/DeepCopy/issues",
|
"issues": "https://github.com/myclabs/DeepCopy/issues",
|
||||||
"source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
|
"source": "https://github.com/myclabs/DeepCopy/tree/1.14.0"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
|
"url": "https://github.com/mnapoli",
|
||||||
"type": "tidelift"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-08-01T08:46:24+00:00"
|
"time": "2026-08-11T10:17:44+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nikic/php-parser",
|
"name": "nikic/php-parser",
|
||||||
@@ -1689,23 +1689,23 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-file-iterator",
|
"name": "phpunit/php-file-iterator",
|
||||||
"version": "6.0.1",
|
"version": "6.0.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
||||||
"reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5"
|
"reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
|
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5",
|
||||||
"reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5",
|
"reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3"
|
"php": ">=8.3"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^12.0"
|
"phpunit/phpunit": "^12.5.33"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -1738,7 +1738,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
|
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
|
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1"
|
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -1758,7 +1758,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-02-02T14:04:18+00:00"
|
"time": "2026-08-25T14:40:53+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-invoker",
|
"name": "phpunit/php-invoker",
|
||||||
@@ -1946,16 +1946,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "12.5.31",
|
"version": "12.5.35",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d"
|
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0608d157a284f15cc73b99a3327eff06b66a176d",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
|
||||||
"reference": "0608d157a284f15cc73b99a3327eff06b66a176d",
|
"reference": "345ac22e42cb14cdc7aa8428655d8bb8c82a4aab",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1965,18 +1965,18 @@
|
|||||||
"ext-libxml": "*",
|
"ext-libxml": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-xmlwriter": "*",
|
"ext-xmlwriter": "*",
|
||||||
"myclabs/deep-copy": "^1.13.4",
|
"myclabs/deep-copy": "^1.14.0",
|
||||||
"phar-io/manifest": "^2.0.4",
|
"phar-io/manifest": "^2.0.4",
|
||||||
"phar-io/version": "^3.2.1",
|
"phar-io/version": "^3.2.1",
|
||||||
"php": ">=8.3",
|
"php": ">=8.3",
|
||||||
"phpunit/php-code-coverage": "^12.5.7",
|
"phpunit/php-code-coverage": "^12.5.7",
|
||||||
"phpunit/php-file-iterator": "^6.0.1",
|
"phpunit/php-file-iterator": "^6.0.2",
|
||||||
"phpunit/php-invoker": "^6.0.0",
|
"phpunit/php-invoker": "^6.0.0",
|
||||||
"phpunit/php-text-template": "^5.0.0",
|
"phpunit/php-text-template": "^5.0.0",
|
||||||
"phpunit/php-timer": "^8.0.0",
|
"phpunit/php-timer": "^8.0.0",
|
||||||
"sebastian/cli-parser": "^4.2.1",
|
"sebastian/cli-parser": "^4.2.1",
|
||||||
"sebastian/comparator": "^7.1.8",
|
"sebastian/comparator": "^7.1.8",
|
||||||
"sebastian/diff": "^7.0.0",
|
"sebastian/diff": "^7.0.1",
|
||||||
"sebastian/environment": "^8.1.2",
|
"sebastian/environment": "^8.1.2",
|
||||||
"sebastian/exporter": "^7.0.3",
|
"sebastian/exporter": "^7.0.3",
|
||||||
"sebastian/global-state": "^8.0.3",
|
"sebastian/global-state": "^8.0.3",
|
||||||
@@ -2024,7 +2024,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.31"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.35"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2032,7 +2032,7 @@
|
|||||||
"type": "other"
|
"type": "other"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-06T14:54:16+00:00"
|
"time": "2026-09-09T04:48:50+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
@@ -2255,24 +2255,24 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/diff",
|
"name": "sebastian/diff",
|
||||||
"version": "7.0.0",
|
"version": "7.0.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/diff.git",
|
"url": "https://github.com/sebastianbergmann/diff.git",
|
||||||
"reference": "7ab1ea946c012266ca32390913653d844ecd085f"
|
"reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f",
|
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
|
||||||
"reference": "7ab1ea946c012266ca32390913653d844ecd085f",
|
"reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.3"
|
"php": ">=8.3"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^12.0",
|
"phpunit/phpunit": "^12.5.33",
|
||||||
"symfony/process": "^7.2"
|
"symfony/process": "^7.4.17"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -2310,15 +2310,27 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/diff/issues",
|
"issues": "https://github.com/sebastianbergmann/diff/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/diff/security/policy",
|
"security": "https://github.com/sebastianbergmann/diff/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/diff/tree/7.0.0"
|
"source": "https://github.com/sebastianbergmann/diff/tree/7.0.1"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"url": "https://github.com/sebastianbergmann",
|
"url": "https://github.com/sebastianbergmann",
|
||||||
"type": "github"
|
"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/diff",
|
||||||
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-02-07T04:55:46+00:00"
|
"time": "2026-08-25T15:35:54+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/environment",
|
"name": "sebastian/environment",
|
||||||
@@ -3057,5 +3069,5 @@
|
|||||||
"ext-iconv": "*"
|
"ext-iconv": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ final readonly class TerminationReport
|
|||||||
public array $failures = [],
|
public array $failures = [],
|
||||||
public bool $deadlineExceeded = false,
|
public bool $deadlineExceeded = false,
|
||||||
public bool $limitExceeded = false,
|
public bool $limitExceeded = false,
|
||||||
|
public int $deferredListenerInvocations = 0,
|
||||||
|
public bool $deferredEventLimitExceeded = false,
|
||||||
|
public bool $deferredListenerInvocationLimitExceeded = false,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Console\Tenant;
|
namespace KTXC\Console\Tenant;
|
||||||
|
|
||||||
|
use KTXC\Context\TenantContext;
|
||||||
use KTXC\Models\Tenant\DomainCollection;
|
use KTXC\Models\Tenant\DomainCollection;
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
use KTXC\Models\Tenant\TenantObject;
|
use KTXC\Models\Tenant\TenantObject;
|
||||||
use KTXC\Service\TenantService;
|
use KTXC\Service\TenantService;
|
||||||
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
use KTXC\Stores\UserRolesStore;
|
use KTXC\Stores\UserRolesStore;
|
||||||
use KTXF\Utile\UUID;
|
use KTXF\Utile\UUID;
|
||||||
@@ -35,6 +37,8 @@ class TenantCreateCommand extends Command
|
|||||||
private readonly TenantService $tenantService,
|
private readonly TenantService $tenantService,
|
||||||
private readonly UserRolesStore $rolesStore,
|
private readonly UserRolesStore $rolesStore,
|
||||||
private readonly UserAccountsStore $userStore,
|
private readonly UserAccountsStore $userStore,
|
||||||
|
private readonly UserAccountsService $userService,
|
||||||
|
private readonly TenantContext $tenantContext,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
@@ -97,6 +101,10 @@ class TenantCreateCommand extends Command
|
|||||||
$io->error('Failed to create tenant.');
|
$io->error('Failed to create tenant.');
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
if (!$this->tenantContext->resolveIdentifier($identifier)) {
|
||||||
|
throw new \RuntimeException("Failed to initialize tenant context for '{$identifier}'.");
|
||||||
|
}
|
||||||
|
$identifier = $this->tenantContext->requireIdentifier();
|
||||||
|
|
||||||
$this->logger->info('Tenant created via console', [
|
$this->logger->info('Tenant created via console', [
|
||||||
'identifier' => $identifier,
|
'identifier' => $identifier,
|
||||||
@@ -134,7 +142,7 @@ class TenantCreateCommand extends Command
|
|||||||
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
|
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
|
||||||
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
|
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
|
||||||
} else {
|
} else {
|
||||||
$this->userStore->createUser($identifier, [
|
$this->userService->createUser([
|
||||||
'identity' => $adminIdentity,
|
'identity' => $adminIdentity,
|
||||||
'label' => 'Administrator',
|
'label' => 'Administrator',
|
||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Console\Tenant;
|
||||||
|
|
||||||
|
use KTXC\SystemStore\SystemStoreConfigurationService;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'tenant:store:list', description: 'List logical stores configured for a tenant')]
|
||||||
|
class TenantStoreListCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SystemStoreConfigurationService $stores)
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$tenant = (string) $input->getArgument('tenant');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stores = $this->stores->list($tenant);
|
||||||
|
if ($stores === []) {
|
||||||
|
$io->text("No logical stores configured for tenant '{$tenant}'.");
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($stores as $name => $store) {
|
||||||
|
$rows[] = [$name, $store->provider, (string) $store->service, $store->namespace];
|
||||||
|
}
|
||||||
|
$io->table(['Name', 'Provider', 'Service', 'Namespace'], $rows);
|
||||||
|
return Command::SUCCESS;
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$io->error('Failed to list tenant stores: ' . $error->getMessage());
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Console\Tenant;
|
||||||
|
|
||||||
|
use KTXC\SystemStore\SystemStoreConfigurationService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'tenant:store:remove', description: 'Remove a logical store from a tenant')]
|
||||||
|
class TenantStoreRemoveCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SystemStoreConfigurationService $stores,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||||
|
->addArgument('name', InputArgument::REQUIRED, 'Logical store name');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$tenant = (string) $input->getArgument('tenant');
|
||||||
|
$name = (string) $input->getArgument('name');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!$this->stores->remove($tenant, $name)) {
|
||||||
|
$io->warning("Logical store '{$name}' was not configured for tenant '{$tenant}'.");
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info('Tenant logical store removed via console', compact('tenant', 'name'));
|
||||||
|
$io->success("Logical store '{$name}' removed from tenant '{$tenant}'.");
|
||||||
|
return Command::SUCCESS;
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$this->logger->error('Tenant logical store removal failed', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'name' => $name,
|
||||||
|
'error' => $error->getMessage(),
|
||||||
|
]);
|
||||||
|
$io->error('Failed to remove tenant store: ' . $error->getMessage());
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Console\Tenant;
|
||||||
|
|
||||||
|
use KTXC\SystemStore\SystemStoreConfigurationService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'tenant:store:set', description: 'Configure a logical store for a tenant')]
|
||||||
|
class TenantStoreSetCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SystemStoreConfigurationService $stores,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||||
|
->addArgument('name', InputArgument::REQUIRED, 'Logical store name, such as previews')
|
||||||
|
->addArgument('provider', InputArgument::REQUIRED, 'System-store provider identifier')
|
||||||
|
->addArgument('service', InputArgument::REQUIRED, 'System-store service identifier')
|
||||||
|
->addArgument('namespace', InputArgument::REQUIRED, 'Provider key namespace');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$tenant = (string) $input->getArgument('tenant');
|
||||||
|
$name = (string) $input->getArgument('name');
|
||||||
|
$provider = (string) $input->getArgument('provider');
|
||||||
|
$service = (string) $input->getArgument('service');
|
||||||
|
$namespace = (string) $input->getArgument('namespace');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->stores->set($tenant, $name, $provider, $service, $namespace);
|
||||||
|
$this->logger->info('Tenant logical store configured via console', compact(
|
||||||
|
'tenant', 'name', 'provider', 'service', 'namespace',
|
||||||
|
));
|
||||||
|
$io->success("Logical store '{$name}' configured for tenant '{$tenant}'.");
|
||||||
|
return Command::SUCCESS;
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
$this->logger->error('Tenant logical store configuration failed', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'name' => $name,
|
||||||
|
'error' => $error->getMessage(),
|
||||||
|
]);
|
||||||
|
$io->error('Failed to configure tenant store: ' . $error->getMessage());
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Console\User;
|
namespace KTXC\Console\User;
|
||||||
|
|
||||||
use KTXC\Service\TenantService;
|
use KTXC\Context\TenantContext;
|
||||||
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
use KTXC\Stores\UserRolesStore;
|
use KTXC\Stores\UserRolesStore;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -28,8 +29,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
class UserCreateCommand extends Command
|
class UserCreateCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantService $tenantService,
|
private readonly TenantContext $tenantContext,
|
||||||
private readonly UserAccountsStore $userStore,
|
private readonly UserAccountsStore $userStore,
|
||||||
|
private readonly UserAccountsService $userService,
|
||||||
private readonly UserRolesStore $rolesStore,
|
private readonly UserRolesStore $rolesStore,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
@@ -59,11 +61,11 @@ class UserCreateCommand extends Command
|
|||||||
$io->title('Create User');
|
$io->title('Create User');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure the tenant exists
|
if (!$this->tenantContext->resolveIdentifier($tenant)) {
|
||||||
if (!$this->tenantService->fetchById($tenant)) {
|
|
||||||
$io->error("Tenant '{$tenant}' not found.");
|
$io->error("Tenant '{$tenant}' not found.");
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
$tenant = $this->tenantContext->requireIdentifier();
|
||||||
|
|
||||||
// Ensure identity is unique within the tenant
|
// Ensure identity is unique within the tenant
|
||||||
if ($this->userStore->fetchByIdentity($tenant, $identity)) {
|
if ($this->userStore->fetchByIdentity($tenant, $identity)) {
|
||||||
@@ -95,7 +97,7 @@ class UserCreateCommand extends Command
|
|||||||
$userData['uid'] = $input->getOption('uid');
|
$userData['uid'] = $input->getOption('uid');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $this->userStore->createUser($tenant, $userData);
|
$user = $this->userService->createUser($userData);
|
||||||
|
|
||||||
$this->logger->info('User created via console', [
|
$this->logger->info('User created via console', [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXC\Console\User;
|
namespace KTXC\Console\User;
|
||||||
|
|
||||||
|
use KTXC\Context\TenantContext;
|
||||||
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
@@ -26,7 +28,9 @@ use Symfony\Component\Console\Style\SymfonyStyle;
|
|||||||
class UserDeleteCommand extends Command
|
class UserDeleteCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private readonly TenantContext $tenantContext,
|
||||||
private readonly UserAccountsStore $userStore,
|
private readonly UserAccountsStore $userStore,
|
||||||
|
private readonly UserAccountsService $userService,
|
||||||
private readonly LoggerInterface $logger
|
private readonly LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
@@ -52,6 +56,12 @@ class UserDeleteCommand extends Command
|
|||||||
$io->title('Delete User');
|
$io->title('Delete User');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!$this->tenantContext->resolveIdentifier($tenant)) {
|
||||||
|
$io->error("Tenant '{$tenant}' not found.");
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
$tenant = $this->tenantContext->requireIdentifier();
|
||||||
|
|
||||||
$user = $this->userStore->fetchByIdentity($tenant, $identity);
|
$user = $this->userStore->fetchByIdentity($tenant, $identity);
|
||||||
|
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
@@ -64,7 +74,7 @@ class UserDeleteCommand extends Command
|
|||||||
return Command::SUCCESS;
|
return Command::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
|
if (!$this->userService->deleteUser($user['uid'])) {
|
||||||
$io->error("Failed to delete user '{$identity}'.");
|
$io->error("Failed to delete user '{$identity}'.");
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
}
|
}
|
||||||
|
|
||||||
$request = AuthenticationRequest::verify($session, $method, $response);
|
$request = AuthenticationRequest::verify($session, $method, $response);
|
||||||
$authResponse = $this->authManager->handle($request);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
return $this->buildJsonResponse($authResponse);
|
return $this->buildJsonResponse($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,8 +120,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
$host = $request->getHost();
|
$host = $request->getHost();
|
||||||
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
$request = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
||||||
$response = $this->authManager->handle($authRequest);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
return $this->buildJsonResponse($response);
|
return $this->buildJsonResponse($response);
|
||||||
}
|
}
|
||||||
@@ -142,8 +142,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
return $this->redirectWithError('Missing state parameter');
|
return $this->redirectWithError('Missing state parameter');
|
||||||
}
|
}
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
|
$request = AuthenticationRequest::callback($sessionId, $provider, $params);
|
||||||
$response = $this->authManager->handle($authRequest);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
if ($response->isSuccess()) {
|
if ($response->isSuccess()) {
|
||||||
$returnUrl = $response->returnUrl ?? '/';
|
$returnUrl = $response->returnUrl ?? '/';
|
||||||
@@ -178,8 +178,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::status($sessionId);
|
$request = AuthenticationRequest::status($sessionId);
|
||||||
$response = $this->authManager->handle($authRequest);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
return $this->buildJsonResponse($response);
|
return $this->buildJsonResponse($response);
|
||||||
}
|
}
|
||||||
@@ -192,8 +192,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
$sessionId = $request->query->get('session', '');
|
$sessionId = $request->query->get('session', '');
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::cancel($sessionId);
|
$request = AuthenticationRequest::cancel($sessionId);
|
||||||
$this->authManager->handle($authRequest);
|
$this->authManager->handle($request);
|
||||||
|
|
||||||
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
||||||
}
|
}
|
||||||
@@ -217,8 +217,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::refresh($refreshToken);
|
$request = AuthenticationRequest::refresh($refreshToken);
|
||||||
$response = $this->authManager->handle($authRequest);
|
$response = $this->authManager->handle($request);
|
||||||
|
|
||||||
if ($response->isFailed()) {
|
if ($response->isFailed()) {
|
||||||
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||||
@@ -259,8 +259,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
$token = $request->cookies->get('accessToken');
|
$token = $request->cookies->get('accessToken');
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::logout($token, false);
|
$request = AuthenticationRequest::logout($token, false);
|
||||||
$this->authManager->handle($authRequest);
|
$this->authManager->handle($request);
|
||||||
|
|
||||||
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
|
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
|
||||||
return $this->clearTokenCookies($response);
|
return $this->clearTokenCookies($response);
|
||||||
@@ -274,8 +274,8 @@ class AuthenticationController extends ControllerAbstract
|
|||||||
{
|
{
|
||||||
$token = $request->cookies->get('accessToken');
|
$token = $request->cookies->get('accessToken');
|
||||||
|
|
||||||
$authRequest = AuthenticationRequest::logout($token, true);
|
$request = AuthenticationRequest::logout($token, true);
|
||||||
$this->authManager->handle($authRequest);
|
$this->authManager->handle($request);
|
||||||
|
|
||||||
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
|
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
|
||||||
return $this->clearTokenCookies($response);
|
return $this->clearTokenCookies($response);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use KTXC\Http\Request\Request;
|
|||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
use KTXC\L10N\LocaleResolver;
|
use KTXC\L10N\LocaleResolver;
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
|
use KTXC\Module\Configuration\BrowserModuleContext;
|
||||||
use KTXC\Security\Authorization\PermissionChecker;
|
use KTXC\Security\Authorization\PermissionChecker;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\Context\IdentityContextInterface;
|
||||||
@@ -30,7 +31,11 @@ class InitController extends ControllerAbstract
|
|||||||
$configuration = [];
|
$configuration = [];
|
||||||
|
|
||||||
// modules - filter by permissions
|
// modules - filter by permissions
|
||||||
$configuration['modules'] = [];
|
$browserContext = new BrowserModuleContext(
|
||||||
|
$this->tenantContext->requireIdentifier(),
|
||||||
|
$this->identityContext->requireIdentifier(),
|
||||||
|
);
|
||||||
|
|
||||||
foreach ($this->moduleManager->list(true, true) as $module) {
|
foreach ($this->moduleManager->list(true, true) as $module) {
|
||||||
// Check if user has permission to view this module
|
// Check if user has permission to view this module
|
||||||
// Allow access if user has: {module_handle}, {module_handle}.*, or * permission
|
// Allow access if user has: {module_handle}, {module_handle}.*, or * permission
|
||||||
@@ -39,12 +44,12 @@ class InitController extends ControllerAbstract
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$integrations = $module->registerBI();
|
$module->configure($browserContext);
|
||||||
if ($integrations !== null) {
|
|
||||||
$configuration['modules'][$handle] = $integrations;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$configuration['modules'] = $browserContext->modules();
|
||||||
|
$configuration = array_merge($configuration, $browserContext->configuration());
|
||||||
|
|
||||||
// localization
|
// localization
|
||||||
$configuration['l10n'] = [
|
$configuration['l10n'] = [
|
||||||
'locale' => $this->localeResolver->resolve($request),
|
'locale' => $this->localeResolver->resolve($request),
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ final readonly class DeferredProcessingResult
|
|||||||
public int $remaining,
|
public int $remaining,
|
||||||
public bool $deadlineExceeded,
|
public bool $deadlineExceeded,
|
||||||
public bool $limitExceeded = false,
|
public bool $limitExceeded = false,
|
||||||
|
public int $listenerInvocations = 0,
|
||||||
|
public bool $eventLimitExceeded = false,
|
||||||
|
public bool $listenerInvocationLimitExceeded = false,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ use Psr\Log\LoggerInterface;
|
|||||||
|
|
||||||
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
|
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
|
||||||
{
|
{
|
||||||
|
private const DEFAULT_DEFERRED_PROCESSING_TIMEOUT_SECONDS = 300.0;
|
||||||
|
private const DEFAULT_MAX_DEFERRED_EVENTS = 1000;
|
||||||
|
private const DEFAULT_MAX_DEFERRED_LISTENER_INVOCATIONS = 50000;
|
||||||
|
|
||||||
/** @var array<string, list<Event>> */
|
/** @var array<string, list<Event>> */
|
||||||
private array $deferred = [];
|
private array $deferred = [];
|
||||||
private ?string $activeExecution = null;
|
private ?string $activeExecution = null;
|
||||||
@@ -22,7 +26,19 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
private readonly EventListenerRegistry $registry,
|
private readonly EventListenerRegistry $registry,
|
||||||
private readonly ContainerInterface $container,
|
private readonly ContainerInterface $container,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
|
private readonly float $deferredProcessingTimeoutSeconds = self::DEFAULT_DEFERRED_PROCESSING_TIMEOUT_SECONDS,
|
||||||
|
private readonly int $maxDeferredEvents = self::DEFAULT_MAX_DEFERRED_EVENTS,
|
||||||
|
private readonly int $maxDeferredListenerInvocations = self::DEFAULT_MAX_DEFERRED_LISTENER_INVOCATIONS,
|
||||||
) {
|
) {
|
||||||
|
if ($this->deferredProcessingTimeoutSeconds <= 0) {
|
||||||
|
throw new \InvalidArgumentException('The deferred processing timeout must be greater than zero.');
|
||||||
|
}
|
||||||
|
if ($this->maxDeferredEvents <= 0) {
|
||||||
|
throw new \InvalidArgumentException('The deferred event limit must be greater than zero.');
|
||||||
|
}
|
||||||
|
if ($this->maxDeferredListenerInvocations <= 0) {
|
||||||
|
throw new \InvalidArgumentException('The deferred listener invocation limit must be greater than zero.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function dispatch(Event $event): void
|
public function dispatch(Event $event): void
|
||||||
@@ -34,7 +50,7 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$this->invoke($event, DeliveryMode::Immediate);
|
$this->invoke($event, DeliveryMode::Immediate);
|
||||||
if ($this->registry->listeners($event->getName(), DeliveryMode::Deferred) !== []) {
|
if ($this->registry->listeners($event->label(), DeliveryMode::Deferred) !== []) {
|
||||||
if ($this->activeExecution === null) {
|
if ($this->activeExecution === null) {
|
||||||
throw new \LogicException('Deferred events require an active execution scope.');
|
throw new \LogicException('Deferred events require an active execution scope.');
|
||||||
}
|
}
|
||||||
@@ -60,13 +76,16 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
throw new \LogicException('Cannot process deferred events for an inactive execution.');
|
throw new \LogicException('Cannot process deferred events for an inactive execution.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$processed = 0;
|
try {
|
||||||
$deadline = microtime(true) + 1.0;
|
$processedEvents = 0;
|
||||||
|
$listenerInvocations = 0;
|
||||||
|
$deadline = microtime(true) + $this->deferredProcessingTimeoutSeconds;
|
||||||
$deadlineExceeded = false;
|
$deadlineExceeded = false;
|
||||||
$limitExceeded = false;
|
$eventLimitExceeded = false;
|
||||||
|
$listenerInvocationLimitExceeded = false;
|
||||||
while (($event = array_shift($this->deferred[$executionId])) !== null) {
|
while (($event = array_shift($this->deferred[$executionId])) !== null) {
|
||||||
if ($processed >= 1000) {
|
if ($processedEvents >= $this->maxDeferredEvents) {
|
||||||
$limitExceeded = true;
|
$eventLimitExceeded = true;
|
||||||
array_unshift($this->deferred[$executionId], $event);
|
array_unshift($this->deferred[$executionId], $event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -75,19 +94,33 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
array_unshift($this->deferred[$executionId], $event);
|
array_unshift($this->deferred[$executionId], $event);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$processed += $this->invoke($event, DeliveryMode::Deferred);
|
|
||||||
|
$eventListenerCount = count($this->registry->listeners(
|
||||||
|
$event->label(),
|
||||||
|
DeliveryMode::Deferred,
|
||||||
|
));
|
||||||
|
if ($listenerInvocations + $eventListenerCount > $this->maxDeferredListenerInvocations) {
|
||||||
|
$listenerInvocationLimitExceeded = true;
|
||||||
|
array_unshift($this->deferred[$executionId], $event);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remaining = count($this->deferred[$executionId]);
|
$listenerInvocations += $this->invoke($event, DeliveryMode::Deferred);
|
||||||
unset($this->deferred[$executionId]);
|
$processedEvents++;
|
||||||
$this->activeExecution = null;
|
}
|
||||||
|
|
||||||
return new DeferredProcessingResult(
|
return new DeferredProcessingResult(
|
||||||
$processed,
|
processed: $processedEvents,
|
||||||
$remaining,
|
remaining: count($this->deferred[$executionId]),
|
||||||
$deadlineExceeded,
|
deadlineExceeded: $deadlineExceeded,
|
||||||
$limitExceeded,
|
limitExceeded: $eventLimitExceeded || $listenerInvocationLimitExceeded,
|
||||||
|
listenerInvocations: $listenerInvocations,
|
||||||
|
eventLimitExceeded: $eventLimitExceeded,
|
||||||
|
listenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
$this->discardDeferred($executionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function discardDeferred(string $executionId): void
|
public function discardDeferred(string $executionId): void
|
||||||
@@ -101,18 +134,18 @@ final class EventDispatcher implements EventDispatcherInterface, DeferredEventPr
|
|||||||
private function invoke(Event $event, DeliveryMode $delivery): int
|
private function invoke(Event $event, DeliveryMode $delivery): int
|
||||||
{
|
{
|
||||||
$processed = 0;
|
$processed = 0;
|
||||||
foreach ($this->registry->listeners($event->getName(), $delivery) as $listener) {
|
foreach ($this->registry->listeners($event->label(), $delivery) as $listener) {
|
||||||
if ($event->isPropagationStopped()) {
|
if ($event->isPropagationStopped()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$processed++;
|
||||||
try {
|
try {
|
||||||
$service = $this->container->get($listener->service);
|
$service = $this->container->get($listener->service);
|
||||||
$service->{$listener->method}($event);
|
$service->{$listener->method}($event);
|
||||||
$processed++;
|
|
||||||
} catch (\Throwable $error) {
|
} catch (\Throwable $error) {
|
||||||
$this->logger->error('Event listener failed.', [
|
$this->logger->error('Event listener failed.', [
|
||||||
'event' => $event->getName(),
|
'event' => $event->label(),
|
||||||
'module' => $listener->module,
|
'module' => $listener->module,
|
||||||
'listener' => $listener->service . '::' . $listener->method,
|
'listener' => $listener->service . '::' . $listener->method,
|
||||||
'exception' => $error,
|
'exception' => $error,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds the HTTP request for the duration of the current runtime execution.
|
||||||
|
*/
|
||||||
|
final class RequestContext
|
||||||
|
{
|
||||||
|
private ?Request $request = null;
|
||||||
|
|
||||||
|
public function initialize(Request $request): void
|
||||||
|
{
|
||||||
|
if ($this->request !== null) {
|
||||||
|
throw new \LogicException('The request context has already been initialized.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->request = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function current(): ?Request
|
||||||
|
{
|
||||||
|
return $this->request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->request = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -238,6 +238,9 @@ class Kernel implements KernelInterface
|
|||||||
$remaining = 0;
|
$remaining = 0;
|
||||||
$deadlineExceeded = false;
|
$deadlineExceeded = false;
|
||||||
$limitExceeded = false;
|
$limitExceeded = false;
|
||||||
|
$listenerInvocations = 0;
|
||||||
|
$eventLimitExceeded = false;
|
||||||
|
$listenerInvocationLimitExceeded = false;
|
||||||
$failures = [];
|
$failures = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -249,6 +252,9 @@ class Kernel implements KernelInterface
|
|||||||
$remaining = $result->remaining;
|
$remaining = $result->remaining;
|
||||||
$deadlineExceeded = $result->deadlineExceeded;
|
$deadlineExceeded = $result->deadlineExceeded;
|
||||||
$limitExceeded = $result->limitExceeded;
|
$limitExceeded = $result->limitExceeded;
|
||||||
|
$listenerInvocations = $result->listenerInvocations;
|
||||||
|
$eventLimitExceeded = $result->eventLimitExceeded;
|
||||||
|
$listenerInvocationLimitExceeded = $result->listenerInvocationLimitExceeded;
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$failures[] = $e;
|
$failures[] = $e;
|
||||||
@@ -287,6 +293,9 @@ class Kernel implements KernelInterface
|
|||||||
failures: $failures,
|
failures: $failures,
|
||||||
deadlineExceeded: $deadlineExceeded,
|
deadlineExceeded: $deadlineExceeded,
|
||||||
limitExceeded: $limitExceeded,
|
limitExceeded: $limitExceeded,
|
||||||
|
deferredListenerInvocations: $listenerInvocations,
|
||||||
|
deferredEventLimitExceeded: $eventLimitExceeded,
|
||||||
|
deferredListenerInvocationLimitExceeded: $listenerInvocationLimitExceeded,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,17 @@ class TenantConfiguration extends JsonSerializableObject
|
|||||||
protected TenantAuthentication $authentication;
|
protected TenantAuthentication $authentication;
|
||||||
protected TenantSecurity $security;
|
protected TenantSecurity $security;
|
||||||
protected TenantFirewall $firewall;
|
protected TenantFirewall $firewall;
|
||||||
|
protected TenantStores $stores;
|
||||||
|
protected TenantPreview $preview;
|
||||||
|
protected array $services = [];
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->authentication = new TenantAuthentication();
|
$this->authentication = new TenantAuthentication();
|
||||||
$this->security = new TenantSecurity();
|
$this->security = new TenantSecurity();
|
||||||
$this->firewall = new TenantFirewall();
|
$this->firewall = new TenantFirewall();
|
||||||
|
$this->stores = new TenantStores();
|
||||||
|
$this->preview = new TenantPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function authentication(): TenantAuthentication {
|
public function authentication(): TenantAuthentication {
|
||||||
@@ -32,4 +37,16 @@ class TenantConfiguration extends JsonSerializableObject
|
|||||||
return $this->firewall;
|
return $this->firewall;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function services(): array {
|
||||||
|
return $this->services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stores(): TenantStores {
|
||||||
|
return $this->stores;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(): TenantPreview {
|
||||||
|
return $this->preview;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXF\Json\JsonSerializableObject;
|
||||||
|
|
||||||
|
final class TenantPreview extends JsonSerializableObject
|
||||||
|
{
|
||||||
|
protected bool $enabled = true;
|
||||||
|
protected string $store = 'previews';
|
||||||
|
protected int $maxSourceSize = 26214400;
|
||||||
|
protected TenantPreviewVariants $variants;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->variants = new TenantPreviewVariants();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonDeserialize(array|string $data): static
|
||||||
|
{
|
||||||
|
if (is_string($data)) {
|
||||||
|
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('enabled', $data)) {
|
||||||
|
if (!is_bool($data['enabled'])) {
|
||||||
|
throw new InvalidArgumentException('Preview enabled must be a boolean');
|
||||||
|
}
|
||||||
|
$this->enabled = $data['enabled'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('store', $data)) {
|
||||||
|
if (!is_string($data['store']) || preg_match('/^[a-z][a-z0-9-]*$/', $data['store']) !== 1) {
|
||||||
|
throw new InvalidArgumentException('Preview store must be a logical system-store name');
|
||||||
|
}
|
||||||
|
$this->store = $data['store'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('maxSourceSize', $data)) {
|
||||||
|
if (!is_int($data['maxSourceSize']) || $data['maxSourceSize'] < 1) {
|
||||||
|
throw new InvalidArgumentException('Preview maximum source size must be a positive integer');
|
||||||
|
}
|
||||||
|
$this->maxSourceSize = $data['maxSourceSize'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('variants', $data)) {
|
||||||
|
if (!is_array($data['variants'])) {
|
||||||
|
throw new InvalidArgumentException('Preview variants configuration must be an object');
|
||||||
|
}
|
||||||
|
$this->variants->jsonDeserialize($data['variants']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->enabled && $this->variants->all() === []) {
|
||||||
|
throw new InvalidArgumentException('At least one preview variant is required when previews are enabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(): string
|
||||||
|
{
|
||||||
|
return $this->store;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxSourceSize(): int
|
||||||
|
{
|
||||||
|
return $this->maxSourceSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function variants(): TenantPreviewVariants
|
||||||
|
{
|
||||||
|
return $this->variants;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXF\Preview\MimeType;
|
||||||
|
|
||||||
|
final readonly class TenantPreviewVariant
|
||||||
|
{
|
||||||
|
public string $format;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $width,
|
||||||
|
public int $height,
|
||||||
|
string $format,
|
||||||
|
public int $quality,
|
||||||
|
) {
|
||||||
|
if ($this->width < 1 || $this->height < 1) {
|
||||||
|
throw new InvalidArgumentException('Preview variant dimensions must be positive');
|
||||||
|
}
|
||||||
|
if ($this->quality < 1 || $this->quality > 100) {
|
||||||
|
throw new InvalidArgumentException('Preview variant quality must be between 1 and 100');
|
||||||
|
}
|
||||||
|
$this->format = MimeType::normalize($format);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data, ?self $defaults = null): self
|
||||||
|
{
|
||||||
|
$width = $data['width'] ?? $defaults?->width;
|
||||||
|
$height = $data['height'] ?? $defaults?->height;
|
||||||
|
$format = $data['format'] ?? $defaults?->format;
|
||||||
|
$quality = $data['quality'] ?? $defaults?->quality;
|
||||||
|
|
||||||
|
if (!is_int($width) || !is_int($height) || !is_string($format) || !is_int($quality)) {
|
||||||
|
throw new InvalidArgumentException('Preview variants require integer dimensions and quality plus a MIME format');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($width, $height, $format, $quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{width:int,height:int,format:string,quality:int} */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'width' => $this->width,
|
||||||
|
'height' => $this->height,
|
||||||
|
'format' => $this->format,
|
||||||
|
'quality' => $this->quality,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXF\Json\JsonSerializableObject;
|
||||||
|
|
||||||
|
final class TenantPreviewVariants extends JsonSerializableObject
|
||||||
|
{
|
||||||
|
/** @var array<string, TenantPreviewVariant> */
|
||||||
|
private array $entries;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->entries = self::defaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonDeserialize(array|string $data): static
|
||||||
|
{
|
||||||
|
if (is_string($data)) {
|
||||||
|
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
$defaults = self::defaults();
|
||||||
|
$entries = [];
|
||||||
|
foreach ($data as $name => $variant) {
|
||||||
|
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
|
||||||
|
throw new InvalidArgumentException('Invalid preview variant name');
|
||||||
|
}
|
||||||
|
if (!is_array($variant)) {
|
||||||
|
throw new InvalidArgumentException('Preview variant configuration must be an object');
|
||||||
|
}
|
||||||
|
$entries[$name] = TenantPreviewVariant::fromArray($variant, $defaults[$name] ?? null);
|
||||||
|
}
|
||||||
|
$this->entries = $entries;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function variant(string $name): ?TenantPreviewVariant
|
||||||
|
{
|
||||||
|
return $this->entries[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, TenantPreviewVariant> */
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return $this->entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn(TenantPreviewVariant $variant): array => $variant->toArray(),
|
||||||
|
$this->entries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, TenantPreviewVariant> */
|
||||||
|
private static function defaults(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'thumbnail' => new TenantPreviewVariant(128, 128, 'image/webp', 70),
|
||||||
|
'inline' => new TenantPreviewVariant(640, 640, 'image/webp', 80),
|
||||||
|
'fullscreen' => new TenantPreviewVariant(2560, 2560, 'image/webp', 90),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Models\Tenant;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXF\Json\JsonSerializableObject;
|
||||||
|
use KTXF\SystemStore\StoreReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logical system stores configured for a tenant.
|
||||||
|
*/
|
||||||
|
final class TenantStores extends JsonSerializableObject
|
||||||
|
{
|
||||||
|
/** @var array<string, StoreReference> */
|
||||||
|
private array $entries = [];
|
||||||
|
|
||||||
|
public function jsonDeserialize(array|string $data): static
|
||||||
|
{
|
||||||
|
if (is_string($data)) {
|
||||||
|
$data = json_decode($data, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entries = [];
|
||||||
|
foreach ($data as $name => $store) {
|
||||||
|
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
|
||||||
|
throw new InvalidArgumentException('Invalid logical system-store name');
|
||||||
|
}
|
||||||
|
if (!is_array($store)) {
|
||||||
|
throw new InvalidArgumentException('Invalid tenant store configuration entry');
|
||||||
|
}
|
||||||
|
$this->entries[$name] = StoreReference::fromArray($store);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize(): array
|
||||||
|
{
|
||||||
|
return array_map(
|
||||||
|
static fn(StoreReference $store): array => $store->toArray(),
|
||||||
|
$this->entries,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(string $name): ?StoreReference
|
||||||
|
{
|
||||||
|
return $this->entries[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, StoreReference> */
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return $this->entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Module\Configuration;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LogicException;
|
||||||
|
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ModuleContextType;
|
||||||
|
use KTXF\Module\ModuleInstanceInterface;
|
||||||
|
|
||||||
|
final class BrowserModuleContext implements BrowserModuleContextInterface
|
||||||
|
{
|
||||||
|
private const RESERVED_KEYS = ['modules', 'tenant', 'user', 'l10n'];
|
||||||
|
|
||||||
|
/** @var array<string,array<string,mixed>> */
|
||||||
|
private array $modules = [];
|
||||||
|
|
||||||
|
/** @var array<string,mixed> */
|
||||||
|
private array $configuration = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $tenantIdentifier,
|
||||||
|
private readonly string $identityIdentifier,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function type(): ModuleContextType
|
||||||
|
{
|
||||||
|
return ModuleContextType::Browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerModule(
|
||||||
|
ModuleInstanceInterface $module,
|
||||||
|
string $namespace,
|
||||||
|
?string $boot = null,
|
||||||
|
): void {
|
||||||
|
if (trim($namespace) === '' || ($boot !== null && trim($boot) === '')) {
|
||||||
|
throw new InvalidArgumentException('Browser namespace must not be empty and boot path must be null or non-empty');
|
||||||
|
}
|
||||||
|
$handle = $module->handle();
|
||||||
|
if (isset($this->modules[$handle])) {
|
||||||
|
throw new LogicException("Browser module '{$handle}' is already registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'handle' => $handle,
|
||||||
|
'namespace' => $namespace,
|
||||||
|
'version' => $module->version(),
|
||||||
|
'label' => $module->label(),
|
||||||
|
'author' => $module->author(),
|
||||||
|
'description' => $module->description(),
|
||||||
|
];
|
||||||
|
if ($boot !== null) {
|
||||||
|
$entry['boot'] = $boot;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->modules[$handle] = $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
if (trim($key) === '') {
|
||||||
|
throw new InvalidArgumentException('Browser configuration key must not be empty');
|
||||||
|
}
|
||||||
|
if (in_array($key, self::RESERVED_KEYS, true) || array_key_exists($key, $this->configuration)) {
|
||||||
|
throw new LogicException("Browser configuration '{$key}' is already reserved or registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->configuration[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantIdentifier(): string
|
||||||
|
{
|
||||||
|
return $this->tenantIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function identityIdentifier(): string
|
||||||
|
{
|
||||||
|
return $this->identityIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function modules(): array
|
||||||
|
{
|
||||||
|
return $this->modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configuration(): array
|
||||||
|
{
|
||||||
|
return $this->configuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Module\Configuration;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ModuleContextType;
|
||||||
|
|
||||||
|
final class ConsoleModuleContext implements ConsoleModuleContextInterface
|
||||||
|
{
|
||||||
|
/** @var array<class-string,true> */
|
||||||
|
private array $commands = [];
|
||||||
|
|
||||||
|
public function type(): ModuleContextType
|
||||||
|
{
|
||||||
|
return ModuleContextType::Console;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerCommand(string $command): void
|
||||||
|
{
|
||||||
|
if (trim($command) === '') {
|
||||||
|
throw new InvalidArgumentException('Console command class must not be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->commands[$command] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commands(): array
|
||||||
|
{
|
||||||
|
return array_keys($this->commands);
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-22
@@ -4,6 +4,7 @@ namespace KTXC\Module;
|
|||||||
|
|
||||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||||
|
use KTXC\Preview\PreviewManager;
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
use KTXC\Service\SystemFirewallLogService;
|
use KTXC\Service\SystemFirewallLogService;
|
||||||
use KTXC\Service\SystemFirewallRuleService;
|
use KTXC\Service\SystemFirewallRuleService;
|
||||||
@@ -11,12 +12,23 @@ use KTXC\Service\SystemFirewallStatusService;
|
|||||||
use KTXC\Service\TenantFirewallLogService;
|
use KTXC\Service\TenantFirewallLogService;
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
use KTXC\Service\TenantFirewallRuleService;
|
||||||
use KTXC\Service\TenantFirewallStatusService;
|
use KTXC\Service\TenantFirewallStatusService;
|
||||||
|
use KTXC\Security\Event\AccessDeniedEvent;
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
||||||
|
use KTXC\Security\Event\BruteForceDetectedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
||||||
|
use KTXC\Security\Event\RateLimitExceededEvent;
|
||||||
|
use KTXC\Security\Event\SuspiciousActivityEvent;
|
||||||
use KTXF\Event\DeliveryMode;
|
use KTXF\Event\DeliveryMode;
|
||||||
use KTXF\Event\EventListenerRegistrarInterface;
|
use KTXF\Event\EventListenerRegistrarInterface;
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
||||||
use KTXF\Module\ModuleConsoleInterface;
|
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||||
use KTXF\Module\ModuleInstanceAbstract;
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,10 +36,11 @@ use KTXF\Module\ModuleInstanceAbstract;
|
|||||||
*
|
*
|
||||||
* Provides core system functionality and permissions
|
* Provides core system functionality and permissions
|
||||||
*/
|
*/
|
||||||
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
class Module extends ModuleInstanceAbstract
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EventListenerRegistrarInterface $events,
|
private readonly EventListenerRegistrarInterface $events,
|
||||||
|
private readonly PreviewManager $previews,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,21 +51,29 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
AuthenticationFailedEvent::class,
|
AuthenticationFailedEvent::class,
|
||||||
FirewallService::class,
|
FirewallService::class,
|
||||||
'handleAuthFailure',
|
'handleAuthFailure',
|
||||||
|
DeliveryMode::Immediate,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->events->listen(
|
||||||
|
'core',
|
||||||
|
AuthenticationSucceededEvent::class,
|
||||||
|
FirewallService::class,
|
||||||
|
'logAuthenticationSuccess',
|
||||||
|
DeliveryMode::Deferred,
|
||||||
|
);
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
SecurityEvent::AUTH_SUCCESS,
|
AccessDeniedEvent::class,
|
||||||
SecurityEvent::ACCESS_DENIED,
|
BruteForceDetectedEvent::class,
|
||||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
RateLimitExceededEvent::class,
|
||||||
SecurityEvent::RATE_LIMIT_EXCEEDED,
|
SuspiciousActivityEvent::class,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY,
|
FirewallRuleCreatedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
FirewallRuleExtendedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
FirewallRuleEnabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
FirewallRuleDisabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
FirewallRuleRemovedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
FirewallSettingsUpdatedEvent::class,
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
|
|
||||||
] as $event) {
|
] as $event) {
|
||||||
$this->events->listen(
|
$this->events->listen(
|
||||||
'core',
|
'core',
|
||||||
@@ -195,9 +216,20 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerCI(): array
|
public function configure(ModuleContextInterface $context): void
|
||||||
{
|
{
|
||||||
return [
|
if ($context instanceof BrowserModuleContextInterface) {
|
||||||
|
$context->set(
|
||||||
|
'preview',
|
||||||
|
$this->previews->availability($context->tenantIdentifier()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$context instanceof ConsoleModuleContextInterface) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
FirewallSetupCommand::class,
|
FirewallSetupCommand::class,
|
||||||
FirewallMaintenanceCommand::class,
|
FirewallMaintenanceCommand::class,
|
||||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
\KTXC\Console\Event\EventsDebugCommand::class,
|
||||||
@@ -211,6 +243,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
\KTXC\Console\Tenant\TenantListCommand::class,
|
\KTXC\Console\Tenant\TenantListCommand::class,
|
||||||
\KTXC\Console\Tenant\TenantDeleteCommand::class,
|
\KTXC\Console\Tenant\TenantDeleteCommand::class,
|
||||||
\KTXC\Console\Tenant\TenantAuthEnableCommand::class,
|
\KTXC\Console\Tenant\TenantAuthEnableCommand::class,
|
||||||
|
\KTXC\Console\Tenant\TenantStoreListCommand::class,
|
||||||
|
\KTXC\Console\Tenant\TenantStoreSetCommand::class,
|
||||||
|
\KTXC\Console\Tenant\TenantStoreRemoveCommand::class,
|
||||||
\KTXC\Console\User\UserCreateCommand::class,
|
\KTXC\Console\User\UserCreateCommand::class,
|
||||||
\KTXC\Console\User\UserListCommand::class,
|
\KTXC\Console\User\UserListCommand::class,
|
||||||
\KTXC\Console\User\UserDeleteCommand::class,
|
\KTXC\Console\User\UserDeleteCommand::class,
|
||||||
@@ -219,11 +254,8 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
|||||||
\KTXC\Console\Role\RoleDeleteCommand::class,
|
\KTXC\Console\Role\RoleDeleteCommand::class,
|
||||||
\KTXC\Console\Role\RoleAssignCommand::class,
|
\KTXC\Console\Role\RoleAssignCommand::class,
|
||||||
\KTXC\Console\Role\RoleRevokeCommand::class,
|
\KTXC\Console\Role\RoleRevokeCommand::class,
|
||||||
];
|
] as $command) {
|
||||||
|
$context->registerCommand($command);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerBI(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ namespace KTXC\Module;
|
|||||||
|
|
||||||
use JsonSerializable;
|
use JsonSerializable;
|
||||||
use KTXC\Module\Store\ModuleEntry;
|
use KTXC\Module\Store\ModuleEntry;
|
||||||
use KTXF\Module\ModuleBrowserInterface;
|
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||||
use KTXF\Module\ModuleConsoleInterface;
|
|
||||||
use KTXF\Module\ModuleInstanceInterface;
|
use KTXF\Module\ModuleInstanceInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,20 +174,9 @@ class ModuleObject implements JsonSerializable
|
|||||||
$this->instance?->upgrade();
|
$this->instance?->upgrade();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function registerBI(): array | null
|
public function configure(ModuleContextInterface $context): void
|
||||||
{
|
{
|
||||||
if ($this->instance instanceof ModuleBrowserInterface) {
|
$this->instance?->configure($context);
|
||||||
return $this->instance->registerBI();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function registerCI(): array | null
|
|
||||||
{
|
|
||||||
if ($this->instance instanceof ModuleConsoleInterface) {
|
|
||||||
return $this->instance->registerCI();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Preview;
|
||||||
|
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
|
||||||
|
final readonly class Preview
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public BinaryResource $resource,
|
||||||
|
public int $size,
|
||||||
|
public string $etag,
|
||||||
|
public ?int $width = null,
|
||||||
|
public ?int $height = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Preview;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXC\Service\TenantService;
|
||||||
|
use KTXC\SystemStore\SystemStoreManager;
|
||||||
|
use KTXF\Preview\MimeType;
|
||||||
|
use KTXF\Preview\PreviewGenerationException;
|
||||||
|
use KTXF\Preview\PreviewRequest;
|
||||||
|
use KTXF\Preview\PreviewResult;
|
||||||
|
use KTXF\Preview\PreviewSource;
|
||||||
|
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXF\SystemStore\BlobInfo;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class PreviewManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TenantService $tenants,
|
||||||
|
private ProviderManager $providers,
|
||||||
|
private SystemStoreManager $stores,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{enabled:bool,storage:bool,generation:bool} */
|
||||||
|
public function availability(string $tenantId): array
|
||||||
|
{
|
||||||
|
$tenant = $this->tenants->fetchById($tenantId);
|
||||||
|
if ($tenant === null) {
|
||||||
|
return ['enabled' => false, 'storage' => false, 'generation' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$configuration = $tenant->getConfiguration()->preview();
|
||||||
|
$enabled = $configuration->enabled();
|
||||||
|
$storage = $enabled
|
||||||
|
&& $tenant->getConfiguration()->stores()->store($configuration->store()) !== null;
|
||||||
|
$generation = $storage && $this->previewProviders() !== [];
|
||||||
|
|
||||||
|
if ($enabled && !$storage) {
|
||||||
|
$this->logger->debug('Preview storage is not configured', ['tenantId' => $tenantId]);
|
||||||
|
} elseif ($enabled && $storage && !$generation) {
|
||||||
|
$this->logger->debug('No preview generation provider is available', ['tenantId' => $tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('enabled', 'storage', 'generation');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetch(
|
||||||
|
string $tenantId,
|
||||||
|
PreviewSource $source,
|
||||||
|
string $variant = 'inline',
|
||||||
|
): ?Preview {
|
||||||
|
$tenant = $this->tenants->fetchById($tenantId);
|
||||||
|
if ($tenant === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$configuration = $tenant->getConfiguration()->preview();
|
||||||
|
$variantConfiguration = $configuration->variants()->variant($variant);
|
||||||
|
if (
|
||||||
|
!$configuration->enabled()
|
||||||
|
|| $variantConfiguration === null
|
||||||
|
|| ($source->size !== null && $source->size > $configuration->maxSourceSize())
|
||||||
|
|| $tenant->getConfiguration()->stores()->store($configuration->store()) === null
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = new PreviewRequest(
|
||||||
|
maxWidth: $variantConfiguration->width,
|
||||||
|
maxHeight: $variantConfiguration->height,
|
||||||
|
preferredMimeType: $variantConfiguration->format,
|
||||||
|
quality: $variantConfiguration->quality,
|
||||||
|
maxSourceSize: $configuration->maxSourceSize(),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$cached = $this->readCache(
|
||||||
|
$tenantId,
|
||||||
|
$configuration->store(),
|
||||||
|
$source,
|
||||||
|
$request,
|
||||||
|
);
|
||||||
|
if ($cached !== null) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = $this->selectProvider($source->mimeType, $request);
|
||||||
|
if ($provider === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$result = $provider->generate($source, $request);
|
||||||
|
$blob = $this->writeCache(
|
||||||
|
$tenantId,
|
||||||
|
$configuration->store(),
|
||||||
|
$source,
|
||||||
|
$request,
|
||||||
|
$result,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->readStoredPreview(
|
||||||
|
$tenantId,
|
||||||
|
$configuration->store(),
|
||||||
|
$blob,
|
||||||
|
$request,
|
||||||
|
);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->logger->warning('Preview unavailable', [
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'sourceType' => $source->sourceType,
|
||||||
|
'variant' => $variant,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readCache(
|
||||||
|
string $tenantId,
|
||||||
|
string $store,
|
||||||
|
PreviewSource $source,
|
||||||
|
PreviewRequest $request,
|
||||||
|
): ?Preview {
|
||||||
|
$key = $this->cacheKey($tenantId, $source, $request);
|
||||||
|
$blob = $this->stores->stat($tenantId, $store, $key);
|
||||||
|
if (!$this->validBlob($blob, $key, $request)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->readStoredPreview($tenantId, $store, $blob, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readStoredPreview(
|
||||||
|
string $tenantId,
|
||||||
|
string $store,
|
||||||
|
BlobInfo $blob,
|
||||||
|
PreviewRequest $request,
|
||||||
|
): ?Preview {
|
||||||
|
$resource = $this->stores->read($tenantId, $store, $blob->key);
|
||||||
|
if ($resource === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (MimeType::normalize($resource->mimeType()) !== $request->preferredMimeType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Preview(
|
||||||
|
resource: $resource,
|
||||||
|
size: $blob->size,
|
||||||
|
etag: $blob->etag,
|
||||||
|
width: $blob->attributes['width'] ?? null,
|
||||||
|
height: $blob->attributes['height'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeCache(
|
||||||
|
string $tenantId,
|
||||||
|
string $store,
|
||||||
|
PreviewSource $source,
|
||||||
|
PreviewRequest $request,
|
||||||
|
PreviewResult $result,
|
||||||
|
): BlobInfo {
|
||||||
|
$outputMimeType = MimeType::normalize($result->resource->mimeType());
|
||||||
|
if ($outputMimeType !== $request->preferredMimeType) {
|
||||||
|
throw new PreviewGenerationException('Generated preview MIME does not match the requested output MIME');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
($result->width !== null && $result->width > $request->maxWidth)
|
||||||
|
|| ($result->height !== null && $result->height > $request->maxHeight)
|
||||||
|
) {
|
||||||
|
throw new PreviewGenerationException('Generated preview dimensions exceed the requested limits');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->stores->write(
|
||||||
|
$tenantId,
|
||||||
|
$store,
|
||||||
|
$this->cacheKey($tenantId, $source, $request),
|
||||||
|
$result->resource,
|
||||||
|
['width' => $result->width, 'height' => $result->height],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheKey(
|
||||||
|
string $tenantId,
|
||||||
|
PreviewSource $source,
|
||||||
|
PreviewRequest $request,
|
||||||
|
): string {
|
||||||
|
if (trim($tenantId) === '') {
|
||||||
|
throw new InvalidArgumentException('Preview cache keys require a tenant identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
$canonical = json_encode([
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'source' => [
|
||||||
|
'type' => $source->sourceType,
|
||||||
|
'identity' => $source->identity,
|
||||||
|
'signature' => $source->signature,
|
||||||
|
'mimeType' => $source->mimeType,
|
||||||
|
],
|
||||||
|
'request' => [
|
||||||
|
'maxWidth' => $request->maxWidth,
|
||||||
|
'maxHeight' => $request->maxHeight,
|
||||||
|
'preferredMimeType' => $request->preferredMimeType,
|
||||||
|
'quality' => $request->quality,
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION);
|
||||||
|
$digest = hash('sha256', $canonical);
|
||||||
|
|
||||||
|
return "generated/{$source->sourceType}/" . substr($digest, 0, 2) . "/{$digest}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validBlob(?BlobInfo $blob, string $key, PreviewRequest $request): bool
|
||||||
|
{
|
||||||
|
if ($blob === null || $blob->key !== $key || $blob->size < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = $blob->attributes['width'] ?? null;
|
||||||
|
$height = $blob->attributes['height'] ?? null;
|
||||||
|
if (
|
||||||
|
($width !== null && (!is_int($width) || $width < 1 || $width > $request->maxWidth))
|
||||||
|
|| ($height !== null && (!is_int($height) || $height < 1 || $height > $request->maxHeight))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return MimeType::normalize($blob->mimeType) === $request->preferredMimeType;
|
||||||
|
} catch (Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function selectProvider(
|
||||||
|
string $sourceMimeType,
|
||||||
|
PreviewRequest $request,
|
||||||
|
): ?PreviewProviderInterface {
|
||||||
|
$sourceMimeType = MimeType::normalize($sourceMimeType);
|
||||||
|
$candidates = [];
|
||||||
|
|
||||||
|
foreach ($this->previewProviders() as $identifier => $provider) {
|
||||||
|
if (
|
||||||
|
!$provider instanceof PreviewProviderInterface
|
||||||
|
|| !$provider->supports($sourceMimeType, $request)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates[] = ['identifier' => $identifier, 'provider' => $provider];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($candidates === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($candidates, static function (array $left, array $right): int {
|
||||||
|
return ($right['provider']->priority() <=> $left['provider']->priority())
|
||||||
|
?: strcmp($left['identifier'], $right['identifier']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $candidates[0]['provider'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, PreviewProviderInterface> */
|
||||||
|
private function previewProviders(): array
|
||||||
|
{
|
||||||
|
return array_filter(
|
||||||
|
$this->providers->providers(ProviderInterface::TYPE_PREVIEW),
|
||||||
|
static fn(ProviderInterface $provider): bool => $provider instanceof PreviewProviderInterface,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ namespace KTXC\Runtime\Console;
|
|||||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||||
use KTXC\Kernel;
|
use KTXC\Kernel;
|
||||||
use KTXC\KernelInterface;
|
use KTXC\KernelInterface;
|
||||||
|
use KTXC\Module\Configuration\ConsoleModuleContext;
|
||||||
use KTXC\Module\ModuleManager;
|
use KTXC\Module\ModuleManager;
|
||||||
use KTXF\Module\ModuleConsoleInterface;
|
|
||||||
use Psr\Container\ContainerInterface;
|
use Psr\Container\ContainerInterface;
|
||||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
@@ -34,16 +34,14 @@ final class ConsoleRuntime
|
|||||||
|
|
||||||
/** @var ModuleManager $moduleManager */
|
/** @var ModuleManager $moduleManager */
|
||||||
$moduleManager = $container->get(ModuleManager::class);
|
$moduleManager = $container->get(ModuleManager::class);
|
||||||
|
$moduleContext = new ConsoleModuleContext();
|
||||||
foreach ($moduleManager->list() as $module) {
|
foreach ($moduleManager->list() as $module) {
|
||||||
$instance = $module->instance();
|
$module->configure($moduleContext);
|
||||||
if (!$instance instanceof ModuleConsoleInterface) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($instance->registerCI() as $commandClass) {
|
foreach ($moduleContext->commands() as $commandClass) {
|
||||||
$this->registerCommand($console, $container, $commandClass);
|
$this->registerCommand($console, $container, $commandClass);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return $console->run($input, $output);
|
return $console->run($input, $output);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use KTXC\Http\Middleware\MiddlewarePipeline;
|
|||||||
use KTXC\Http\Middleware\RouterMiddleware;
|
use KTXC\Http\Middleware\RouterMiddleware;
|
||||||
use KTXC\Http\Middleware\TenantMiddleware;
|
use KTXC\Http\Middleware\TenantMiddleware;
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
|
use KTXC\Http\Request\RequestContext;
|
||||||
use KTXC\Http\Response\Response;
|
use KTXC\Http\Response\Response;
|
||||||
use KTXC\KernelInterface;
|
use KTXC\KernelInterface;
|
||||||
|
|
||||||
@@ -25,10 +26,15 @@ final class HttpRuntime
|
|||||||
public function run(?Request $request = null, bool $send = true): Response
|
public function run(?Request $request = null, bool $send = true): Response
|
||||||
{
|
{
|
||||||
$request ??= Request::createFromGlobals();
|
$request ??= Request::createFromGlobals();
|
||||||
|
$requestContext = null;
|
||||||
|
|
||||||
|
try {
|
||||||
return $this->kernel->executionRunner()->execute(
|
return $this->kernel->executionRunner()->execute(
|
||||||
ExecutionDescriptor::http(),
|
ExecutionDescriptor::http(),
|
||||||
function () use ($request, $send): Response {
|
function () use ($request, $send, &$requestContext): Response {
|
||||||
|
$requestContext = $this->kernel->container()->get(RequestContext::class);
|
||||||
|
$requestContext->initialize($request);
|
||||||
|
|
||||||
$response = $this->pipeline()->handle($request);
|
$response = $this->pipeline()->handle($request);
|
||||||
if ($send) {
|
if ($send) {
|
||||||
$response->send();
|
$response->send();
|
||||||
@@ -45,6 +51,9 @@ final class HttpRuntime
|
|||||||
return $response;
|
return $response;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
$requestContext?->clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pipeline(): MiddlewarePipeline
|
private function pipeline(): MiddlewarePipeline
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ use KTXC\Models\Identity\User;
|
|||||||
use KTXC\Resource\ProviderManager;
|
use KTXC\Resource\ProviderManager;
|
||||||
use KTXC\Security\Authentication\AuthenticationRequest;
|
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||||
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
|
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
||||||
use KTXC\Service\TokenService;
|
use KTXC\Service\TokenService;
|
||||||
use KTXC\Service\UserAccountsService;
|
use KTXC\Service\UserAccountsService;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
use KTXF\Cache\CacheScope;
|
use KTXF\Cache\CacheScope;
|
||||||
use KTXF\Cache\EphemeralCacheInterface;
|
use KTXF\Cache\EphemeralCacheInterface;
|
||||||
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||||
use KTXF\Security\Authentication\AuthenticationSession;
|
use KTXF\Security\Authentication\AuthenticationSession;
|
||||||
use KTXF\Security\Authentication\ProviderContext;
|
use KTXF\Security\Authentication\ProviderContext;
|
||||||
@@ -31,6 +34,7 @@ class AuthenticationManager
|
|||||||
private readonly ProviderManager $providerManager,
|
private readonly ProviderManager $providerManager,
|
||||||
private readonly TokenService $tokenService,
|
private readonly TokenService $tokenService,
|
||||||
private readonly UserAccountsService $userService,
|
private readonly UserAccountsService $userService,
|
||||||
|
private readonly EventDispatcherInterface $events,
|
||||||
) {
|
) {
|
||||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
||||||
}
|
}
|
||||||
@@ -187,6 +191,10 @@ class AuthenticationManager
|
|||||||
|
|
||||||
if (!$result->isSuccess()) {
|
if (!$result->isSuccess()) {
|
||||||
$this->saveSession($session);
|
$this->saveSession($session);
|
||||||
|
$this->publishAuthenticationFailure(
|
||||||
|
$session,
|
||||||
|
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
|
);
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
'Authentication failed. If you haven\'t set up this method, try another option.',
|
'Authentication failed. If you haven\'t set up this method, try another option.',
|
||||||
@@ -389,6 +397,10 @@ class AuthenticationManager
|
|||||||
$result = $provider->completeRedirect($context, $request->params);
|
$result = $provider->completeRedirect($context, $request->params);
|
||||||
|
|
||||||
if ($result->isFailed()) {
|
if ($result->isFailed()) {
|
||||||
|
$this->publishAuthenticationFailure(
|
||||||
|
$session,
|
||||||
|
$result->errorCode ?? AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
|
);
|
||||||
$this->deleteSession($session->id);
|
$this->deleteSession($session->id);
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||||
@@ -566,6 +578,17 @@ class AuthenticationManager
|
|||||||
// Helper Methods
|
// Helper Methods
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
|
private function publishAuthenticationFailure(
|
||||||
|
AuthenticationSession $session,
|
||||||
|
string $reason,
|
||||||
|
): void {
|
||||||
|
$this->events->dispatch(new AuthenticationFailedEvent(
|
||||||
|
userId: $session->userIdentifier,
|
||||||
|
reason: $reason,
|
||||||
|
tenantId: $session->tenantIdentifier,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build provider context from session
|
* Build provider context from session
|
||||||
*/
|
*/
|
||||||
@@ -594,7 +617,16 @@ class AuthenticationManager
|
|||||||
*/
|
*/
|
||||||
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
|
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
|
||||||
{
|
{
|
||||||
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
|
$userId = $session->userIdentifier;
|
||||||
|
if ($userId === null) {
|
||||||
|
return AuthenticationResponse::failed(
|
||||||
|
AuthenticationResponse::ERROR_INVALID_SESSION,
|
||||||
|
'Authenticated user is missing',
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userData = $this->userService->fetchByIdentifier($userId);
|
||||||
|
|
||||||
if ($userData === null) {
|
if ($userData === null) {
|
||||||
return AuthenticationResponse::failed(
|
return AuthenticationResponse::failed(
|
||||||
@@ -611,6 +643,11 @@ class AuthenticationManager
|
|||||||
|
|
||||||
$this->deleteSession($session->id);
|
$this->deleteSession($session->id);
|
||||||
|
|
||||||
|
$this->events->dispatch(new AuthenticationSucceededEvent(
|
||||||
|
$userId,
|
||||||
|
$session->tenantIdentifier,
|
||||||
|
));
|
||||||
|
|
||||||
return AuthenticationResponse::success(
|
return AuthenticationResponse::success(
|
||||||
$this->buildUserData($user),
|
$this->buildUserData($user),
|
||||||
$tokens
|
$tokens
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class AccessDeniedEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly string $ruleId,
|
||||||
|
private readonly string $ruleScope,
|
||||||
|
private readonly ?string $deviceFingerprint = null,
|
||||||
|
private readonly ?string $reason = null,
|
||||||
|
?string $tenantId = null,
|
||||||
|
?string $identityId = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Access denial requires an IP address.');
|
||||||
|
}
|
||||||
|
if ($ruleId === '') {
|
||||||
|
throw new \InvalidArgumentException('Access denial requires a firewall rule ID.');
|
||||||
|
}
|
||||||
|
if (!in_array($ruleScope, [FirewallRuleObject::SCOPE_SYSTEM, FirewallRuleObject::SCOPE_TENANT], true)) {
|
||||||
|
throw new \InvalidArgumentException('Access denial requires a valid firewall rule scope.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
|
||||||
|
$tenantId,
|
||||||
|
$identityId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleId(): string
|
||||||
|
{
|
||||||
|
return $this->ruleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleScope(): string
|
||||||
|
{
|
||||||
|
return $this->ruleScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::WARNING;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,15 +9,10 @@ use KTXF\Event\Event;
|
|||||||
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
|
final class AuthenticationFailedEvent extends Event implements SecurityEventInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $ipAddress,
|
|
||||||
private readonly ?string $deviceFingerprint = null,
|
|
||||||
private readonly ?string $userId = null,
|
private readonly ?string $userId = null,
|
||||||
private readonly ?string $reason = null,
|
private readonly ?string $reason = null,
|
||||||
?string $tenantId = null,
|
?string $tenantId = null,
|
||||||
?string $identityId = null,
|
?string $identityId = null,
|
||||||
private readonly ?string $userAgent = null,
|
|
||||||
private readonly ?string $requestPath = null,
|
|
||||||
private readonly ?string $requestMethod = null,
|
|
||||||
) {
|
) {
|
||||||
parent::__construct(
|
parent::__construct(
|
||||||
self::class,
|
self::class,
|
||||||
@@ -27,31 +22,6 @@ final class AuthenticationFailedEvent extends Event implements SecurityEventInte
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getIpAddress(): string
|
|
||||||
{
|
|
||||||
return $this->ipAddress;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string
|
|
||||||
{
|
|
||||||
return $this->deviceFingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return $this->userAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
public function getUserId(): ?string
|
||||||
{
|
{
|
||||||
return $this->userId;
|
return $this->userId;
|
||||||
@@ -62,8 +32,8 @@ final class AuthenticationFailedEvent extends Event implements SecurityEventInte
|
|||||||
return $this->reason;
|
return $this->reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSeverity(): int
|
public function getSeverity(): SecurityEventSeverity
|
||||||
{
|
{
|
||||||
return SecurityEvent::SEVERITY_WARNING;
|
return SecurityEventSeverity::WARNING;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class AuthenticationSucceededEvent extends Event implements SecurityEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $userId,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($userId === '') {
|
||||||
|
throw new \InvalidArgumentException('Successful authentication requires a user ID.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['userId' => $userId],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): string
|
||||||
|
{
|
||||||
|
return $this->userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class BruteForceDetectedEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
private readonly string $reason;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly int $failureCount,
|
||||||
|
private readonly int $windowSeconds,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Brute-force detection requires an IP address.');
|
||||||
|
}
|
||||||
|
if ($failureCount < 1) {
|
||||||
|
throw new \InvalidArgumentException('Brute-force detection requires at least one failure.');
|
||||||
|
}
|
||||||
|
if ($windowSeconds < 1) {
|
||||||
|
throw new \InvalidArgumentException('Brute-force detection requires a positive window.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reason = sprintf(
|
||||||
|
'%d failed attempts in %d seconds',
|
||||||
|
$failureCount,
|
||||||
|
$windowSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFailureCount(): int
|
||||||
|
{
|
||||||
|
return $this->failureCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWindowSeconds(): int
|
||||||
|
{
|
||||||
|
return $this->windowSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::CRITICAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class DeviceBlockedEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $deviceFingerprint,
|
||||||
|
private readonly ?string $reason = null,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($deviceFingerprint === '') {
|
||||||
|
throw new \InvalidArgumentException('Device-block events require a fingerprint.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['device' => $deviceFingerprint, 'reason' => $reason],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): string
|
||||||
|
{
|
||||||
|
return $this->deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::CRITICAL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
abstract class FirewallIpEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::INFO;
|
||||||
|
|
||||||
|
final public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly ?string $reason = null,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall IP events require an IP address.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
static::class,
|
||||||
|
['ip' => $ipAddress, 'reason' => $reason],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return static::SEVERITY;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleCreatedEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleDisabledEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleEnabledEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
abstract class FirewallRuleEvent extends Event implements SecurityEventInterface
|
||||||
|
{
|
||||||
|
final protected function __construct(
|
||||||
|
private readonly string $ruleId,
|
||||||
|
private readonly string $ruleScope,
|
||||||
|
private readonly string $ruleType,
|
||||||
|
private readonly string $ruleAction,
|
||||||
|
private readonly string $ruleValue,
|
||||||
|
private readonly ?string $reason,
|
||||||
|
private readonly string $origin,
|
||||||
|
private readonly ?string $expiresAt,
|
||||||
|
private readonly array $details,
|
||||||
|
?string $tenantId,
|
||||||
|
?string $identityId,
|
||||||
|
) {
|
||||||
|
if ($ruleId === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall rule events require a rule ID.');
|
||||||
|
}
|
||||||
|
foreach ([
|
||||||
|
'scope' => $ruleScope,
|
||||||
|
'type' => $ruleType,
|
||||||
|
'action' => $ruleAction,
|
||||||
|
'value' => $ruleValue,
|
||||||
|
'origin' => $origin,
|
||||||
|
] as $field => $value) {
|
||||||
|
if ($value === '') {
|
||||||
|
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ([
|
||||||
|
'scope' => $ruleScope,
|
||||||
|
'type' => $ruleType,
|
||||||
|
'action' => $ruleAction,
|
||||||
|
'value' => $ruleValue,
|
||||||
|
'origin' => $origin,
|
||||||
|
] as $field => $value) {
|
||||||
|
if ($value === '') {
|
||||||
|
throw new \InvalidArgumentException("Firewall rule events require a rule {$field}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
static::class,
|
||||||
|
[
|
||||||
|
'ruleId' => $ruleId,
|
||||||
|
'ruleScope' => $ruleScope,
|
||||||
|
'ruleType' => $ruleType,
|
||||||
|
'ruleAction' => $ruleAction,
|
||||||
|
'ruleValue' => $ruleValue,
|
||||||
|
'reason' => $reason,
|
||||||
|
'origin' => $origin,
|
||||||
|
'expiresAt' => $expiresAt,
|
||||||
|
...$details,
|
||||||
|
],
|
||||||
|
$tenantId,
|
||||||
|
$identityId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromRule(
|
||||||
|
FirewallRuleObject $rule,
|
||||||
|
?string $actorId = null,
|
||||||
|
array $change = [],
|
||||||
|
): static {
|
||||||
|
$metadata = $rule->getMetadata() ?? [];
|
||||||
|
$details = [...$metadata, ...$change];
|
||||||
|
foreach ([
|
||||||
|
'ruleId',
|
||||||
|
'ruleScope',
|
||||||
|
'ruleType',
|
||||||
|
'ruleAction',
|
||||||
|
'ruleValue',
|
||||||
|
'reason',
|
||||||
|
'origin',
|
||||||
|
'expiresAt',
|
||||||
|
] as $reservedKey) {
|
||||||
|
unset($details[$reservedKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new static(
|
||||||
|
ruleId: (string) $rule->getId(),
|
||||||
|
ruleScope: (string) $rule->getScope(),
|
||||||
|
ruleType: (string) $rule->getType(),
|
||||||
|
ruleAction: (string) $rule->getAction(),
|
||||||
|
ruleValue: (string) $rule->getValue(),
|
||||||
|
reason: $rule->getReason(),
|
||||||
|
origin: (string) ($metadata['origin'] ?? 'manual'),
|
||||||
|
expiresAt: $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
||||||
|
details: $details,
|
||||||
|
tenantId: $rule->getTenantId(),
|
||||||
|
identityId: $actorId ?? $rule->getCreatedBy(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleId(): string
|
||||||
|
{
|
||||||
|
return $this->ruleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleScope(): string
|
||||||
|
{
|
||||||
|
return $this->ruleScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleType(): string
|
||||||
|
{
|
||||||
|
return $this->ruleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleAction(): string
|
||||||
|
{
|
||||||
|
return $this->ruleAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRuleValue(): string
|
||||||
|
{
|
||||||
|
return $this->ruleValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrigin(): string
|
||||||
|
{
|
||||||
|
return $this->origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExpiresAt(): ?string
|
||||||
|
{
|
||||||
|
return $this->expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetails(): array
|
||||||
|
{
|
||||||
|
return $this->details;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleExtendedEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class FirewallRuleRemovedEvent extends FirewallRuleEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class FirewallSettingsUpdatedEvent extends Event implements SecurityEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $changeReason,
|
||||||
|
private readonly array $previous,
|
||||||
|
private readonly array $current,
|
||||||
|
string $tenantId,
|
||||||
|
?string $actorId = null,
|
||||||
|
private readonly string $changeOrigin = 'manual',
|
||||||
|
) {
|
||||||
|
if ($changeReason === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall settings updates require a change reason.');
|
||||||
|
}
|
||||||
|
if ($tenantId === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall settings updates require a tenant ID.');
|
||||||
|
}
|
||||||
|
if ($changeOrigin === '') {
|
||||||
|
throw new \InvalidArgumentException('Firewall settings updates require a change origin.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
[
|
||||||
|
'changeReason' => $changeReason,
|
||||||
|
'changeOrigin' => $changeOrigin,
|
||||||
|
'previous' => $previous,
|
||||||
|
'current' => $current,
|
||||||
|
],
|
||||||
|
$tenantId,
|
||||||
|
$actorId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChangeReason(): string
|
||||||
|
{
|
||||||
|
return $this->changeReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPrevious(): array
|
||||||
|
{
|
||||||
|
return $this->previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrent(): array
|
||||||
|
{
|
||||||
|
return $this->current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChangeOrigin(): string
|
||||||
|
{
|
||||||
|
return $this->changeOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): string
|
||||||
|
{
|
||||||
|
return $this->changeReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class IpAllowedEvent extends FirewallIpEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
final class IpBlockedEvent extends FirewallIpEvent
|
||||||
|
{
|
||||||
|
protected const SecurityEventSeverity SEVERITY = SecurityEventSeverity::CRITICAL;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class RateLimitExceededEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
private readonly string $reason;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly int $requestCount,
|
||||||
|
private readonly int $windowSeconds,
|
||||||
|
private readonly ?string $endpoint = null,
|
||||||
|
?string $tenantId = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Rate-limit detection requires an IP address.');
|
||||||
|
}
|
||||||
|
if ($requestCount < 1) {
|
||||||
|
throw new \InvalidArgumentException('Rate-limit detection requires at least one request.');
|
||||||
|
}
|
||||||
|
if ($windowSeconds < 1) {
|
||||||
|
throw new \InvalidArgumentException('Rate-limit detection requires a positive window.');
|
||||||
|
}
|
||||||
|
if ($endpoint === '') {
|
||||||
|
throw new \InvalidArgumentException('A supplied rate-limit endpoint cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reason = sprintf(
|
||||||
|
'%d requests in %d seconds',
|
||||||
|
$requestCount,
|
||||||
|
$windowSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
[
|
||||||
|
'requestCount' => $requestCount,
|
||||||
|
'windowSeconds' => $windowSeconds,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
],
|
||||||
|
$tenantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestCount(): int
|
||||||
|
{
|
||||||
|
return $this->requestCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getWindowSeconds(): int
|
||||||
|
{
|
||||||
|
return $this->windowSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndpoint(): ?string
|
||||||
|
{
|
||||||
|
return $this->endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXC\Security\Event;
|
|
||||||
|
|
||||||
use KTXF\Event\Event;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Security-specific event for authentication and access control events
|
|
||||||
*/
|
|
||||||
final class SecurityEvent extends Event implements SecurityEventInterface
|
|
||||||
{
|
|
||||||
// Event names
|
|
||||||
public const AUTH_SUCCESS = 'security.auth.success';
|
|
||||||
public const AUTH_LOGOUT = 'security.auth.logout';
|
|
||||||
public const TOKEN_REFRESH = 'security.token.refresh';
|
|
||||||
public const TOKEN_REVOKED = 'security.token.revoked';
|
|
||||||
|
|
||||||
public const ACCESS_DENIED = 'security.access.denied';
|
|
||||||
public const ACCESS_GRANTED = 'security.access.granted';
|
|
||||||
|
|
||||||
public const BRUTE_FORCE_DETECTED = 'security.brute_force.detected';
|
|
||||||
public const RATE_LIMIT_EXCEEDED = 'security.rate_limit.exceeded';
|
|
||||||
public const SUSPICIOUS_ACTIVITY = 'security.suspicious.activity';
|
|
||||||
|
|
||||||
public const IP_BLOCKED = 'security.ip.blocked';
|
|
||||||
public const IP_ALLOWED = 'security.ip.allowed';
|
|
||||||
public const DEVICE_BLOCKED = 'security.device.blocked';
|
|
||||||
public const FIREWALL_RULE_CREATED = 'security.firewall.rule.created';
|
|
||||||
public const FIREWALL_RULE_EXTENDED = 'security.firewall.rule.extended';
|
|
||||||
public const FIREWALL_RULE_ENABLED = 'security.firewall.rule.enabled';
|
|
||||||
public const FIREWALL_RULE_DISABLED = 'security.firewall.rule.disabled';
|
|
||||||
public const FIREWALL_RULE_REMOVED = 'security.firewall.rule.removed';
|
|
||||||
public const FIREWALL_SETTINGS_UPDATED = 'security.firewall.settings.updated';
|
|
||||||
|
|
||||||
// Severity levels
|
|
||||||
public const SEVERITY_DEBUG = 0;
|
|
||||||
public const SEVERITY_INFO = 1;
|
|
||||||
public const SEVERITY_WARNING = 2;
|
|
||||||
public const SEVERITY_ERROR = 3;
|
|
||||||
public const SEVERITY_CRITICAL = 4;
|
|
||||||
|
|
||||||
private readonly int $severity;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
string $name,
|
|
||||||
array $data = [],
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
private readonly ?string $ipAddress = null,
|
|
||||||
private readonly ?string $deviceFingerprint = null,
|
|
||||||
private readonly ?string $userAgent = null,
|
|
||||||
private readonly ?string $requestPath = null,
|
|
||||||
private readonly ?string $requestMethod = null,
|
|
||||||
private readonly ?string $userId = null,
|
|
||||||
private readonly ?string $reason = null,
|
|
||||||
?int $severity = null,
|
|
||||||
) {
|
|
||||||
parent::__construct($name, $data, $tenantId, $identityId);
|
|
||||||
|
|
||||||
$this->severity = $severity ?? self::getSeverityForEvent($name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a security event with common parameters
|
|
||||||
*/
|
|
||||||
public static function create(
|
|
||||||
string $name,
|
|
||||||
?string $ipAddress = null,
|
|
||||||
?string $deviceFingerprint = null,
|
|
||||||
array $data = [],
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
?string $userAgent = null,
|
|
||||||
?string $requestPath = null,
|
|
||||||
?string $requestMethod = null,
|
|
||||||
?string $userId = null,
|
|
||||||
?string $reason = null,
|
|
||||||
?int $severity = null,
|
|
||||||
): self {
|
|
||||||
return new self(
|
|
||||||
$name,
|
|
||||||
$data,
|
|
||||||
$tenantId,
|
|
||||||
$identityId,
|
|
||||||
$ipAddress,
|
|
||||||
$deviceFingerprint,
|
|
||||||
$userAgent,
|
|
||||||
$requestPath,
|
|
||||||
$requestMethod,
|
|
||||||
$userId,
|
|
||||||
$reason,
|
|
||||||
$severity,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an authentication success event
|
|
||||||
*/
|
|
||||||
public static function authSuccess(
|
|
||||||
string $ipAddress,
|
|
||||||
?string $deviceFingerprint = null,
|
|
||||||
?string $userId = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
): self {
|
|
||||||
return self::create(
|
|
||||||
self::AUTH_SUCCESS,
|
|
||||||
$ipAddress,
|
|
||||||
$deviceFingerprint,
|
|
||||||
['userId' => $userId],
|
|
||||||
tenantId: $tenantId,
|
|
||||||
userId: $userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a brute force detection event
|
|
||||||
*/
|
|
||||||
public static function bruteForceDetected(
|
|
||||||
string $ipAddress,
|
|
||||||
int $failureCount,
|
|
||||||
int $windowSeconds,
|
|
||||||
?string $tenantId = null,
|
|
||||||
): self {
|
|
||||||
return self::create(
|
|
||||||
self::BRUTE_FORCE_DETECTED,
|
|
||||||
$ipAddress,
|
|
||||||
data: ['failureCount' => $failureCount, 'windowSeconds' => $windowSeconds],
|
|
||||||
tenantId: $tenantId,
|
|
||||||
reason: sprintf('%d failed attempts in %d seconds', $failureCount, $windowSeconds),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a rate limit exceeded event
|
|
||||||
*/
|
|
||||||
public static function rateLimitExceeded(
|
|
||||||
string $ipAddress,
|
|
||||||
int $requestCount,
|
|
||||||
int $windowSeconds,
|
|
||||||
?string $endpoint = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
): self {
|
|
||||||
return self::create(
|
|
||||||
self::RATE_LIMIT_EXCEEDED,
|
|
||||||
$ipAddress,
|
|
||||||
data: [
|
|
||||||
'requestCount' => $requestCount,
|
|
||||||
'windowSeconds' => $windowSeconds,
|
|
||||||
'endpoint' => $endpoint,
|
|
||||||
],
|
|
||||||
tenantId: $tenantId,
|
|
||||||
requestPath: $endpoint,
|
|
||||||
reason: sprintf('%d requests in %d seconds', $requestCount, $windowSeconds),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an access denied event
|
|
||||||
*/
|
|
||||||
public static function accessDenied(
|
|
||||||
string $ipAddress,
|
|
||||||
?string $deviceFingerprint = null,
|
|
||||||
?string $ruleId = null,
|
|
||||||
?string $ruleScope = null,
|
|
||||||
?string $reason = null,
|
|
||||||
?string $tenantId = null,
|
|
||||||
?string $identityId = null,
|
|
||||||
): self {
|
|
||||||
return self::create(
|
|
||||||
self::ACCESS_DENIED,
|
|
||||||
$ipAddress,
|
|
||||||
$deviceFingerprint,
|
|
||||||
['ruleId' => $ruleId, 'ruleScope' => $ruleScope, 'reason' => $reason],
|
|
||||||
$tenantId,
|
|
||||||
$identityId,
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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::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 getDeviceFingerprint(): ?string
|
|
||||||
{
|
|
||||||
return $this->deviceFingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string
|
|
||||||
{
|
|
||||||
return $this->userAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string
|
|
||||||
{
|
|
||||||
return $this->requestMethod;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserId(): ?string
|
|
||||||
{
|
|
||||||
return $this->userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getReason(): ?string
|
|
||||||
{
|
|
||||||
return $this->reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSeverity(): int
|
|
||||||
{
|
|
||||||
return $this->severity;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -6,31 +6,21 @@ namespace KTXC\Security\Event;
|
|||||||
|
|
||||||
interface SecurityEventInterface
|
interface SecurityEventInterface
|
||||||
{
|
{
|
||||||
public function getName(): string;
|
public function label(): string;
|
||||||
|
|
||||||
public function get(string $key, mixed $default = null): mixed;
|
public function get(string $key, mixed $default = null): mixed;
|
||||||
|
|
||||||
public function getData(): array;
|
public function context(): array;
|
||||||
|
|
||||||
public function getEventId(): string;
|
public function identifier(): string;
|
||||||
|
|
||||||
public function getTenantId(): ?string;
|
public function tenantIdentifier(): ?string;
|
||||||
|
|
||||||
public function getIdentityId(): ?string;
|
public function actorIdentity(): ?string;
|
||||||
|
|
||||||
public function getIpAddress(): ?string;
|
|
||||||
|
|
||||||
public function getDeviceFingerprint(): ?string;
|
|
||||||
|
|
||||||
public function getUserAgent(): ?string;
|
|
||||||
|
|
||||||
public function getRequestPath(): ?string;
|
|
||||||
|
|
||||||
public function getRequestMethod(): ?string;
|
|
||||||
|
|
||||||
public function getUserId(): ?string;
|
public function getUserId(): ?string;
|
||||||
|
|
||||||
public function getReason(): ?string;
|
public function getReason(): ?string;
|
||||||
|
|
||||||
public function getSeverity(): int;
|
public function getSeverity(): SecurityEventSeverity;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
enum SecurityEventSeverity: int
|
||||||
|
{
|
||||||
|
case DEBUG = 0;
|
||||||
|
case INFO = 1;
|
||||||
|
case WARNING = 2;
|
||||||
|
case ERROR = 3;
|
||||||
|
case CRITICAL = 4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
interface SecurityRequestEventInterface extends SecurityEventInterface
|
||||||
|
{
|
||||||
|
public function getIpAddress(): ?string;
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string;
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string;
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string;
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\Security\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
final class SuspiciousActivityEvent extends Event implements SecurityRequestEventInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $ipAddress,
|
||||||
|
private readonly string $detector,
|
||||||
|
private readonly array $detectionData = [],
|
||||||
|
?string $tenantId = null,
|
||||||
|
?string $identityId = null,
|
||||||
|
private readonly ?string $deviceFingerprint = null,
|
||||||
|
private readonly ?string $userAgent = null,
|
||||||
|
private readonly ?string $requestPath = null,
|
||||||
|
private readonly ?string $requestMethod = null,
|
||||||
|
private readonly ?string $userId = null,
|
||||||
|
private readonly ?string $reason = null,
|
||||||
|
) {
|
||||||
|
if ($ipAddress === '') {
|
||||||
|
throw new \InvalidArgumentException('Suspicious activity requires an IP address.');
|
||||||
|
}
|
||||||
|
if ($detector === '') {
|
||||||
|
throw new \InvalidArgumentException('Suspicious activity requires a detector.');
|
||||||
|
}
|
||||||
|
if (array_key_exists('detector', $detectionData)) {
|
||||||
|
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
|
||||||
|
}
|
||||||
|
if (array_key_exists('detector', $detectionData)) {
|
||||||
|
throw new \InvalidArgumentException('Detection data cannot replace the detector.');
|
||||||
|
}
|
||||||
|
if ($requestPath === '') {
|
||||||
|
throw new \InvalidArgumentException('A supplied request path cannot be empty.');
|
||||||
|
}
|
||||||
|
if ($requestMethod === '') {
|
||||||
|
throw new \InvalidArgumentException('A supplied request method cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::__construct(
|
||||||
|
self::class,
|
||||||
|
['detector' => $detector] + $detectionData,
|
||||||
|
$tenantId,
|
||||||
|
$identityId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getIpAddress(): string
|
||||||
|
{
|
||||||
|
return $this->ipAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetector(): string
|
||||||
|
{
|
||||||
|
return $this->detector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetectionData(): array
|
||||||
|
{
|
||||||
|
return $this->detectionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceFingerprint(): ?string
|
||||||
|
{
|
||||||
|
return $this->deviceFingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserAgent(): ?string
|
||||||
|
{
|
||||||
|
return $this->userAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRequestMethod(): ?string
|
||||||
|
{
|
||||||
|
return $this->requestMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserId(): ?string
|
||||||
|
{
|
||||||
|
return $this->userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getReason(): ?string
|
||||||
|
{
|
||||||
|
return $this->reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSeverity(): SecurityEventSeverity
|
||||||
|
{
|
||||||
|
return SecurityEventSeverity::ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,17 @@ declare(strict_types=1);
|
|||||||
namespace KTXC\Service;
|
namespace KTXC\Service;
|
||||||
|
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
|
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
||||||
|
use KTXC\Security\Event\DeviceBlockedEvent;
|
||||||
|
use KTXC\Security\Event\IpAllowedEvent;
|
||||||
|
use KTXC\Security\Event\IpBlockedEvent;
|
||||||
use KTXC\Stores\FirewallStore;
|
use KTXC\Stores\FirewallStore;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
|
||||||
use KTXF\IpUtils;
|
use KTXF\IpUtils;
|
||||||
|
|
||||||
final class FirewallRuleManager
|
final class FirewallRuleManager
|
||||||
@@ -186,7 +194,7 @@ final class FirewallRuleManager
|
|||||||
$origin,
|
$origin,
|
||||||
$metadata
|
$metadata
|
||||||
);
|
);
|
||||||
$this->publishIpEvent(SecurityEvent::IP_BLOCKED, $scope, $ipAddress, $reason);
|
$this->events->dispatch(new IpBlockedEvent($ipAddress, $reason, $scope->tenantId));
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -209,7 +217,7 @@ final class FirewallRuleManager
|
|||||||
null,
|
null,
|
||||||
$origin
|
$origin
|
||||||
);
|
);
|
||||||
$this->publishIpEvent(SecurityEvent::IP_ALLOWED, $scope, $ipAddress, $reason);
|
$this->events->dispatch(new IpAllowedEvent($ipAddress, $reason, $scope->tenantId));
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -254,13 +262,7 @@ final class FirewallRuleManager
|
|||||||
$origin
|
$origin
|
||||||
);
|
);
|
||||||
|
|
||||||
$event = new SecurityEvent(
|
$event = new DeviceBlockedEvent($fingerprint, $reason, $scope->tenantId);
|
||||||
SecurityEvent::DEVICE_BLOCKED,
|
|
||||||
['device' => $fingerprint, 'reason' => $reason],
|
|
||||||
tenantId: $scope->tenantId,
|
|
||||||
deviceFingerprint: $fingerprint,
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
@@ -282,7 +284,7 @@ final class FirewallRuleManager
|
|||||||
$this->store->depositRule($rule);
|
$this->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(
|
$this->publishLifecycleEvent(
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
FirewallRuleDisabledEvent::class,
|
||||||
$rule,
|
$rule,
|
||||||
$actorId,
|
$actorId,
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||||
@@ -321,7 +323,7 @@ final class FirewallRuleManager
|
|||||||
$this->store->depositRule($rule);
|
$this->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(
|
$this->publishLifecycleEvent(
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
FirewallRuleEnabledEvent::class,
|
||||||
$rule,
|
$rule,
|
||||||
$actorId,
|
$actorId,
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||||
@@ -365,7 +367,7 @@ final class FirewallRuleManager
|
|||||||
$this->store->depositRule($rule);
|
$this->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(
|
$this->publishLifecycleEvent(
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
FirewallRuleExtendedEvent::class,
|
||||||
$rule,
|
$rule,
|
||||||
$actorId,
|
$actorId,
|
||||||
[
|
[
|
||||||
@@ -392,7 +394,7 @@ final class FirewallRuleManager
|
|||||||
$this->store->destroyRule($rule);
|
$this->store->destroyRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(
|
$this->publishLifecycleEvent(
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
FirewallRuleRemovedEvent::class,
|
||||||
$rule,
|
$rule,
|
||||||
$actorId,
|
$actorId,
|
||||||
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
['changeReason' => $reason, 'changeOrigin' => self::ORIGIN_MANUAL]
|
||||||
@@ -447,9 +449,10 @@ final class FirewallRuleManager
|
|||||||
}
|
}
|
||||||
$rule->setMetadata($metadata);
|
$rule->setMetadata($metadata);
|
||||||
|
|
||||||
$this->store->depositRule($rule);
|
$rule = $this->store->depositRule($rule)
|
||||||
|
?? throw new \RuntimeException('Failed to persist firewall rule.');
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_CREATED, $rule);
|
$this->publishLifecycleEvent(FirewallRuleCreatedEvent::class, $rule);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -486,7 +489,7 @@ final class FirewallRuleManager
|
|||||||
|
|
||||||
$this->store->depositRule($rule);
|
$this->store->depositRule($rule);
|
||||||
$this->cache->invalidate();
|
$this->cache->invalidate();
|
||||||
$this->publishLifecycleEvent(SecurityEvent::FIREWALL_RULE_EXTENDED, $rule);
|
$this->publishLifecycleEvent(FirewallRuleExtendedEvent::class, $rule);
|
||||||
|
|
||||||
return $rule;
|
return $rule;
|
||||||
}
|
}
|
||||||
@@ -498,46 +501,17 @@ final class FirewallRuleManager
|
|||||||
return $rule && $scope->owns($rule) ? $rule : null;
|
return $rule && $scope->owns($rule) ? $rule : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function publishIpEvent(
|
/**
|
||||||
string $name,
|
* @param class-string<FirewallRuleEvent> $eventClass
|
||||||
FirewallRuleScope $scope,
|
*/
|
||||||
string $ipAddress,
|
|
||||||
?string $reason
|
|
||||||
): void {
|
|
||||||
$event = new SecurityEvent(
|
|
||||||
$name,
|
|
||||||
['ip' => $ipAddress, 'reason' => $reason],
|
|
||||||
tenantId: $scope->tenantId,
|
|
||||||
ipAddress: $ipAddress,
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
$this->events->dispatch($event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function publishLifecycleEvent(
|
private function publishLifecycleEvent(
|
||||||
string $name,
|
string $eventClass,
|
||||||
FirewallRuleObject $rule,
|
FirewallRuleObject $rule,
|
||||||
?string $actorId = null,
|
?string $actorId = null,
|
||||||
array $change = []
|
array $change = []
|
||||||
): void
|
): void
|
||||||
{
|
{
|
||||||
$event = new SecurityEvent(
|
$event = $eventClass::fromRule($rule, $actorId, $change);
|
||||||
$name,
|
|
||||||
[
|
|
||||||
'ruleId' => $rule->getId(),
|
|
||||||
'ruleScope' => $rule->getScope(),
|
|
||||||
'ruleType' => $rule->getType(),
|
|
||||||
'ruleAction' => $rule->getAction(),
|
|
||||||
'ruleValue' => $rule->getValue(),
|
|
||||||
'reason' => $rule->getReason(),
|
|
||||||
'origin' => $rule->getMetadata()['origin'] ?? self::ORIGIN_MANUAL,
|
|
||||||
'expiresAt' => $rule->getExpiresAt()?->format(\DateTimeInterface::ATOM),
|
|
||||||
...($rule->getMetadata() ?? []),
|
|
||||||
...$change,
|
|
||||||
],
|
|
||||||
tenantId: $rule->getTenantId(),
|
|
||||||
identityId: $actorId ?? $rule->getCreatedBy(),
|
|
||||||
);
|
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,25 @@ declare(strict_types=1);
|
|||||||
namespace KTXC\Service;
|
namespace KTXC\Service;
|
||||||
|
|
||||||
use KTXC\Http\Request\Request;
|
use KTXC\Http\Request\Request;
|
||||||
|
use KTXC\Http\Request\RequestContext;
|
||||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||||
use KTXC\Models\Firewall\FirewallLogObject;
|
use KTXC\Models\Firewall\FirewallLogObject;
|
||||||
use KTXC\Stores\FirewallStore;
|
use KTXC\Stores\FirewallStore;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXC\Security\Event\AccessDeniedEvent;
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
use KTXC\Security\Event\AuthenticationSucceededEvent;
|
||||||
|
use KTXC\Security\Event\BruteForceDetectedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleCreatedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleDisabledEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleEnabledEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleExtendedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallRuleRemovedEvent;
|
||||||
|
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
||||||
|
use KTXC\Security\Event\RateLimitExceededEvent;
|
||||||
|
use KTXC\Security\Event\SuspiciousActivityEvent;
|
||||||
use KTXC\Security\Event\SecurityEventInterface;
|
use KTXC\Security\Event\SecurityEventInterface;
|
||||||
|
use KTXC\Security\Event\SecurityRequestEventInterface;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXF\IpUtils;
|
use KTXF\IpUtils;
|
||||||
|
|
||||||
@@ -47,6 +59,7 @@ class FirewallService
|
|||||||
private readonly EventDispatcherInterface $events,
|
private readonly EventDispatcherInterface $events,
|
||||||
private readonly FirewallRuleManager $rules,
|
private readonly FirewallRuleManager $rules,
|
||||||
private readonly FirewallRuleCache $ruleCache,
|
private readonly FirewallRuleCache $ruleCache,
|
||||||
|
private readonly RequestContext $requestContext,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,14 +147,15 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
public function handleAuthFailure(AuthenticationFailedEvent $event): void
|
public function handleAuthFailure(AuthenticationFailedEvent $event): void
|
||||||
{
|
{
|
||||||
$ipAddress = $event->getIpAddress();
|
$request = $this->requestContext->current();
|
||||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
$ipAddress = $request?->getClientIp();
|
||||||
|
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
||||||
|
|
||||||
if (!$ipAddress || !$tenantId) {
|
if (!$ipAddress || !$tenantId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$log = $this->securityLog($event);
|
$log = $this->securityLog($event, $request);
|
||||||
if ($log === null || !$this->store->createLogOnce($log)) {
|
if ($log === null || !$this->store->createLogOnce($log)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -196,7 +210,7 @@ class FirewallService
|
|||||||
int $blockDuration
|
int $blockDuration
|
||||||
): void {
|
): void {
|
||||||
// Publish brute force event
|
// Publish brute force event
|
||||||
$event = SecurityEvent::bruteForceDetected(
|
$event = new BruteForceDetectedEvent(
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$failureCount,
|
$failureCount,
|
||||||
$windowSeconds,
|
$windowSeconds,
|
||||||
@@ -235,29 +249,45 @@ class FirewallService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function securityLog(SecurityEventInterface $event): ?FirewallLogObject
|
public function logAuthenticationSuccess(AuthenticationSucceededEvent $event): void
|
||||||
{
|
{
|
||||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
$log = $this->securityLog($event, $this->requestContext->current());
|
||||||
|
if ($log !== null) {
|
||||||
|
$this->store->createLog($log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function securityLog(
|
||||||
|
SecurityEventInterface $event,
|
||||||
|
?Request $request = null,
|
||||||
|
): ?FirewallLogObject
|
||||||
|
{
|
||||||
|
$tenantId = $event->tenantIdentifier() ?? $this->tenantContext->identifier();
|
||||||
$ruleScope = $event->get('ruleScope');
|
$ruleScope = $event->get('ruleScope');
|
||||||
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
if (!$tenantId && $ruleScope !== FirewallRuleObject::SCOPE_SYSTEM) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$requestEvent = $event instanceof SecurityRequestEventInterface ? $event : null;
|
||||||
|
|
||||||
$log = new FirewallLogObject();
|
$log = new FirewallLogObject();
|
||||||
return $log->setEventId($event->getEventId())
|
return $log->setEventId($event->identifier())
|
||||||
->setTenantId($tenantId)
|
->setTenantId($tenantId)
|
||||||
->setIpAddress($event->getIpAddress())
|
->setIpAddress($request?->getClientIp() ?? $requestEvent?->getIpAddress())
|
||||||
->setDeviceFingerprint($event->getDeviceFingerprint())
|
->setDeviceFingerprint(
|
||||||
->setUserAgent($event->getUserAgent())
|
$request?->headers->get('X-Device-Fingerprint')
|
||||||
->setRequestPath($event->getRequestPath())
|
?? $requestEvent?->getDeviceFingerprint()
|
||||||
->setRequestMethod($event->getRequestMethod())
|
)
|
||||||
->setEventType($this->mapEventToLogType($event->getName()))
|
->setUserAgent($request?->headers->get('User-Agent') ?? $requestEvent?->getUserAgent())
|
||||||
|
->setRequestPath($request?->getPathInfo() ?? $requestEvent?->getRequestPath())
|
||||||
|
->setRequestMethod($request?->getMethod() ?? $requestEvent?->getRequestMethod())
|
||||||
|
->setEventType($this->mapEventToLogType($event->label()))
|
||||||
->setResult($this->mapEventToResult($event))
|
->setResult($this->mapEventToResult($event))
|
||||||
->setRuleId($event->get('ruleId'))
|
->setRuleId($event->get('ruleId'))
|
||||||
->setRuleScope($ruleScope)
|
->setRuleScope($ruleScope)
|
||||||
->setIdentityId($event->getUserId() ?? $event->getIdentityId())
|
->setIdentityId($event->getUserId() ?? $event->actorIdentity())
|
||||||
->setTimestamp(new \DateTimeImmutable())
|
->setTimestamp(new \DateTimeImmutable())
|
||||||
->setMetadata($event->getData());
|
->setMetadata($event->context());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -267,17 +297,17 @@ class FirewallService
|
|||||||
{
|
{
|
||||||
return match ($eventName) {
|
return match ($eventName) {
|
||||||
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE,
|
AuthenticationFailedEvent::class => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||||
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
|
AuthenticationSucceededEvent::class => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
|
BruteForceDetectedEvent::class => FirewallLogObject::EVENT_BRUTE_FORCE,
|
||||||
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
|
RateLimitExceededEvent::class => FirewallLogObject::EVENT_RATE_LIMIT,
|
||||||
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
AccessDeniedEvent::class => FirewallLogObject::EVENT_RULE_MATCH,
|
||||||
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
SuspiciousActivityEvent::class => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED => FirewallLogObject::EVENT_RULE_CREATED,
|
FirewallRuleCreatedEvent::class => FirewallLogObject::EVENT_RULE_CREATED,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED => FirewallLogObject::EVENT_RULE_EXTENDED,
|
FirewallRuleExtendedEvent::class => FirewallLogObject::EVENT_RULE_EXTENDED,
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED => FirewallLogObject::EVENT_RULE_ENABLED,
|
FirewallRuleEnabledEvent::class => FirewallLogObject::EVENT_RULE_ENABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED => FirewallLogObject::EVENT_RULE_DISABLED,
|
FirewallRuleDisabledEvent::class => FirewallLogObject::EVENT_RULE_DISABLED,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED => FirewallLogObject::EVENT_RULE_REMOVED,
|
FirewallRuleRemovedEvent::class => FirewallLogObject::EVENT_RULE_REMOVED,
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
FirewallSettingsUpdatedEvent::class => FirewallLogObject::EVENT_SETTINGS_UPDATED,
|
||||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -287,15 +317,14 @@ class FirewallService
|
|||||||
*/
|
*/
|
||||||
private function mapEventToResult(SecurityEventInterface $event): string
|
private function mapEventToResult(SecurityEventInterface $event): string
|
||||||
{
|
{
|
||||||
return match ($event->getName()) {
|
return match ($event->label()) {
|
||||||
SecurityEvent::AUTH_SUCCESS,
|
AuthenticationSucceededEvent::class => FirewallLogObject::RESULT_ALLOWED,
|
||||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
FirewallRuleCreatedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_CREATED,
|
FirewallRuleExtendedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_EXTENDED,
|
FirewallRuleEnabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_ENABLED,
|
FirewallRuleDisabledEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_DISABLED,
|
FirewallRuleRemovedEvent::class,
|
||||||
SecurityEvent::FIREWALL_RULE_REMOVED,
|
FirewallSettingsUpdatedEvent::class => FirewallLogObject::RESULT_RECORDED,
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED => FirewallLogObject::RESULT_RECORDED,
|
|
||||||
default => FirewallLogObject::RESULT_BLOCKED,
|
default => FirewallLogObject::RESULT_BLOCKED,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -308,13 +337,13 @@ class FirewallService
|
|||||||
?string $deviceFingerprint,
|
?string $deviceFingerprint,
|
||||||
FirewallRuleObject $rule
|
FirewallRuleObject $rule
|
||||||
): void {
|
): void {
|
||||||
$event = SecurityEvent::accessDenied(
|
$event = new AccessDeniedEvent(
|
||||||
$ipAddress,
|
ipAddress: $ipAddress,
|
||||||
$deviceFingerprint,
|
ruleId: $rule->getId(),
|
||||||
$rule->getId(),
|
ruleScope: $rule->getScope(),
|
||||||
$rule->getScope(),
|
deviceFingerprint: $deviceFingerprint,
|
||||||
$rule->getReason(),
|
reason: $rule->getReason(),
|
||||||
$this->tenantContext->identifier(),
|
tenantId: $this->tenantContext->identifier(),
|
||||||
);
|
);
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace KTXC\Service;
|
|||||||
|
|
||||||
use KTXC\Models\Tenant\TenantConfiguration;
|
use KTXC\Models\Tenant\TenantConfiguration;
|
||||||
use KTXF\Event\EventDispatcherInterface;
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
use KTXC\Security\Event\SecurityEvent;
|
use KTXC\Security\Event\FirewallSettingsUpdatedEvent;
|
||||||
|
|
||||||
final class FirewallSettingsService
|
final class FirewallSettingsService
|
||||||
{
|
{
|
||||||
@@ -52,16 +52,13 @@ final class FirewallSettingsService
|
|||||||
$tenant->setConfiguration($configuration);
|
$tenant->setConfiguration($configuration);
|
||||||
$this->tenants->deposit($tenant);
|
$this->tenants->deposit($tenant);
|
||||||
|
|
||||||
$event = new SecurityEvent(
|
$event = new FirewallSettingsUpdatedEvent(
|
||||||
SecurityEvent::FIREWALL_SETTINGS_UPDATED,
|
changeReason: $reason,
|
||||||
[
|
previous: $previous,
|
||||||
'changeReason' => $reason,
|
current: $current,
|
||||||
'changeOrigin' => FirewallRuleManager::ORIGIN_MANUAL,
|
|
||||||
'previous' => $previous,
|
|
||||||
'current' => $current,
|
|
||||||
],
|
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
identityId: $actorId,
|
actorId: $actorId,
|
||||||
|
changeOrigin: FirewallRuleManager::ORIGIN_MANUAL,
|
||||||
);
|
);
|
||||||
$this->events->dispatch($event);
|
$this->events->dispatch($event);
|
||||||
|
|
||||||
|
|||||||
@@ -73,4 +73,15 @@ class TenantService
|
|||||||
{
|
{
|
||||||
return $this->store->storeSettings($identifier, $settings);
|
return $this->store->storeSettings($identifier, $settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function fetchServiceConfiguration(string $identifier, string $name): ?array
|
||||||
|
{
|
||||||
|
return $this->store->fetchServiceConfiguration($identifier, $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeServiceConfiguration(string $identifier, string $name, array $configuration): bool
|
||||||
|
{
|
||||||
|
return $this->store->storeServiceConfiguration($identifier, $name, $configuration);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ use KTXC\Models\Identity\User;
|
|||||||
use KTXC\Context\IdentityContextInterface;
|
use KTXC\Context\IdentityContextInterface;
|
||||||
use KTXC\Context\TenantContextInterface;
|
use KTXC\Context\TenantContextInterface;
|
||||||
use KTXC\Stores\UserAccountsStore;
|
use KTXC\Stores\UserAccountsStore;
|
||||||
|
use KTXC\User\Event\UserCreatedEvent;
|
||||||
|
use KTXC\User\Event\UserDeletingEvent;
|
||||||
|
use KTXC\User\Event\UserUpdatedEvent;
|
||||||
|
use KTXF\Event\EventDispatcherInterface;
|
||||||
|
|
||||||
class UserAccountsService
|
class UserAccountsService
|
||||||
{
|
{
|
||||||
@@ -13,7 +17,8 @@ class UserAccountsService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TenantContextInterface $tenantContext,
|
private readonly TenantContextInterface $tenantContext,
|
||||||
private readonly IdentityContextInterface $identityContext,
|
private readonly IdentityContextInterface $identityContext,
|
||||||
private readonly UserAccountsStore $userStore
|
private readonly UserAccountsStore $userStore,
|
||||||
|
private readonly EventDispatcherInterface $events,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,17 +70,53 @@ class UserAccountsService
|
|||||||
|
|
||||||
public function createUser(array $userData): array
|
public function createUser(array $userData): array
|
||||||
{
|
{
|
||||||
return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
|
$tenantId = $this->tenantContext->requireIdentifier();
|
||||||
|
$user = $this->userStore->createUser($tenantId, $userData);
|
||||||
|
$this->events->dispatch(UserCreatedEvent::fromUser(
|
||||||
|
$user,
|
||||||
|
$tenantId,
|
||||||
|
$this->identityContext->identifier(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateUser(string $uid, array $updates): bool
|
public function updateUser(string $userId, array $updates): bool
|
||||||
{
|
{
|
||||||
return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
|
$tenantId = $this->tenantContext->requireIdentifier();
|
||||||
|
if (!$this->userStore->updateUser($tenantId, $userId, $updates)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteUser(string $uid): bool
|
$user = $this->userStore->fetchByIdentifier($tenantId, $userId);
|
||||||
|
if ($user === null) {
|
||||||
|
throw new \RuntimeException("Updated user '{$userId}' could not be retrieved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->events->dispatch(UserUpdatedEvent::fromUser(
|
||||||
|
$user,
|
||||||
|
$tenantId,
|
||||||
|
$this->identityContext->identifier(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteUser(string $userId): bool
|
||||||
{
|
{
|
||||||
return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
|
$tenantId = $this->tenantContext->requireIdentifier();
|
||||||
|
$user = $this->userStore->fetchByIdentifier($tenantId, $userId);
|
||||||
|
if ($user === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->events->dispatch(UserDeletingEvent::fromUser(
|
||||||
|
$user,
|
||||||
|
$tenantId,
|
||||||
|
$this->identityContext->identifier(),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->userStore->deleteUser($tenantId, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -126,10 +167,6 @@ class UserAccountsService
|
|||||||
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
|
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Helper Methods
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a profile field is editable by the user
|
* Check if a profile field is editable by the user
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -77,6 +77,49 @@ class TenantStore
|
|||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
|
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Configuration Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
public function fetchConfiguration(string $identifier, string $path): ?array
|
||||||
|
{
|
||||||
|
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(
|
||||||
|
['identifier' => $identifier],
|
||||||
|
['projection' => ['configuration.' . $path => 1]],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->readPath($entry, "configuration.{$path}");
|
||||||
|
return $value === null ? null : (array) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeConfiguration(string $identifier, string $path, array $value): bool
|
||||||
|
{
|
||||||
|
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||||
|
['identifier' => $identifier],
|
||||||
|
['$set' => ['configuration.' . $path => $value]],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $result->getMatchedCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeConfiguration(string $identifier, string $path): ?bool
|
||||||
|
{
|
||||||
|
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||||
|
['identifier' => $identifier],
|
||||||
|
['$unset' => ['configuration.' . $path => '']],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->getMatchedCount() === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result->getModifiedCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Settings Operations
|
// Settings Operations
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -130,4 +173,54 @@ class TenantStore
|
|||||||
return $result->getMatchedCount() > 0;
|
return $result->getMatchedCount() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically creates or replaces one logical store reference.
|
||||||
|
*
|
||||||
|
* @param array{provider: string, service: string|int, namespace: string} $reference
|
||||||
|
*/
|
||||||
|
public function storeConfigurationStore(string $identifier, string $name, array $reference): bool
|
||||||
|
{
|
||||||
|
return $this->storeConfiguration($identifier, "stores.{$name}", $reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically removes one logical store reference.
|
||||||
|
*/
|
||||||
|
public function removeConfigurationStore(string $identifier, string $name): ?bool
|
||||||
|
{
|
||||||
|
return $this->removeConfiguration($identifier, "stores.{$name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fetchServiceConfiguration(string $identifier, string $name): ?array
|
||||||
|
{
|
||||||
|
$this->validateServiceName($name);
|
||||||
|
return $this->fetchConfiguration($identifier, "services.{$name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save one service atomically. */
|
||||||
|
public function storeServiceConfiguration(string $identifier, string $name, array $configuration): bool
|
||||||
|
{
|
||||||
|
$this->validateServiceName($name);
|
||||||
|
return $this->storeConfiguration($identifier, "services.{$name}", $configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateServiceName(string $name): void
|
||||||
|
{
|
||||||
|
if (preg_match('/^[a-z][a-z0-9_]*$/D', $name) !== 1) {
|
||||||
|
throw new \InvalidArgumentException('Invalid service name.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readPath(array $data, string $path): mixed
|
||||||
|
{
|
||||||
|
$value = $data;
|
||||||
|
foreach (explode('.', $path) as $segment) {
|
||||||
|
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$value = $value[$segment];
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\SystemStore;
|
||||||
|
|
||||||
|
use KTXC\Stores\TenantStore;
|
||||||
|
use KTXF\SystemStore\StoreReference;
|
||||||
|
use KTXF\SystemStore\SystemStoreException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Superuser-facing configuration operations for tenant logical stores.
|
||||||
|
*/
|
||||||
|
class SystemStoreConfigurationService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TenantStore $tenants)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, StoreReference> */
|
||||||
|
public function list(string $tenantId): array
|
||||||
|
{
|
||||||
|
$tenant = $this->tenants->fetch($tenantId);
|
||||||
|
if ($tenant === null) {
|
||||||
|
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant->getConfiguration()->stores()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set(
|
||||||
|
string $tenantId,
|
||||||
|
string $name,
|
||||||
|
string $provider,
|
||||||
|
string|int $service,
|
||||||
|
string $namespace,
|
||||||
|
): StoreReference {
|
||||||
|
self::validateName($name);
|
||||||
|
$reference = new StoreReference($provider, $service, $namespace);
|
||||||
|
|
||||||
|
if (!$this->tenants->storeConfigurationStore($tenantId, $name, $reference->toArray())) {
|
||||||
|
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(string $tenantId, string $name): bool
|
||||||
|
{
|
||||||
|
self::validateName($name);
|
||||||
|
$removed = $this->tenants->removeConfigurationStore($tenantId, $name);
|
||||||
|
if ($removed === null) {
|
||||||
|
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function validateName(string $name): void
|
||||||
|
{
|
||||||
|
if (preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
|
||||||
|
throw new \InvalidArgumentException('Logical store names must use lowercase letters, numbers, and hyphens');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\SystemStore;
|
||||||
|
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXC\Service\TenantService;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXF\Resource\SystemIdentity;
|
||||||
|
use KTXF\SystemStore\BlobInfo;
|
||||||
|
use KTXF\SystemStore\InvalidKeyException;
|
||||||
|
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||||
|
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||||
|
use KTXF\SystemStore\StoreReference;
|
||||||
|
use KTXF\SystemStore\SystemStoreException;
|
||||||
|
use KTXF\SystemStore\SystemStoreManagerInterface;
|
||||||
|
use KTXF\SystemStore\WriteCondition;
|
||||||
|
|
||||||
|
final readonly class SystemStoreManager implements SystemStoreManagerInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TenantService $tenants,
|
||||||
|
private ProviderManager $providers,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stat(string $tenantId, string $store, string $key): ?BlobInfo
|
||||||
|
{
|
||||||
|
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||||
|
$info = $service->stat($this->qualify($reference, $key));
|
||||||
|
|
||||||
|
return $info === null ? null : $this->logicalInfo($info, $reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read(string $tenantId, string $store, string $key): ?BinaryResource
|
||||||
|
{
|
||||||
|
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||||
|
return $service->read($this->qualify($reference, $key));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write(
|
||||||
|
string $tenantId,
|
||||||
|
string $store,
|
||||||
|
string $key,
|
||||||
|
BinaryResource $content,
|
||||||
|
array $metadata = [],
|
||||||
|
?WriteCondition $condition = null,
|
||||||
|
): BlobInfo {
|
||||||
|
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||||
|
$info = $service->write(
|
||||||
|
$this->qualify($reference, $key),
|
||||||
|
$content,
|
||||||
|
$metadata,
|
||||||
|
$condition,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->logicalInfo($info, $reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(
|
||||||
|
string $tenantId,
|
||||||
|
string $store,
|
||||||
|
string $key,
|
||||||
|
?WriteCondition $condition = null,
|
||||||
|
): bool {
|
||||||
|
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||||
|
return $service->delete($this->qualify($reference, $key), $condition);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $tenantId, string $store, string $prefix = ''): iterable
|
||||||
|
{
|
||||||
|
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||||
|
$physicalPrefix = $reference->namespace . '/';
|
||||||
|
if ($prefix !== '') {
|
||||||
|
$physicalPrefix .= $this->normalizeKey($prefix, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->logicalItems($service->list($physicalPrefix), $reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{SystemStoreServiceInterface, StoreReference} */
|
||||||
|
private function resolve(string $tenantId, string $store): array
|
||||||
|
{
|
||||||
|
$tenant = $this->tenants->fetchById($tenantId);
|
||||||
|
if ($tenant === null) {
|
||||||
|
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
$reference = $tenant->getConfiguration()->stores()->store($store);
|
||||||
|
if ($reference === null) {
|
||||||
|
throw new SystemStoreException("System store '{$store}' is not configured for tenant '{$tenantId}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = $this->providers->resolve(ProviderInterface::TYPE_SYSTEM_STORE, $reference->provider);
|
||||||
|
if (!$provider instanceof ProviderBaseInterface) {
|
||||||
|
throw new SystemStoreException("System-store provider '{$reference->provider}' is unavailable or incompatible");
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = $provider->serviceFetch($tenantId, SystemIdentity::USER, $reference->service);
|
||||||
|
if (!$service instanceof SystemStoreServiceInterface) {
|
||||||
|
throw new SystemStoreException("System-store service '{$reference->service}' is unavailable or incompatible");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$service, $reference];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function qualify(StoreReference $reference, string $key): string
|
||||||
|
{
|
||||||
|
return $reference->namespace . '/' . $this->normalizeKey($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeKey(string $key, bool $allowTrailingSlash = false): string
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$key === ''
|
||||||
|
|| str_starts_with($key, '/')
|
||||||
|
|| (!$allowTrailingSlash && str_ends_with($key, '/'))
|
||||||
|
|| str_contains($key, '\\')
|
||||||
|
|| str_contains($key, "\0")
|
||||||
|
) {
|
||||||
|
throw new InvalidKeyException('System-store keys must be non-empty normalized relative keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = explode('/', $key);
|
||||||
|
if ($allowTrailingSlash && end($segments) === '') {
|
||||||
|
array_pop($segments);
|
||||||
|
}
|
||||||
|
if (in_array('', $segments, true) || in_array('.', $segments, true) || in_array('..', $segments, true)) {
|
||||||
|
throw new InvalidKeyException('System-store keys cannot contain empty or traversal segments');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logicalInfo(BlobInfo $info, StoreReference $reference): BlobInfo
|
||||||
|
{
|
||||||
|
$prefix = $reference->namespace . '/';
|
||||||
|
if (!str_starts_with($info->key, $prefix)) {
|
||||||
|
throw new SystemStoreException('System-store provider returned a blob outside the configured namespace');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BlobInfo(
|
||||||
|
key: substr($info->key, strlen($prefix)),
|
||||||
|
mimeType: $info->mimeType,
|
||||||
|
size: $info->size,
|
||||||
|
etag: $info->etag,
|
||||||
|
modifiedAt: $info->modifiedAt,
|
||||||
|
attributes: $info->attributes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<BlobInfo> $items
|
||||||
|
* @return iterable<BlobInfo>
|
||||||
|
*/
|
||||||
|
private function logicalItems(iterable $items, StoreReference $reference): iterable
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (!$item instanceof BlobInfo) {
|
||||||
|
throw new SystemStoreException('System-store provider returned invalid listing metadata');
|
||||||
|
}
|
||||||
|
yield $this->logicalInfo($item, $reference);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\User\Event;
|
||||||
|
|
||||||
|
final class UserCreatedEvent extends UserEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\User\Event;
|
||||||
|
|
||||||
|
final class UserDeletingEvent extends UserEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\User\Event;
|
||||||
|
|
||||||
|
use KTXF\Event\Event;
|
||||||
|
|
||||||
|
abstract class UserEvent extends Event
|
||||||
|
{
|
||||||
|
final public function __construct(
|
||||||
|
private readonly string $userIdentifier,
|
||||||
|
private readonly string $userIdentity,
|
||||||
|
private readonly string $userLabel,
|
||||||
|
private readonly bool $userEnabled,
|
||||||
|
private readonly array $userRoles,
|
||||||
|
private readonly string $tenantIdentifier,
|
||||||
|
private readonly ?string $actorIdentifier = null,
|
||||||
|
) {
|
||||||
|
if ($userIdentifier === '') {
|
||||||
|
throw new \InvalidArgumentException('User lifecycle events require a user ID.');
|
||||||
|
}
|
||||||
|
if ($userIdentity === '') {
|
||||||
|
throw new \InvalidArgumentException('User lifecycle events require a user identity.');
|
||||||
|
}
|
||||||
|
if ($tenantIdentifier === '') {
|
||||||
|
throw new \InvalidArgumentException('User lifecycle events require a tenant ID.');
|
||||||
|
}
|
||||||
|
parent::__construct(
|
||||||
|
static::class,
|
||||||
|
[
|
||||||
|
'identifier' => $userIdentifier,
|
||||||
|
'identity' => $userIdentity,
|
||||||
|
'label' => $userLabel,
|
||||||
|
'roles' => $userRoles,
|
||||||
|
'enabled' => $userEnabled,
|
||||||
|
],
|
||||||
|
$tenantIdentifier,
|
||||||
|
$actorIdentifier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromUser(
|
||||||
|
array $user,
|
||||||
|
string $tenantIdentifier,
|
||||||
|
?string $actorIdentifier = null,
|
||||||
|
): static {
|
||||||
|
return new static(
|
||||||
|
(string) ($user['uid'] ?? ''),
|
||||||
|
(string) ($user['identity'] ?? ''),
|
||||||
|
(string) ($user['label'] ?? $user['identity'] ?? ''),
|
||||||
|
(bool) ($user['enabled'] ?? true),
|
||||||
|
array_values((array) ($user['roles'] ?? [])),
|
||||||
|
$tenantIdentifier,
|
||||||
|
$actorIdentifier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userIdentifier(): string
|
||||||
|
{
|
||||||
|
return $this->userIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenantIdentifier(): string
|
||||||
|
{
|
||||||
|
return $this->tenantIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userIdentity(): string
|
||||||
|
{
|
||||||
|
return $this->userIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userLabel(): string
|
||||||
|
{
|
||||||
|
return $this->userLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userRoles(): array
|
||||||
|
{
|
||||||
|
return $this->userRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->userEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function actorIdentifier(): ?string
|
||||||
|
{
|
||||||
|
return $this->actorIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXC\User\Event;
|
||||||
|
|
||||||
|
final class UserUpdatedEvent extends UserEvent
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -88,12 +88,9 @@ const userAvatar = computed(() => userStore.getProfileField('avatar') || default
|
|||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn class="profileBtn" variant="text" rounded="sm" v-bind="props">
|
<v-btn class="profileBtn" variant="text" rounded="sm" v-bind="props">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<v-avatar class="mr-sm-2 mr-0" size="32">
|
<v-avatar size="32">
|
||||||
<v-img :src="userAvatar" :alt="userAuth?.label || 'User'" cover />
|
<v-img :src="userAvatar" :alt="userAuth?.label || 'User'" cover />
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
<h6 class="text-subtitle-1 mb-0 d-sm-block d-none">
|
|
||||||
{{ userAuth?.label }}
|
|
||||||
</h6>
|
|
||||||
</div>
|
</div>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, watch } from 'vue';
|
||||||
import { useLayoutStore, type MenuMode } from '@KTXC/stores/layoutStore';
|
import { useLayoutStore, type MenuMode } from '@KTXC/stores/layoutStore';
|
||||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
|
import type { IntegrationPointType } from '@KTXC/types/integrationTypes';
|
||||||
import { useL10n } from '@KTXC/composables/useL10n';
|
import { useL10n } from '@KTXC/composables/useL10n';
|
||||||
import Logo from '@KTXC/layouts/logo/LogoDark.vue';
|
import Logo from '@KTXC/layouts/logo/LogoDark.vue';
|
||||||
import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue';
|
import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue';
|
||||||
@@ -12,25 +13,33 @@ const layoutStore = useLayoutStore();
|
|||||||
const integrationStore = useIntegrationStore();
|
const integrationStore = useIntegrationStore();
|
||||||
const { t } = useL10n('core');
|
const { t } = useL10n('core');
|
||||||
|
|
||||||
// Get all entries based on current menu mode
|
const menuPointByMode: Record<MenuMode, IntegrationPointType> = {
|
||||||
const menuEntries = computed(() => {
|
apps: 'app_menu',
|
||||||
switch (layoutStore.menuMode) {
|
'user-settings': 'user_settings_menu',
|
||||||
case 'user-settings':
|
'admin-settings': 'admin_settings_menu',
|
||||||
return integrationStore.getPoint('user_settings_menu');
|
};
|
||||||
case 'admin-settings':
|
|
||||||
return integrationStore.getPoint('admin_settings_menu');
|
|
||||||
case 'apps':
|
|
||||||
default:
|
|
||||||
return integrationStore.getPoint('app_menu');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Static list of menu modes shown as icon buttons
|
// Get all entries based on current menu mode
|
||||||
const menuModes = computed((): Array<{ value: MenuMode; icon: string; label: string }> => [
|
const menuEntries = computed(() => integrationStore.getPoint(menuPointByMode[layoutStore.menuMode]));
|
||||||
|
|
||||||
|
// Only show menu modes that currently have visible items.
|
||||||
|
const menuModes = computed((): Array<{ value: MenuMode; icon: string; label: string }> => {
|
||||||
|
const modes: Array<{ value: MenuMode; icon: string; label: string }> = [
|
||||||
{ value: 'apps', icon: 'mdi-view-dashboard', label: t('systemMenu.apps', 'Applications') },
|
{ value: 'apps', icon: 'mdi-view-dashboard', label: t('systemMenu.apps', 'Applications') },
|
||||||
{ value: 'user-settings', icon: 'mdi-account-cog', label: t('systemMenu.personalSettings', 'Settings') },
|
{ value: 'user-settings', icon: 'mdi-account-cog', label: t('systemMenu.personalSettings', 'Settings') },
|
||||||
{ value: 'admin-settings', icon: 'mdi-shield-crown', label: t('systemMenu.adminSettings', 'System') },
|
{ value: 'admin-settings', icon: 'mdi-shield-crown', label: t('systemMenu.adminSettings', 'System') },
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
return modes.filter(mode => integrationStore.getPoint(menuPointByMode[mode.value]).length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the active menu becomes empty (for example, after a module is disabled),
|
||||||
|
// move to the first menu that still has content.
|
||||||
|
watch(menuModes, (availableModes) => {
|
||||||
|
if (availableModes.length > 0 && !availableModes.some(mode => mode.value === layoutStore.menuMode)) {
|
||||||
|
layoutStore.setMenuMode(availableModes[0].value);
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -84,6 +93,7 @@ export default {
|
|||||||
|
|
||||||
<!-- Menu Mode Switcher -->
|
<!-- Menu Mode Switcher -->
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
|
<div v-if="menuModes.length">
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<div class="menu-mode-switcher d-flex justify-space-around align-center py-2">
|
<div class="menu-mode-switcher d-flex justify-space-around align-center py-2">
|
||||||
<v-tooltip
|
<v-tooltip
|
||||||
@@ -107,6 +117,7 @@ export default {
|
|||||||
<span>{{ mode.label }}</span>
|
<span>{{ mode.label }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import { computed } from 'vue';
|
|||||||
import { useUserStore } from '@KTXC/stores/userStore';
|
import { useUserStore } from '@KTXC/stores/userStore';
|
||||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n';
|
import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n';
|
||||||
import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
|
import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
|
||||||
|
|
||||||
const { t } = useL10n('core');
|
const { t } = useL10n('core');
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const integrationStore = useIntegrationStore();
|
const integrationStore = useIntegrationStore();
|
||||||
const layoutStore = useLayoutStore();
|
const layoutStore = useLayoutStore();
|
||||||
@@ -43,12 +41,6 @@ const cycleTheme = () => {
|
|||||||
layoutStore.setTheme(nextThemeMode.value);
|
layoutStore.setTheme(nextThemeMode.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Navigate to settings
|
|
||||||
const goToSettings = () => {
|
|
||||||
layoutStore.setMenuMode('settings');
|
|
||||||
// Navigate to first settings item or a default settings route
|
|
||||||
router.push('/modules'); // TODO: Make this dynamic based on first settings menu item
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -62,9 +54,9 @@ const goToSettings = () => {
|
|||||||
<v-img :src="userAvatar" :alt="userName" cover />
|
<v-img :src="userAvatar" :alt="userName" cover />
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<h6 class="text-h6 mb-0 font-weight-medium">
|
<h5 class="user-menu-name mb-0 font-weight-bold">
|
||||||
{{ userName }}
|
{{ userName }}
|
||||||
</h6>
|
</h5>
|
||||||
<p class="text-caption mb-0 text-medium-emphasis">{{ userEmail }}</p>
|
<p class="text-caption mb-0 text-medium-emphasis">{{ userEmail }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,14 +90,6 @@ const goToSettings = () => {
|
|||||||
<v-list-item-title class="text-h6">{{ t(MODE_PRESENTATION[nextThemeMode].l10n, MODE_PRESENTATION[nextThemeMode].label) }}</v-list-item-title>
|
<v-list-item-title class="text-h6">{{ t(MODE_PRESENTATION[nextThemeMode].l10n, MODE_PRESENTATION[nextThemeMode].label) }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
<!-- Go to Settings -->
|
|
||||||
<v-list-item @click="goToSettings" color="primary" rounded="0">
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<v-icon>mdi-cog-outline</v-icon>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title class="text-h6">{{ t('userMenu.settings', 'Settings') }}</v-list-item-title>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-divider class="my-2" />
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
<!-- Logout -->
|
<!-- Logout -->
|
||||||
@@ -119,3 +103,10 @@ const goToSettings = () => {
|
|||||||
</perfect-scrollbar>
|
</perfect-scrollbar>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-menu-name {
|
||||||
|
font-size: 18px !important;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createPinia } from 'pinia'
|
|||||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||||
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
||||||
import { useTenantStore } from '@KTXC/stores/tenantStore'
|
import { useTenantStore } from '@KTXC/stores/tenantStore'
|
||||||
|
import { usePreviewStore } from '@KTXC/stores/previewStore'
|
||||||
import { useUserStore } from '@KTXC/stores/userStore'
|
import { useUserStore } from '@KTXC/stores/userStore'
|
||||||
import { useL10nStore } from '@KTXC/stores/l10nStore'
|
import { useL10nStore } from '@KTXC/stores/l10nStore'
|
||||||
import { useThemeStore } from '@KTXC/stores/themeStore'
|
import { useThemeStore } from '@KTXC/stores/themeStore'
|
||||||
@@ -41,6 +42,7 @@ globalWindow.Pinia = PiniaLib as unknown
|
|||||||
(async () => {
|
(async () => {
|
||||||
const moduleStore = useModuleStore();
|
const moduleStore = useModuleStore();
|
||||||
const tenantStore = useTenantStore();
|
const tenantStore = useTenantStore();
|
||||||
|
const previewStore = usePreviewStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const l10nStore = useL10nStore();
|
const l10nStore = useL10nStore();
|
||||||
const themeStore = useThemeStore();
|
const themeStore = useThemeStore();
|
||||||
@@ -49,6 +51,7 @@ globalWindow.Pinia = PiniaLib as unknown
|
|||||||
try {
|
try {
|
||||||
const payload = await fetchWrapper.get('/init');
|
const payload = await fetchWrapper.get('/init');
|
||||||
moduleStore.init(payload?.modules ?? {});
|
moduleStore.init(payload?.modules ?? {});
|
||||||
|
previewStore.init(payload?.preview ?? null);
|
||||||
tenantStore.init(payload?.tenant ?? null);
|
tenantStore.init(payload?.tenant ?? null);
|
||||||
userStore.init(payload?.user ?? {});
|
userStore.init(payload?.user ?? {});
|
||||||
layoutStore.hydrateFromSettings();
|
layoutStore.hydrateFromSettings();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
// Stores
|
// Stores
|
||||||
export { useModuleStore } from '../stores/moduleStore'
|
export { useModuleStore } from '../stores/moduleStore'
|
||||||
export { useTenantStore } from '../stores/tenantStore'
|
export { useTenantStore } from '../stores/tenantStore'
|
||||||
|
export { usePreviewStore } from '../stores/previewStore'
|
||||||
|
export type { PreviewAvailability } from '../stores/previewStore'
|
||||||
export { useUserStore } from '../stores/userStore'
|
export { useUserStore } from '../stores/userStore'
|
||||||
export { useIntegrationStore } from '../stores/integrationStore'
|
export { useIntegrationStore } from '../stores/integrationStore'
|
||||||
export { useLayoutStore } from '../stores/layoutStore'
|
export { useLayoutStore } from '../stores/layoutStore'
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ export const useIntegrationStore = defineStore('integrationStore', {
|
|||||||
|
|
||||||
return Array.from(point.items.values())
|
return Array.from(point.items.values())
|
||||||
.filter(entry => entry.visible !== false)
|
.filter(entry => entry.visible !== false)
|
||||||
|
.map(entry => 'items' in entry
|
||||||
|
? { ...entry, items: entry.items.filter(item => item.visible !== false) }
|
||||||
|
: entry)
|
||||||
|
.filter(entry => !('items' in entry) || entry.items.length > 0)
|
||||||
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export const useLayoutStore = defineStore('layout', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sidebarDrawer = ref<boolean>(booleanSetting('sidebar_drawer', true));
|
const sidebarDrawer = ref<boolean>(booleanSetting('sidebar_drawer', true));
|
||||||
const miniSidebar = ref<boolean>(booleanSetting('mini_sidebar', false));
|
const miniSidebar = ref<boolean>(booleanSetting('mini_sidebar', true));
|
||||||
const menuMode = ref<MenuMode>('apps');
|
const menuMode = ref<MenuMode>('apps');
|
||||||
|
|
||||||
// Theme mode - user choice, falling back to the tenant default
|
// Theme mode - user choice, falling back to the tenant default
|
||||||
@@ -60,7 +60,7 @@ export const useLayoutStore = defineStore('layout', () => {
|
|||||||
hydratingFromSettings = true;
|
hydratingFromSettings = true;
|
||||||
try {
|
try {
|
||||||
sidebarDrawer.value = booleanSetting('sidebar_drawer', true);
|
sidebarDrawer.value = booleanSetting('sidebar_drawer', true);
|
||||||
miniSidebar.value = booleanSetting('mini_sidebar', false);
|
miniSidebar.value = booleanSetting('mini_sidebar', true);
|
||||||
theme.value = initialTheme();
|
theme.value = initialTheme();
|
||||||
} finally {
|
} finally {
|
||||||
hydratingFromSettings = false;
|
hydratingFromSettings = false;
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export interface PreviewAvailability {
|
||||||
|
enabled: boolean
|
||||||
|
storage: boolean
|
||||||
|
generation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailable = (): PreviewAvailability => ({
|
||||||
|
enabled: false,
|
||||||
|
storage: false,
|
||||||
|
generation: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const usePreviewStore = defineStore('previewStore', () => {
|
||||||
|
const availability = ref<PreviewAvailability>(unavailable())
|
||||||
|
|
||||||
|
function init(data?: Partial<PreviewAvailability> | null): void {
|
||||||
|
availability.value = {
|
||||||
|
enabled: data?.enabled ?? false,
|
||||||
|
storage: data?.storage ?? false,
|
||||||
|
generation: data?.generation ?? false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
availability.value = unavailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
availability,
|
||||||
|
init,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed, watch } from 'vue';
|
import { ref, onMounted, computed, watch, nextTick } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useUserStore } from '@KTXC/stores/userStore';
|
import { useUserStore } from '@KTXC/stores/userStore';
|
||||||
import { authenticationService } from '@KTXC/services/authenticationService';
|
import { authenticationService } from '@KTXC/services/authenticationService';
|
||||||
@@ -15,6 +15,7 @@ type LoginPhase = 'identity' | 'method' | 'mfa';
|
|||||||
// Form state
|
// Form state
|
||||||
const identity = ref('');
|
const identity = ref('');
|
||||||
const authResponse = ref(''); // password, code, etc.
|
const authResponse = ref(''); // password, code, etc.
|
||||||
|
const authInput = ref<{ focus: () => void } | null>(null);
|
||||||
const showPassword = ref(false);
|
const showPassword = ref(false);
|
||||||
const rememberMe = ref(false);
|
const rememberMe = ref(false);
|
||||||
|
|
||||||
@@ -60,21 +61,19 @@ const pageTitle = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Input label/type based on selected method
|
// Input label/type based on selected method
|
||||||
|
const isPasswordMethod = computed(() => selectedMethod.value?.id === 'password');
|
||||||
|
|
||||||
const authInputLabel = computed(() => {
|
const authInputLabel = computed(() => {
|
||||||
if (!selectedMethod.value) return 'Password';
|
return isPasswordMethod.value ? 'Password' : 'Verification Code';
|
||||||
return selectedMethod.value.method === 'credential' ? 'Password' : 'Verification Code';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const authInputType = computed(() => {
|
const authInputType = computed(() => {
|
||||||
if (!selectedMethod.value) return 'password';
|
return isPasswordMethod.value ? 'password' : 'text';
|
||||||
return selectedMethod.value.method === 'credential' ? 'password' : 'text';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const identityRules = [
|
const identityRules = [
|
||||||
(v: string) => !!v.trim() || 'Email is required',
|
(v: string) => !!v.trim() || 'Login ID is required',
|
||||||
(v: string) => !/\s/.test(v.trim()) || 'Email must not contain spaces',
|
|
||||||
(v: string) => /.+@.+\..+/.test(v.trim()) || 'Email must be valid'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const authResponseRules = [
|
const authResponseRules = [
|
||||||
@@ -117,6 +116,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
// Watch for method selection changes (for challenge-based methods)
|
// Watch for method selection changes (for challenge-based methods)
|
||||||
watch(selectedMethod, async (newMethod) => {
|
watch(selectedMethod, async (newMethod) => {
|
||||||
|
await nextTick();
|
||||||
|
authInput.value?.focus();
|
||||||
|
|
||||||
if (newMethod && newMethod.method === 'challenge' && !challengeSent.value) {
|
if (newMethod && newMethod.method === 'challenge' && !challengeSent.value) {
|
||||||
// Initiate challenge for methods that need it (SMS, email, TOTP)
|
// Initiate challenge for methods that need it (SMS, email, TOTP)
|
||||||
await initiateChallenge(newMethod.id);
|
await initiateChallenge(newMethod.id);
|
||||||
@@ -313,7 +315,16 @@ function backToIdentity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getMethodIcon(method: AuthenticationMethod): string {
|
function getMethodIcon(method: AuthenticationMethod): string {
|
||||||
if (method.icon) return method.icon;
|
if (method.icon?.startsWith('mdi-') || method.icon?.startsWith('$')) {
|
||||||
|
return method.icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication providers may return a bare Material Design icon name
|
||||||
|
// (for example, "lock" or "mail") instead of Vuetify's "mdi-*" form.
|
||||||
|
if (method.icon && /^[a-z0-9-]+$/i.test(method.icon)) {
|
||||||
|
return `mdi-${method.icon}`;
|
||||||
|
}
|
||||||
|
|
||||||
switch (method.method) {
|
switch (method.method) {
|
||||||
case 'credential': return 'mdi-key';
|
case 'credential': return 'mdi-key';
|
||||||
case 'challenge': return 'mdi-shield-check';
|
case 'challenge': return 'mdi-shield-check';
|
||||||
@@ -351,16 +362,15 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
v-slot="{ errors, isSubmitting }"
|
v-slot="{ errors, isSubmitting }"
|
||||||
>
|
>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-label>Email Address</v-label>
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="identity"
|
v-model="identity"
|
||||||
:rules="identityRules"
|
:rules="identityRules"
|
||||||
class="mt-2"
|
aria-label="Login ID"
|
||||||
required
|
required
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
autocomplete="email"
|
autocomplete="username"
|
||||||
autofocus
|
autofocus
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
</div>
|
</div>
|
||||||
@@ -391,7 +401,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
size="large"
|
size="large"
|
||||||
@click="initiateSsoLogin(method.id)"
|
@click="initiateSsoLogin(method.id)"
|
||||||
>
|
>
|
||||||
<v-icon v-if="method.icon" start>{{ method.icon }}</v-icon>
|
<v-icon start>{{ getMethodIcon(method) }}</v-icon>
|
||||||
{{ method.label }}
|
{{ method.label }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,18 +455,18 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
v-slot="{ errors, isSubmitting }"
|
v-slot="{ errors, isSubmitting }"
|
||||||
>
|
>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-label>{{ authInputLabel }}</v-label>
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
ref="authInput"
|
||||||
v-model="authResponse"
|
v-model="authResponse"
|
||||||
:rules="authResponseRules"
|
:rules="authResponseRules"
|
||||||
:type="authInputType === 'password' && !showPassword ? 'password' : 'text'"
|
:type="authInputType === 'password' && !showPassword ? 'password' : 'text'"
|
||||||
class="mt-2"
|
:aria-label="authInputLabel"
|
||||||
required
|
required
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
:autocomplete="selectedMethod?.method === 'credential' ? 'current-password' : 'one-time-code'"
|
:autocomplete="isPasswordMethod ? 'current-password' : 'off'"
|
||||||
:inputmode="selectedMethod?.method !== 'credential' ? 'numeric' : undefined"
|
:inputmode="isPasswordMethod ? undefined : 'numeric'"
|
||||||
autofocus
|
autofocus
|
||||||
>
|
>
|
||||||
<template v-if="authInputType === 'password'" v-slot:append-inner>
|
<template v-if="authInputType === 'password'" v-slot:append-inner>
|
||||||
@@ -470,7 +480,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
</v-text-field>
|
</v-text-field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="selectedMethod?.method === 'credential'" class="d-flex align-center mt-4 mb-7 mb-sm-0">
|
<div v-if="isPasswordMethod" class="d-flex align-center mt-4 mb-7 mb-sm-0">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="rememberMe"
|
v-model="rememberMe"
|
||||||
label="Keep me logged in"
|
label="Keep me logged in"
|
||||||
@@ -493,7 +503,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
size="large"
|
size="large"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{{ selectedMethod?.method === 'credential' ? 'Login' : 'Verify' }}
|
{{ isPasswordMethod ? 'Login' : 'Verify' }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -534,6 +544,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<v-label>Verification Code</v-label>
|
<v-label>Verification Code</v-label>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
ref="authInput"
|
||||||
v-model="authResponse"
|
v-model="authResponse"
|
||||||
:rules="authResponseRules"
|
:rules="authResponseRules"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -542,7 +553,7 @@ function getMethodIcon(method: AuthenticationMethod): string {
|
|||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
autocomplete="one-time-code"
|
autocomplete="off"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autofocus
|
autofocus
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
|
|||||||
@@ -33,6 +33,30 @@ require_command()
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Cron runs with a bare PATH, so nvm-installed npm (added to PATH only by
|
||||||
|
# .bashrc sourcing nvm.sh in an interactive shell) is invisible here even
|
||||||
|
# though it works fine when this script is run by hand. Resolve nvm's
|
||||||
|
# current npm via bash (nvm.sh is not POSIX sh compatible) and prepend it,
|
||||||
|
# so a later `nvm use`/`nvm install` doesn't require updating this script
|
||||||
|
# or the crontab.
|
||||||
|
ensure_npm_on_path()
|
||||||
|
{
|
||||||
|
command -v npm >/dev/null 2>&1 && return 0
|
||||||
|
|
||||||
|
nvm_dir=${NVM_DIR:-${HOME:-/root}/.nvm}
|
||||||
|
[ -s "$nvm_dir/nvm.sh" ] || return 0
|
||||||
|
command -v bash >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
|
npm_path=$(bash -c ". \"\$1/nvm.sh\" >/dev/null 2>&1 && command -v npm" _ "$nvm_dir" 2>/dev/null) || return 0
|
||||||
|
[ -n "$npm_path" ] || return 0
|
||||||
|
|
||||||
|
PATH=$(dirname -- "$npm_path"):$PATH
|
||||||
|
export PATH
|
||||||
|
log "Resolved npm via nvm: $npm_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_npm_on_path
|
||||||
|
|
||||||
git_in()
|
git_in()
|
||||||
{
|
{
|
||||||
repository=$1
|
repository=$1
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ use finfo;
|
|||||||
class Signature {
|
class Signature {
|
||||||
|
|
||||||
/** Minimum bytes needed for reliable detection */
|
/** Minimum bytes needed for reliable detection */
|
||||||
public const HEADER_SIZE = 256;
|
public const SAMPLE_SIZE = 65536;
|
||||||
|
|
||||||
|
/** Cached finfo instance */
|
||||||
|
private static ?finfo $finfo = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback magic byte signatures for when finfo is unavailable
|
* Fallback magic byte signatures for when finfo is unavailable
|
||||||
@@ -41,16 +44,32 @@ class Signature {
|
|||||||
['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP
|
['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Cached finfo instance */
|
/**
|
||||||
private static ?finfo $finfo = null;
|
* Zip-container marker strings used to distinguish OOXML/ODF/EPUB documents
|
||||||
|
* from a generic ZIP archive. Filenames inside a ZIP's local file headers
|
||||||
|
* (and ODF's mandatory uncompressed "mimetype" entry content) are stored
|
||||||
|
* as plain text, so a simple substring search reliably identifies these
|
||||||
|
* formats without needing to parse the archive structure.
|
||||||
|
*/
|
||||||
|
private const ZIP_CONTAINER_MARKERS = [
|
||||||
|
'word/document.xml' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'docx'],
|
||||||
|
'xl/workbook.xml' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'],
|
||||||
|
'ppt/presentation.xml' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation', 'pptx'],
|
||||||
|
'application/vnd.oasis.opendocument.text' => ['application/vnd.oasis.opendocument.text', 'odt'],
|
||||||
|
'application/vnd.oasis.opendocument.spreadsheet' => ['application/vnd.oasis.opendocument.spreadsheet', 'ods'],
|
||||||
|
'application/vnd.oasis.opendocument.presentation' => ['application/vnd.oasis.opendocument.presentation', 'odp'],
|
||||||
|
'application/epub+zip' => ['application/epub+zip', 'epub'],
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect both MIME type and format from content bytes in a single operation
|
* Detect both MIME type and format from content bytes in a single operation
|
||||||
*
|
*
|
||||||
* @param string $headerBytes First bytes of the file content (256 recommended)
|
* @param string $headerBytes First bytes of the file content
|
||||||
|
* @param string|null $content Full (or larger) content, when available, used to
|
||||||
|
* distinguish OOXML/ODF/EPUB documents from a generic ZIP archive
|
||||||
* @return array{mime: string, format: string} Array with 'mime' and 'format' keys
|
* @return array{mime: string, format: string} Array with 'mime' and 'format' keys
|
||||||
*/
|
*/
|
||||||
public static function detect(string $headerBytes): array {
|
public static function detect(string $headerBytes, ?string $content = null): array {
|
||||||
if (strlen($headerBytes) === 0) {
|
if (strlen($headerBytes) === 0) {
|
||||||
return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY];
|
return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY];
|
||||||
}
|
}
|
||||||
@@ -75,6 +94,16 @@ class Signature {
|
|||||||
$format = self::detectFromMagicBytes($headerBytes);
|
$format = self::detectFromMagicBytes($headerBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A bare "zip" result is a generic container; when more of the file is
|
||||||
|
// available, check for OOXML/ODF/EPUB markers rather than reporting the
|
||||||
|
// misleadingly generic zip mime/format for what is really a document.
|
||||||
|
if ($format === 'zip' && $content !== null) {
|
||||||
|
$container = self::detectZipContainer($content);
|
||||||
|
if ($container !== null) {
|
||||||
|
[$mime, $format] = $container;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure MIME type is set
|
// Ensure MIME type is set
|
||||||
if ($mime === null || $mime === MimeTypes::MIME_BINARY) {
|
if ($mime === null || $mime === MimeTypes::MIME_BINARY) {
|
||||||
$mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY;
|
$mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY;
|
||||||
@@ -83,6 +112,32 @@ class Signature {
|
|||||||
return ['mime' => $mime, 'format' => $format];
|
return ['mime' => $mime, 'format' => $format];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look for known OOXML/ODF/EPUB entry markers within ZIP content
|
||||||
|
*
|
||||||
|
* Only a fixed-size window from the head and tail of the content is scanned,
|
||||||
|
* regardless of total length, so detection cost does not grow with the size
|
||||||
|
* of large archive uploads. Every entry name is mirrored in full in the ZIP
|
||||||
|
* central directory near the end of the archive, so sampling the head and
|
||||||
|
* tail is enough without scanning the whole file.
|
||||||
|
*
|
||||||
|
* @param string $data ZIP content to scan (filenames/mimetype entry are stored uncompressed)
|
||||||
|
* @return array{0: string, 1: string}|null [mime, format] pair, or null if no marker matched
|
||||||
|
*/
|
||||||
|
private static function detectZipContainer(string $data): ?array {
|
||||||
|
$length = strlen($data);
|
||||||
|
$sample = $length <= self::SAMPLE_SIZE * 2
|
||||||
|
? $data
|
||||||
|
: substr($data, 0, self::SAMPLE_SIZE) . substr($data, -self::SAMPLE_SIZE);
|
||||||
|
|
||||||
|
foreach (self::ZIP_CONTAINER_MARKERS as $marker => $result) {
|
||||||
|
if (str_contains($sample, $marker)) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect both MIME type and format from a stream in a single operation
|
* Detect both MIME type and format from a stream in a single operation
|
||||||
*
|
*
|
||||||
@@ -91,7 +146,7 @@ class Signature {
|
|||||||
*/
|
*/
|
||||||
public static function detectFromStream($stream): array {
|
public static function detectFromStream($stream): array {
|
||||||
$position = ftell($stream);
|
$position = ftell($stream);
|
||||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
$headerBytes = fread($stream, self::SAMPLE_SIZE);
|
||||||
fseek($stream, $position);
|
fseek($stream, $position);
|
||||||
|
|
||||||
if ($headerBytes === false || $headerBytes === '') {
|
if ($headerBytes === false || $headerBytes === '') {
|
||||||
@@ -140,7 +195,7 @@ class Signature {
|
|||||||
*/
|
*/
|
||||||
public static function detectFormatFromStream($stream): string {
|
public static function detectFormatFromStream($stream): string {
|
||||||
$position = ftell($stream);
|
$position = ftell($stream);
|
||||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
$headerBytes = fread($stream, self::SAMPLE_SIZE);
|
||||||
fseek($stream, $position);
|
fseek($stream, $position);
|
||||||
|
|
||||||
if ($headerBytes === false || $headerBytes === '') {
|
if ($headerBytes === false || $headerBytes === '') {
|
||||||
@@ -158,7 +213,7 @@ class Signature {
|
|||||||
*/
|
*/
|
||||||
public static function detectMimeTypeFromStream($stream): ?string {
|
public static function detectMimeTypeFromStream($stream): ?string {
|
||||||
$position = ftell($stream);
|
$position = ftell($stream);
|
||||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
$headerBytes = fread($stream, self::SAMPLE_SIZE);
|
||||||
fseek($stream, $position);
|
fseek($stream, $position);
|
||||||
|
|
||||||
if ($headerBytes === false || $headerBytes === '') {
|
if ($headerBytes === false || $headerBytes === '') {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXF\Documents\Service;
|
||||||
|
|
||||||
|
use Generator;
|
||||||
|
use KTXF\Documents\Collection\CollectionBaseInterface;
|
||||||
|
use KTXF\Documents\Entity\EntityBaseInterface;
|
||||||
|
use KTXF\Resource\Filter\IFilter;
|
||||||
|
use KTXF\Resource\Range\IRange;
|
||||||
|
use KTXF\Resource\Range\RangeType;
|
||||||
|
use KTXF\Resource\Sort\ISort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service Node List Interface
|
||||||
|
*
|
||||||
|
* @since 2026.09.01
|
||||||
|
*/
|
||||||
|
interface ServiceNodeListInterface {
|
||||||
|
|
||||||
|
// Node capabilities
|
||||||
|
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';
|
||||||
|
// Filter capabilities
|
||||||
|
public const CAPABILITY_NODE_FILTER_LABEL = 'label';
|
||||||
|
// Sort capabilities
|
||||||
|
public const CAPABILITY_NODE_SORT_LABEL = 'label';
|
||||||
|
public const CAPABILITY_NODE_SORT_SIZE = 'size';
|
||||||
|
public const CAPABILITY_NODE_SORT_CREATED_ON = 'createdOn';
|
||||||
|
public const CAPABILITY_NODE_SORT_MODIFIED_ON = 'modifiedOn';
|
||||||
|
// Range capabilities
|
||||||
|
public const CAPABILITY_NODE_RANGE_TALLY = 'tally';
|
||||||
|
public const CAPABILITY_NODE_RANGE_TALLY_ABSOLUTE = 'absolute';
|
||||||
|
public const CAPABILITY_NODE_RANGE_TALLY_RELATIVE = 'relative';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists collections and entities within a location as one unified, ordered set
|
||||||
|
*
|
||||||
|
* Folders are always ordered before files, regardless of the sort applied within
|
||||||
|
* each group.
|
||||||
|
*
|
||||||
|
* @since 2026.09.01
|
||||||
|
*
|
||||||
|
* @param string|int|null $location Parent collection identifier to list within (null for root)
|
||||||
|
* @param IFilter|null $filter Optional filter criteria
|
||||||
|
* @param ISort|null $sort Optional sort order
|
||||||
|
* @param IRange|null $range Optional pagination
|
||||||
|
*
|
||||||
|
* @return Generator<string|int,CollectionBaseInterface|EntityBaseInterface> Nodes yielded by identifier, folders before files
|
||||||
|
*/
|
||||||
|
public function nodeList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null): Generator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a filter builder for the unified node list
|
||||||
|
*
|
||||||
|
* @since 2026.09.01
|
||||||
|
*/
|
||||||
|
public function nodeListFilter(): IFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a sort builder for the unified node list
|
||||||
|
*
|
||||||
|
* @since 2026.09.01
|
||||||
|
*/
|
||||||
|
public function nodeListSort(): ISort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a range builder for the unified node list
|
||||||
|
*
|
||||||
|
* @since 2026.09.01
|
||||||
|
*
|
||||||
|
* @param RangeType $type Range type
|
||||||
|
*/
|
||||||
|
public function nodeListRange(RangeType $type): IRange;
|
||||||
|
|
||||||
|
}
|
||||||
+33
-33
@@ -10,74 +10,74 @@ namespace KTXF\Event;
|
|||||||
class Event
|
class Event
|
||||||
{
|
{
|
||||||
private bool $propagationStopped = false;
|
private bool $propagationStopped = false;
|
||||||
private readonly array $data;
|
private readonly array $context;
|
||||||
private readonly float $timestamp;
|
private readonly float $timestamp;
|
||||||
private readonly string $eventId;
|
private readonly string $identifier;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly string $name,
|
private readonly string $label,
|
||||||
array $data = [],
|
array $context = [],
|
||||||
private readonly ?string $tenantId = null,
|
private readonly ?string $tenantIdentifier = null,
|
||||||
private readonly ?string $identityId = null,
|
private readonly ?string $actorIdentity = null,
|
||||||
) {
|
) {
|
||||||
self::validateData($data);
|
self::validateContext($context);
|
||||||
|
|
||||||
$this->data = $data;
|
$this->context = $context;
|
||||||
$this->timestamp = microtime(true);
|
$this->timestamp = microtime(true);
|
||||||
$this->eventId = bin2hex(random_bytes(16));
|
$this->identifier = bin2hex(random_bytes(16));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the event name
|
* Get the event label
|
||||||
*/
|
*/
|
||||||
public function getName(): string
|
public function label(): string
|
||||||
{
|
{
|
||||||
return $this->name;
|
return $this->label;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a data value by key
|
* Get a context value by key
|
||||||
*/
|
*/
|
||||||
public function get(string $key, mixed $default = null): mixed
|
public function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
return $this->data[$key] ?? $default;
|
return $this->context[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a data key exists
|
* Check if a context key exists
|
||||||
*/
|
*/
|
||||||
public function has(string $key): bool
|
public function has(string $key): bool
|
||||||
{
|
{
|
||||||
return array_key_exists($key, $this->data);
|
return array_key_exists($key, $this->context);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all data
|
* Get the event context
|
||||||
*/
|
*/
|
||||||
public function getData(): array
|
public function context(): array
|
||||||
{
|
{
|
||||||
return $this->data;
|
return $this->context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alias for getData() for backward compatibility
|
* Get all event context
|
||||||
*/
|
*/
|
||||||
public function all(): array
|
public function all(): array
|
||||||
{
|
{
|
||||||
return $this->data;
|
return $this->context;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the event timestamp
|
* Get the event timestamp
|
||||||
*/
|
*/
|
||||||
public function getTimestamp(): float
|
public function timestamp(): float
|
||||||
{
|
{
|
||||||
return $this->timestamp;
|
return $this->timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEventId(): string
|
public function identifier(): string
|
||||||
{
|
{
|
||||||
return $this->eventId;
|
return $this->identifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,29 +99,29 @@ class Event
|
|||||||
/**
|
/**
|
||||||
* Get tenant ID for multi-tenant context
|
* Get tenant ID for multi-tenant context
|
||||||
*/
|
*/
|
||||||
public function getTenantId(): ?string
|
public function tenantIdentifier(): ?string
|
||||||
{
|
{
|
||||||
return $this->tenantId;
|
return $this->tenantIdentifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get identity ID (user who triggered the event)
|
* Get the identity of the actor who triggered the event
|
||||||
*/
|
*/
|
||||||
public function getIdentityId(): ?string
|
public function actorIdentity(): ?string
|
||||||
{
|
{
|
||||||
return $this->identityId;
|
return $this->actorIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function validateData(array $data): void
|
private static function validateContext(array $context): void
|
||||||
{
|
{
|
||||||
foreach ($data as $value) {
|
foreach ($context as $value) {
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
self::validateData($value);
|
self::validateContext($value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($value !== null && !is_scalar($value)) {
|
if ($value !== null && !is_scalar($value)) {
|
||||||
throw new \InvalidArgumentException(
|
throw new \InvalidArgumentException(
|
||||||
'Event data must contain only scalar, null, or array values.',
|
'Event context must contain only scalar, null, or array values.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ namespace KTXF\Mail\Provider;
|
|||||||
|
|
||||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||||
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
|
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
|
||||||
|
use KTXF\Resource\SystemIdentity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider Base Interface
|
* Provider Base Interface
|
||||||
@@ -24,15 +25,9 @@ interface ProviderBaseInterface extends ResourceProviderBaseInterface{
|
|||||||
public const JSON_TYPE = 'mail:provider';
|
public const JSON_TYPE = 'mail:provider';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserved user identifier for system-owned mail services
|
* @deprecated Use SystemIdentity::USER.
|
||||||
*
|
|
||||||
* Services stored under this user id belong to the tenant itself rather
|
|
||||||
* than any real user (e.g. accounts used to deliver system-generated
|
|
||||||
* messages such as verification codes and password resets).
|
|
||||||
*
|
|
||||||
* @since 2026.07.01
|
|
||||||
*/
|
*/
|
||||||
public const USER_SYSTEM = 'system';
|
public const USER_SYSTEM = SystemIdentity::USER;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a service that handles a specific email address
|
* Finds a service that handles a specific email address
|
||||||
|
|||||||
@@ -56,12 +56,19 @@ interface ServiceEntityMutableInterface {
|
|||||||
/**
|
/**
|
||||||
* Modifies an existing entity
|
* Modifies an existing entity
|
||||||
*
|
*
|
||||||
|
* The returned entity's identifier is authoritative and may differ from
|
||||||
|
* the target identifier. Providers without in-place update support may
|
||||||
|
* implement this as an append-and-replace (or equivalent create-then-
|
||||||
|
* destroy) operation, in which case the target identifier is superseded
|
||||||
|
* and no longer resolvable; callers must persist the returned identifier
|
||||||
|
* rather than assume the target remains valid.
|
||||||
|
*
|
||||||
* @since 2025.05.01
|
* @since 2025.05.01
|
||||||
*
|
*
|
||||||
* @param EntityIdentifier $target Target entity identifier
|
* @param EntityIdentifier $target Target entity identifier
|
||||||
* @param MessagePropertiesMutableInterface $properties Entity properties to update
|
* @param MessagePropertiesMutableInterface $properties Entity properties to update
|
||||||
*
|
*
|
||||||
* @return EntityBaseInterface Modified entity
|
* @return EntityBaseInterface Modified entity, with its own authoritative identifier
|
||||||
*/
|
*/
|
||||||
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityBaseInterface;
|
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityBaseInterface;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Module\Configuration;
|
||||||
|
|
||||||
|
use KTXF\Module\ModuleInstanceInterface;
|
||||||
|
|
||||||
|
interface BrowserModuleContextInterface extends ModuleContextInterface
|
||||||
|
{
|
||||||
|
public function registerModule(
|
||||||
|
ModuleInstanceInterface $module,
|
||||||
|
string $namespace,
|
||||||
|
?string $boot = null,
|
||||||
|
): void;
|
||||||
|
|
||||||
|
public function set(string $key, mixed $value): void;
|
||||||
|
|
||||||
|
public function tenantIdentifier(): string;
|
||||||
|
|
||||||
|
public function identityIdentifier(): string;
|
||||||
|
|
||||||
|
/** @return array<string,array<string,mixed>> */
|
||||||
|
public function modules(): array;
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
public function configuration(): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Module\Configuration;
|
||||||
|
|
||||||
|
interface ConsoleModuleContextInterface extends ModuleContextInterface
|
||||||
|
{
|
||||||
|
/** @param class-string $command */
|
||||||
|
public function registerCommand(string $command): void;
|
||||||
|
|
||||||
|
/** @return list<class-string> */
|
||||||
|
public function commands(): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Module\Configuration;
|
||||||
|
|
||||||
|
interface ModuleContextInterface
|
||||||
|
{
|
||||||
|
public function type(): ModuleContextType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Module\Configuration;
|
||||||
|
|
||||||
|
enum ModuleContextType: string
|
||||||
|
{
|
||||||
|
case Browser = 'browser';
|
||||||
|
case Console = 'console';
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Module;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module Browser Interface
|
|
||||||
*/
|
|
||||||
interface ModuleBrowserInterface
|
|
||||||
{
|
|
||||||
public function registerBI(): array;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace KTXF\Module;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module Console Interface
|
|
||||||
*/
|
|
||||||
interface ModuleConsoleInterface
|
|
||||||
{
|
|
||||||
public function registerCI(): array;
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace KTXF\Module;
|
namespace KTXF\Module;
|
||||||
|
|
||||||
|
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||||
|
|
||||||
abstract class ModuleInstanceAbstract implements ModuleInstanceInterface
|
abstract class ModuleInstanceAbstract implements ModuleInstanceInterface
|
||||||
{
|
{
|
||||||
// mandatory methods that must be implemented by each concrete module
|
// mandatory methods that must be implemented by each concrete module
|
||||||
@@ -46,6 +48,11 @@ abstract class ModuleInstanceAbstract implements ModuleInstanceInterface
|
|||||||
// Override in specific modules if needed
|
// Override in specific modules if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function configure(ModuleContextInterface $context): void
|
||||||
|
{
|
||||||
|
// Override when the module supplies browser or console integrations.
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permissions provided by this module
|
* Permissions provided by this module
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,8 +2,15 @@
|
|||||||
|
|
||||||
namespace KTXF\Module;
|
namespace KTXF\Module;
|
||||||
|
|
||||||
|
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||||
|
|
||||||
interface ModuleInstanceInterface
|
interface ModuleInstanceInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Declare the integrations supplied by this module.
|
||||||
|
*/
|
||||||
|
public function configure(ModuleContextInterface $context): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get module version
|
* Get module version
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Preview;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class MimeType
|
||||||
|
{
|
||||||
|
public static function normalize(string $mimeType): string
|
||||||
|
{
|
||||||
|
$mimeType = strtolower(trim(explode(';', $mimeType, 2)[0]));
|
||||||
|
if (preg_match('~^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$~', $mimeType) !== 1) {
|
||||||
|
throw new InvalidArgumentException("Invalid MIME type '{$mimeType}'");
|
||||||
|
}
|
||||||
|
return $mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function matches(string $pattern, string $mimeType): bool
|
||||||
|
{
|
||||||
|
$mimeType = self::normalize($mimeType);
|
||||||
|
$pattern = strtolower(trim($pattern));
|
||||||
|
if ($pattern === '*/*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (preg_match('~^[a-z0-9!#$&^_.+-]+/\*$~', $pattern) === 1) {
|
||||||
|
return str_starts_with($mimeType, substr($pattern, 0, -1));
|
||||||
|
}
|
||||||
|
return self::normalize($pattern) === $mimeType;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Preview;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class PreviewGenerationException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Preview;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final readonly class PreviewRequest
|
||||||
|
{
|
||||||
|
public string $preferredMimeType;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $maxWidth,
|
||||||
|
public int $maxHeight,
|
||||||
|
string $preferredMimeType,
|
||||||
|
public int $quality,
|
||||||
|
public int $maxSourceSize = 26214400,
|
||||||
|
) {
|
||||||
|
if ($this->maxWidth < 1 || $this->maxHeight < 1) {
|
||||||
|
throw new InvalidArgumentException('Preview dimensions must be positive');
|
||||||
|
}
|
||||||
|
if ($this->quality < 1 || $this->quality > 100) {
|
||||||
|
throw new InvalidArgumentException('Preview quality must be between 1 and 100');
|
||||||
|
}
|
||||||
|
if ($this->maxSourceSize < 1) {
|
||||||
|
throw new InvalidArgumentException('Preview maximum source size must be positive');
|
||||||
|
}
|
||||||
|
$this->preferredMimeType = MimeType::normalize($preferredMimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Preview;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
|
||||||
|
final readonly class PreviewResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public BinaryResource $resource,
|
||||||
|
public ?int $width = null,
|
||||||
|
public ?int $height = null,
|
||||||
|
) {
|
||||||
|
MimeType::normalize($this->resource->mimeType());
|
||||||
|
if ($this->width !== null && $this->width < 1) {
|
||||||
|
throw new InvalidArgumentException('Preview result width must be positive');
|
||||||
|
}
|
||||||
|
if ($this->height !== null && $this->height < 1) {
|
||||||
|
throw new InvalidArgumentException('Preview result height must be positive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Preview;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
|
||||||
|
final readonly class PreviewSource
|
||||||
|
{
|
||||||
|
public string $mimeType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Closure(): BinaryResource $resourceFactory
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $sourceType,
|
||||||
|
public string $identity,
|
||||||
|
public string $signature,
|
||||||
|
string $mimeType,
|
||||||
|
public ?int $size,
|
||||||
|
public Closure $resourceFactory,
|
||||||
|
) {
|
||||||
|
if (preg_match('/^[a-z][a-z0-9-]*$/', $this->sourceType) !== 1) {
|
||||||
|
throw new InvalidArgumentException('Preview source type must use lowercase letters, numbers, and hyphens');
|
||||||
|
}
|
||||||
|
if ($this->identity === '' || $this->signature === '') {
|
||||||
|
throw new InvalidArgumentException('Preview sources require an identity and content signature');
|
||||||
|
}
|
||||||
|
if ($this->size !== null && $this->size < 0) {
|
||||||
|
throw new InvalidArgumentException('Preview source size cannot be negative');
|
||||||
|
}
|
||||||
|
$this->mimeType = MimeType::normalize($mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function open(): BinaryResource
|
||||||
|
{
|
||||||
|
$resource = ($this->resourceFactory)();
|
||||||
|
if (!$resource instanceof BinaryResource) {
|
||||||
|
throw new PreviewGenerationException('Preview source factory did not return a BinaryResource');
|
||||||
|
}
|
||||||
|
return $resource;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Preview\Provider;
|
||||||
|
|
||||||
|
use KTXF\Preview\PreviewRequest;
|
||||||
|
use KTXF\Preview\PreviewResult;
|
||||||
|
use KTXF\Preview\PreviewSource;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
|
||||||
|
interface PreviewProviderInterface extends ProviderInterface
|
||||||
|
{
|
||||||
|
public function supports(string $sourceMimeType, PreviewRequest $request): bool;
|
||||||
|
|
||||||
|
public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult;
|
||||||
|
|
||||||
|
public function priority(): int;
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ interface ProviderInterface
|
|||||||
public const TYPE_PEOPLE = 'people';
|
public const TYPE_PEOPLE = 'people';
|
||||||
public const TYPE_CHRONO = 'chrono';
|
public const TYPE_CHRONO = 'chrono';
|
||||||
public const TYPE_MAIL = 'mail';
|
public const TYPE_MAIL = 'mail';
|
||||||
|
public const TYPE_SYSTEM_STORE = 'system-store';
|
||||||
|
public const TYPE_PREVIEW = 'preview';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider type (e.g., 'authentication', 'document', 'people', 'chrono', 'mail')
|
* Provider type (e.g., 'authentication', 'document', 'people', 'chrono', 'mail')
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved identities used by tenant-scoped internal services.
|
||||||
|
*/
|
||||||
|
final class SystemIdentity
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* User identifier for resources owned by the tenant rather than a person.
|
||||||
|
*/
|
||||||
|
public const USER = 'system';
|
||||||
|
|
||||||
|
private function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-neutral information about a blob in a system store.
|
||||||
|
*/
|
||||||
|
final readonly class BlobInfo
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $key,
|
||||||
|
public string $mimeType,
|
||||||
|
public int $size,
|
||||||
|
public string $etag,
|
||||||
|
public DateTimeImmutable $modifiedAt,
|
||||||
|
public array $attributes = [],
|
||||||
|
) {
|
||||||
|
if ($this->key === '') {
|
||||||
|
throw new InvalidArgumentException('Blob key cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->mimeType === '') {
|
||||||
|
throw new InvalidArgumentException('Blob MIME type cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->size < 0) {
|
||||||
|
throw new InvalidArgumentException('Blob size cannot be negative');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->etag === '') {
|
||||||
|
throw new InvalidArgumentException('Blob ETag cannot be empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a logical system-store key is not safely normalized.
|
||||||
|
*/
|
||||||
|
final class InvalidKeyException extends InvalidArgumentException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore\Provider;
|
||||||
|
|
||||||
|
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider for tenant-scoped internal storage services.
|
||||||
|
*/
|
||||||
|
interface ProviderBaseInterface extends ResourceProviderBaseInterface
|
||||||
|
{
|
||||||
|
public const JSON_TYPE = 'system-store:provider';
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore\Service;
|
||||||
|
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Provider\ResourceServiceBaseInterface;
|
||||||
|
use KTXF\SystemStore\BlobInfo;
|
||||||
|
use KTXF\SystemStore\WriteCondition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic key-addressed storage contract for tenant-owned system data.
|
||||||
|
*/
|
||||||
|
interface SystemStoreServiceInterface extends ResourceServiceBaseInterface
|
||||||
|
{
|
||||||
|
public const JSON_TYPE = 'system-store:service';
|
||||||
|
|
||||||
|
public function stat(string $key): ?BlobInfo;
|
||||||
|
|
||||||
|
public function read(string $key): ?BinaryResource;
|
||||||
|
|
||||||
|
public function write(
|
||||||
|
string $key,
|
||||||
|
BinaryResource $content,
|
||||||
|
array $metadata = [],
|
||||||
|
?WriteCondition $condition = null,
|
||||||
|
): BlobInfo;
|
||||||
|
|
||||||
|
public function delete(
|
||||||
|
string $key,
|
||||||
|
?WriteCondition $condition = null,
|
||||||
|
): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates stored metadata lazily. Implementations must keep memory use
|
||||||
|
* bounded and handle any backend pagination internally.
|
||||||
|
*
|
||||||
|
* @return iterable<BlobInfo>
|
||||||
|
*/
|
||||||
|
public function list(string $prefix = ''): iterable;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider and service selected for one logical tenant system store.
|
||||||
|
*/
|
||||||
|
final readonly class StoreReference
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $provider,
|
||||||
|
public string|int $service,
|
||||||
|
public string $namespace,
|
||||||
|
) {
|
||||||
|
if ($this->provider === '') {
|
||||||
|
throw new InvalidArgumentException('System-store provider cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->service === '') {
|
||||||
|
throw new InvalidArgumentException('System-store service cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$this->namespace === ''
|
||||||
|
|| str_starts_with($this->namespace, '/')
|
||||||
|
|| str_ends_with($this->namespace, '/')
|
||||||
|
|| str_contains($this->namespace, '\\')
|
||||||
|
|| str_contains($this->namespace, "\0")
|
||||||
|
|| in_array('', explode('/', $this->namespace), true)
|
||||||
|
|| in_array('.', explode('/', $this->namespace), true)
|
||||||
|
|| in_array('..', explode('/', $this->namespace), true)
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('System-store namespace must be a normalized relative key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
$provider = $data['provider'] ?? null;
|
||||||
|
$service = $data['service'] ?? null;
|
||||||
|
$namespace = $data['namespace'] ?? null;
|
||||||
|
|
||||||
|
if (!is_string($provider) || (!is_string($service) && !is_int($service)) || !is_string($namespace)) {
|
||||||
|
throw new InvalidArgumentException('Invalid system-store reference');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($provider, $service, $namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'provider' => $this->provider,
|
||||||
|
'service' => $this->service,
|
||||||
|
'namespace' => $this->namespace,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a tenant system store cannot be resolved or trusted.
|
||||||
|
*/
|
||||||
|
class SystemStoreException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore;
|
||||||
|
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant-scoped access to logical internal stores.
|
||||||
|
*/
|
||||||
|
interface SystemStoreManagerInterface
|
||||||
|
{
|
||||||
|
public function stat(string $tenantId, string $store, string $key): ?BlobInfo;
|
||||||
|
|
||||||
|
public function read(string $tenantId, string $store, string $key): ?BinaryResource;
|
||||||
|
|
||||||
|
public function write(
|
||||||
|
string $tenantId,
|
||||||
|
string $store,
|
||||||
|
string $key,
|
||||||
|
BinaryResource $content,
|
||||||
|
array $metadata = [],
|
||||||
|
?WriteCondition $condition = null,
|
||||||
|
): BlobInfo;
|
||||||
|
|
||||||
|
public function delete(
|
||||||
|
string $tenantId,
|
||||||
|
string $store,
|
||||||
|
string $key,
|
||||||
|
?WriteCondition $condition = null,
|
||||||
|
): bool;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return iterable<BlobInfo>
|
||||||
|
*/
|
||||||
|
public function list(string $tenantId, string $store, string $prefix = ''): iterable;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXF\SystemStore;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional precondition applied atomically by a system-store write or delete.
|
||||||
|
*/
|
||||||
|
final readonly class WriteCondition
|
||||||
|
{
|
||||||
|
public const NONE = 'none';
|
||||||
|
public const IF_ABSENT = 'if-absent';
|
||||||
|
public const IF_MATCH = 'if-match';
|
||||||
|
|
||||||
|
private function __construct(
|
||||||
|
public string $mode,
|
||||||
|
public ?string $etag = null,
|
||||||
|
) {
|
||||||
|
if (!in_array($this->mode, [self::NONE, self::IF_ABSENT, self::IF_MATCH], true)) {
|
||||||
|
throw new InvalidArgumentException('Invalid system-store write condition: ' . $this->mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->mode === self::IF_MATCH && ($this->etag === null || $this->etag === '')) {
|
||||||
|
throw new InvalidArgumentException('An ETag is required for an if-match condition');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->mode !== self::IF_MATCH && $this->etag !== null) {
|
||||||
|
throw new InvalidArgumentException('An ETag is only valid for an if-match condition');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function none(): self
|
||||||
|
{
|
||||||
|
return new self(self::NONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ifAbsent(): self
|
||||||
|
{
|
||||||
|
return new self(self::IF_ABSENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ifMatch(string $etag): self
|
||||||
|
{
|
||||||
|
return new self(self::IF_MATCH, $etag);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user