14 Commits

Author SHA1 Message Date
Sebastian d58a6d1967 Merge pull request 'chore(deps): update dependency jsdom to v30' (#145) from renovate/jsdom-30.x into main
Reviewed-on: #145
2026-09-14 23:15:07 +00:00
Sebastian a89c036da4 Merge pull request 'chore(deps): update dependency vue-i18n to v11.4.10' (#144) from renovate/vue-i18n-11.x-lockfile into main
Reviewed-on: #144
2026-09-14 23:14:51 +00:00
Sebastian f719875ee6 Merge pull request 'chore(deps): update dependency symfony/console to v7.4.18' (#146) from renovate/symfony into main
Reviewed-on: #146
2026-09-14 23:14:43 +00:00
Sebastian 56cbc6771b Merge pull request 'chore(deps): update dependency vuetify to v4.2.1' (#147) from renovate/vuetify-4.x-lockfile into main
Reviewed-on: #147
2026-09-14 23:14:33 +00:00
Sebastian 4dd09cc7ec feat: add background to pages
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-14 18:46:36 -04:00
Sebastian 2df825ffc9 chore(deps): update dependency vuetify to v4.2.1
Build Test / build (pull_request) Successful in 27s
JS Unit Tests / test (pull_request) Successful in 23s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m25s
PHP Unit Tests / test (pull_request) Successful in 1m21s
2026-09-11 03:02:22 +00:00
Sebastian 0a5da12d0e chore(deps): update dependency vue-i18n to v11.4.10
Build Test / build (pull_request) Successful in 17s
JS Unit Tests / test (pull_request) Successful in 16s
PHP Unit Tests / test (pull_request) Successful in 1m15s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m24s
2026-09-08 03:02:15 +00:00
Sebastian 09355d14a4 feat: add services configuration accessors
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-07 13:39:16 -04:00
Sebastian fbc0559a64 feat: improve office document discovery
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-07 12:55:11 -04:00
Sebastian 625db726f6 feat: Service Node List Interface
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-09-04 20:59:01 -04:00
Sebastian 8cedd8a18a refactor(modules): configure integrations through runtime contexts
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian 9fcfe32ea7 feat(preview): expose availability and handle missing services
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-08-31 22:55:11 -04:00
Sebastian ae26169f37 chore(deps): update dependency symfony/console to v7.4.18
Build Test / build (pull_request) Successful in 31s
JS Unit Tests / test (pull_request) Successful in 21s
PHP Unit Tests / test (pull_request) Successful in 55s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m15s
2026-08-31 03:03:06 +00:00
Sebastian 36d7402f0d chore(deps): update dependency jsdom to v30
JS Unit Tests / test (pull_request) Successful in 25s
Build Test / build (pull_request) Successful in 27s
PHP Unit Tests / test (pull_request) Failing after 1m22s
PHP Integration Tests / Integration Tests (pull_request) Failing after 1m52s
2026-07-28 03:02:11 +00:00
29 changed files with 908 additions and 212 deletions
Generated
+8 -8
View File
@@ -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": "25e4604f76b54a60904f1517dd3b210d",
"packages": [ "packages": [
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
@@ -606,16 +606,16 @@
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v7.4.14", "version": "v7.4.18",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/console.git", "url": "https://github.com/symfony/console.git",
"reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -680,7 +680,7 @@
"terminal" "terminal"
], ],
"support": { "support": {
"source": "https://github.com/symfony/console/tree/v7.4.14" "source": "https://github.com/symfony/console/tree/v7.4.18"
}, },
"funding": [ "funding": [
{ {
@@ -700,7 +700,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-16T11:50:14+00:00" "time": "2026-08-25T14:18:37+00:00"
}, },
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
@@ -3057,5 +3057,5 @@
"ext-iconv": "*" "ext-iconv": "*"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }
+10 -5
View File
@@ -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);
}
}
+22 -11
View File
@@ -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 [];
} }
} }
+3 -15
View File
@@ -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;
} }
} }
+40 -5
View File
@@ -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,
@@ -174,6 +201,7 @@ final readonly class PreviewManager
} }
$canonical = json_encode([ $canonical = json_encode([
'version' => 2, // Regenerate previews with opaque document pages and outlines.
'tenantId' => $tenantId, 'tenantId' => $tenantId,
'source' => [ 'source' => [
'type' => $source->sourceType, 'type' => $source->sourceType,
@@ -218,11 +246,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 +262,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 +272,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,
);
}
} }
+4 -6
View File
@@ -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
View File
@@ -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);
}
} }
+73 -12
View File
@@ -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;
}
} }
+3
View File
@@ -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();
+2
View File
@@ -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'
+36
View File
@@ -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,
}
})
+158 -101
View File
@@ -39,7 +39,7 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1", "eslint-plugin-vue": "^10.9.1",
"jsdom": "^29.1.1", "jsdom": "^30.0.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"sass": "^1.99.0", "sass": "^1.99.0",
"sass-loader": "^17.0.0", "sass-loader": "^17.0.0",
@@ -53,56 +53,38 @@
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
"version": "5.1.11", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.1",
"@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.9",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0" "@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.5.2"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.13.0 || >=24.0.0"
} }
}, },
"node_modules/@asamuzakjp/dom-selector": { "node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3", "bidi-js": "^1.0.3",
"css-tree": "^3.2.1", "css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1" "is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.5.2"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^22.13.0 || >=24.0.0"
} }
}, },
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
@@ -236,9 +218,9 @@
} }
}, },
"node_modules/@csstools/color-helpers": { "node_modules/@csstools/color-helpers": {
"version": "6.0.2", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -256,9 +238,9 @@
} }
}, },
"node_modules/@csstools/css-calc": { "node_modules/@csstools/css-calc": {
"version": "3.2.1", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -280,9 +262,9 @@
} }
}, },
"node_modules/@csstools/css-color-parser": { "node_modules/@csstools/css-color-parser": {
"version": "4.1.1", "version": "4.1.10",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -296,8 +278,8 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@csstools/color-helpers": "^6.0.2", "@csstools/color-helpers": "^6.1.0",
"@csstools/css-calc": "^3.2.1" "@csstools/css-calc": "^3.3.0"
}, },
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
@@ -331,9 +313,9 @@
} }
}, },
"node_modules/@csstools/css-syntax-patches-for-csstree": { "node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.4", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -527,9 +509,9 @@
} }
}, },
"node_modules/@exodus/bytes": { "node_modules/@exodus/bytes": {
"version": "1.15.0", "version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -642,14 +624,14 @@
} }
}, },
"node_modules/@intlify/core-base": { "node_modules/@intlify/core-base": {
"version": "11.4.7", "version": "11.4.10",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz", "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.10.tgz",
"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==", "integrity": "sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/devtools-types": "11.4.7", "@intlify/devtools-types": "11.4.10",
"@intlify/message-compiler": "11.4.7", "@intlify/message-compiler": "11.4.10",
"@intlify/shared": "11.4.7" "@intlify/shared": "11.4.10"
}, },
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@@ -659,13 +641,13 @@
} }
}, },
"node_modules/@intlify/devtools-types": { "node_modules/@intlify/devtools-types": {
"version": "11.4.7", "version": "11.4.10",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz", "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.10.tgz",
"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==", "integrity": "sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/core-base": "11.4.7", "@intlify/core-base": "11.4.10",
"@intlify/shared": "11.4.7" "@intlify/shared": "11.4.10"
}, },
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@@ -675,12 +657,12 @@
} }
}, },
"node_modules/@intlify/message-compiler": { "node_modules/@intlify/message-compiler": {
"version": "11.4.7", "version": "11.4.10",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz", "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.10.tgz",
"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==", "integrity": "sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/shared": "11.4.7", "@intlify/shared": "11.4.10",
"source-map-js": "^1.0.2" "source-map-js": "^1.0.2"
}, },
"engines": { "engines": {
@@ -691,9 +673,9 @@
} }
}, },
"node_modules/@intlify/shared": { "node_modules/@intlify/shared": {
"version": "11.4.7", "version": "11.4.10",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz", "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.10.tgz",
"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==", "integrity": "sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@@ -2192,6 +2174,63 @@
"vuetify": ">=3" "vuetify": ">=3"
} }
}, },
"node_modules/@vuetify/v0": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@vuetify/v0/-/v0-1.2.2.tgz",
"integrity": "sha512-7AvM89l6l/pShSYKa+a5/zsht/xj/l4jFpWFOR6UqSelBrPfKxEQ8heO8iCNEGNK5yljwsdARx0JmDJjJRJdVw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/johnleider"
},
{
"type": "opencollective",
"url": "https://opencollective.com/vuetify"
}
],
"license": "MIT",
"peerDependencies": {
"@adobe/leonardo-contrast-colors": ">=1.0.0",
"@ant-design/colors": ">=7.0.0",
"@flagsmith/flagsmith": ">=11.0.0",
"@floating-ui/dom": ">=1.8.0",
"@js-temporal/polyfill": ">=0.5.0",
"@material/material-color-utilities": ">=0.3.0",
"launchdarkly-js-client-sdk": "^3.0.0",
"posthog-js": "^1.0.0",
"vue": ">=3.5.0 || >=3.6.0-0",
"vue-i18n": ">=10.0.0"
},
"peerDependenciesMeta": {
"@adobe/leonardo-contrast-colors": {
"optional": true
},
"@ant-design/colors": {
"optional": true
},
"@flagsmith/flagsmith": {
"optional": true
},
"@floating-ui/dom": {
"optional": true
},
"@js-temporal/polyfill": {
"optional": true
},
"@material/material-color-utilities": {
"optional": true
},
"launchdarkly-js-client-sdk": {
"optional": true
},
"posthog-js": {
"optional": true
},
"vue-i18n": {
"optional": true
}
}
},
"node_modules/@webassemblyjs/ast": { "node_modules/@webassemblyjs/ast": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -4009,39 +4048,39 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsdom": { "node_modules/jsdom": {
"version": "29.1.1", "version": "30.0.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/css-color": "^6.0.5",
"@asamuzakjp/dom-selector": "^7.1.1", "@asamuzakjp/dom-selector": "^8.2.5",
"@bramus/specificity": "^2.4.2", "@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@csstools/css-syntax-patches-for-csstree": "^1.1.6",
"@exodus/bytes": "^1.15.0", "@exodus/bytes": "^1.15.1",
"css-tree": "^3.2.1", "css-tree": "^3.2.1",
"data-urls": "^7.0.0", "data-urls": "^7.0.0",
"decimal.js": "^10.6.0", "decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0", "html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1", "is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5", "lru-cache": "^11.5.2",
"parse5": "^8.0.1", "parse5": "^8.0.1",
"saxes": "^6.0.0", "saxes": "^6.0.0",
"symbol-tree": "^3.2.4", "symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1", "tough-cookie": "^6.0.2",
"undici": "^7.25.0", "undici": "^8.7.0",
"w3c-xmlserializer": "^5.0.0", "w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1", "webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0", "whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1", "whatwg-url": "^17.1.0",
"xml-name-validator": "^5.0.0" "xml-name-validator": "^5.0.0"
}, },
"engines": { "engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0" "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"canvas": "^3.0.0" "canvas": "^3.2.3"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"canvas": { "canvas": {
@@ -4049,6 +4088,21 @@
} }
} }
}, },
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz",
"integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.15.1",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^22.14.0 || >=24.0.0"
}
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -4425,9 +4479,9 @@
} }
}, },
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.3.6", "version": "11.5.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
"dev": true, "dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
@@ -5872,9 +5926,9 @@
} }
}, },
"node_modules/tough-cookie": { "node_modules/tough-cookie": {
"version": "6.0.1", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
@@ -5984,13 +6038,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.25.0", "version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=22.19.0"
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
@@ -6537,14 +6591,14 @@
} }
}, },
"node_modules/vue-i18n": { "node_modules/vue-i18n": {
"version": "11.4.7", "version": "11.4.10",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.10.tgz",
"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==", "integrity": "sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/core-base": "11.4.7", "@intlify/core-base": "11.4.10",
"@intlify/devtools-types": "11.4.7", "@intlify/devtools-types": "11.4.10",
"@intlify/shared": "11.4.7", "@intlify/shared": "11.4.10",
"@vue/devtools-api": "^6.5.0" "@vue/devtools-api": "^6.5.0"
}, },
"engines": { "engines": {
@@ -6642,10 +6696,13 @@
} }
}, },
"node_modules/vuetify": { "node_modules/vuetify": {
"version": "4.1.6", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.6.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.2.1.tgz",
"integrity": "sha512-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g==", "integrity": "sha512-K3ZIqu7YkorQFB6//dR5KEBpbfOo+y/LkcGtknsnneE3JN1pW3R7o/JB9QDkJuP1ELhENIdBZIkiSmXu0dQ9lQ==",
"license": "MIT", "license": "MIT",
"dependencies": {
"@vuetify/v0": "^1.2.1"
},
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/johnleider" "url": "https://github.com/sponsors/johnleider"
@@ -6653,7 +6710,7 @@
"peerDependencies": { "peerDependencies": {
"typescript": ">=4.7", "typescript": ">=4.7",
"vite-plugin-vuetify": ">=2.1.0", "vite-plugin-vuetify": ">=2.1.0",
"vue": "^3.5.0", "vue": "^3.5.0 || ^3.6.0-0",
"webpack-plugin-vuetify": ">=3.1.0" "webpack-plugin-vuetify": ">=3.1.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
+1 -1
View File
@@ -53,7 +53,7 @@
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.3.0", "eslint": "^10.3.0",
"eslint-plugin-vue": "^10.9.1", "eslint-plugin-vue": "^10.9.1",
"jsdom": "^29.1.1", "jsdom": "^30.0.0",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"sass": "^1.99.0", "sass": "^1.99.0",
"sass-loader": "^17.0.0", "sass-loader": "^17.0.0",
+63 -8
View File
@@ -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
*/ */
+48 -6
View File
@@ -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');
}
}
}
+46 -6
View File
@@ -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
{ {
@@ -358,6 +397,7 @@ final class PreviewManagerTest extends TestCase
PreviewRequest $request, PreviewRequest $request,
): string { ): string {
$canonical = json_encode([ $canonical = json_encode([
'version' => 2,
'tenantId' => 'tenant-a', 'tenantId' => 'tenant-a',
'source' => [ 'source' => [
'type' => $source->sourceType, 'type' => $source->sourceType,