Compare commits
1 Commits
main
..
9d4b32a9a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d4b32a9a7 |
Generated
+8
-8
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "25e4604f76b54a60904f1517dd3b210d",
|
||||
"content-hash": "68916c1b58d9ce06a59f6c58e4a2d226",
|
||||
"packages": [
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
@@ -606,16 +606,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v7.4.18",
|
||||
"version": "v7.4.14",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/console.git",
|
||||
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0"
|
||||
"reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
|
||||
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87",
|
||||
"reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -680,7 +680,7 @@
|
||||
"terminal"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.18"
|
||||
"source": "https://github.com/symfony/console/tree/v7.4.14"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -700,7 +700,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-25T14:18:37+00:00"
|
||||
"time": "2026-06-16T11:50:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
@@ -3057,5 +3057,5 @@
|
||||
"ext-iconv": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Tenant;
|
||||
|
||||
use KTXC\SystemStore\SystemStoreConfigurationService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'tenant:store:list', description: 'List logical stores configured for a tenant')]
|
||||
class TenantStoreListCommand extends Command
|
||||
{
|
||||
public function __construct(private readonly SystemStoreConfigurationService $stores)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = (string) $input->getArgument('tenant');
|
||||
|
||||
try {
|
||||
$stores = $this->stores->list($tenant);
|
||||
if ($stores === []) {
|
||||
$io->text("No logical stores configured for tenant '{$tenant}'.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($stores as $name => $store) {
|
||||
$rows[] = [$name, $store->provider, (string) $store->service, $store->namespace];
|
||||
}
|
||||
$io->table(['Name', 'Provider', 'Service', 'Namespace'], $rows);
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $error) {
|
||||
$io->error('Failed to list tenant stores: ' . $error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Tenant;
|
||||
|
||||
use KTXC\SystemStore\SystemStoreConfigurationService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'tenant:store:remove', description: 'Remove a logical store from a tenant')]
|
||||
class TenantStoreRemoveCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SystemStoreConfigurationService $stores,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('name', InputArgument::REQUIRED, 'Logical store name');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = (string) $input->getArgument('tenant');
|
||||
$name = (string) $input->getArgument('name');
|
||||
|
||||
try {
|
||||
if (!$this->stores->remove($tenant, $name)) {
|
||||
$io->warning("Logical store '{$name}' was not configured for tenant '{$tenant}'.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->logger->info('Tenant logical store removed via console', compact('tenant', 'name'));
|
||||
$io->success("Logical store '{$name}' removed from tenant '{$tenant}'.");
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $error) {
|
||||
$this->logger->error('Tenant logical store removal failed', [
|
||||
'tenant' => $tenant,
|
||||
'name' => $name,
|
||||
'error' => $error->getMessage(),
|
||||
]);
|
||||
$io->error('Failed to remove tenant store: ' . $error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Tenant;
|
||||
|
||||
use KTXC\SystemStore\SystemStoreConfigurationService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(name: 'tenant:store:set', description: 'Configure a logical store for a tenant')]
|
||||
class TenantStoreSetCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SystemStoreConfigurationService $stores,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('name', InputArgument::REQUIRED, 'Logical store name, such as previews')
|
||||
->addArgument('provider', InputArgument::REQUIRED, 'System-store provider identifier')
|
||||
->addArgument('service', InputArgument::REQUIRED, 'System-store service identifier')
|
||||
->addArgument('namespace', InputArgument::REQUIRED, 'Provider key namespace');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = (string) $input->getArgument('tenant');
|
||||
$name = (string) $input->getArgument('name');
|
||||
$provider = (string) $input->getArgument('provider');
|
||||
$service = (string) $input->getArgument('service');
|
||||
$namespace = (string) $input->getArgument('namespace');
|
||||
|
||||
try {
|
||||
$this->stores->set($tenant, $name, $provider, $service, $namespace);
|
||||
$this->logger->info('Tenant logical store configured via console', compact(
|
||||
'tenant', 'name', 'provider', 'service', 'namespace',
|
||||
));
|
||||
$io->success("Logical store '{$name}' configured for tenant '{$tenant}'.");
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $error) {
|
||||
$this->logger->error('Tenant logical store configuration failed', [
|
||||
'tenant' => $tenant,
|
||||
'name' => $name,
|
||||
'error' => $error->getMessage(),
|
||||
]);
|
||||
$io->error('Failed to configure tenant store: ' . $error->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\L10N\LocaleResolver;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXC\Module\Configuration\BrowserModuleContext;
|
||||
use KTXC\Security\Authorization\PermissionChecker;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
@@ -31,11 +30,7 @@ class InitController extends ControllerAbstract
|
||||
$configuration = [];
|
||||
|
||||
// modules - filter by permissions
|
||||
$browserContext = new BrowserModuleContext(
|
||||
$this->tenantContext->requireIdentifier(),
|
||||
$this->identityContext->requireIdentifier(),
|
||||
);
|
||||
|
||||
$configuration['modules'] = [];
|
||||
foreach ($this->moduleManager->list(true, true) as $module) {
|
||||
// Check if user has permission to view this module
|
||||
// Allow access if user has: {module_handle}, {module_handle}.*, or * permission
|
||||
@@ -44,12 +39,12 @@ class InitController extends ControllerAbstract
|
||||
continue;
|
||||
}
|
||||
|
||||
$module->configure($browserContext);
|
||||
$integrations = $module->registerBI();
|
||||
if ($integrations !== null) {
|
||||
$configuration['modules'][$handle] = $integrations;
|
||||
}
|
||||
}
|
||||
|
||||
$configuration['modules'] = $browserContext->modules();
|
||||
$configuration = array_merge($configuration, $browserContext->configuration());
|
||||
|
||||
// localization
|
||||
$configuration['l10n'] = [
|
||||
'locale' => $this->localeResolver->resolve($request),
|
||||
|
||||
@@ -12,17 +12,12 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
protected TenantAuthentication $authentication;
|
||||
protected TenantSecurity $security;
|
||||
protected TenantFirewall $firewall;
|
||||
protected TenantStores $stores;
|
||||
protected TenantPreview $preview;
|
||||
protected array $services = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->authentication = new TenantAuthentication();
|
||||
$this->security = new TenantSecurity();
|
||||
$this->firewall = new TenantFirewall();
|
||||
$this->stores = new TenantStores();
|
||||
$this->preview = new TenantPreview();
|
||||
}
|
||||
|
||||
public function authentication(): TenantAuthentication {
|
||||
@@ -37,16 +32,4 @@ class TenantConfiguration extends JsonSerializableObject
|
||||
return $this->firewall;
|
||||
}
|
||||
|
||||
public function services(): array {
|
||||
return $this->services;
|
||||
}
|
||||
|
||||
public function stores(): TenantStores {
|
||||
return $this->stores;
|
||||
}
|
||||
|
||||
public function preview(): TenantPreview {
|
||||
return $this->preview;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
final class TenantPreview extends JsonSerializableObject
|
||||
{
|
||||
protected bool $enabled = true;
|
||||
protected string $store = 'previews';
|
||||
protected int $maxSourceSize = 26214400;
|
||||
protected TenantPreviewVariants $variants;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->variants = new TenantPreviewVariants();
|
||||
}
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
if (array_key_exists('enabled', $data)) {
|
||||
if (!is_bool($data['enabled'])) {
|
||||
throw new InvalidArgumentException('Preview enabled must be a boolean');
|
||||
}
|
||||
$this->enabled = $data['enabled'];
|
||||
}
|
||||
|
||||
if (array_key_exists('store', $data)) {
|
||||
if (!is_string($data['store']) || preg_match('/^[a-z][a-z0-9-]*$/', $data['store']) !== 1) {
|
||||
throw new InvalidArgumentException('Preview store must be a logical system-store name');
|
||||
}
|
||||
$this->store = $data['store'];
|
||||
}
|
||||
|
||||
if (array_key_exists('maxSourceSize', $data)) {
|
||||
if (!is_int($data['maxSourceSize']) || $data['maxSourceSize'] < 1) {
|
||||
throw new InvalidArgumentException('Preview maximum source size must be a positive integer');
|
||||
}
|
||||
$this->maxSourceSize = $data['maxSourceSize'];
|
||||
}
|
||||
|
||||
if (array_key_exists('variants', $data)) {
|
||||
if (!is_array($data['variants'])) {
|
||||
throw new InvalidArgumentException('Preview variants configuration must be an object');
|
||||
}
|
||||
$this->variants->jsonDeserialize($data['variants']);
|
||||
}
|
||||
|
||||
if ($this->enabled && $this->variants->all() === []) {
|
||||
throw new InvalidArgumentException('At least one preview variant is required when previews are enabled');
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function store(): string
|
||||
{
|
||||
return $this->store;
|
||||
}
|
||||
|
||||
public function maxSourceSize(): int
|
||||
{
|
||||
return $this->maxSourceSize;
|
||||
}
|
||||
|
||||
public function variants(): TenantPreviewVariants
|
||||
{
|
||||
return $this->variants;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Preview\MimeType;
|
||||
|
||||
final readonly class TenantPreviewVariant
|
||||
{
|
||||
public string $format;
|
||||
|
||||
public function __construct(
|
||||
public int $width,
|
||||
public int $height,
|
||||
string $format,
|
||||
public int $quality,
|
||||
) {
|
||||
if ($this->width < 1 || $this->height < 1) {
|
||||
throw new InvalidArgumentException('Preview variant dimensions must be positive');
|
||||
}
|
||||
if ($this->quality < 1 || $this->quality > 100) {
|
||||
throw new InvalidArgumentException('Preview variant quality must be between 1 and 100');
|
||||
}
|
||||
$this->format = MimeType::normalize($format);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data, ?self $defaults = null): self
|
||||
{
|
||||
$width = $data['width'] ?? $defaults?->width;
|
||||
$height = $data['height'] ?? $defaults?->height;
|
||||
$format = $data['format'] ?? $defaults?->format;
|
||||
$quality = $data['quality'] ?? $defaults?->quality;
|
||||
|
||||
if (!is_int($width) || !is_int($height) || !is_string($format) || !is_int($quality)) {
|
||||
throw new InvalidArgumentException('Preview variants require integer dimensions and quality plus a MIME format');
|
||||
}
|
||||
|
||||
return new self($width, $height, $format, $quality);
|
||||
}
|
||||
|
||||
/** @return array{width:int,height:int,format:string,quality:int} */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'format' => $this->format,
|
||||
'quality' => $this->quality,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
final class TenantPreviewVariants extends JsonSerializableObject
|
||||
{
|
||||
/** @var array<string, TenantPreviewVariant> */
|
||||
private array $entries;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->entries = self::defaults();
|
||||
}
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
$defaults = self::defaults();
|
||||
$entries = [];
|
||||
foreach ($data as $name => $variant) {
|
||||
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
|
||||
throw new InvalidArgumentException('Invalid preview variant name');
|
||||
}
|
||||
if (!is_array($variant)) {
|
||||
throw new InvalidArgumentException('Preview variant configuration must be an object');
|
||||
}
|
||||
$entries[$name] = TenantPreviewVariant::fromArray($variant, $defaults[$name] ?? null);
|
||||
}
|
||||
$this->entries = $entries;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function variant(string $name): ?TenantPreviewVariant
|
||||
{
|
||||
return $this->entries[$name] ?? null;
|
||||
}
|
||||
|
||||
/** @return array<string, TenantPreviewVariant> */
|
||||
public function all(): array
|
||||
{
|
||||
return $this->entries;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn(TenantPreviewVariant $variant): array => $variant->toArray(),
|
||||
$this->entries,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, TenantPreviewVariant> */
|
||||
private static function defaults(): array
|
||||
{
|
||||
return [
|
||||
'thumbnail' => new TenantPreviewVariant(128, 128, 'image/webp', 70),
|
||||
'inline' => new TenantPreviewVariant(640, 640, 'image/webp', 80),
|
||||
'fullscreen' => new TenantPreviewVariant(2560, 2560, 'image/webp', 90),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
use KTXF\SystemStore\StoreReference;
|
||||
|
||||
/**
|
||||
* Logical system stores configured for a tenant.
|
||||
*/
|
||||
final class TenantStores extends JsonSerializableObject
|
||||
{
|
||||
/** @var array<string, StoreReference> */
|
||||
private array $entries = [];
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
|
||||
$this->entries = [];
|
||||
foreach ($data as $name => $store) {
|
||||
if (!is_string($name) || preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
|
||||
throw new InvalidArgumentException('Invalid logical system-store name');
|
||||
}
|
||||
if (!is_array($store)) {
|
||||
throw new InvalidArgumentException('Invalid tenant store configuration entry');
|
||||
}
|
||||
$this->entries[$name] = StoreReference::fromArray($store);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn(StoreReference $store): array => $store->toArray(),
|
||||
$this->entries,
|
||||
);
|
||||
}
|
||||
|
||||
public function store(string $name): ?StoreReference
|
||||
{
|
||||
return $this->entries[$name] ?? null;
|
||||
}
|
||||
|
||||
/** @return array<string, StoreReference> */
|
||||
public function all(): array
|
||||
{
|
||||
return $this->entries;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
+11
-25
@@ -4,7 +4,6 @@ namespace KTXC\Module;
|
||||
|
||||
use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Preview\PreviewManager;
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
use KTXC\Service\SystemFirewallRuleService;
|
||||
@@ -26,9 +25,8 @@ use KTXC\Security\Event\RateLimitExceededEvent;
|
||||
use KTXC\Security\Event\SuspiciousActivityEvent;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistrarInterface;
|
||||
use KTXF\Module\Configuration\BrowserModuleContextInterface;
|
||||
use KTXF\Module\Configuration\ConsoleModuleContextInterface;
|
||||
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
/**
|
||||
@@ -36,11 +34,10 @@ use KTXF\Module\ModuleInstanceAbstract;
|
||||
*
|
||||
* Provides core system functionality and permissions
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract
|
||||
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventListenerRegistrarInterface $events,
|
||||
private readonly PreviewManager $previews,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -216,20 +213,9 @@ class Module extends ModuleInstanceAbstract
|
||||
];
|
||||
}
|
||||
|
||||
public function configure(ModuleContextInterface $context): void
|
||||
public function registerCI(): array
|
||||
{
|
||||
if ($context instanceof BrowserModuleContextInterface) {
|
||||
$context->set(
|
||||
'preview',
|
||||
$this->previews->availability($context->tenantIdentifier()),
|
||||
);
|
||||
}
|
||||
|
||||
if (!$context instanceof ConsoleModuleContextInterface) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
return [
|
||||
FirewallSetupCommand::class,
|
||||
FirewallMaintenanceCommand::class,
|
||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
||||
@@ -243,9 +229,6 @@ class Module extends ModuleInstanceAbstract
|
||||
\KTXC\Console\Tenant\TenantListCommand::class,
|
||||
\KTXC\Console\Tenant\TenantDeleteCommand::class,
|
||||
\KTXC\Console\Tenant\TenantAuthEnableCommand::class,
|
||||
\KTXC\Console\Tenant\TenantStoreListCommand::class,
|
||||
\KTXC\Console\Tenant\TenantStoreSetCommand::class,
|
||||
\KTXC\Console\Tenant\TenantStoreRemoveCommand::class,
|
||||
\KTXC\Console\User\UserCreateCommand::class,
|
||||
\KTXC\Console\User\UserListCommand::class,
|
||||
\KTXC\Console\User\UserDeleteCommand::class,
|
||||
@@ -254,8 +237,11 @@ class Module extends ModuleInstanceAbstract
|
||||
\KTXC\Console\Role\RoleDeleteCommand::class,
|
||||
\KTXC\Console\Role\RoleAssignCommand::class,
|
||||
\KTXC\Console\Role\RoleRevokeCommand::class,
|
||||
] as $command) {
|
||||
$context->registerCommand($command);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
public function registerBI(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace KTXC\Module;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXC\Module\Store\ModuleEntry;
|
||||
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use KTXF\Module\ModuleInstanceInterface;
|
||||
|
||||
/**
|
||||
@@ -174,9 +175,20 @@ class ModuleObject implements JsonSerializable
|
||||
$this->instance?->upgrade();
|
||||
}
|
||||
|
||||
public function configure(ModuleContextInterface $context): void
|
||||
public function registerBI(): array | null
|
||||
{
|
||||
$this->instance?->configure($context);
|
||||
if ($this->instance instanceof ModuleBrowserInterface) {
|
||||
return $this->instance->registerBI();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function registerCI(): array | null
|
||||
{
|
||||
if ($this->instance instanceof ModuleConsoleInterface) {
|
||||
return $this->instance->registerCI();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Preview;
|
||||
|
||||
use KTXF\Resource\BinaryResource;
|
||||
|
||||
final readonly class Preview
|
||||
{
|
||||
public function __construct(
|
||||
public BinaryResource $resource,
|
||||
public int $size,
|
||||
public string $etag,
|
||||
public ?int $width = null,
|
||||
public ?int $height = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Preview;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\SystemStore\SystemStoreManager;
|
||||
use KTXF\Preview\MimeType;
|
||||
use KTXF\Preview\PreviewGenerationException;
|
||||
use KTXF\Preview\PreviewRequest;
|
||||
use KTXF\Preview\PreviewResult;
|
||||
use KTXF\Preview\PreviewSource;
|
||||
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXF\SystemStore\BlobInfo;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Throwable;
|
||||
|
||||
final readonly class PreviewManager
|
||||
{
|
||||
public function __construct(
|
||||
private TenantService $tenants,
|
||||
private ProviderManager $providers,
|
||||
private SystemStoreManager $stores,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{enabled:bool,storage:bool,generation:bool} */
|
||||
public function availability(string $tenantId): array
|
||||
{
|
||||
$tenant = $this->tenants->fetchById($tenantId);
|
||||
if ($tenant === null) {
|
||||
return ['enabled' => false, 'storage' => false, 'generation' => false];
|
||||
}
|
||||
|
||||
$configuration = $tenant->getConfiguration()->preview();
|
||||
$enabled = $configuration->enabled();
|
||||
$storage = $enabled
|
||||
&& $tenant->getConfiguration()->stores()->store($configuration->store()) !== null;
|
||||
$generation = $storage && $this->previewProviders() !== [];
|
||||
|
||||
if ($enabled && !$storage) {
|
||||
$this->logger->debug('Preview storage is not configured', ['tenantId' => $tenantId]);
|
||||
} elseif ($enabled && $storage && !$generation) {
|
||||
$this->logger->debug('No preview generation provider is available', ['tenantId' => $tenantId]);
|
||||
}
|
||||
|
||||
return compact('enabled', 'storage', 'generation');
|
||||
}
|
||||
|
||||
public function fetch(
|
||||
string $tenantId,
|
||||
PreviewSource $source,
|
||||
string $variant = 'inline',
|
||||
): ?Preview {
|
||||
$tenant = $this->tenants->fetchById($tenantId);
|
||||
if ($tenant === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$configuration = $tenant->getConfiguration()->preview();
|
||||
$variantConfiguration = $configuration->variants()->variant($variant);
|
||||
if (
|
||||
!$configuration->enabled()
|
||||
|| $variantConfiguration === null
|
||||
|| ($source->size !== null && $source->size > $configuration->maxSourceSize())
|
||||
|| $tenant->getConfiguration()->stores()->store($configuration->store()) === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$request = new PreviewRequest(
|
||||
maxWidth: $variantConfiguration->width,
|
||||
maxHeight: $variantConfiguration->height,
|
||||
preferredMimeType: $variantConfiguration->format,
|
||||
quality: $variantConfiguration->quality,
|
||||
maxSourceSize: $configuration->maxSourceSize(),
|
||||
);
|
||||
|
||||
try {
|
||||
$cached = $this->readCache(
|
||||
$tenantId,
|
||||
$configuration->store(),
|
||||
$source,
|
||||
$request,
|
||||
);
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$provider = $this->selectProvider($source->mimeType, $request);
|
||||
if ($provider === null) {
|
||||
return null;
|
||||
}
|
||||
$result = $provider->generate($source, $request);
|
||||
$blob = $this->writeCache(
|
||||
$tenantId,
|
||||
$configuration->store(),
|
||||
$source,
|
||||
$request,
|
||||
$result,
|
||||
);
|
||||
|
||||
return $this->readStoredPreview(
|
||||
$tenantId,
|
||||
$configuration->store(),
|
||||
$blob,
|
||||
$request,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->logger->warning('Preview unavailable', [
|
||||
'tenantId' => $tenantId,
|
||||
'sourceType' => $source->sourceType,
|
||||
'variant' => $variant,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function readCache(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
PreviewSource $source,
|
||||
PreviewRequest $request,
|
||||
): ?Preview {
|
||||
$key = $this->cacheKey($tenantId, $source, $request);
|
||||
$blob = $this->stores->stat($tenantId, $store, $key);
|
||||
if (!$this->validBlob($blob, $key, $request)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->readStoredPreview($tenantId, $store, $blob, $request);
|
||||
}
|
||||
|
||||
private function readStoredPreview(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
BlobInfo $blob,
|
||||
PreviewRequest $request,
|
||||
): ?Preview {
|
||||
$resource = $this->stores->read($tenantId, $store, $blob->key);
|
||||
if ($resource === null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (MimeType::normalize($resource->mimeType()) !== $request->preferredMimeType) {
|
||||
return null;
|
||||
}
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Preview(
|
||||
resource: $resource,
|
||||
size: $blob->size,
|
||||
etag: $blob->etag,
|
||||
width: $blob->attributes['width'] ?? null,
|
||||
height: $blob->attributes['height'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
private function writeCache(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
PreviewSource $source,
|
||||
PreviewRequest $request,
|
||||
PreviewResult $result,
|
||||
): BlobInfo {
|
||||
$outputMimeType = MimeType::normalize($result->resource->mimeType());
|
||||
if ($outputMimeType !== $request->preferredMimeType) {
|
||||
throw new PreviewGenerationException('Generated preview MIME does not match the requested output MIME');
|
||||
}
|
||||
if (
|
||||
($result->width !== null && $result->width > $request->maxWidth)
|
||||
|| ($result->height !== null && $result->height > $request->maxHeight)
|
||||
) {
|
||||
throw new PreviewGenerationException('Generated preview dimensions exceed the requested limits');
|
||||
}
|
||||
|
||||
return $this->stores->write(
|
||||
$tenantId,
|
||||
$store,
|
||||
$this->cacheKey($tenantId, $source, $request),
|
||||
$result->resource,
|
||||
['width' => $result->width, 'height' => $result->height],
|
||||
);
|
||||
}
|
||||
|
||||
private function cacheKey(
|
||||
string $tenantId,
|
||||
PreviewSource $source,
|
||||
PreviewRequest $request,
|
||||
): string {
|
||||
if (trim($tenantId) === '') {
|
||||
throw new InvalidArgumentException('Preview cache keys require a tenant identifier');
|
||||
}
|
||||
|
||||
$canonical = json_encode([
|
||||
'version' => 2, // Regenerate previews with opaque document pages and outlines.
|
||||
'tenantId' => $tenantId,
|
||||
'source' => [
|
||||
'type' => $source->sourceType,
|
||||
'identity' => $source->identity,
|
||||
'signature' => $source->signature,
|
||||
'mimeType' => $source->mimeType,
|
||||
],
|
||||
'request' => [
|
||||
'maxWidth' => $request->maxWidth,
|
||||
'maxHeight' => $request->maxHeight,
|
||||
'preferredMimeType' => $request->preferredMimeType,
|
||||
'quality' => $request->quality,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION);
|
||||
$digest = hash('sha256', $canonical);
|
||||
|
||||
return "generated/{$source->sourceType}/" . substr($digest, 0, 2) . "/{$digest}";
|
||||
}
|
||||
|
||||
private function validBlob(?BlobInfo $blob, string $key, PreviewRequest $request): bool
|
||||
{
|
||||
if ($blob === null || $blob->key !== $key || $blob->size < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$width = $blob->attributes['width'] ?? null;
|
||||
$height = $blob->attributes['height'] ?? null;
|
||||
if (
|
||||
($width !== null && (!is_int($width) || $width < 1 || $width > $request->maxWidth))
|
||||
|| ($height !== null && (!is_int($height) || $height < 1 || $height > $request->maxHeight))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return MimeType::normalize($blob->mimeType) === $request->preferredMimeType;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function selectProvider(
|
||||
string $sourceMimeType,
|
||||
PreviewRequest $request,
|
||||
): ?PreviewProviderInterface {
|
||||
$sourceMimeType = MimeType::normalize($sourceMimeType);
|
||||
$candidates = [];
|
||||
|
||||
foreach ($this->previewProviders() as $identifier => $provider) {
|
||||
if (
|
||||
!$provider instanceof PreviewProviderInterface
|
||||
|| !$provider->supports($sourceMimeType, $request)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = ['identifier' => $identifier, 'provider' => $provider];
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($candidates, static function (array $left, array $right): int {
|
||||
return ($right['provider']->priority() <=> $left['provider']->priority())
|
||||
?: strcmp($left['identifier'], $right['identifier']);
|
||||
});
|
||||
|
||||
return $candidates[0]['provider'];
|
||||
}
|
||||
|
||||
/** @return array<string, PreviewProviderInterface> */
|
||||
private function previewProviders(): array
|
||||
{
|
||||
return array_filter(
|
||||
$this->providers->providers(ProviderInterface::TYPE_PREVIEW),
|
||||
static fn(ProviderInterface $provider): bool => $provider instanceof PreviewProviderInterface,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ namespace KTXC\Runtime\Console;
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Kernel;
|
||||
use KTXC\KernelInterface;
|
||||
use KTXC\Module\Configuration\ConsoleModuleContext;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
@@ -34,13 +34,15 @@ final class ConsoleRuntime
|
||||
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
$moduleContext = new ConsoleModuleContext();
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$module->configure($moduleContext);
|
||||
}
|
||||
$instance = $module->instance();
|
||||
if (!$instance instanceof ModuleConsoleInterface) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($moduleContext->commands() as $commandClass) {
|
||||
$this->registerCommand($console, $container, $commandClass);
|
||||
foreach ($instance->registerCI() as $commandClass) {
|
||||
$this->registerCommand($console, $container, $commandClass);
|
||||
}
|
||||
}
|
||||
|
||||
return $console->run($input, $output);
|
||||
|
||||
@@ -73,15 +73,4 @@ class TenantService
|
||||
{
|
||||
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,49 +77,6 @@ class TenantStore
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Configuration Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchConfiguration(string $identifier, string $path): ?array
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(
|
||||
['identifier' => $identifier],
|
||||
['projection' => ['configuration.' . $path => 1]],
|
||||
);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $this->readPath($entry, "configuration.{$path}");
|
||||
return $value === null ? null : (array) $value;
|
||||
}
|
||||
|
||||
public function storeConfiguration(string $identifier, string $path, array $value): bool
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||
['identifier' => $identifier],
|
||||
['$set' => ['configuration.' . $path => $value]],
|
||||
);
|
||||
|
||||
return $result->getMatchedCount() > 0;
|
||||
}
|
||||
|
||||
public function removeConfiguration(string $identifier, string $path): ?bool
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||
['identifier' => $identifier],
|
||||
['$unset' => ['configuration.' . $path => '']],
|
||||
);
|
||||
|
||||
if ($result->getMatchedCount() === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $result->getModifiedCount() > 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings Operations
|
||||
// =========================================================================
|
||||
@@ -173,54 +130,4 @@ class TenantStore
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\SystemStore;
|
||||
|
||||
use KTXC\Stores\TenantStore;
|
||||
use KTXF\SystemStore\StoreReference;
|
||||
use KTXF\SystemStore\SystemStoreException;
|
||||
|
||||
/**
|
||||
* Superuser-facing configuration operations for tenant logical stores.
|
||||
*/
|
||||
class SystemStoreConfigurationService
|
||||
{
|
||||
public function __construct(private readonly TenantStore $tenants)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array<string, StoreReference> */
|
||||
public function list(string $tenantId): array
|
||||
{
|
||||
$tenant = $this->tenants->fetch($tenantId);
|
||||
if ($tenant === null) {
|
||||
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
|
||||
}
|
||||
|
||||
return $tenant->getConfiguration()->stores()->all();
|
||||
}
|
||||
|
||||
public function set(
|
||||
string $tenantId,
|
||||
string $name,
|
||||
string $provider,
|
||||
string|int $service,
|
||||
string $namespace,
|
||||
): StoreReference {
|
||||
self::validateName($name);
|
||||
$reference = new StoreReference($provider, $service, $namespace);
|
||||
|
||||
if (!$this->tenants->storeConfigurationStore($tenantId, $name, $reference->toArray())) {
|
||||
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
|
||||
}
|
||||
|
||||
return $reference;
|
||||
}
|
||||
|
||||
public function remove(string $tenantId, string $name): bool
|
||||
{
|
||||
self::validateName($name);
|
||||
$removed = $this->tenants->removeConfigurationStore($tenantId, $name);
|
||||
if ($removed === null) {
|
||||
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
|
||||
}
|
||||
|
||||
return $removed;
|
||||
}
|
||||
|
||||
private static function validateName(string $name): void
|
||||
{
|
||||
if (preg_match('/^[a-z][a-z0-9-]*$/', $name) !== 1) {
|
||||
throw new \InvalidArgumentException('Logical store names must use lowercase letters, numbers, and hyphens');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\SystemStore;
|
||||
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXF\Resource\SystemIdentity;
|
||||
use KTXF\SystemStore\BlobInfo;
|
||||
use KTXF\SystemStore\InvalidKeyException;
|
||||
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||
use KTXF\SystemStore\StoreReference;
|
||||
use KTXF\SystemStore\SystemStoreException;
|
||||
use KTXF\SystemStore\SystemStoreManagerInterface;
|
||||
use KTXF\SystemStore\WriteCondition;
|
||||
|
||||
final readonly class SystemStoreManager implements SystemStoreManagerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private TenantService $tenants,
|
||||
private ProviderManager $providers,
|
||||
) {
|
||||
}
|
||||
|
||||
public function stat(string $tenantId, string $store, string $key): ?BlobInfo
|
||||
{
|
||||
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||
$info = $service->stat($this->qualify($reference, $key));
|
||||
|
||||
return $info === null ? null : $this->logicalInfo($info, $reference);
|
||||
}
|
||||
|
||||
public function read(string $tenantId, string $store, string $key): ?BinaryResource
|
||||
{
|
||||
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||
return $service->read($this->qualify($reference, $key));
|
||||
}
|
||||
|
||||
public function write(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
string $key,
|
||||
BinaryResource $content,
|
||||
array $metadata = [],
|
||||
?WriteCondition $condition = null,
|
||||
): BlobInfo {
|
||||
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||
$info = $service->write(
|
||||
$this->qualify($reference, $key),
|
||||
$content,
|
||||
$metadata,
|
||||
$condition,
|
||||
);
|
||||
|
||||
return $this->logicalInfo($info, $reference);
|
||||
}
|
||||
|
||||
public function delete(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
string $key,
|
||||
?WriteCondition $condition = null,
|
||||
): bool {
|
||||
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||
return $service->delete($this->qualify($reference, $key), $condition);
|
||||
}
|
||||
|
||||
public function list(string $tenantId, string $store, string $prefix = ''): iterable
|
||||
{
|
||||
[$service, $reference] = $this->resolve($tenantId, $store);
|
||||
$physicalPrefix = $reference->namespace . '/';
|
||||
if ($prefix !== '') {
|
||||
$physicalPrefix .= $this->normalizeKey($prefix, true);
|
||||
}
|
||||
|
||||
return $this->logicalItems($service->list($physicalPrefix), $reference);
|
||||
}
|
||||
|
||||
/** @return array{SystemStoreServiceInterface, StoreReference} */
|
||||
private function resolve(string $tenantId, string $store): array
|
||||
{
|
||||
$tenant = $this->tenants->fetchById($tenantId);
|
||||
if ($tenant === null) {
|
||||
throw new SystemStoreException("Tenant '{$tenantId}' was not found");
|
||||
}
|
||||
|
||||
$reference = $tenant->getConfiguration()->stores()->store($store);
|
||||
if ($reference === null) {
|
||||
throw new SystemStoreException("System store '{$store}' is not configured for tenant '{$tenantId}'");
|
||||
}
|
||||
|
||||
$provider = $this->providers->resolve(ProviderInterface::TYPE_SYSTEM_STORE, $reference->provider);
|
||||
if (!$provider instanceof ProviderBaseInterface) {
|
||||
throw new SystemStoreException("System-store provider '{$reference->provider}' is unavailable or incompatible");
|
||||
}
|
||||
|
||||
$service = $provider->serviceFetch($tenantId, SystemIdentity::USER, $reference->service);
|
||||
if (!$service instanceof SystemStoreServiceInterface) {
|
||||
throw new SystemStoreException("System-store service '{$reference->service}' is unavailable or incompatible");
|
||||
}
|
||||
|
||||
return [$service, $reference];
|
||||
}
|
||||
|
||||
private function qualify(StoreReference $reference, string $key): string
|
||||
{
|
||||
return $reference->namespace . '/' . $this->normalizeKey($key);
|
||||
}
|
||||
|
||||
private function normalizeKey(string $key, bool $allowTrailingSlash = false): string
|
||||
{
|
||||
if (
|
||||
$key === ''
|
||||
|| str_starts_with($key, '/')
|
||||
|| (!$allowTrailingSlash && str_ends_with($key, '/'))
|
||||
|| str_contains($key, '\\')
|
||||
|| str_contains($key, "\0")
|
||||
) {
|
||||
throw new InvalidKeyException('System-store keys must be non-empty normalized relative keys');
|
||||
}
|
||||
|
||||
$segments = explode('/', $key);
|
||||
if ($allowTrailingSlash && end($segments) === '') {
|
||||
array_pop($segments);
|
||||
}
|
||||
if (in_array('', $segments, true) || in_array('.', $segments, true) || in_array('..', $segments, true)) {
|
||||
throw new InvalidKeyException('System-store keys cannot contain empty or traversal segments');
|
||||
}
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
private function logicalInfo(BlobInfo $info, StoreReference $reference): BlobInfo
|
||||
{
|
||||
$prefix = $reference->namespace . '/';
|
||||
if (!str_starts_with($info->key, $prefix)) {
|
||||
throw new SystemStoreException('System-store provider returned a blob outside the configured namespace');
|
||||
}
|
||||
|
||||
return new BlobInfo(
|
||||
key: substr($info->key, strlen($prefix)),
|
||||
mimeType: $info->mimeType,
|
||||
size: $info->size,
|
||||
etag: $info->etag,
|
||||
modifiedAt: $info->modifiedAt,
|
||||
attributes: $info->attributes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<BlobInfo> $items
|
||||
* @return iterable<BlobInfo>
|
||||
*/
|
||||
private function logicalItems(iterable $items, StoreReference $reference): iterable
|
||||
{
|
||||
foreach ($items as $item) {
|
||||
if (!$item instanceof BlobInfo) {
|
||||
throw new SystemStoreException('System-store provider returned invalid listing metadata');
|
||||
}
|
||||
yield $this->logicalInfo($item, $reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,9 +88,12 @@ const userAvatar = computed(() => userStore.getProfileField('avatar') || default
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn class="profileBtn" variant="text" rounded="sm" v-bind="props">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar size="32">
|
||||
<v-avatar class="mr-sm-2 mr-0" size="32">
|
||||
<v-img :src="userAvatar" :alt="userAuth?.label || 'User'" cover />
|
||||
</v-avatar>
|
||||
<h6 class="text-subtitle-1 mb-0 d-sm-block d-none">
|
||||
{{ userAuth?.label }}
|
||||
</h6>
|
||||
</div>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useLayoutStore, type MenuMode } from '@KTXC/stores/layoutStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import type { IntegrationPointType } from '@KTXC/types/integrationTypes';
|
||||
import { useL10n } from '@KTXC/composables/useL10n';
|
||||
import Logo from '@KTXC/layouts/logo/LogoDark.vue';
|
||||
import SystemMenuGroupStatic from './LayoutSystemMenuGroupStatic.vue';
|
||||
@@ -13,33 +12,25 @@ const layoutStore = useLayoutStore();
|
||||
const integrationStore = useIntegrationStore();
|
||||
const { t } = useL10n('core');
|
||||
|
||||
const menuPointByMode: Record<MenuMode, IntegrationPointType> = {
|
||||
apps: 'app_menu',
|
||||
'user-settings': 'user_settings_menu',
|
||||
'admin-settings': 'admin_settings_menu',
|
||||
};
|
||||
|
||||
// Get all entries based on current menu mode
|
||||
const menuEntries = computed(() => integrationStore.getPoint(menuPointByMode[layoutStore.menuMode]));
|
||||
|
||||
// Only show menu modes that currently have visible items.
|
||||
const menuModes = computed((): Array<{ value: MenuMode; icon: string; label: string }> => {
|
||||
const modes: Array<{ value: MenuMode; icon: string; label: string }> = [
|
||||
{ value: 'apps', icon: 'mdi-view-dashboard', label: t('systemMenu.apps', 'Applications') },
|
||||
{ value: 'user-settings', icon: 'mdi-account-cog', label: t('systemMenu.personalSettings', 'Settings') },
|
||||
{ value: 'admin-settings', icon: 'mdi-shield-crown', label: t('systemMenu.adminSettings', 'System') },
|
||||
];
|
||||
|
||||
return modes.filter(mode => integrationStore.getPoint(menuPointByMode[mode.value]).length > 0);
|
||||
const menuEntries = computed(() => {
|
||||
switch (layoutStore.menuMode) {
|
||||
case 'user-settings':
|
||||
return integrationStore.getPoint('user_settings_menu');
|
||||
case 'admin-settings':
|
||||
return integrationStore.getPoint('admin_settings_menu');
|
||||
case 'apps':
|
||||
default:
|
||||
return integrationStore.getPoint('app_menu');
|
||||
}
|
||||
});
|
||||
|
||||
// If the active menu becomes empty (for example, after a module is disabled),
|
||||
// move to the first menu that still has content.
|
||||
watch(menuModes, (availableModes) => {
|
||||
if (availableModes.length > 0 && !availableModes.some(mode => mode.value === layoutStore.menuMode)) {
|
||||
layoutStore.setMenuMode(availableModes[0].value);
|
||||
}
|
||||
}, { immediate: true });
|
||||
// Static list of menu modes shown as icon buttons
|
||||
const menuModes = computed((): Array<{ value: MenuMode; icon: string; label: string }> => [
|
||||
{ value: 'apps', icon: 'mdi-view-dashboard', label: t('systemMenu.apps', 'Applications') },
|
||||
{ value: 'user-settings', icon: 'mdi-account-cog', label: t('systemMenu.personalSettings', 'Settings') },
|
||||
{ value: 'admin-settings', icon: 'mdi-shield-crown', label: t('systemMenu.adminSettings', 'System') },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -93,30 +84,28 @@ export default {
|
||||
|
||||
<!-- Menu Mode Switcher -->
|
||||
<template v-slot:append>
|
||||
<div v-if="menuModes.length">
|
||||
<v-divider />
|
||||
<div class="menu-mode-switcher d-flex justify-space-around align-center py-2">
|
||||
<v-tooltip
|
||||
v-for="mode in menuModes"
|
||||
:key="mode.value"
|
||||
location="right"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
:color="layoutStore.menuMode === mode.value ? 'primary' : undefined"
|
||||
:class="['menu-mode-btn', { 'menu-mode-btn--active': layoutStore.menuMode === mode.value }]"
|
||||
@click="layoutStore.setMenuMode(mode.value)"
|
||||
>
|
||||
<v-icon>{{ mode.icon }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ mode.label }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-divider />
|
||||
<div class="menu-mode-switcher d-flex justify-space-around align-center py-2">
|
||||
<v-tooltip
|
||||
v-for="mode in menuModes"
|
||||
:key="mode.value"
|
||||
location="right"
|
||||
>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
density="comfortable"
|
||||
:color="layoutStore.menuMode === mode.value ? 'primary' : undefined"
|
||||
:class="['menu-mode-btn', { 'menu-mode-btn--active': layoutStore.menuMode === mode.value }]"
|
||||
@click="layoutStore.setMenuMode(mode.value)"
|
||||
>
|
||||
<v-icon>{{ mode.icon }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ mode.label }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { computed } from 'vue';
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useL10n, t as tGlobal } from '@KTXC/composables/useL10n';
|
||||
import defaultAvatar from '@KTXC/assets/images/users/avatar-1.png';
|
||||
|
||||
const { t } = useL10n('core');
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const integrationStore = useIntegrationStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
@@ -41,6 +43,12 @@ const cycleTheme = () => {
|
||||
layoutStore.setTheme(nextThemeMode.value);
|
||||
};
|
||||
|
||||
// Navigate to settings
|
||||
const goToSettings = () => {
|
||||
layoutStore.setMenuMode('settings');
|
||||
// Navigate to first settings item or a default settings route
|
||||
router.push('/modules'); // TODO: Make this dynamic based on first settings menu item
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -54,9 +62,9 @@ const cycleTheme = () => {
|
||||
<v-img :src="userAvatar" :alt="userName" cover />
|
||||
</v-avatar>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="user-menu-name mb-0 font-weight-bold">
|
||||
<h6 class="text-h6 mb-0 font-weight-medium">
|
||||
{{ userName }}
|
||||
</h5>
|
||||
</h6>
|
||||
<p class="text-caption mb-0 text-medium-emphasis">{{ userEmail }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,6 +98,14 @@ const cycleTheme = () => {
|
||||
<v-list-item-title class="text-h6">{{ t(MODE_PRESENTATION[nextThemeMode].l10n, MODE_PRESENTATION[nextThemeMode].label) }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- Go to Settings -->
|
||||
<v-list-item @click="goToSettings" color="primary" rounded="0">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-cog-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-h6">{{ t('userMenu.settings', 'Settings') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<!-- Logout -->
|
||||
@@ -103,10 +119,3 @@ const cycleTheme = () => {
|
||||
</perfect-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-menu-name {
|
||||
font-size: 18px !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createPinia } from 'pinia'
|
||||
import { PerfectScrollbarPlugin } from 'vue3-perfect-scrollbar'
|
||||
import { useModuleStore } from '@KTXC/stores/moduleStore'
|
||||
import { useTenantStore } from '@KTXC/stores/tenantStore'
|
||||
import { usePreviewStore } from '@KTXC/stores/previewStore'
|
||||
import { useUserStore } from '@KTXC/stores/userStore'
|
||||
import { useL10nStore } from '@KTXC/stores/l10nStore'
|
||||
import { useThemeStore } from '@KTXC/stores/themeStore'
|
||||
@@ -42,7 +41,6 @@ globalWindow.Pinia = PiniaLib as unknown
|
||||
(async () => {
|
||||
const moduleStore = useModuleStore();
|
||||
const tenantStore = useTenantStore();
|
||||
const previewStore = usePreviewStore();
|
||||
const userStore = useUserStore();
|
||||
const l10nStore = useL10nStore();
|
||||
const themeStore = useThemeStore();
|
||||
@@ -51,7 +49,6 @@ globalWindow.Pinia = PiniaLib as unknown
|
||||
try {
|
||||
const payload = await fetchWrapper.get('/init');
|
||||
moduleStore.init(payload?.modules ?? {});
|
||||
previewStore.init(payload?.preview ?? null);
|
||||
tenantStore.init(payload?.tenant ?? null);
|
||||
userStore.init(payload?.user ?? {});
|
||||
layoutStore.hydrateFromSettings();
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
// Stores
|
||||
export { useModuleStore } from '../stores/moduleStore'
|
||||
export { useTenantStore } from '../stores/tenantStore'
|
||||
export { usePreviewStore } from '../stores/previewStore'
|
||||
export type { PreviewAvailability } from '../stores/previewStore'
|
||||
export { useUserStore } from '../stores/userStore'
|
||||
export { useIntegrationStore } from '../stores/integrationStore'
|
||||
export { useLayoutStore } from '../stores/layoutStore'
|
||||
|
||||
@@ -156,10 +156,6 @@ export const useIntegrationStore = defineStore('integrationStore', {
|
||||
|
||||
return Array.from(point.items.values())
|
||||
.filter(entry => entry.visible !== false)
|
||||
.map(entry => 'items' in entry
|
||||
? { ...entry, items: entry.items.filter(item => item.visible !== false) }
|
||||
: entry)
|
||||
.filter(entry => !('items' in entry) || entry.items.length > 0)
|
||||
.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
|
||||
},
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
}
|
||||
|
||||
const sidebarDrawer = ref<boolean>(booleanSetting('sidebar_drawer', true));
|
||||
const miniSidebar = ref<boolean>(booleanSetting('mini_sidebar', true));
|
||||
const miniSidebar = ref<boolean>(booleanSetting('mini_sidebar', false));
|
||||
const menuMode = ref<MenuMode>('apps');
|
||||
|
||||
// Theme mode - user choice, falling back to the tenant default
|
||||
@@ -60,7 +60,7 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
hydratingFromSettings = true;
|
||||
try {
|
||||
sidebarDrawer.value = booleanSetting('sidebar_drawer', true);
|
||||
miniSidebar.value = booleanSetting('mini_sidebar', true);
|
||||
miniSidebar.value = booleanSetting('mini_sidebar', false);
|
||||
theme.value = initialTheme();
|
||||
} finally {
|
||||
hydratingFromSettings = false;
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
Generated
+101
-158
@@ -39,7 +39,7 @@
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"jsdom": "^30.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.99.0",
|
||||
"sass-loader": "^17.0.0",
|
||||
@@ -53,38 +53,56 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz",
|
||||
"integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==",
|
||||
"version": "5.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^3.2.1",
|
||||
"@csstools/css-color-parser": "^4.1.9",
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@csstools/css-calc": "^3.2.0",
|
||||
"@csstools/css-color-parser": "^4.1.0",
|
||||
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||
"@csstools/css-tokenizer": "^4.0.0",
|
||||
"lru-cache": "^11.5.2"
|
||||
"@csstools/css-tokenizer": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/dom-selector": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz",
|
||||
"integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||
"bidi-js": "^1.0.3",
|
||||
"css-tree": "^3.2.1",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2"
|
||||
"is-potential-custom-element-name": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.13.0 || >=24.0.0"
|
||||
"node": "^20.19.0 || ^22.12.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": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
|
||||
@@ -218,9 +236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
|
||||
"integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -238,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
|
||||
"integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
|
||||
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -262,9 +280,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
|
||||
"integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz",
|
||||
"integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -278,8 +296,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^6.1.0",
|
||||
"@csstools/css-calc": "^3.3.0"
|
||||
"@csstools/color-helpers": "^6.0.2",
|
||||
"@csstools/css-calc": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
@@ -313,9 +331,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
|
||||
"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz",
|
||||
"integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -509,9 +527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
|
||||
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -624,14 +642,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.10.tgz",
|
||||
"integrity": "sha512-+yJ74JRWVJokdgG9zYNMyTSzeNV3O9T4vVxk8PvLFHmI+R/BYA//cITh7vhRK37hWLZ4/kTcKcUz1dlWOpypIg==",
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
|
||||
"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/devtools-types": "11.4.10",
|
||||
"@intlify/message-compiler": "11.4.10",
|
||||
"@intlify/shared": "11.4.10"
|
||||
"@intlify/devtools-types": "11.4.8",
|
||||
"@intlify/message-compiler": "11.4.8",
|
||||
"@intlify/shared": "11.4.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -641,13 +659,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/devtools-types": {
|
||||
"version": "11.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.10.tgz",
|
||||
"integrity": "sha512-xZxzZsAuu6/0zoLRVQWdpXWe5Kjl0LnWpjlQA3r9u9FbLYMhapqt7IwkgQyn0Tm2GUNAqhj9eZiUmYOrB024BQ==",
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
|
||||
"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.10",
|
||||
"@intlify/shared": "11.4.10"
|
||||
"@intlify/core-base": "11.4.8",
|
||||
"@intlify/shared": "11.4.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -657,12 +675,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "11.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.10.tgz",
|
||||
"integrity": "sha512-oUB/scz2EJENXDiUJ7JjZffOrH8UIZ1BuZeHvonbi5fWLavLt04aivuk2OIByOZA0tsci1bkeeQRmwhb5M8Imw==",
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
|
||||
"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "11.4.10",
|
||||
"@intlify/shared": "11.4.8",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -673,9 +691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "11.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.10.tgz",
|
||||
"integrity": "sha512-FeImVdPeoSHTm3NBFFZHv0eRP9gQ3F4lj2puDBX5Kw7iiM1uJW6JTf39ian0K/17pbXCI3ef5i9RVsRrALqI6Q==",
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
|
||||
"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
@@ -2174,63 +2192,6 @@
|
||||
"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": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
@@ -4048,39 +4009,39 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "30.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz",
|
||||
"integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==",
|
||||
"version": "29.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^6.0.5",
|
||||
"@asamuzakjp/dom-selector": "^8.2.5",
|
||||
"@asamuzakjp/css-color": "^5.1.11",
|
||||
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.6",
|
||||
"@exodus/bytes": "^1.15.1",
|
||||
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||
"@exodus/bytes": "^1.15.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"data-urls": "^7.0.0",
|
||||
"decimal.js": "^10.6.0",
|
||||
"html-encoding-sniffer": "^6.0.0",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lru-cache": "^11.3.5",
|
||||
"parse5": "^8.0.1",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^6.0.2",
|
||||
"undici": "^8.7.0",
|
||||
"tough-cookie": "^6.0.1",
|
||||
"undici": "^7.25.0",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^8.0.1",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"whatwg-url": "^17.1.0",
|
||||
"whatwg-url": "^16.0.1",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.2.3"
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
@@ -4088,21 +4049,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -4479,9 +4425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.2",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
||||
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
||||
"version": "11.3.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
@@ -5926,9 +5872,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
|
||||
"integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -6038,13 +5984,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz",
|
||||
"integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==",
|
||||
"version": "7.25.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
@@ -6591,14 +6537,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.4.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.10.tgz",
|
||||
"integrity": "sha512-Lp+BjOxqzOY87DS6Z8KrQrpiTr9IN/Lt4kZEilwyXG2Wrx+AcU6IVsAW92HNXtVcn1HFFPV6ty41p9e/qDpyvg==",
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
|
||||
"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.10",
|
||||
"@intlify/devtools-types": "11.4.10",
|
||||
"@intlify/shared": "11.4.10",
|
||||
"@intlify/core-base": "11.4.8",
|
||||
"@intlify/devtools-types": "11.4.8",
|
||||
"@intlify/shared": "11.4.8",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -6696,13 +6642,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vuetify": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.2.1.tgz",
|
||||
"integrity": "sha512-K3ZIqu7YkorQFB6//dR5KEBpbfOo+y/LkcGtknsnneE3JN1pW3R7o/JB9QDkJuP1ELhENIdBZIkiSmXu0dQ9lQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.6.tgz",
|
||||
"integrity": "sha512-VOsRTsNfs+FE4JXMONZkqX2yznSqC/FXG9/pgVVjsoqIjUvJg+CfFTuOlgyWVFrZYY+crk7LV9uaiN8bZREV/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vuetify/v0": "^1.2.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/johnleider"
|
||||
@@ -6710,7 +6653,7 @@
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.7",
|
||||
"vite-plugin-vuetify": ">=2.1.0",
|
||||
"vue": "^3.5.0 || ^3.6.0-0",
|
||||
"vue": "^3.5.0",
|
||||
"webpack-plugin-vuetify": ">=3.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"jsdom": "^30.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"sass": "^1.99.0",
|
||||
"sass-loader": "^17.0.0",
|
||||
|
||||
@@ -23,10 +23,7 @@ use finfo;
|
||||
class Signature {
|
||||
|
||||
/** Minimum bytes needed for reliable detection */
|
||||
public const SAMPLE_SIZE = 65536;
|
||||
|
||||
/** Cached finfo instance */
|
||||
private static ?finfo $finfo = null;
|
||||
public const HEADER_SIZE = 256;
|
||||
|
||||
/**
|
||||
* Fallback magic byte signatures for when finfo is unavailable
|
||||
@@ -44,32 +41,16 @@ class Signature {
|
||||
['offset' => 0, 'bytes' => '52494646', 'format' => 'riff'], // WAV/AVI/WEBP
|
||||
];
|
||||
|
||||
/**
|
||||
* 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'],
|
||||
];
|
||||
/** Cached finfo instance */
|
||||
private static ?finfo $finfo = null;
|
||||
|
||||
/**
|
||||
* Detect both MIME type and format from content bytes in a single operation
|
||||
*
|
||||
* @param string $headerBytes First bytes of the file content
|
||||
* @param string|null $content Full (or larger) content, when available, used to
|
||||
* distinguish OOXML/ODF/EPUB documents from a generic ZIP archive
|
||||
* @param string $headerBytes First bytes of the file content (256 recommended)
|
||||
* @return array{mime: string, format: string} Array with 'mime' and 'format' keys
|
||||
*/
|
||||
public static function detect(string $headerBytes, ?string $content = null): array {
|
||||
public static function detect(string $headerBytes): array {
|
||||
if (strlen($headerBytes) === 0) {
|
||||
return ['mime' => MimeTypes::MIME_BINARY, 'format' => MimeTypes::FORMAT_BINARY];
|
||||
}
|
||||
@@ -94,16 +75,6 @@ class Signature {
|
||||
$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
|
||||
if ($mime === null || $mime === MimeTypes::MIME_BINARY) {
|
||||
$mime = MimeTypes::toMime($format) ?? MimeTypes::MIME_BINARY;
|
||||
@@ -112,32 +83,6 @@ class Signature {
|
||||
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
|
||||
*
|
||||
@@ -146,7 +91,7 @@ class Signature {
|
||||
*/
|
||||
public static function detectFromStream($stream): array {
|
||||
$position = ftell($stream);
|
||||
$headerBytes = fread($stream, self::SAMPLE_SIZE);
|
||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
||||
fseek($stream, $position);
|
||||
|
||||
if ($headerBytes === false || $headerBytes === '') {
|
||||
@@ -195,7 +140,7 @@ class Signature {
|
||||
*/
|
||||
public static function detectFormatFromStream($stream): string {
|
||||
$position = ftell($stream);
|
||||
$headerBytes = fread($stream, self::SAMPLE_SIZE);
|
||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
||||
fseek($stream, $position);
|
||||
|
||||
if ($headerBytes === false || $headerBytes === '') {
|
||||
@@ -213,7 +158,7 @@ class Signature {
|
||||
*/
|
||||
public static function detectMimeTypeFromStream($stream): ?string {
|
||||
$position = ftell($stream);
|
||||
$headerBytes = fread($stream, self::SAMPLE_SIZE);
|
||||
$headerBytes = fread($stream, self::HEADER_SIZE);
|
||||
fseek($stream, $position);
|
||||
|
||||
if ($headerBytes === false || $headerBytes === '') {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<?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;
|
||||
|
||||
}
|
||||
@@ -11,7 +11,6 @@ namespace KTXF\Mail\Provider;
|
||||
|
||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
|
||||
use KTXF\Resource\SystemIdentity;
|
||||
|
||||
/**
|
||||
* Provider Base Interface
|
||||
@@ -25,9 +24,15 @@ interface ProviderBaseInterface extends ResourceProviderBaseInterface{
|
||||
public const JSON_TYPE = 'mail:provider';
|
||||
|
||||
/**
|
||||
* @deprecated Use SystemIdentity::USER.
|
||||
* Reserved user identifier for system-owned mail services
|
||||
*
|
||||
* Services stored under this user id belong to the tenant itself rather
|
||||
* than any real user (e.g. accounts used to deliver system-generated
|
||||
* messages such as verification codes and password resets).
|
||||
*
|
||||
* @since 2026.07.01
|
||||
*/
|
||||
public const USER_SYSTEM = SystemIdentity::USER;
|
||||
public const USER_SYSTEM = 'system';
|
||||
|
||||
/**
|
||||
* Finds a service that handles a specific email address
|
||||
|
||||
@@ -56,19 +56,12 @@ interface ServiceEntityMutableInterface {
|
||||
/**
|
||||
* Modifies an existing entity
|
||||
*
|
||||
* The returned entity's identifier is authoritative and may differ from
|
||||
* the target identifier. Providers without in-place update support may
|
||||
* implement this as an append-and-replace (or equivalent create-then-
|
||||
* destroy) operation, in which case the target identifier is superseded
|
||||
* and no longer resolvable; callers must persist the returned identifier
|
||||
* rather than assume the target remains valid.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param EntityIdentifier $target Target entity identifier
|
||||
* @param MessagePropertiesMutableInterface $properties Entity properties to update
|
||||
*
|
||||
* @return EntityBaseInterface Modified entity, with its own authoritative identifier
|
||||
* @return EntityBaseInterface Modified entity
|
||||
*/
|
||||
public function entityModify(EntityIdentifier $target, MessagePropertiesMutableInterface $properties): EntityBaseInterface;
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Module\Configuration;
|
||||
|
||||
interface ModuleContextInterface
|
||||
{
|
||||
public function type(): ModuleContextType;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Module\Configuration;
|
||||
|
||||
enum ModuleContextType: string
|
||||
{
|
||||
case Browser = 'browser';
|
||||
case Console = 'console';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Module;
|
||||
|
||||
/**
|
||||
* Module Browser Interface
|
||||
*/
|
||||
interface ModuleBrowserInterface
|
||||
{
|
||||
public function registerBI(): array;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Module;
|
||||
|
||||
/**
|
||||
* Module Console Interface
|
||||
*/
|
||||
interface ModuleConsoleInterface
|
||||
{
|
||||
public function registerCI(): array;
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace KTXF\Module;
|
||||
|
||||
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||
|
||||
abstract class ModuleInstanceAbstract implements ModuleInstanceInterface
|
||||
{
|
||||
// mandatory methods that must be implemented by each concrete module
|
||||
@@ -48,11 +46,6 @@ abstract class ModuleInstanceAbstract implements ModuleInstanceInterface
|
||||
// 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
|
||||
*
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
|
||||
namespace KTXF\Module;
|
||||
|
||||
use KTXF\Module\Configuration\ModuleContextInterface;
|
||||
|
||||
interface ModuleInstanceInterface
|
||||
{
|
||||
/**
|
||||
* Declare the integrations supplied by this module.
|
||||
*/
|
||||
public function configure(ModuleContextInterface $context): void;
|
||||
|
||||
/**
|
||||
* Get module version
|
||||
*/
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Preview;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class MimeType
|
||||
{
|
||||
public static function normalize(string $mimeType): string
|
||||
{
|
||||
$mimeType = strtolower(trim(explode(';', $mimeType, 2)[0]));
|
||||
if (preg_match('~^[a-z0-9!#$&^_.+-]+/[a-z0-9!#$&^_.+-]+$~', $mimeType) !== 1) {
|
||||
throw new InvalidArgumentException("Invalid MIME type '{$mimeType}'");
|
||||
}
|
||||
return $mimeType;
|
||||
}
|
||||
|
||||
public static function matches(string $pattern, string $mimeType): bool
|
||||
{
|
||||
$mimeType = self::normalize($mimeType);
|
||||
$pattern = strtolower(trim($pattern));
|
||||
if ($pattern === '*/*') {
|
||||
return true;
|
||||
}
|
||||
if (preg_match('~^[a-z0-9!#$&^_.+-]+/\*$~', $pattern) === 1) {
|
||||
return str_starts_with($mimeType, substr($pattern, 0, -1));
|
||||
}
|
||||
return self::normalize($pattern) === $mimeType;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Preview;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class PreviewGenerationException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Preview;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final readonly class PreviewRequest
|
||||
{
|
||||
public string $preferredMimeType;
|
||||
|
||||
public function __construct(
|
||||
public int $maxWidth,
|
||||
public int $maxHeight,
|
||||
string $preferredMimeType,
|
||||
public int $quality,
|
||||
public int $maxSourceSize = 26214400,
|
||||
) {
|
||||
if ($this->maxWidth < 1 || $this->maxHeight < 1) {
|
||||
throw new InvalidArgumentException('Preview dimensions must be positive');
|
||||
}
|
||||
if ($this->quality < 1 || $this->quality > 100) {
|
||||
throw new InvalidArgumentException('Preview quality must be between 1 and 100');
|
||||
}
|
||||
if ($this->maxSourceSize < 1) {
|
||||
throw new InvalidArgumentException('Preview maximum source size must be positive');
|
||||
}
|
||||
$this->preferredMimeType = MimeType::normalize($preferredMimeType);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Preview;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
|
||||
final readonly class PreviewResult
|
||||
{
|
||||
public function __construct(
|
||||
public BinaryResource $resource,
|
||||
public ?int $width = null,
|
||||
public ?int $height = null,
|
||||
) {
|
||||
MimeType::normalize($this->resource->mimeType());
|
||||
if ($this->width !== null && $this->width < 1) {
|
||||
throw new InvalidArgumentException('Preview result width must be positive');
|
||||
}
|
||||
if ($this->height !== null && $this->height < 1) {
|
||||
throw new InvalidArgumentException('Preview result height must be positive');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Preview;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
|
||||
final readonly class PreviewSource
|
||||
{
|
||||
public string $mimeType;
|
||||
|
||||
/**
|
||||
* @param Closure(): BinaryResource $resourceFactory
|
||||
*/
|
||||
public function __construct(
|
||||
public string $sourceType,
|
||||
public string $identity,
|
||||
public string $signature,
|
||||
string $mimeType,
|
||||
public ?int $size,
|
||||
public Closure $resourceFactory,
|
||||
) {
|
||||
if (preg_match('/^[a-z][a-z0-9-]*$/', $this->sourceType) !== 1) {
|
||||
throw new InvalidArgumentException('Preview source type must use lowercase letters, numbers, and hyphens');
|
||||
}
|
||||
if ($this->identity === '' || $this->signature === '') {
|
||||
throw new InvalidArgumentException('Preview sources require an identity and content signature');
|
||||
}
|
||||
if ($this->size !== null && $this->size < 0) {
|
||||
throw new InvalidArgumentException('Preview source size cannot be negative');
|
||||
}
|
||||
$this->mimeType = MimeType::normalize($mimeType);
|
||||
}
|
||||
|
||||
public function open(): BinaryResource
|
||||
{
|
||||
$resource = ($this->resourceFactory)();
|
||||
if (!$resource instanceof BinaryResource) {
|
||||
throw new PreviewGenerationException('Preview source factory did not return a BinaryResource');
|
||||
}
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Preview\Provider;
|
||||
|
||||
use KTXF\Preview\PreviewRequest;
|
||||
use KTXF\Preview\PreviewResult;
|
||||
use KTXF\Preview\PreviewSource;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
|
||||
interface PreviewProviderInterface extends ProviderInterface
|
||||
{
|
||||
public function supports(string $sourceMimeType, PreviewRequest $request): bool;
|
||||
|
||||
public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult;
|
||||
|
||||
public function priority(): int;
|
||||
}
|
||||
@@ -15,8 +15,6 @@ interface ProviderInterface
|
||||
public const TYPE_PEOPLE = 'people';
|
||||
public const TYPE_CHRONO = 'chrono';
|
||||
public const TYPE_MAIL = 'mail';
|
||||
public const TYPE_SYSTEM_STORE = 'system-store';
|
||||
public const TYPE_PREVIEW = 'preview';
|
||||
|
||||
/**
|
||||
* Provider type (e.g., 'authentication', 'document', 'people', 'chrono', 'mail')
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Resource;
|
||||
|
||||
/**
|
||||
* Reserved identities used by tenant-scoped internal services.
|
||||
*/
|
||||
final class SystemIdentity
|
||||
{
|
||||
/**
|
||||
* User identifier for resources owned by the tenant rather than a person.
|
||||
*/
|
||||
public const USER = 'system';
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Provider-neutral information about a blob in a system store.
|
||||
*/
|
||||
final readonly class BlobInfo
|
||||
{
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $mimeType,
|
||||
public int $size,
|
||||
public string $etag,
|
||||
public DateTimeImmutable $modifiedAt,
|
||||
public array $attributes = [],
|
||||
) {
|
||||
if ($this->key === '') {
|
||||
throw new InvalidArgumentException('Blob key cannot be empty');
|
||||
}
|
||||
|
||||
if ($this->mimeType === '') {
|
||||
throw new InvalidArgumentException('Blob MIME type cannot be empty');
|
||||
}
|
||||
|
||||
if ($this->size < 0) {
|
||||
throw new InvalidArgumentException('Blob size cannot be negative');
|
||||
}
|
||||
|
||||
if ($this->etag === '') {
|
||||
throw new InvalidArgumentException('Blob ETag cannot be empty');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Raised when a logical system-store key is not safely normalized.
|
||||
*/
|
||||
final class InvalidKeyException extends InvalidArgumentException
|
||||
{
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore\Provider;
|
||||
|
||||
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
|
||||
|
||||
/**
|
||||
* Provider for tenant-scoped internal storage services.
|
||||
*/
|
||||
interface ProviderBaseInterface extends ResourceProviderBaseInterface
|
||||
{
|
||||
public const JSON_TYPE = 'system-store:provider';
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore\Service;
|
||||
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ResourceServiceBaseInterface;
|
||||
use KTXF\SystemStore\BlobInfo;
|
||||
use KTXF\SystemStore\WriteCondition;
|
||||
|
||||
/**
|
||||
* Basic key-addressed storage contract for tenant-owned system data.
|
||||
*/
|
||||
interface SystemStoreServiceInterface extends ResourceServiceBaseInterface
|
||||
{
|
||||
public const JSON_TYPE = 'system-store:service';
|
||||
|
||||
public function stat(string $key): ?BlobInfo;
|
||||
|
||||
public function read(string $key): ?BinaryResource;
|
||||
|
||||
public function write(
|
||||
string $key,
|
||||
BinaryResource $content,
|
||||
array $metadata = [],
|
||||
?WriteCondition $condition = null,
|
||||
): BlobInfo;
|
||||
|
||||
public function delete(
|
||||
string $key,
|
||||
?WriteCondition $condition = null,
|
||||
): bool;
|
||||
|
||||
/**
|
||||
* Iterates stored metadata lazily. Implementations must keep memory use
|
||||
* bounded and handle any backend pagination internally.
|
||||
*
|
||||
* @return iterable<BlobInfo>
|
||||
*/
|
||||
public function list(string $prefix = ''): iterable;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Provider and service selected for one logical tenant system store.
|
||||
*/
|
||||
final readonly class StoreReference
|
||||
{
|
||||
public function __construct(
|
||||
public string $provider,
|
||||
public string|int $service,
|
||||
public string $namespace,
|
||||
) {
|
||||
if ($this->provider === '') {
|
||||
throw new InvalidArgumentException('System-store provider cannot be empty');
|
||||
}
|
||||
|
||||
if ($this->service === '') {
|
||||
throw new InvalidArgumentException('System-store service cannot be empty');
|
||||
}
|
||||
|
||||
if (
|
||||
$this->namespace === ''
|
||||
|| str_starts_with($this->namespace, '/')
|
||||
|| str_ends_with($this->namespace, '/')
|
||||
|| str_contains($this->namespace, '\\')
|
||||
|| str_contains($this->namespace, "\0")
|
||||
|| in_array('', explode('/', $this->namespace), true)
|
||||
|| in_array('.', explode('/', $this->namespace), true)
|
||||
|| in_array('..', explode('/', $this->namespace), true)
|
||||
) {
|
||||
throw new InvalidArgumentException('System-store namespace must be a normalized relative key');
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$provider = $data['provider'] ?? null;
|
||||
$service = $data['service'] ?? null;
|
||||
$namespace = $data['namespace'] ?? null;
|
||||
|
||||
if (!is_string($provider) || (!is_string($service) && !is_int($service)) || !is_string($namespace)) {
|
||||
throw new InvalidArgumentException('Invalid system-store reference');
|
||||
}
|
||||
|
||||
return new self($provider, $service, $namespace);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'provider' => $this->provider,
|
||||
'service' => $this->service,
|
||||
'namespace' => $this->namespace,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Raised when a tenant system store cannot be resolved or trusted.
|
||||
*/
|
||||
class SystemStoreException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore;
|
||||
|
||||
use KTXF\Resource\BinaryResource;
|
||||
|
||||
/**
|
||||
* Tenant-scoped access to logical internal stores.
|
||||
*/
|
||||
interface SystemStoreManagerInterface
|
||||
{
|
||||
public function stat(string $tenantId, string $store, string $key): ?BlobInfo;
|
||||
|
||||
public function read(string $tenantId, string $store, string $key): ?BinaryResource;
|
||||
|
||||
public function write(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
string $key,
|
||||
BinaryResource $content,
|
||||
array $metadata = [],
|
||||
?WriteCondition $condition = null,
|
||||
): BlobInfo;
|
||||
|
||||
public function delete(
|
||||
string $tenantId,
|
||||
string $store,
|
||||
string $key,
|
||||
?WriteCondition $condition = null,
|
||||
): bool;
|
||||
|
||||
/**
|
||||
* @return iterable<BlobInfo>
|
||||
*/
|
||||
public function list(string $tenantId, string $store, string $prefix = ''): iterable;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Optional precondition applied atomically by a system-store write or delete.
|
||||
*/
|
||||
final readonly class WriteCondition
|
||||
{
|
||||
public const NONE = 'none';
|
||||
public const IF_ABSENT = 'if-absent';
|
||||
public const IF_MATCH = 'if-match';
|
||||
|
||||
private function __construct(
|
||||
public string $mode,
|
||||
public ?string $etag = null,
|
||||
) {
|
||||
if (!in_array($this->mode, [self::NONE, self::IF_ABSENT, self::IF_MATCH], true)) {
|
||||
throw new InvalidArgumentException('Invalid system-store write condition: ' . $this->mode);
|
||||
}
|
||||
|
||||
if ($this->mode === self::IF_MATCH && ($this->etag === null || $this->etag === '')) {
|
||||
throw new InvalidArgumentException('An ETag is required for an if-match condition');
|
||||
}
|
||||
|
||||
if ($this->mode !== self::IF_MATCH && $this->etag !== null) {
|
||||
throw new InvalidArgumentException('An ETag is only valid for an if-match condition');
|
||||
}
|
||||
}
|
||||
|
||||
public static function none(): self
|
||||
{
|
||||
return new self(self::NONE);
|
||||
}
|
||||
|
||||
public static function ifAbsent(): self
|
||||
{
|
||||
return new self(self::IF_ABSENT);
|
||||
}
|
||||
|
||||
public static function ifMatch(string $etag): self
|
||||
{
|
||||
return new self(self::IF_MATCH, $etag);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXF\SystemStore;
|
||||
|
||||
/** Raised when an atomic write or delete precondition is not satisfied. */
|
||||
final class WriteConflictException extends SystemStoreException
|
||||
{
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Console\Tenant;
|
||||
|
||||
use KTXC\Console\Tenant\TenantStoreListCommand;
|
||||
use KTXC\Console\Tenant\TenantStoreRemoveCommand;
|
||||
use KTXC\Console\Tenant\TenantStoreSetCommand;
|
||||
use KTXC\SystemStore\SystemStoreConfigurationService;
|
||||
use KTXF\SystemStore\StoreReference;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
final class TenantStoreCommandsTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function listsTenantStores(): void
|
||||
{
|
||||
$stores = $this->createStub(SystemStoreConfigurationService::class);
|
||||
$stores->method('list')->willReturn([
|
||||
'previews' => new StoreReference('local', 'default', 'previews'),
|
||||
]);
|
||||
$tester = new CommandTester(new TenantStoreListCommand($stores));
|
||||
|
||||
$status = $tester->execute(['tenant' => 'tenant-a']);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $status);
|
||||
self::assertStringContainsString('previews', $tester->getDisplay());
|
||||
self::assertStringContainsString('local', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function configuresATenantStore(): void
|
||||
{
|
||||
$stores = $this->createMock(SystemStoreConfigurationService::class);
|
||||
$stores->expects(self::once())
|
||||
->method('set')
|
||||
->with('tenant-a', 'previews', 'local', 'default', 'previews')
|
||||
->willReturn(new StoreReference('local', 'default', 'previews'));
|
||||
$tester = new CommandTester(new TenantStoreSetCommand($stores, new NullLogger()));
|
||||
|
||||
$status = $tester->execute([
|
||||
'tenant' => 'tenant-a',
|
||||
'name' => 'previews',
|
||||
'provider' => 'local',
|
||||
'service' => 'default',
|
||||
'namespace' => 'previews',
|
||||
]);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $status);
|
||||
self::assertStringContainsString('configured', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function removesATenantStore(): void
|
||||
{
|
||||
$stores = $this->createMock(SystemStoreConfigurationService::class);
|
||||
$stores->expects(self::once())->method('remove')->with('tenant-a', 'previews')->willReturn(true);
|
||||
$tester = new CommandTester(new TenantStoreRemoveCommand($stores, new NullLogger()));
|
||||
|
||||
$status = $tester->execute(['tenant' => 'tenant-a', 'name' => 'previews']);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $status);
|
||||
self::assertStringContainsString('removed', $tester->getDisplay());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function removingAnAbsentStoreIsIdempotent(): void
|
||||
{
|
||||
$stores = $this->createStub(SystemStoreConfigurationService::class);
|
||||
$stores->method('remove')->willReturn(false);
|
||||
$tester = new CommandTester(new TenantStoreRemoveCommand($stores, new NullLogger()));
|
||||
|
||||
$status = $tester->execute(['tenant' => 'tenant-a', 'name' => 'previews']);
|
||||
|
||||
self::assertSame(Command::SUCCESS, $status);
|
||||
self::assertStringContainsString('not configured', $tester->getDisplay());
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,6 @@ use KTXC\Console\Firewall\FirewallMaintenanceCommand;
|
||||
use KTXC\Console\Firewall\FirewallSetupCommand;
|
||||
use KTXC\Console\Event\EventsDebugCommand;
|
||||
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\SystemFirewallRuleService;
|
||||
use KTXC\Service\SystemFirewallLogService;
|
||||
@@ -19,8 +15,6 @@ use KTXC\Service\TenantFirewallLogService;
|
||||
use KTXC\Service\TenantFirewallStatusService;
|
||||
use KTXC\Service\SystemFirewallStatusService;
|
||||
use KTXC\Service\TenantFirewallRuleService;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\SystemStore\SystemStoreManager;
|
||||
use KTXC\Event\EventListenerRegistry;
|
||||
use KTXC\Security\Event\AccessDeniedEvent;
|
||||
use KTXC\Security\Event\AuthenticationFailedEvent;
|
||||
@@ -38,8 +32,6 @@ use KTXF\Event\DeliveryMode;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class CoreModuleTest extends TestCase
|
||||
{
|
||||
@@ -48,7 +40,7 @@ final class CoreModuleTest extends TestCase
|
||||
public function registersListeners(): void
|
||||
{
|
||||
$registry = new EventListenerRegistry();
|
||||
$module = $this->module($registry);
|
||||
$module = new Module($registry);
|
||||
|
||||
$module->boot();
|
||||
$definitions = $registry->definitions();
|
||||
@@ -91,21 +83,18 @@ final class CoreModuleTest extends TestCase
|
||||
#[TestDox('Core exposes the event registry debug command')]
|
||||
public function exposesDebugCommand(): void
|
||||
{
|
||||
$module = $this->module();
|
||||
$context = new ConsoleModuleContext();
|
||||
$module->configure($context);
|
||||
$commands = $context->commands();
|
||||
$module = new Module(new EventListenerRegistry());
|
||||
|
||||
self::assertContains(EventsDebugCommand::class, $commands);
|
||||
self::assertContains(FirewallSetupCommand::class, $commands);
|
||||
self::assertContains(FirewallMaintenanceCommand::class, $commands);
|
||||
self::assertContains(EventsDebugCommand::class, $module->registerCI());
|
||||
self::assertContains(FirewallSetupCommand::class, $module->registerCI());
|
||||
self::assertContains(FirewallMaintenanceCommand::class, $module->registerCI());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestDox('Core registers dedicated system firewall permissions')]
|
||||
public function registersSystemFirewallPermissions(): void
|
||||
{
|
||||
$permissions = $this->module()->permissions();
|
||||
$permissions = (new Module(new EventListenerRegistry()))->permissions();
|
||||
|
||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions);
|
||||
self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions);
|
||||
@@ -118,35 +107,4 @@ final class CoreModuleTest extends TestCase
|
||||
self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Preview;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class PreviewConfigurationTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function providesSafeDefaultsWhenPreviewConfigurationIsAbsent(): void
|
||||
{
|
||||
$preview = (new TenantConfiguration())->preview();
|
||||
|
||||
self::assertTrue($preview->enabled());
|
||||
self::assertSame('previews', $preview->store());
|
||||
self::assertSame(26214400, $preview->maxSourceSize());
|
||||
self::assertSame(128, $preview->variants()->variant('thumbnail')?->width);
|
||||
self::assertSame(128, $preview->variants()->variant('thumbnail')?->height);
|
||||
self::assertSame(70, $preview->variants()->variant('thumbnail')?->quality);
|
||||
self::assertSame(640, $preview->variants()->variant('inline')?->width);
|
||||
self::assertSame(640, $preview->variants()->variant('inline')?->height);
|
||||
self::assertSame('image/webp', $preview->variants()->variant('inline')?->format);
|
||||
self::assertSame(80, $preview->variants()->variant('inline')?->quality);
|
||||
self::assertSame(2560, $preview->variants()->variant('fullscreen')?->width);
|
||||
self::assertSame(2560, $preview->variants()->variant('fullscreen')?->height);
|
||||
self::assertSame(90, $preview->variants()->variant('fullscreen')?->quality);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function loadsAndSerializesTenantPreviewConfiguration(): void
|
||||
{
|
||||
$data = [
|
||||
'enabled' => true,
|
||||
'store' => 'generated-content',
|
||||
'maxSourceSize' => 1024,
|
||||
'variants' => [
|
||||
'inline' => [
|
||||
'width' => 800,
|
||||
'height' => 600,
|
||||
'format' => 'IMAGE/PNG',
|
||||
'quality' => 90,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize(['preview' => $data]);
|
||||
|
||||
self::assertSame('generated-content', $configuration->preview()->store());
|
||||
self::assertSame(1024, $configuration->preview()->maxSourceSize());
|
||||
self::assertSame(['inline'], array_keys($configuration->preview()->variants()->all()));
|
||||
self::assertSame('image/png', $configuration->preview()->variants()->variant('inline')?->format);
|
||||
$data['variants']['inline']['format'] = 'image/png';
|
||||
self::assertSame($data, $configuration->jsonSerialize()['preview']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function mergesKnownVariantDefaultsForPartialOverrides(): void
|
||||
{
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'preview' => [
|
||||
'variants' => [
|
||||
'inline' => ['width' => 900],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$variant = $configuration->preview()->variants()->variant('inline');
|
||||
self::assertSame(900, $variant?->width);
|
||||
self::assertSame(640, $variant?->height);
|
||||
self::assertSame('image/webp', $variant?->format);
|
||||
self::assertSame(80, $variant?->quality);
|
||||
self::assertNull($configuration->preview()->variants()->variant('thumbnail'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function allowsNoVariantsWhenPreviewsAreDisabled(): void
|
||||
{
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'preview' => ['enabled' => false, 'variants' => []],
|
||||
]);
|
||||
|
||||
self::assertFalse($configuration->preview()->enabled());
|
||||
self::assertSame([], $configuration->preview()->variants()->all());
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array<string, mixed>}> */
|
||||
public static function invalidConfigurations(): iterable
|
||||
{
|
||||
yield 'invalid store' => [['store' => '../previews']];
|
||||
yield 'zero source limit' => [['maxSourceSize' => 0]];
|
||||
yield 'invalid variant name' => [['variants' => ['../inline' => []]]];
|
||||
yield 'invalid dimensions' => [['variants' => ['inline' => ['width' => 0]]]];
|
||||
yield 'invalid output MIME' => [['variants' => ['inline' => ['format' => 'webp']]]];
|
||||
yield 'invalid quality' => [['variants' => ['inline' => ['quality' => 101]]]];
|
||||
yield 'enabled without variants' => [['enabled' => true, 'variants' => []]];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[\PHPUnit\Framework\Attributes\DataProvider('invalidConfigurations')]
|
||||
public function rejectsInvalidConfiguration(array $data): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
(new TenantConfiguration())->jsonDeserialize(['preview' => $data]);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Preview;
|
||||
|
||||
use KTXF\Preview\MimeType;
|
||||
use KTXF\Preview\PreviewGenerationException;
|
||||
use KTXF\Preview\PreviewRequest;
|
||||
use KTXF\Preview\PreviewResult;
|
||||
use KTXF\Preview\PreviewSource;
|
||||
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
|
||||
final class PreviewContractsTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function registersThePreviewProviderType(): void
|
||||
{
|
||||
self::assertSame('preview', ProviderInterface::TYPE_PREVIEW);
|
||||
self::assertTrue((new ReflectionClass(PreviewProviderInterface::class))->isSubclassOf(ProviderInterface::class));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normalizesMimeTypes(): void
|
||||
{
|
||||
self::assertSame('image/jpeg', MimeType::normalize(' Image/JPEG; charset=binary '));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function requestNormalizesItsOutputMimeType(): void
|
||||
{
|
||||
$request = new PreviewRequest(640, 440, 'IMAGE/WEBP', 80);
|
||||
|
||||
self::assertSame('image/webp', $request->preferredMimeType);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sourceDoesNotOpenUntilRequested(): void
|
||||
{
|
||||
$opened = 0;
|
||||
$source = new PreviewSource(
|
||||
sourceType: 'document',
|
||||
identity: 'provider/service/entity',
|
||||
signature: 'revision-1',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 7,
|
||||
resourceFactory: function () use (&$opened): BinaryResource {
|
||||
$opened++;
|
||||
return $this->resource('source');
|
||||
},
|
||||
);
|
||||
|
||||
self::assertSame(0, $opened);
|
||||
self::assertInstanceOf(BinaryResource::class, $source->open());
|
||||
self::assertSame(1, $opened);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function sourceRejectsAnInvalidFactoryResult(): void
|
||||
{
|
||||
$source = new PreviewSource('document', 'entity', 'revision', 'image/jpeg', 1, fn() => 'invalid');
|
||||
$this->expectException(PreviewGenerationException::class);
|
||||
$source->open();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function resultUsesItsBinaryResourceMimeType(): void
|
||||
{
|
||||
$result = new PreviewResult($this->resource('preview'), 320, 200);
|
||||
|
||||
self::assertSame('image/webp', $result->resource->mimeType());
|
||||
self::assertSame(320, $result->width);
|
||||
self::assertSame(200, $result->height);
|
||||
}
|
||||
|
||||
private function resource(string $content): BinaryResource
|
||||
{
|
||||
return new BinaryResource(
|
||||
'preview.webp',
|
||||
'image/webp',
|
||||
(function () use ($content): \Generator { yield $content; })(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Preview;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Preview\PreviewManager;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\SystemStore\SystemStoreManager;
|
||||
use KTXF\Preview\PreviewGenerationException;
|
||||
use KTXF\Preview\PreviewRequest;
|
||||
use KTXF\Preview\PreviewResult;
|
||||
use KTXF\Preview\PreviewSource;
|
||||
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXF\SystemStore\BlobInfo;
|
||||
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\MockObject\Stub;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class PreviewManagerTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function returnsAValidCacheHitWithoutGenerating(): void
|
||||
{
|
||||
$source = $this->source();
|
||||
$provider = $this->provider();
|
||||
$request = $this->request();
|
||||
$key = $this->cacheKey($source, $request);
|
||||
$blob = $this->blob($this->physicalKey($key));
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$service->expects(self::once())->method('stat')->willReturn($blob);
|
||||
$service->expects(self::once())->method('read')->willReturn($this->resource());
|
||||
$service->expects(self::never())->method('write');
|
||||
$provider->expects(self::never())->method('generate');
|
||||
|
||||
$cached = $this->manager($provider, $service)->fetch('tenant-a', $source);
|
||||
|
||||
self::assertSame($blob->etag, $cached?->etag);
|
||||
self::assertSame(7, $cached?->size);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function generatesStoresAndReturnsAPreviewOnCacheMiss(): void
|
||||
{
|
||||
$source = $this->source();
|
||||
$provider = $this->provider();
|
||||
$request = $this->request();
|
||||
$key = $this->cacheKey($source, $request);
|
||||
$result = new PreviewResult($this->resource(), 320, 200);
|
||||
$blob = $this->blob($this->physicalKey($key));
|
||||
$provider->expects(self::once())->method('generate')->with($source, self::isInstanceOf(PreviewRequest::class))->willReturn($result);
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$service->expects(self::once())->method('stat')->willReturn(null);
|
||||
$service->expects(self::once())->method('write')->with(
|
||||
$this->physicalKey($key),
|
||||
$result->resource,
|
||||
['width' => 320, 'height' => 200],
|
||||
null,
|
||||
)->willReturn($blob);
|
||||
$service->expects(self::once())->method('read')->willReturn($this->resource());
|
||||
|
||||
$cached = $this->manager($provider, $service)->fetch('tenant-a', $source);
|
||||
|
||||
self::assertSame($blob->etag, $cached?->etag);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function disabledOrOversizedSourcesDoNotReachProviders(): void
|
||||
{
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'preview' => ['enabled' => false],
|
||||
]);
|
||||
$providerManager = $this->createMock(ProviderManager::class);
|
||||
$providerManager->expects(self::never())->method('providers');
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$service->expects(self::never())->method('stat');
|
||||
$provider = $this->provider();
|
||||
$provider->expects(self::never())->method('generate');
|
||||
|
||||
self::assertNull($this->manager(
|
||||
$provider,
|
||||
$service,
|
||||
$configuration,
|
||||
$providerManager,
|
||||
)->fetch('tenant-a', $this->source()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function generationFailuresBecomePreviewUnavailable(): void
|
||||
{
|
||||
$source = $this->source();
|
||||
$provider = $this->provider();
|
||||
$provider->expects(self::once())->method('generate')->willThrowException(new \RuntimeException('decode failed'));
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$service->expects(self::once())->method('stat')->willReturn(null);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::once())->method('warning')->with('Preview unavailable', self::arrayHasKey('exception'));
|
||||
|
||||
self::assertNull($this->manager($provider, $service, logger: $logger)->fetch('tenant-a', $source));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function selectsACompatibleProviderByPriorityThenRegistryIdentifier(): void
|
||||
{
|
||||
$source = $this->source();
|
||||
$alpha = $this->candidate('alpha', 20, true);
|
||||
$zeta = $this->candidate('zeta', 20, true);
|
||||
$low = $this->candidate('low', 10, true);
|
||||
$rejecting = $this->candidate('rejecting', 100, false);
|
||||
$providerManager = $this->createMock(ProviderManager::class);
|
||||
$providerManager->expects(self::once())
|
||||
->method('providers')
|
||||
->with(ProviderInterface::TYPE_PREVIEW)
|
||||
->willReturn([
|
||||
'rejecting' => $rejecting,
|
||||
'zeta' => $zeta,
|
||||
'low' => $low,
|
||||
'alpha' => $alpha,
|
||||
]);
|
||||
|
||||
$key = $this->cacheKey($source, $this->request());
|
||||
$result = new PreviewResult($this->resource(), 320, 200);
|
||||
$alpha->expects(self::once())->method('generate')->willReturn($result);
|
||||
$zeta->expects(self::never())->method('generate');
|
||||
$low->expects(self::never())->method('generate');
|
||||
$rejecting->expects(self::never())->method('generate');
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$blob = $this->blob($this->physicalKey($key));
|
||||
$service->expects(self::once())->method('stat')->with($this->physicalKey($key))->willReturn(null);
|
||||
$service->expects(self::once())->method('write')->willReturn($blob);
|
||||
$service->expects(self::once())->method('read')->willReturn($this->resource());
|
||||
|
||||
$cached = $this->manager($alpha, $service, providerManager: $providerManager)->fetch('tenant-a', $source);
|
||||
|
||||
self::assertSame($blob->etag, $cached?->etag);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function unsupportedRequestsBecomePreviewUnavailable(): void
|
||||
{
|
||||
$provider = $this->candidate('pdf', 10, false);
|
||||
$providerManager = $this->createMock(ProviderManager::class);
|
||||
$providerManager->expects(self::once())->method('providers')->willReturn(['pdf' => $provider]);
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$service->expects(self::once())->method('stat')->willReturn(null);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::never())->method('warning');
|
||||
|
||||
self::assertNull($this->manager(
|
||||
$provider,
|
||||
$service,
|
||||
providerManager: $providerManager,
|
||||
logger: $logger,
|
||||
)->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]
|
||||
public function cacheKeysAreDeterministicOpaqueAndChangeWithSourceIdentity(): void
|
||||
{
|
||||
$provider = $this->provider();
|
||||
$provider->expects(self::never())->method('generate');
|
||||
$providerManager = $this->createMock(ProviderManager::class);
|
||||
$providerManager->expects(self::never())->method('providers');
|
||||
$observedKeys = [];
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$service->expects(self::exactly(3))->method('stat')->willReturnCallback(
|
||||
function (string $key) use (&$observedKeys): BlobInfo {
|
||||
$observedKeys[] = $key;
|
||||
return $this->blob($key);
|
||||
},
|
||||
);
|
||||
$service->expects(self::exactly(3))->method('read')->willReturnCallback(fn() => $this->resource());
|
||||
$manager = $this->manager($provider, $service, providerManager: $providerManager);
|
||||
$source = $this->source();
|
||||
$changedSource = new PreviewSource(
|
||||
'document',
|
||||
'quarterly-report',
|
||||
'revision-2',
|
||||
'image/jpeg',
|
||||
1234,
|
||||
fn(): BinaryResource => $this->resource('image/jpeg'),
|
||||
);
|
||||
|
||||
self::assertNotNull($manager->fetch('tenant-a', $source));
|
||||
self::assertNotNull($manager->fetch('tenant-a', $source));
|
||||
self::assertNotNull($manager->fetch('tenant-a', $changedSource));
|
||||
|
||||
self::assertSame($observedKeys[0], $observedKeys[1]);
|
||||
self::assertNotSame($observedKeys[0], $observedKeys[2]);
|
||||
self::assertMatchesRegularExpression(
|
||||
'~^tenant-previews/generated/document/[a-f0-9]{2}/[a-f0-9]{64}$~',
|
||||
$observedKeys[0],
|
||||
);
|
||||
self::assertStringNotContainsString('tenant-a', $observedKeys[0]);
|
||||
self::assertStringNotContainsString('quarterly-report', $observedKeys[0]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalidCachedMetadataIsTreatedAsAMiss(): void
|
||||
{
|
||||
$provider = $this->provider();
|
||||
$provider->expects(self::once())->method('generate')->willThrowException(new \RuntimeException('stop after miss'));
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$service->expects(self::once())->method('stat')->willReturnCallback(
|
||||
fn(string $key): BlobInfo => $this->blob($key, ['width' => 900, 'height' => 200]),
|
||||
);
|
||||
$service->expects(self::never())->method('read');
|
||||
$service->expects(self::never())->method('write');
|
||||
|
||||
self::assertNull($this->manager($provider, $service)->fetch('tenant-a', $this->source()));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function refusesGeneratedContentWithTheWrongMimeType(): void
|
||||
{
|
||||
$provider = $this->provider();
|
||||
$provider->expects(self::once())->method('generate')->willReturn(
|
||||
new PreviewResult($this->resource('image/png'), 320, 200),
|
||||
);
|
||||
$service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$service->expects(self::once())->method('stat')->willReturn(null);
|
||||
$service->expects(self::never())->method('write');
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::once())->method('warning')->with(
|
||||
'Preview unavailable',
|
||||
self::callback(static fn(array $context): bool =>
|
||||
($context['exception'] ?? null) instanceof PreviewGenerationException
|
||||
),
|
||||
);
|
||||
|
||||
self::assertNull($this->manager($provider, $service, logger: $logger)->fetch('tenant-a', $this->source()));
|
||||
}
|
||||
|
||||
private function manager(
|
||||
PreviewProviderInterface $provider,
|
||||
SystemStoreServiceInterface $service,
|
||||
?TenantConfiguration $configuration = null,
|
||||
?ProviderManager $providerManager = null,
|
||||
?LoggerInterface $logger = null,
|
||||
): PreviewManager {
|
||||
$effectiveConfiguration = $configuration ?? new TenantConfiguration();
|
||||
if ($configuration === null) {
|
||||
$effectiveConfiguration->jsonDeserialize([
|
||||
'stores' => [
|
||||
'previews' => [
|
||||
'provider' => 'storage-provider',
|
||||
'service' => 'preview-storage',
|
||||
'namespace' => 'tenant-previews',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
$tenant = (new TenantObject())
|
||||
->setIdentifier('tenant-a')
|
||||
->setConfiguration($effectiveConfiguration);
|
||||
$tenants = $this->createStub(TenantService::class);
|
||||
$tenants->method('fetchById')->willReturn($tenant);
|
||||
|
||||
if ($providerManager === null) {
|
||||
$providerManager = $this->createStub(ProviderManager::class);
|
||||
$providerManager->method('providers')->willReturn(['imagemagick' => $provider]);
|
||||
}
|
||||
|
||||
$storageProvider = $this->createStub(ProviderBaseInterface::class);
|
||||
$storageProvider->method('serviceFetch')->willReturn($service);
|
||||
$systemProviders = $this->createStub(ProviderManager::class);
|
||||
$systemProviders->method('resolve')->willReturn($storageProvider);
|
||||
|
||||
return new PreviewManager(
|
||||
$tenants,
|
||||
$providerManager,
|
||||
new SystemStoreManager($tenants, $systemProviders),
|
||||
$logger ?? $this->createStub(LoggerInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function provider(): PreviewProviderInterface&MockObject
|
||||
{
|
||||
$provider = $this->createMock(PreviewProviderInterface::class);
|
||||
$provider->method('identifier')->willReturn('imagemagick');
|
||||
$provider->method('supports')->willReturn(true);
|
||||
$provider->method('priority')->willReturn(100);
|
||||
return $provider;
|
||||
}
|
||||
|
||||
private function candidate(
|
||||
string $identifier,
|
||||
int $priority,
|
||||
bool $supports,
|
||||
): PreviewProviderInterface&MockObject {
|
||||
$provider = $this->createMock(PreviewProviderInterface::class);
|
||||
$provider->method('identifier')->willReturn($identifier);
|
||||
$provider->method('supports')->willReturn($supports);
|
||||
$provider->method('priority')->willReturn($priority);
|
||||
return $provider;
|
||||
}
|
||||
|
||||
private function source(): PreviewSource
|
||||
{
|
||||
return new PreviewSource(
|
||||
'document',
|
||||
'quarterly-report',
|
||||
'revision-1',
|
||||
'image/jpeg',
|
||||
1234,
|
||||
fn(): BinaryResource => $this->resource('image/jpeg'),
|
||||
);
|
||||
}
|
||||
|
||||
private function request(): PreviewRequest
|
||||
{
|
||||
return new PreviewRequest(640, 640, 'image/webp', 80);
|
||||
}
|
||||
|
||||
private function resource(string $mimeType = 'image/webp'): BinaryResource
|
||||
{
|
||||
return new BinaryResource(
|
||||
'preview.bin',
|
||||
$mimeType,
|
||||
(static function (): \Generator { yield 'preview'; })(),
|
||||
);
|
||||
}
|
||||
|
||||
private function blob(string $key, array $attributes = ['width' => 320, 'height' => 200]): BlobInfo
|
||||
{
|
||||
return new BlobInfo(
|
||||
key: $key,
|
||||
mimeType: 'image/webp',
|
||||
size: 7,
|
||||
etag: 'etag-1',
|
||||
modifiedAt: new DateTimeImmutable('2026-08-29T12:00:00+00:00'),
|
||||
attributes: $attributes,
|
||||
);
|
||||
}
|
||||
|
||||
private function physicalKey(string $key): string
|
||||
{
|
||||
return 'tenant-previews/' . $key;
|
||||
}
|
||||
|
||||
private function cacheKey(
|
||||
PreviewSource $source,
|
||||
PreviewRequest $request,
|
||||
): string {
|
||||
$canonical = json_encode([
|
||||
'version' => 2,
|
||||
'tenantId' => 'tenant-a',
|
||||
'source' => [
|
||||
'type' => $source->sourceType,
|
||||
'identity' => $source->identity,
|
||||
'signature' => $source->signature,
|
||||
'mimeType' => $source->mimeType,
|
||||
],
|
||||
'request' => [
|
||||
'maxWidth' => $request->maxWidth,
|
||||
'maxHeight' => $request->maxHeight,
|
||||
'preferredMimeType' => $request->preferredMimeType,
|
||||
'quality' => $request->quality,
|
||||
],
|
||||
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION);
|
||||
$digest = hash('sha256', $canonical);
|
||||
|
||||
return "generated/{$source->sourceType}/" . substr($digest, 0, 2) . "/{$digest}";
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\Resource;
|
||||
|
||||
use KTXF\Mail\Provider\ProviderBaseInterface;
|
||||
use KTXF\Resource\SystemIdentity;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SystemIdentityTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function exposesTheReservedSystemUser(): void
|
||||
{
|
||||
self::assertSame('system', SystemIdentity::USER);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function retainsTheLegacyMailAlias(): void
|
||||
{
|
||||
self::assertSame(SystemIdentity::USER, ProviderBaseInterface::USER_SYSTEM);
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\SystemStore;
|
||||
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Stores\TenantStore;
|
||||
use KTXC\SystemStore\SystemStoreConfigurationService;
|
||||
use KTXF\SystemStore\SystemStoreException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SystemStoreConfigurationServiceTest extends TestCase
|
||||
{
|
||||
private TenantStore&MockObject $tenants;
|
||||
private SystemStoreConfigurationService $configuration;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tenants = $this->createMock(TenantStore::class);
|
||||
$this->configuration = new SystemStoreConfigurationService($this->tenants);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function listsConfiguredLogicalStores(): void
|
||||
{
|
||||
$tenant = (new TenantObject())->setConfiguration(
|
||||
(new TenantConfiguration())->jsonDeserialize([
|
||||
'stores' => [
|
||||
'previews' => [
|
||||
'provider' => 'local',
|
||||
'service' => 'default',
|
||||
'namespace' => 'previews',
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
$this->tenants->expects(self::once())->method('fetch')->with('tenant-a')->willReturn($tenant);
|
||||
|
||||
$stores = $this->configuration->list('tenant-a');
|
||||
|
||||
self::assertSame('local', $stores['previews']->provider);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function atomicallyStoresOneLogicalStoreReference(): void
|
||||
{
|
||||
$this->tenants->expects(self::once())
|
||||
->method('storeConfigurationStore')
|
||||
->with('tenant-a', 'previews', [
|
||||
'provider' => 'local',
|
||||
'service' => 'default',
|
||||
'namespace' => 'previews',
|
||||
])
|
||||
->willReturn(true);
|
||||
|
||||
$reference = $this->configuration->set(
|
||||
'tenant-a', 'previews', 'local', 'default', 'previews',
|
||||
);
|
||||
|
||||
self::assertSame('local', $reference->provider);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reportsAMissingTenantDuringConfiguration(): void
|
||||
{
|
||||
$this->tenants->expects(self::once())->method('storeConfigurationStore')->willReturn(false);
|
||||
$this->expectException(SystemStoreException::class);
|
||||
|
||||
$this->configuration->set('missing', 'previews', 'local', 'default', 'previews');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function atomicallyRemovesOneLogicalStoreReference(): void
|
||||
{
|
||||
$this->tenants->expects(self::once())
|
||||
->method('removeConfigurationStore')
|
||||
->with('tenant-a', 'previews')
|
||||
->willReturn(true);
|
||||
|
||||
self::assertTrue($this->configuration->remove('tenant-a', 'previews'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function reportsAMissingTenantDuringRemoval(): void
|
||||
{
|
||||
$this->tenants->expects(self::once())->method('removeConfigurationStore')->willReturn(null);
|
||||
$this->expectException(SystemStoreException::class);
|
||||
|
||||
$this->configuration->remove('missing', 'previews');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsUnsafeStoreNamesBeforeBuildingMongoPaths(): void
|
||||
{
|
||||
$this->tenants->expects(self::never())->method('storeConfigurationStore');
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
$this->configuration->set('tenant-a', 'previews.other', 'local', 'default', 'previews');
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\SystemStore;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
|
||||
use KTXF\Resource\Provider\ResourceServiceBaseInterface;
|
||||
use KTXF\SystemStore\BlobInfo;
|
||||
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||
use KTXF\SystemStore\StoreReference;
|
||||
use KTXF\SystemStore\SystemStoreManagerInterface;
|
||||
use KTXF\SystemStore\WriteCondition;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionClass;
|
||||
|
||||
final class SystemStoreContractsTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function registersTheSystemStoreProviderType(): void
|
||||
{
|
||||
self::assertSame('system-store', ProviderInterface::TYPE_SYSTEM_STORE);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function providerAndServiceContractsExtendResourceContracts(): void
|
||||
{
|
||||
self::assertTrue((new ReflectionClass(ProviderBaseInterface::class))->isSubclassOf(ResourceProviderBaseInterface::class));
|
||||
self::assertTrue((new ReflectionClass(SystemStoreServiceInterface::class))->isSubclassOf(ResourceServiceBaseInterface::class));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function listingIsAProviderPaginatedIterable(): void
|
||||
{
|
||||
$method = (new ReflectionClass(SystemStoreServiceInterface::class))->getMethod('list');
|
||||
|
||||
self::assertSame('iterable', (string) $method->getReturnType());
|
||||
self::assertCount(1, $method->getParameters());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function managerContractExposesTenantScopedStoreOperations(): void
|
||||
{
|
||||
$manager = new ReflectionClass(SystemStoreManagerInterface::class);
|
||||
|
||||
self::assertSame(
|
||||
['stat', 'read', 'write', 'delete', 'list'],
|
||||
array_map(static fn($method): string => $method->getName(), $manager->getMethods()),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function createsSupportedWriteConditions(): void
|
||||
{
|
||||
self::assertSame(WriteCondition::NONE, WriteCondition::none()->mode);
|
||||
self::assertSame(WriteCondition::IF_ABSENT, WriteCondition::ifAbsent()->mode);
|
||||
|
||||
$condition = WriteCondition::ifMatch('revision-1');
|
||||
self::assertSame(WriteCondition::IF_MATCH, $condition->mode);
|
||||
self::assertSame('revision-1', $condition->etag);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsAnEmptyIfMatchEtag(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
WriteCondition::ifMatch('');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function exposesBlobInformation(): void
|
||||
{
|
||||
$metadata = new BlobInfo(
|
||||
key: 'previews/example.webp',
|
||||
mimeType: 'image/webp',
|
||||
size: 123,
|
||||
etag: 'revision-1',
|
||||
modifiedAt: new DateTimeImmutable('2026-08-28T12:00:00+00:00'),
|
||||
attributes: ['variant' => 'popover'],
|
||||
);
|
||||
|
||||
self::assertSame('previews/example.webp', $metadata->key);
|
||||
self::assertSame('image/webp', $metadata->mimeType);
|
||||
self::assertSame(123, $metadata->size);
|
||||
self::assertSame('revision-1', $metadata->etag);
|
||||
self::assertSame(['variant' => 'popover'], $metadata->attributes);
|
||||
self::assertNotInstanceOf(\JsonSerializable::class, $metadata);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function parsesLogicalStoresFromTenantConfiguration(): void
|
||||
{
|
||||
$data = [
|
||||
'stores' => [
|
||||
'previews' => [
|
||||
'provider' => 's3',
|
||||
'service' => 'preview-storage',
|
||||
'namespace' => 'previews',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize($data);
|
||||
|
||||
self::assertInstanceOf(StoreReference::class, $configuration->stores()->store('previews'));
|
||||
self::assertSame('s3', $configuration->stores()->store('previews')?->provider);
|
||||
self::assertSame($data['stores'], $configuration->jsonSerialize()['stores']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsInvalidLogicalStoreNames(): void
|
||||
{
|
||||
$this->expectException(InvalidArgumentException::class);
|
||||
(new TenantConfiguration())->jsonDeserialize([
|
||||
'stores' => [
|
||||
'../previews' => [
|
||||
'provider' => 's3',
|
||||
'service' => 'preview-storage',
|
||||
'namespace' => 'previews',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXT\Unit\SystemStore;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\SystemStore\SystemStoreManager;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
use KTXF\Resource\SystemIdentity;
|
||||
use KTXF\SystemStore\BlobInfo;
|
||||
use KTXF\SystemStore\InvalidKeyException;
|
||||
use KTXF\SystemStore\Provider\ProviderBaseInterface;
|
||||
use KTXF\SystemStore\Service\SystemStoreServiceInterface;
|
||||
use KTXF\SystemStore\SystemStoreException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SystemStoreManagerTest extends TestCase
|
||||
{
|
||||
private TenantService&MockObject $tenants;
|
||||
private ProviderManager&MockObject $providers;
|
||||
private ProviderBaseInterface&MockObject $provider;
|
||||
private SystemStoreServiceInterface&MockObject $service;
|
||||
private SystemStoreManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$configuration = (new TenantConfiguration())->jsonDeserialize([
|
||||
'stores' => [
|
||||
'previews' => [
|
||||
'provider' => 'storage-provider',
|
||||
'service' => 'preview-storage',
|
||||
'namespace' => 'tenant-previews',
|
||||
],
|
||||
],
|
||||
]);
|
||||
$tenant = (new TenantObject())
|
||||
->setIdentifier('tenant-a')
|
||||
->setConfiguration($configuration);
|
||||
|
||||
$this->tenants = $this->createMock(TenantService::class);
|
||||
$this->tenants->method('fetchById')->with('tenant-a')->willReturn($tenant);
|
||||
|
||||
$this->service = $this->createMock(SystemStoreServiceInterface::class);
|
||||
$this->provider = $this->createMock(ProviderBaseInterface::class);
|
||||
$this->provider->method('serviceFetch')
|
||||
->with('tenant-a', SystemIdentity::USER, 'preview-storage')
|
||||
->willReturn($this->service);
|
||||
|
||||
$this->providers = $this->createMock(ProviderManager::class);
|
||||
$this->providers->method('resolve')
|
||||
->with(ProviderInterface::TYPE_SYSTEM_STORE, 'storage-provider')
|
||||
->willReturn($this->provider);
|
||||
|
||||
$this->manager = new SystemStoreManager($this->tenants, $this->providers);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delegatesStatWithPhysicalNamespaceAndReturnsLogicalKey(): void
|
||||
{
|
||||
$this->service->expects(self::once())
|
||||
->method('stat')
|
||||
->with('tenant-previews/documents/report.pdf')
|
||||
->willReturn($this->info('tenant-previews/documents/report.pdf'));
|
||||
|
||||
$info = $this->manager->stat('tenant-a', 'previews', 'documents/report.pdf');
|
||||
|
||||
self::assertSame('documents/report.pdf', $info?->key);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function delegatesReadDeleteAndWriteWithPhysicalNamespace(): void
|
||||
{
|
||||
$resource = $this->resource();
|
||||
|
||||
$this->service->expects(self::once())
|
||||
->method('read')
|
||||
->with('tenant-previews/image.webp')
|
||||
->willReturn($resource);
|
||||
$this->service->expects(self::once())
|
||||
->method('delete')
|
||||
->with('tenant-previews/image.webp', null)
|
||||
->willReturn(true);
|
||||
$this->service->expects(self::once())
|
||||
->method('write')
|
||||
->with('tenant-previews/image.webp', $resource, ['variant' => 'popover'], null)
|
||||
->willReturn($this->info('tenant-previews/image.webp'));
|
||||
|
||||
self::assertSame($resource, $this->manager->read('tenant-a', 'previews', 'image.webp'));
|
||||
self::assertTrue($this->manager->delete('tenant-a', 'previews', 'image.webp'));
|
||||
self::assertSame(
|
||||
'image.webp',
|
||||
$this->manager->write(
|
||||
'tenant-a',
|
||||
'previews',
|
||||
'image.webp',
|
||||
$resource,
|
||||
['variant' => 'popover'],
|
||||
)->key,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function lazilyListsLogicalKeysWithinTheNamespace(): void
|
||||
{
|
||||
$this->service->expects(self::once())
|
||||
->method('list')
|
||||
->with('tenant-previews/documents/')
|
||||
->willReturn((function (): iterable {
|
||||
yield $this->info('tenant-previews/documents/one.webp');
|
||||
yield $this->info('tenant-previews/documents/two.webp');
|
||||
})());
|
||||
|
||||
$items = iterator_to_array($this->manager->list('tenant-a', 'previews', 'documents/'));
|
||||
|
||||
self::assertSame(['documents/one.webp', 'documents/two.webp'], array_column($items, 'key'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsTraversalKeys(): void
|
||||
{
|
||||
$this->service->expects(self::never())->method('read');
|
||||
$this->expectException(InvalidKeyException::class);
|
||||
|
||||
$this->manager->read('tenant-a', 'previews', '../secret');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsProviderResultsOutsideTheConfiguredNamespace(): void
|
||||
{
|
||||
$this->service->expects(self::once())
|
||||
->method('stat')
|
||||
->willReturn($this->info('another-tenant/secret'));
|
||||
$this->expectException(SystemStoreException::class);
|
||||
|
||||
$this->manager->stat('tenant-a', 'previews', 'secret');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rejectsAnUnconfiguredLogicalStore(): void
|
||||
{
|
||||
$this->service->expects(self::never())->method('stat');
|
||||
$this->expectException(SystemStoreException::class);
|
||||
$this->expectExceptionMessage("System store 'icons' is not configured");
|
||||
|
||||
$this->manager->stat('tenant-a', 'icons', 'logo.svg');
|
||||
}
|
||||
|
||||
private function info(string $key): BlobInfo
|
||||
{
|
||||
return new BlobInfo(
|
||||
key: $key,
|
||||
mimeType: 'image/webp',
|
||||
size: 123,
|
||||
etag: 'revision-1',
|
||||
modifiedAt: new DateTimeImmutable('2026-08-28T12:00:00+00:00'),
|
||||
);
|
||||
}
|
||||
|
||||
private function resource(): BinaryResource
|
||||
{
|
||||
return new BinaryResource(
|
||||
'image.webp',
|
||||
'image/webp',
|
||||
(function (): \Generator {
|
||||
yield 'content';
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user