Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f10cdde0c | |||
| 09355d14a4 | |||
| fbc0559a64 | |||
| 625db726f6 | |||
| 8cedd8a18a | |||
| 9fcfe32ea7 |
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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class TenantConfiguration extends JsonSerializableObject
|
|||||||
protected TenantFirewall $firewall;
|
protected TenantFirewall $firewall;
|
||||||
protected TenantStores $stores;
|
protected TenantStores $stores;
|
||||||
protected TenantPreview $preview;
|
protected TenantPreview $preview;
|
||||||
|
protected array $services = [];
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,10 @@ class TenantConfiguration extends JsonSerializableObject
|
|||||||
return $this->firewall;
|
return $this->firewall;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function services(): array {
|
||||||
|
return $this->services;
|
||||||
|
}
|
||||||
|
|
||||||
public function stores(): TenantStores {
|
public function stores(): TenantStores {
|
||||||
return $this->stores;
|
return $this->stores;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-10
@@ -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;
|
||||||
@@ -25,8 +26,9 @@ use KTXC\Security\Event\RateLimitExceededEvent;
|
|||||||
use KTXC\Security\Event\SuspiciousActivityEvent;
|
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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,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,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,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,
|
||||||
@@ -240,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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,29 @@ final readonly class PreviewManager
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @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(
|
public function fetch(
|
||||||
string $tenantId,
|
string $tenantId,
|
||||||
PreviewSource $source,
|
PreviewSource $source,
|
||||||
@@ -45,6 +68,7 @@ final readonly class PreviewManager
|
|||||||
!$configuration->enabled()
|
!$configuration->enabled()
|
||||||
|| $variantConfiguration === null
|
|| $variantConfiguration === null
|
||||||
|| ($source->size !== null && $source->size > $configuration->maxSourceSize())
|
|| ($source->size !== null && $source->size > $configuration->maxSourceSize())
|
||||||
|
|| $tenant->getConfiguration()->stores()->store($configuration->store()) === null
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -69,6 +93,9 @@ final readonly class PreviewManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
$provider = $this->selectProvider($source->mimeType, $request);
|
$provider = $this->selectProvider($source->mimeType, $request);
|
||||||
|
if ($provider === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
$result = $provider->generate($source, $request);
|
$result = $provider->generate($source, $request);
|
||||||
$blob = $this->writeCache(
|
$blob = $this->writeCache(
|
||||||
$tenantId,
|
$tenantId,
|
||||||
@@ -218,11 +245,11 @@ final readonly class PreviewManager
|
|||||||
private function selectProvider(
|
private function selectProvider(
|
||||||
string $sourceMimeType,
|
string $sourceMimeType,
|
||||||
PreviewRequest $request,
|
PreviewRequest $request,
|
||||||
): PreviewProviderInterface {
|
): ?PreviewProviderInterface {
|
||||||
$sourceMimeType = MimeType::normalize($sourceMimeType);
|
$sourceMimeType = MimeType::normalize($sourceMimeType);
|
||||||
$candidates = [];
|
$candidates = [];
|
||||||
|
|
||||||
foreach ($this->providers->providers(ProviderInterface::TYPE_PREVIEW) as $identifier => $provider) {
|
foreach ($this->previewProviders() as $identifier => $provider) {
|
||||||
if (
|
if (
|
||||||
!$provider instanceof PreviewProviderInterface
|
!$provider instanceof PreviewProviderInterface
|
||||||
|| !$provider->supports($sourceMimeType, $request)
|
|| !$provider->supports($sourceMimeType, $request)
|
||||||
@@ -234,9 +261,7 @@ final readonly class PreviewManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($candidates === []) {
|
if ($candidates === []) {
|
||||||
throw new PreviewGenerationException(
|
return null;
|
||||||
"No preview provider supports {$sourceMimeType} to {$request->preferredMimeType}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usort($candidates, static function (array $left, array $right): int {
|
usort($candidates, static function (array $left, array $right): int {
|
||||||
@@ -246,4 +271,13 @@ final readonly class PreviewManager
|
|||||||
|
|
||||||
return $candidates[0]['provider'];
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,29 +77,40 @@ class TenantStore
|
|||||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
|
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// =========================================================================
|
||||||
* Atomically creates or replaces one logical store reference.
|
// Configuration Operations
|
||||||
*
|
// =========================================================================
|
||||||
* @param array{provider: string, service: string|int, namespace: string} $reference
|
|
||||||
*/
|
public function fetchConfiguration(string $identifier, string $path): ?array
|
||||||
public function storeConfigurationStore(string $identifier, string $name, array $reference): bool
|
{
|
||||||
|
$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(
|
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||||
['identifier' => $identifier],
|
['identifier' => $identifier],
|
||||||
['$set' => ["configuration.stores.{$name}" => $reference]],
|
['$set' => ['configuration.' . $path => $value]],
|
||||||
);
|
);
|
||||||
|
|
||||||
return $result->getMatchedCount() > 0;
|
return $result->getMatchedCount() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function removeConfiguration(string $identifier, string $path): ?bool
|
||||||
* Atomically removes one logical store reference.
|
|
||||||
*/
|
|
||||||
public function removeConfigurationStore(string $identifier, string $name): ?bool
|
|
||||||
{
|
{
|
||||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||||
['identifier' => $identifier],
|
['identifier' => $identifier],
|
||||||
['$unset' => ["configuration.stores.{$name}" => '']],
|
['$unset' => ['configuration.' . $path => '']],
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($result->getMatchedCount() === 0) {
|
if ($result->getMatchedCount() === 0) {
|
||||||
@@ -162,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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
|||||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||||
use KTXC\Console\Event\EventsDebugCommand;
|
use KTXC\Console\Event\EventsDebugCommand;
|
||||||
use KTXC\Module\Module;
|
use KTXC\Module\Module;
|
||||||
|
use KTXC\Module\Configuration\BrowserModuleContext;
|
||||||
|
use KTXC\Module\Configuration\ConsoleModuleContext;
|
||||||
|
use KTXC\Preview\PreviewManager;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
use KTXC\Service\FirewallService;
|
use KTXC\Service\FirewallService;
|
||||||
use KTXC\Service\SystemFirewallRuleService;
|
use KTXC\Service\SystemFirewallRuleService;
|
||||||
use KTXC\Service\SystemFirewallLogService;
|
use KTXC\Service\SystemFirewallLogService;
|
||||||
@@ -15,6 +19,8 @@ use KTXC\Service\TenantFirewallLogService;
|
|||||||
use KTXC\Service\TenantFirewallStatusService;
|
use KTXC\Service\TenantFirewallStatusService;
|
||||||
use KTXC\Service\SystemFirewallStatusService;
|
use KTXC\Service\SystemFirewallStatusService;
|
||||||
use KTXC\Service\TenantFirewallRuleService;
|
use KTXC\Service\TenantFirewallRuleService;
|
||||||
|
use KTXC\Service\TenantService;
|
||||||
|
use KTXC\SystemStore\SystemStoreManager;
|
||||||
use KTXC\Event\EventListenerRegistry;
|
use KTXC\Event\EventListenerRegistry;
|
||||||
use KTXC\Security\Event\AccessDeniedEvent;
|
use KTXC\Security\Event\AccessDeniedEvent;
|
||||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||||
@@ -32,6 +38,8 @@ use KTXF\Event\DeliveryMode;
|
|||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use PHPUnit\Framework\Attributes\TestDox;
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Container\ContainerInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
final class CoreModuleTest extends TestCase
|
final class CoreModuleTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -40,7 +48,7 @@ final class CoreModuleTest extends TestCase
|
|||||||
public function registersListeners(): void
|
public function registersListeners(): void
|
||||||
{
|
{
|
||||||
$registry = new EventListenerRegistry();
|
$registry = new EventListenerRegistry();
|
||||||
$module = new Module($registry);
|
$module = $this->module($registry);
|
||||||
|
|
||||||
$module->boot();
|
$module->boot();
|
||||||
$definitions = $registry->definitions();
|
$definitions = $registry->definitions();
|
||||||
@@ -83,18 +91,21 @@ final class CoreModuleTest extends TestCase
|
|||||||
#[TestDox('Core exposes the event registry debug command')]
|
#[TestDox('Core exposes the event registry debug command')]
|
||||||
public function exposesDebugCommand(): void
|
public function exposesDebugCommand(): void
|
||||||
{
|
{
|
||||||
$module = new Module(new EventListenerRegistry());
|
$module = $this->module();
|
||||||
|
$context = new ConsoleModuleContext();
|
||||||
|
$module->configure($context);
|
||||||
|
$commands = $context->commands();
|
||||||
|
|
||||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
self::assertContains(EventsDebugCommand::class, $commands);
|
||||||
self::assertContains(FirewallSetupCommand::class, $module->registerCI());
|
self::assertContains(FirewallSetupCommand::class, $commands);
|
||||||
self::assertContains(FirewallMaintenanceCommand::class, $module->registerCI());
|
self::assertContains(FirewallMaintenanceCommand::class, $commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
#[TestDox('Core registers dedicated system firewall permissions')]
|
#[TestDox('Core registers dedicated system firewall permissions')]
|
||||||
public function registersSystemFirewallPermissions(): void
|
public function registersSystemFirewallPermissions(): void
|
||||||
{
|
{
|
||||||
$permissions = (new Module(new EventListenerRegistry()))->permissions();
|
$permissions = $this->module()->permissions();
|
||||||
|
|
||||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
|
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
|
||||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||||
@@ -107,4 +118,35 @@ final class CoreModuleTest extends TestCase
|
|||||||
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions);
|
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions);
|
||||||
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions);
|
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestDox('Core resolves preview browser configuration')]
|
||||||
|
public function resolvesPreviewBrowserConfiguration(): void
|
||||||
|
{
|
||||||
|
$module = $this->module();
|
||||||
|
$context = new BrowserModuleContext('tenant-id', 'identity-id');
|
||||||
|
$module->configure($context);
|
||||||
|
|
||||||
|
self::assertSame([
|
||||||
|
'preview' => [
|
||||||
|
'enabled' => false,
|
||||||
|
'storage' => false,
|
||||||
|
'generation' => false,
|
||||||
|
],
|
||||||
|
], $context->configuration());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function module(?EventListenerRegistry $registry = null): Module
|
||||||
|
{
|
||||||
|
$tenants = $this->createStub(TenantService::class);
|
||||||
|
$providers = new ProviderManager($this->createStub(ContainerInterface::class));
|
||||||
|
$previews = new PreviewManager(
|
||||||
|
$tenants,
|
||||||
|
$providers,
|
||||||
|
new SystemStoreManager($tenants, $providers),
|
||||||
|
$this->createStub(LoggerInterface::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Module($registry ?? new EventListenerRegistry(), $previews);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXT\Unit\Module;
|
||||||
|
|
||||||
|
use KTXC\Module\Configuration\BrowserModuleContext;
|
||||||
|
use KTXC\Module\Configuration\ConsoleModuleContext;
|
||||||
|
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||||
|
use KTXF\Module\Configuration\ModuleContextType;
|
||||||
|
use KTXF\Module\ModuleInstanceAbstract;
|
||||||
|
use LogicException;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ModuleContextTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
public function moduleRegistersItselfWithTheActiveContext(): void
|
||||||
|
{
|
||||||
|
$module = new TestContextModule();
|
||||||
|
$browser = new BrowserModuleContext('tenant-id', 'identity-id');
|
||||||
|
$console = new ConsoleModuleContext();
|
||||||
|
$module->configure($browser);
|
||||||
|
$module->configure($console);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
[
|
||||||
|
'test_context' => [
|
||||||
|
'handle' => 'test_context',
|
||||||
|
'namespace' => 'TestContext',
|
||||||
|
'version' => '0.0.1',
|
||||||
|
'label' => 'Test Context',
|
||||||
|
'author' => 'Ktrix',
|
||||||
|
'description' => 'Tests module contexts',
|
||||||
|
'boot' => 'static/module.mjs',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$browser->modules(),
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['test' => ['tenant' => 'tenant-id', 'identity' => 'identity-id']],
|
||||||
|
$browser->configuration(),
|
||||||
|
);
|
||||||
|
self::assertSame(['ExampleCommand'], $console->commands());
|
||||||
|
self::assertSame(ModuleContextType::Browser, $browser->type());
|
||||||
|
self::assertSame(ModuleContextType::Console, $console->type());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function rejectsDuplicateBrowserConfigurationKeys(): void
|
||||||
|
{
|
||||||
|
$context = new BrowserModuleContext('tenant-id', 'identity-id');
|
||||||
|
$context->set('test', true);
|
||||||
|
|
||||||
|
$this->expectException(LogicException::class);
|
||||||
|
$context->set('test', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TestContextModule extends ModuleInstanceAbstract
|
||||||
|
{
|
||||||
|
public function handle(): string { return 'test_context'; }
|
||||||
|
public function version(): string { return '0.0.1'; }
|
||||||
|
public function label(): string { return 'Test Context'; }
|
||||||
|
public function description(): string { return 'Tests module contexts'; }
|
||||||
|
public function author(): string { return 'Ktrix'; }
|
||||||
|
|
||||||
|
public function configure(ModuleContextInterface $context): void
|
||||||
|
{
|
||||||
|
if ($context instanceof BrowserModuleContextInterface) {
|
||||||
|
$context->registerModule($this, 'TestContext', 'static/module.mjs');
|
||||||
|
$context->set('test', [
|
||||||
|
'tenant' => $context->tenantIdentifier(),
|
||||||
|
'identity' => $context->identityIdentifier(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if ($context instanceof ConsoleModuleContextInterface) {
|
||||||
|
$context->registerCommand('ExampleCommand');
|
||||||
|
$context->registerCommand('ExampleCommand');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -154,12 +154,7 @@ final class PreviewManagerTest extends TestCase
|
|||||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||||
$service->expects(self::once())->method('stat')->willReturn(null);
|
$service->expects(self::once())->method('stat')->willReturn(null);
|
||||||
$logger = $this->createMock(LoggerInterface::class);
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
$logger->expects(self::once())->method('warning')->with(
|
$logger->expects(self::never())->method('warning');
|
||||||
'Preview unavailable',
|
|
||||||
self::callback(static fn(array $context): bool =>
|
|
||||||
($context['exception'] ?? null) instanceof PreviewGenerationException
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
self::assertNull($this->manager(
|
self::assertNull($this->manager(
|
||||||
$provider,
|
$provider,
|
||||||
@@ -169,6 +164,50 @@ final class PreviewManagerTest extends TestCase
|
|||||||
)->fetch('tenant-a', $this->source()));
|
)->fetch('tenant-a', $this->source()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function missingPreviewStorageIsUnavailableWithoutLoggingOrProviderLookup(): void
|
||||||
|
{
|
||||||
|
$provider = $this->provider();
|
||||||
|
$provider->expects(self::never())->method('generate');
|
||||||
|
$providerManager = $this->createMock(ProviderManager::class);
|
||||||
|
$providerManager->expects(self::never())->method('providers');
|
||||||
|
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||||
|
$service->expects(self::never())->method('stat');
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger->expects(self::never())->method('warning');
|
||||||
|
|
||||||
|
self::assertNull($this->manager(
|
||||||
|
$provider,
|
||||||
|
$service,
|
||||||
|
new TenantConfiguration(),
|
||||||
|
$providerManager,
|
||||||
|
$logger,
|
||||||
|
)->fetch('tenant-a', $this->source()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
public function reportsStorageAndGenerationAvailabilitySeparately(): void
|
||||||
|
{
|
||||||
|
$provider = $this->provider();
|
||||||
|
$providerManager = $this->createMock(ProviderManager::class);
|
||||||
|
$providerManager->expects(self::once())->method('providers')->willReturn([]);
|
||||||
|
$logger = $this->createMock(LoggerInterface::class);
|
||||||
|
$logger->expects(self::once())->method('debug')->with(
|
||||||
|
'No preview generation provider is available',
|
||||||
|
['tenantId' => 'tenant-a'],
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame(
|
||||||
|
['enabled' => true, 'storage' => true, 'generation' => false],
|
||||||
|
$this->manager(
|
||||||
|
$provider,
|
||||||
|
$this->createStub(SystemStoreServiceInterface::class),
|
||||||
|
providerManager: $providerManager,
|
||||||
|
logger: $logger,
|
||||||
|
)->availability('tenant-a'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[Test]
|
#[Test]
|
||||||
public function cacheKeysAreDeterministicOpaqueAndChangeWithSourceIdentity(): void
|
public function cacheKeysAreDeterministicOpaqueAndChangeWithSourceIdentity(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user