Compare commits
40 Commits
d5a753e049
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a05313e3a1 | |||
| dfba1d43be | |||
| 6d27b64355 | |||
| 5254b859d2 | |||
| bcb3431aaa | |||
| e770661859 | |||
| 1d706de663 | |||
| ce5c5b3746 | |||
| a533d0dd89 | |||
| 3a2739fdd8 | |||
| 24e046792b | |||
| 1f3e87535b | |||
| 85e89dca87 | |||
| e48ee82530 | |||
| 7799787ffb | |||
| e996774881 | |||
| 2530680c0b | |||
| b68ac538ce | |||
| 6975800ce5 | |||
| 8be5b0b4ee | |||
| 779249a7e7 | |||
| 7696c31332 | |||
| aa52693dd9 | |||
| bb05f3d20f | |||
| 8560aef5e2 | |||
| c687bd0795 | |||
| f44fd85dcc | |||
| d81e894c81 | |||
| c310b96a26 | |||
| 8e931f6650 | |||
| a576d85d74 | |||
| c3241862ca | |||
| 99fa707eb3 | |||
| decda8becc | |||
| cdbf01111f | |||
| 18f8f0e1d1 | |||
| 11014f070d | |||
| 2b539bc883 | |||
| 3a416f3c0b | |||
| 9065ab69f5 |
101
bin/console
101
bin/console
@@ -3,21 +3,12 @@
|
||||
|
||||
/**
|
||||
* Console Entry Point
|
||||
*
|
||||
* Bootstraps the application container and registers console commands
|
||||
* from core and modules using lazy loading via Symfony Console.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use KTXC\Application;
|
||||
use KTXC\Kernel;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use Symfony\Component\Console\Application as ConsoleApplication;
|
||||
use Symfony\Component\Console\Command\LazyCommand;
|
||||
use KTXC\Server;
|
||||
|
||||
// Check dependencies
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
|
||||
exit(1);
|
||||
@@ -26,92 +17,12 @@ if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
try {
|
||||
// Bootstrap the application
|
||||
$projectRoot = dirname(__DIR__);
|
||||
$app = new Application($projectRoot);
|
||||
|
||||
// Boot kernel to initialize container and modules
|
||||
$app->kernel()->boot();
|
||||
|
||||
// Get the container
|
||||
$container = $app->container();
|
||||
|
||||
// Create Symfony Console Application
|
||||
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
|
||||
|
||||
// Collect all command classes
|
||||
$commandClasses = [];
|
||||
|
||||
// Collect commands from modules
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$moduleInstance = $module->instance();
|
||||
|
||||
// Skip if module instance is not available
|
||||
if ($moduleInstance === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if module implements console command provider
|
||||
if ($moduleInstance instanceof ModuleConsoleInterface) {
|
||||
try {
|
||||
$commands = $moduleInstance->registerCI();
|
||||
|
||||
foreach ($commands as $commandClass) {
|
||||
if (!class_exists($commandClass)) {
|
||||
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
|
||||
continue;
|
||||
}
|
||||
$commandClasses[] = $commandClass;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register commands using lazy loading
|
||||
foreach ($commandClasses as $commandClass) {
|
||||
try {
|
||||
// Use reflection to read #[AsCommand] attribute without instantiation
|
||||
$reflection = new \ReflectionClass($commandClass);
|
||||
$attributes = $reflection->getAttributes(\Symfony\Component\Console\Attribute\AsCommand::class);
|
||||
|
||||
if (empty($attributes)) {
|
||||
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get attribute instance
|
||||
/** @var \Symfony\Component\Console\Attribute\AsCommand $commandAttr */
|
||||
$commandAttr = $attributes[0]->newInstance();
|
||||
|
||||
// Create lazy command wrapper that defers instantiation
|
||||
$lazyCommand = new LazyCommand(
|
||||
$commandAttr->name,
|
||||
[],
|
||||
$commandAttr->description ?? '',
|
||||
$commandAttr->hidden ?? false,
|
||||
fn() => $container->get($commandClass) // Only instantiate when executed
|
||||
);
|
||||
|
||||
$console->add($lazyCommand);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Run the console application
|
||||
$exitCode = $console->run();
|
||||
exit($exitCode);
|
||||
|
||||
$server = new Server(dirname(__DIR__));
|
||||
exit($server->runConsole());
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Fatal error: " . $e->getMessage() . "\n");
|
||||
if (isset($app) && $app->debug()) {
|
||||
fwrite(STDERR, $e->getTraceAsString() . "\n");
|
||||
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
|
||||
if (isset($server) && $server->debug()) {
|
||||
fwrite(STDERR, $e->getTraceAsString()."\n");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"mongodb/mongodb": "^2.1",
|
||||
"php-di/php-di": "*",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
"symfony/console": "^8.0"
|
||||
"symfony/console": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0"
|
||||
|
||||
868
composer.lock
generated
868
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,43 @@ return [
|
||||
//'cache.persistent' => 'file',
|
||||
//'cache.blob' => 'file',
|
||||
|
||||
// Logging Configuration
|
||||
'log' => [
|
||||
// Driver: 'file' | 'systemd' | 'syslog' | 'null'
|
||||
// file - writes JSONL to a local file (see 'path' / 'channel' below)
|
||||
// systemd - writes to stderr with <priority> prefix; journald/systemd parses this natively
|
||||
// syslog - writes via PHP syslog() (see 'ident' / 'facility' below)
|
||||
// null - discards all log messages (useful in tests / CI)
|
||||
'driver' => 'file',
|
||||
|
||||
// Minimum PSR-3 log level to record.
|
||||
// Messages below this severity are silently discarded.
|
||||
// From most to least severe: emergency, alert, critical, error, warning, notice, info, debug
|
||||
'level' => 'warning',
|
||||
|
||||
// Per-tenant log files.
|
||||
// When true, messages are written to:
|
||||
// {path}/tenant/{tenantIdentifier}/{channel}.jsonl
|
||||
// once a tenant session is active. Pre-tenant messages (boot phase,
|
||||
// unknown domain rejections, etc.) fall through to the global log file.
|
||||
'per_tenant' => false,
|
||||
|
||||
// ── file driver options ────────────────────────────────────────────────
|
||||
// Absolute path to the log directory. null = <project_root>/var/log
|
||||
'path' => null,
|
||||
|
||||
// Log channel — used as the filename without extension.
|
||||
// 'app' → var/log/app.jsonl (or var/log/tenant/{id}/app.jsonl when per_tenant = true)
|
||||
'channel' => 'app',
|
||||
|
||||
// ── syslog driver options ──────────────────────────────────────────────
|
||||
// Identity tag passed to openlog(); visible in /var/log/syslog and journalctl -t ktrix
|
||||
'ident' => 'ktrix',
|
||||
|
||||
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
|
||||
'facility' => LOG_USER,
|
||||
],
|
||||
|
||||
// Security Configuration
|
||||
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
|
||||
];
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Application class - entry point for the framework
|
||||
* Handles configuration loading and kernel lifecycle
|
||||
*/
|
||||
class Application
|
||||
{
|
||||
private static $composerLoader = null;
|
||||
|
||||
private Kernel $kernel;
|
||||
private array $config;
|
||||
private string $rootDir;
|
||||
|
||||
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
|
||||
{
|
||||
$this->rootDir = $this->resolveProjectRoot($rootDir);
|
||||
|
||||
// Load configuration
|
||||
$this->config = $this->loadConfig();
|
||||
|
||||
// Determine environment and debug mode
|
||||
$environment = $environment ?? $this->config['environment'] ?? 'prod';
|
||||
$debug = $debug ?? $this->config['debug'] ?? false;
|
||||
|
||||
// Create kernel with configuration
|
||||
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application - handle incoming request and send response
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
try {
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $this->handle($request);
|
||||
$response->send();
|
||||
$this->terminate();
|
||||
} catch (\Throwable $e) {
|
||||
// Last resort error handling for kernel initialization failures
|
||||
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
$content = $this->kernel->debug()
|
||||
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
|
||||
: 'An error occurred. Please try again later.';
|
||||
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
$response->send();
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return $this->kernel->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the application - process deferred events
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
$this->kernel->processEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the kernel instance
|
||||
*/
|
||||
public function kernel(): Kernel
|
||||
{
|
||||
return $this->kernel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container instance
|
||||
*/
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
return $this->kernel->container();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application root directory
|
||||
*/
|
||||
public function rootDir(): string
|
||||
{
|
||||
return $this->rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modules directory
|
||||
*/
|
||||
public function moduleDir(): string
|
||||
{
|
||||
return $this->rootDir . '/modules';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
*/
|
||||
public function config(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
// Support dot notation: 'database.uri'
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->config;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment
|
||||
*/
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->kernel->environment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->kernel->debug();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from config directory
|
||||
*/
|
||||
protected function loadConfig(): array
|
||||
{
|
||||
$configFile = $this->rootDir . '/config/system.php';
|
||||
|
||||
if (!file_exists($configFile)) {
|
||||
error_log('Configuration file not found: ' . $configFile);
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException('Configuration file must return an array');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project root directory.
|
||||
*
|
||||
* Some entrypoints may pass the public/ directory or another subdirectory.
|
||||
* We walk up the directory tree until we find composer.json.
|
||||
*/
|
||||
private function resolveProjectRoot(string $startDir): string
|
||||
{
|
||||
$dir = rtrim($startDir, '/');
|
||||
if ($dir === '') {
|
||||
return $startDir;
|
||||
}
|
||||
|
||||
// If startDir is a file path, use its directory.
|
||||
if (is_file($dir)) {
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
|
||||
$current = $dir;
|
||||
while (true) {
|
||||
if (is_file($current . '/composer.json')) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$parent = dirname($current);
|
||||
if ($parent === $current) {
|
||||
// Reached filesystem root
|
||||
return $dir;
|
||||
}
|
||||
$current = $parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Composer ClassLoader instance
|
||||
*/
|
||||
public static function setComposerLoader($loader): void
|
||||
{
|
||||
self::$composerLoader = $loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Composer ClassLoader instance
|
||||
*/
|
||||
public static function getComposerLoader()
|
||||
{
|
||||
return self::$composerLoader;
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,7 @@ class ModuleDisableCommand extends Command
|
||||
}
|
||||
|
||||
// Find the module
|
||||
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false);
|
||||
$module = $modules[$handle] ?? null;
|
||||
$module = $this->moduleManager->fetch($handle);
|
||||
|
||||
if (!$module) {
|
||||
$io->error("Module '{$handle}' not found or not installed.");
|
||||
|
||||
@@ -48,8 +48,7 @@ class ModuleEnableCommand extends Command
|
||||
|
||||
try {
|
||||
// Find the module
|
||||
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false);
|
||||
$module = $modules[$handle] ?? null;
|
||||
$module = $this->moduleManager->fetch($handle);
|
||||
|
||||
if (!$module) {
|
||||
$io->error("Module '{$handle}' not found or not installed.");
|
||||
|
||||
86
core/lib/Console/ModuleInstallCommand.php
Normal file
86
core/lib/Console/ModuleInstallCommand.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Module Install Command
|
||||
*
|
||||
* Installs a module from the filesystem.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'module:install',
|
||||
description: 'Install a module',
|
||||
)]
|
||||
class ModuleInstallCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('handle', InputArgument::REQUIRED, 'Module handle to install')
|
||||
->setHelp('This command installs a module from the filesystem.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$handle = $input->getArgument('handle');
|
||||
|
||||
$io->title('Install Module');
|
||||
|
||||
try {
|
||||
// Prevent installing core module
|
||||
if ($handle === 'core') {
|
||||
$io->error('Cannot install the core module.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Check if the module is already installed
|
||||
$module = $this->moduleManager->fetch($handle);
|
||||
|
||||
if ($module) {
|
||||
$io->warning("Module '{$handle}' is already installed.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Install the module
|
||||
$io->text("Installing module '{$handle}'...");
|
||||
$this->moduleManager->install($handle);
|
||||
|
||||
$this->logger->info('Module installed via console', [
|
||||
'handle' => $handle,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Module '{$handle}' installed successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to install module: ' . $e->getMessage());
|
||||
$this->logger->error('Module install failed', [
|
||||
'handle' => $handle,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,7 @@ class ModuleListCommand extends Command
|
||||
$io->title('Installed Modules');
|
||||
|
||||
try {
|
||||
$modules = $this->moduleManager->list(
|
||||
installedOnly: true,
|
||||
enabledOnly: !$showAll
|
||||
);
|
||||
$modules = $this->moduleManager->list();
|
||||
|
||||
if (count($modules) === 0) {
|
||||
$io->warning('No modules found.');
|
||||
|
||||
95
core/lib/Console/ModuleUninstallCommand.php
Normal file
95
core/lib/Console/ModuleUninstallCommand.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
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\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Module Uninstall Command
|
||||
*
|
||||
* Uninstalls an installed module.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'module:uninstall',
|
||||
description: 'Uninstall a module',
|
||||
)]
|
||||
class ModuleUninstallCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('handle', InputArgument::REQUIRED, 'Module handle to uninstall')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
|
||||
->setHelp('This command uninstalls an installed module.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$handle = $input->getArgument('handle');
|
||||
$force = $input->getOption('force');
|
||||
|
||||
$io->title('Uninstall Module');
|
||||
|
||||
try {
|
||||
// Prevent uninstalling core module
|
||||
if ($handle === 'core') {
|
||||
$io->error('Cannot uninstall the core module.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Find the module
|
||||
$module = $this->moduleManager->fetch($handle);
|
||||
|
||||
if (!$module) {
|
||||
$io->error("Module '{$handle}' not found or not installed.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Confirm unless --force is passed
|
||||
if (!$force && !$io->confirm("Are you sure you want to uninstall module '{$handle}'?", false)) {
|
||||
$io->text('Uninstall cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Uninstall the module
|
||||
$io->text("Uninstalling module '{$handle}'...");
|
||||
$this->moduleManager->uninstall($handle);
|
||||
|
||||
$this->logger->info('Module uninstalled via console', [
|
||||
'handle' => $handle,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Module '{$handle}' uninstalled successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to uninstall module: ' . $e->getMessage());
|
||||
$this->logger->error('Module uninstall failed', [
|
||||
'handle' => $handle,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
144
core/lib/Console/ModuleUpgradeCommand.php
Normal file
144
core/lib/Console/ModuleUpgradeCommand.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
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\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Module Upgrade Command
|
||||
*
|
||||
* Upgrades an installed module to its latest version.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'module:upgrade',
|
||||
description: 'Upgrade a module',
|
||||
)]
|
||||
class ModuleUpgradeCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('handle', InputArgument::OPTIONAL, 'Module handle to upgrade (omit to upgrade all)')
|
||||
->addOption('all', 'a', InputOption::VALUE_NONE, 'Upgrade all modules that need upgrading')
|
||||
->setHelp('This command upgrades an installed module. Use --all to upgrade all modules that need upgrading.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$handle = $input->getArgument('handle');
|
||||
$all = $input->getOption('all');
|
||||
|
||||
$io->title('Upgrade Module');
|
||||
|
||||
try {
|
||||
if ($all || !$handle) {
|
||||
return $this->upgradeAll($io);
|
||||
}
|
||||
|
||||
return $this->upgradeOne($io, $handle);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to upgrade module: ' . $e->getMessage());
|
||||
$this->logger->error('Module upgrade failed', [
|
||||
'handle' => $handle,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
private function upgradeOne(SymfonyStyle $io, string $handle): int
|
||||
{
|
||||
// Find the module
|
||||
$module = $this->moduleManager->fetch($handle);
|
||||
|
||||
if (!$module) {
|
||||
$io->error("Module '{$handle}' not found or not installed.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$module->needsUpgrade()) {
|
||||
$io->success("Module '{$handle}' is already up to date.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->text("Upgrading module '{$handle}'...");
|
||||
$this->moduleManager->upgrade($handle);
|
||||
|
||||
$this->logger->info('Module upgraded via console', [
|
||||
'handle' => $handle,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Module '{$handle}' upgraded successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function upgradeAll(SymfonyStyle $io): int
|
||||
{
|
||||
$modules = $this->moduleManager->list();
|
||||
$pending = [];
|
||||
|
||||
foreach ($modules as $module) {
|
||||
if ($module->needsUpgrade()) {
|
||||
$pending[] = $module->handle();
|
||||
}
|
||||
}
|
||||
|
||||
if (count($pending) === 0) {
|
||||
$io->success('All modules are up to date.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->text(sprintf('Found %d module(s) to upgrade: %s', count($pending), implode(', ', $pending)));
|
||||
|
||||
$failed = [];
|
||||
foreach ($pending as $handle) {
|
||||
try {
|
||||
$io->text("Upgrading module '{$handle}'...");
|
||||
$this->moduleManager->upgrade($handle);
|
||||
$this->logger->info('Module upgraded via console', [
|
||||
'handle' => $handle,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
$io->text("<fg=green>✓ '{$handle}' upgraded.</>");
|
||||
} catch (\Throwable $e) {
|
||||
$failed[] = $handle;
|
||||
$io->text("<fg=red>✗ '{$handle}' failed: {$e->getMessage()}</>");
|
||||
$this->logger->error('Module upgrade failed', [
|
||||
'handle' => $handle,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (count($failed) > 0) {
|
||||
$io->error(sprintf('Failed to upgrade %d module(s): %s', count($failed), implode(', ', $failed)));
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success(sprintf('Successfully upgraded %d module(s).', count($pending)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,7 @@ class AuthenticationController extends ControllerAbstract
|
||||
return $this->clearTokenCookies($httpResponse);
|
||||
}
|
||||
|
||||
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']);
|
||||
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed', 'expires_in' => 900]);
|
||||
|
||||
if ($response->tokens && isset($response->tokens['access'])) {
|
||||
$httpResponse->headers->setCookie(
|
||||
@@ -242,6 +242,15 @@ class AuthenticationController extends ControllerAbstract
|
||||
return $httpResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session health check
|
||||
*/
|
||||
#[AuthenticatedRoute('/auth/ping', name: 'auth.ping', methods: ['GET'])]
|
||||
public function ping(): JsonResponse
|
||||
{
|
||||
return new JsonResponse(['status' => 'ok']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current device
|
||||
*/
|
||||
@@ -281,14 +290,16 @@ class AuthenticationController extends ControllerAbstract
|
||||
*/
|
||||
private function buildJsonResponse(AuthenticationResponse $response): JsonResponse
|
||||
{
|
||||
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||
$data = $response->toArray();
|
||||
|
||||
// Set token cookies if present
|
||||
// Set token cookies and expose expires_in if present
|
||||
if ($response->hasTokens()) {
|
||||
$data['expires_in'] = 900;
|
||||
$httpResponse = new JsonResponse($data, $response->httpStatus);
|
||||
return $this->setTokenCookies($httpResponse, $response->tokens, true);
|
||||
}
|
||||
|
||||
return $httpResponse;
|
||||
return new JsonResponse($data, $response->httpStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,7 @@ class InitController extends ControllerAbstract
|
||||
|
||||
// modules - filter by permissions
|
||||
$configuration['modules'] = [];
|
||||
foreach ($this->moduleManager->list() as $module) {
|
||||
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
|
||||
$handle = $module->handle();
|
||||
@@ -60,7 +60,7 @@ class InitController extends ControllerAbstract
|
||||
'permissions' => $this->userIdentity->identity()->getPermissions(),
|
||||
],
|
||||
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()),
|
||||
'settings' => $this->userService->fetchSettings(),
|
||||
'settings' => $this->userService->fetchSettings([], true),
|
||||
];
|
||||
|
||||
return new JsonResponse($configuration);
|
||||
|
||||
@@ -21,7 +21,7 @@ class ModuleController extends ControllerAbstract
|
||||
)]
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$modules = $this->moduleManager->list(false);
|
||||
$modules = $this->moduleManager->list();
|
||||
|
||||
return new JsonResponse(['modules' => $modules]);
|
||||
}
|
||||
|
||||
74
core/lib/Controllers/TenantSettingsController.php
Normal file
74
core/lib/Controllers/TenantSettingsController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
/**
|
||||
* Tenant-scoped settings controller.
|
||||
*
|
||||
* Mirrors UserSettingsController but operates on the current tenant record
|
||||
* rather than the current user. Write access is guarded by the
|
||||
* `tenant.settings.update` permission so only administrators can mutate
|
||||
* tenant-wide configuration.
|
||||
*/
|
||||
class TenantSettingsController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly TenantService $tenantService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve all settings for the current tenant.
|
||||
*
|
||||
* @return JsonResponse Settings data as key-value pairs
|
||||
*/
|
||||
#[AuthenticatedRoute(
|
||||
'/tenant/settings',
|
||||
name: 'tenant.settings.read',
|
||||
methods: ['GET'],
|
||||
permissions: ['tenant.settings.read'],
|
||||
)]
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
$settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier());
|
||||
|
||||
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update one or more settings for the current tenant.
|
||||
*
|
||||
* @param array $data Key-value pairs to persist
|
||||
*
|
||||
* @example request body:
|
||||
* {
|
||||
* "data": {
|
||||
* "default_mode": "dark",
|
||||
* "primary_color": "#6366F1",
|
||||
* "lock_user_colors": true
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @return JsonResponse The updated values that were written
|
||||
*/
|
||||
#[AuthenticatedRoute(
|
||||
'/tenant/settings',
|
||||
name: 'tenant.settings.update',
|
||||
methods: ['PUT', 'PATCH'],
|
||||
permissions: ['tenant.settings.update'],
|
||||
)]
|
||||
public function update(array $data): JsonResponse
|
||||
{
|
||||
$this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data);
|
||||
|
||||
$updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data));
|
||||
|
||||
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
@@ -18,7 +19,7 @@ class UserSettingsController extends ControllerAbstract
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve user settings
|
||||
* Retrieve user settings, with optional filtering
|
||||
* If no specific settings are requested, all settings are returned
|
||||
*
|
||||
* @return JsonResponse Settings data as key-value pairs
|
||||
@@ -29,10 +30,9 @@ class UserSettingsController extends ControllerAbstract
|
||||
methods: ['GET'],
|
||||
permissions: ['user.settings.read']
|
||||
)]
|
||||
public function read(): JsonResponse
|
||||
public function read(bool $flatten = false): JsonResponse
|
||||
{
|
||||
// Fetch all settings (no filter)
|
||||
$settings = $this->userService->fetchSettings();
|
||||
$settings = $this->userService->fetchSettings(flatten: $flatten);
|
||||
|
||||
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
@@ -55,17 +55,17 @@ class UserSettingsController extends ControllerAbstract
|
||||
*/
|
||||
#[AuthenticatedRoute(
|
||||
'/user/settings',
|
||||
name: 'user.settings.update',
|
||||
methods: ['PUT', 'PATCH'],
|
||||
permissions: ['user.settings.update']
|
||||
name: 'user.settings.write',
|
||||
methods: ['POST', 'PUT', 'PATCH'],
|
||||
permissions: ['user.settings.write']
|
||||
)]
|
||||
public function update(array $data): JsonResponse
|
||||
public function write(array $data): JsonResponse
|
||||
{
|
||||
$this->userService->storeSettings($data);
|
||||
|
||||
// Return updated settings
|
||||
$updatedSettings = $this->userService->fetchSettings(array_keys($data));
|
||||
$settings = $this->userService->fetchSettings(array_keys($data));
|
||||
|
||||
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
|
||||
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
}
|
||||
|
||||
76
core/lib/Http/Response/StreamedNdJsonResponse.php
Normal file
76
core/lib/Http/Response/StreamedNdJsonResponse.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace KTXC\Http\Response;
|
||||
|
||||
/**
|
||||
* StreamedNdJsonResponse streams an HTTP response as Newline Delimited JSON (NDJSON).
|
||||
*
|
||||
* Each item yielded by the provided iterable is serialized as a single JSON value
|
||||
* followed by a newline character (\n). The response is flushed to the client every
|
||||
* $flushInterval items so consumers can process records incrementally without waiting
|
||||
* for the full payload.
|
||||
*
|
||||
* Content-Type is set to `application/x-ndjson` and `X-Accel-Buffering: no` is added
|
||||
* by default to disable nginx proxy buffering.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* function records(): \Generator {
|
||||
* yield ['id' => 1, 'name' => 'Alice'];
|
||||
* yield ['id' => 2, 'name' => 'Bob'];
|
||||
* }
|
||||
*
|
||||
* return new StreamedNdJsonResponse(records());
|
||||
*/
|
||||
class StreamedNdJsonResponse extends StreamedResponse
|
||||
{
|
||||
/**
|
||||
* @param iterable<mixed> $items Items to serialize; each becomes one JSON line
|
||||
* @param int $flushInterval Flush to client after this many items (default 10)
|
||||
* @param int $status HTTP status code (default 200)
|
||||
* @param array<string, string|string[]> $headers Additional HTTP headers
|
||||
* @param int $encodingOptions Flags passed to json_encode()
|
||||
*/
|
||||
public function __construct(
|
||||
iterable $items,
|
||||
int $flushInterval = 10,
|
||||
int $status = 200,
|
||||
array $headers = [],
|
||||
private readonly int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
|
||||
) {
|
||||
parent::__construct(null, $status, $headers);
|
||||
|
||||
if (!$this->headers->get('Content-Type')) {
|
||||
$this->headers->set('Content-Type', 'application/x-ndjson');
|
||||
}
|
||||
|
||||
if (!$this->headers->has('X-Accel-Buffering')) {
|
||||
$this->headers->set('X-Accel-Buffering', 'no');
|
||||
}
|
||||
|
||||
$encodingOptions = $this->encodingOptions;
|
||||
|
||||
$this->setCallback(static function () use ($items, $flushInterval, $encodingOptions): void {
|
||||
$count = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
echo json_encode($item, \JSON_THROW_ON_ERROR | $encodingOptions) . "\n";
|
||||
$count++;
|
||||
|
||||
if ($count >= $flushInterval) {
|
||||
@ob_flush();
|
||||
flush();
|
||||
$count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// final flush for any remaining buffered items
|
||||
if ($count > 0) {
|
||||
@ob_flush();
|
||||
flush();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ use KTXC\Injection\Container;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use KTXC\Logger\FileLogger;
|
||||
use KTXC\Logger\LoggerFactory;
|
||||
use KTXC\Logger\TenantAwareLogger;
|
||||
use KTXF\Event\EventBus;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
@@ -87,10 +88,8 @@ class Kernel
|
||||
$_SERVER['SHELL_VERBOSITY'] = 3;
|
||||
}
|
||||
|
||||
// Create logger with config support
|
||||
$logDir = $this->config['log.directory'] ?? $this->getLogDir();
|
||||
$logChannel = $this->config['log.channel'] ?? 'app';
|
||||
$this->logger = new FileLogger($logDir, $logChannel);
|
||||
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
|
||||
$this->logger = LoggerFactory::create($this->config, $this->folderRoot());
|
||||
|
||||
$this->initializeErrorHandlers();
|
||||
|
||||
@@ -396,8 +395,23 @@ class Kernel
|
||||
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
|
||||
Container::class => \DI\get(ContainerInterface::class),
|
||||
|
||||
// Use the kernel's logger instance
|
||||
LoggerInterface::class => \DI\value($this->logger),
|
||||
LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) {
|
||||
$logConfig = $this->config['log'] ?? [];
|
||||
|
||||
$logDir = $logConfig['path'] ?? ($projectDir . '/var/log');
|
||||
$channel = $logConfig['channel'] ?? 'app';
|
||||
$level = $logConfig['level'] ?? 'debug';
|
||||
$perTenant = (bool) ($logConfig['per_tenant'] ?? false);
|
||||
|
||||
return new TenantAwareLogger(
|
||||
$this->logger,
|
||||
$c->get(SessionTenant::class),
|
||||
$logDir,
|
||||
$channel,
|
||||
$level,
|
||||
$perTenant,
|
||||
);
|
||||
},
|
||||
|
||||
// EventBus as singleton for consistent event handling
|
||||
EventBus::class => \DI\create(EventBus::class),
|
||||
|
||||
@@ -26,7 +26,7 @@ class FileLogger implements LoggerInterface
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0775, true);
|
||||
}
|
||||
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log';
|
||||
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.jsonl';
|
||||
}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
|
||||
@@ -40,11 +40,18 @@ class FileLogger implements LoggerInterface
|
||||
|
||||
public function log($level, $message, array $context = []): void
|
||||
{
|
||||
$timestamp = $this->formatTimestamp();
|
||||
$interpolated = $this->interpolate((string)$message, $context);
|
||||
// Extract tenant id injected by TenantAwareLogger; default to 'system'.
|
||||
$tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
|
||||
? $context['__tenant']
|
||||
: 'system';
|
||||
unset($context['__tenant']);
|
||||
|
||||
$timestamp = $this->formatTimestamp();
|
||||
$interpolated = $this->interpolate((string) $message, $context);
|
||||
$payload = [
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string)$level),
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string) $level),
|
||||
'tenant' => $tenantId,
|
||||
'channel' => $this->channel,
|
||||
'message' => $interpolated,
|
||||
'context' => $this->sanitizeContext($context),
|
||||
@@ -53,11 +60,12 @@ class FileLogger implements LoggerInterface
|
||||
if ($json === false) {
|
||||
// Fallback stringify if encoding fails (should be rare)
|
||||
$json = json_encode([
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string)$level),
|
||||
'channel' => $this->channel,
|
||||
'message' => $interpolated,
|
||||
'context_error' => 'failed to encode context: '.json_last_error_msg(),
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string) $level),
|
||||
'tenant' => $tenantId,
|
||||
'channel' => $this->channel,
|
||||
'message' => $interpolated,
|
||||
'context_error' => 'failed to encode context: ' . json_last_error_msg(),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}';
|
||||
}
|
||||
$this->write($json);
|
||||
|
||||
47
core/lib/Logger/LevelFilterLogger.php
Normal file
47
core/lib/Logger/LevelFilterLogger.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
/**
|
||||
* PSR-3 decorator that discards messages below a configured minimum severity.
|
||||
*
|
||||
* Severity ordering (lower = more severe):
|
||||
* emergency(0) > alert(1) > critical(2) > error(3) > warning(4) > notice(5) > info(6) > debug(7)
|
||||
*
|
||||
* Example: minLevel = 'warning' passes emergency, alert, critical, error, warning
|
||||
* and silently discards notice, info, debug.
|
||||
*/
|
||||
class LevelFilterLogger implements LoggerInterface
|
||||
{
|
||||
private int $minSeverity;
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $inner,
|
||||
string $minLevel = LogLevel::DEBUG,
|
||||
) {
|
||||
LogLevelSeverity::validate($minLevel);
|
||||
$this->minSeverity = LogLevelSeverity::severity($minLevel);
|
||||
}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
|
||||
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
|
||||
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
|
||||
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
|
||||
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
|
||||
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
|
||||
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
|
||||
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
|
||||
|
||||
public function log($level, $message, array $context = []): void
|
||||
{
|
||||
// Messages with severity numerically greater than minSeverity are less severe — discard them.
|
||||
if (LogLevelSeverity::severity((string) $level) > $this->minSeverity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->inner->log($level, $message, $context);
|
||||
}
|
||||
}
|
||||
68
core/lib/Logger/LogLevelSeverity.php
Normal file
68
core/lib/Logger/LogLevelSeverity.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
/**
|
||||
* Maps PSR-3 log level strings to integer severity values.
|
||||
*
|
||||
* Lower integer = higher severity (matches RFC 5424 / syslog convention):
|
||||
* emergency = 0, alert = 1, critical = 2, error = 3,
|
||||
* warning = 4, notice = 5, info = 6, debug = 7
|
||||
*/
|
||||
class LogLevelSeverity
|
||||
{
|
||||
private const MAP = [
|
||||
LogLevel::EMERGENCY => 0,
|
||||
LogLevel::ALERT => 1,
|
||||
LogLevel::CRITICAL => 2,
|
||||
LogLevel::ERROR => 3,
|
||||
LogLevel::WARNING => 4,
|
||||
LogLevel::NOTICE => 5,
|
||||
LogLevel::INFO => 6,
|
||||
LogLevel::DEBUG => 7,
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the integer severity for a PSR-3 level string.
|
||||
*
|
||||
* @throws \InvalidArgumentException for unknown level strings
|
||||
*/
|
||||
public static function severity(string $level): int
|
||||
{
|
||||
$normalized = strtolower($level);
|
||||
|
||||
if (!array_key_exists($normalized, self::MAP)) {
|
||||
throw new \InvalidArgumentException(
|
||||
sprintf(
|
||||
'Unknown log level "%s". Valid levels are: %s.',
|
||||
$level,
|
||||
implode(', ', array_keys(self::MAP))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return self::MAP[$normalized];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a level string is a known PSR-3 level.
|
||||
*
|
||||
* @throws \InvalidArgumentException for unknown level strings
|
||||
*/
|
||||
public static function validate(string $level): void
|
||||
{
|
||||
self::severity($level); // throws on unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all valid PSR-3 level strings ordered from most to least severe.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function levels(): array
|
||||
{
|
||||
return array_keys(self::MAP);
|
||||
}
|
||||
}
|
||||
87
core/lib/Logger/LoggerFactory.php
Normal file
87
core/lib/Logger/LoggerFactory.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* Creates a PSR-3 LoggerInterface instance from the application config.
|
||||
*
|
||||
* Reads the 'log' key from the system config array and builds:
|
||||
* 1. A driver-specific inner logger (file, systemd, syslog, null).
|
||||
* 2. A LevelFilterLogger decorator that discards messages below the
|
||||
* configured minimum level.
|
||||
*
|
||||
* Per-tenant routing (TenantAwareLogger) is applied separately inside the
|
||||
* DI container definition in Kernel::configureContainer(), because that
|
||||
* requires access to the SessionTenant singleton which lives in the container.
|
||||
*
|
||||
* Supported config keys inside $config['log']:
|
||||
*
|
||||
* driver string 'file' | 'systemd' | 'syslog' | 'null' default: 'file'
|
||||
* level string PSR-3 level string (minimum to log) default: 'debug'
|
||||
* per_tenant bool Route to per-tenant files (DI-level only) default: false
|
||||
*
|
||||
* -- file driver --
|
||||
* path ?string Absolute log directory, null = {root}/var/log
|
||||
* channel string File basename without extension default: 'app'
|
||||
*
|
||||
* -- systemd driver --
|
||||
* channel string Channel tag embedded in each line default: 'app'
|
||||
*
|
||||
* -- syslog driver --
|
||||
* ident string openlog() identity tag default: 'ktrix'
|
||||
* facility int openlog() facility constant default: LOG_USER
|
||||
* channel string Prefix embedded in each syslog message default: 'app'
|
||||
*/
|
||||
class LoggerFactory
|
||||
{
|
||||
/**
|
||||
* Build and return a configured, level-filtered PSR-3 logger.
|
||||
*
|
||||
* @param array $config The full system config array (reads $config['log']).
|
||||
* @param string $projectDir Absolute project root path (used for default file path).
|
||||
*/
|
||||
public static function create(array $config, string $projectDir): LoggerInterface
|
||||
{
|
||||
$logConfig = $config['log'] ?? [];
|
||||
|
||||
$driver = $logConfig['driver'] ?? 'file';
|
||||
$level = $logConfig['level'] ?? 'debug';
|
||||
$channel = $logConfig['channel'] ?? 'app';
|
||||
|
||||
// Validate level early for a clear error message.
|
||||
LogLevelSeverity::validate($level);
|
||||
|
||||
$inner = match ($driver) {
|
||||
'file' => self::buildFileLogger($logConfig, $projectDir, $channel),
|
||||
'systemd' => new SystemdLogger($channel),
|
||||
'syslog' => new SyslogLogger(
|
||||
$logConfig['ident'] ?? 'ktrix',
|
||||
$logConfig['facility'] ?? LOG_USER,
|
||||
$channel,
|
||||
),
|
||||
'null' => new NullLogger(),
|
||||
default => throw new \RuntimeException(
|
||||
sprintf(
|
||||
'Unknown log driver "%s". Supported drivers: file, systemd, syslog, null.',
|
||||
$driver
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
return new LevelFilterLogger($inner, $level);
|
||||
}
|
||||
|
||||
private static function buildFileLogger(array $logConfig, string $projectDir, string $channel): FileLogger
|
||||
{
|
||||
$path = $logConfig['path'] ?? null;
|
||||
|
||||
if ($path === null || $path === '') {
|
||||
$path = $projectDir . '/var/log';
|
||||
}
|
||||
|
||||
return new FileLogger($path, $channel);
|
||||
}
|
||||
}
|
||||
62
core/lib/Logger/PlainFileLogger.php
Normal file
62
core/lib/Logger/PlainFileLogger.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Simple file-based PSR-3 logger that writes plain-text lines.
|
||||
*
|
||||
* Each entry is formatted as:
|
||||
* 2026-02-20 12:34:56.123456 message text
|
||||
*/
|
||||
class PlainFileLogger implements LoggerInterface
|
||||
{
|
||||
private string $logFile;
|
||||
|
||||
/**
|
||||
* @param string $logDir Directory where log files are written
|
||||
* @param string $channel Logical channel name (used in filename)
|
||||
*/
|
||||
public function __construct(string $logDir, string $channel = 'app')
|
||||
{
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0775, true);
|
||||
}
|
||||
$this->logFile = rtrim($logDir, '/') . '/' . $channel . '.log';
|
||||
}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
|
||||
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
|
||||
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
|
||||
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
|
||||
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
|
||||
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
|
||||
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
|
||||
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
|
||||
|
||||
public function log($level, $message, array $context = []): void
|
||||
{
|
||||
$dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));
|
||||
$timestamp = $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s');
|
||||
|
||||
$line = $timestamp . ' ' . $this->interpolate((string) $message, $context) . PHP_EOL;
|
||||
|
||||
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
private function interpolate(string $message, array $context): string
|
||||
{
|
||||
if (!str_contains($message, '{')) {
|
||||
return $message;
|
||||
}
|
||||
$replace = [];
|
||||
foreach ($context as $key => $val) {
|
||||
if (is_array($val) || (is_object($val) && !method_exists($val, '__toString'))) {
|
||||
continue;
|
||||
}
|
||||
$replace['{' . $key . '}'] = (string) $val;
|
||||
}
|
||||
return strtr($message, $replace);
|
||||
}
|
||||
}
|
||||
94
core/lib/Logger/SyslogLogger.php
Normal file
94
core/lib/Logger/SyslogLogger.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
/**
|
||||
* PSR-3 logger that writes via PHP's built-in syslog() facility.
|
||||
*
|
||||
* Each message is prefixed with "[channel] " so entries can be filtered easily
|
||||
* in /var/log/syslog (or equivalent) or via journalctl -t {ident}.
|
||||
*/
|
||||
class SyslogLogger implements LoggerInterface
|
||||
{
|
||||
/** Maps PSR-3 levels to PHP syslog priority constants */
|
||||
private const PRIORITY = [
|
||||
LogLevel::EMERGENCY => LOG_EMERG,
|
||||
LogLevel::ALERT => LOG_ALERT,
|
||||
LogLevel::CRITICAL => LOG_CRIT,
|
||||
LogLevel::ERROR => LOG_ERR,
|
||||
LogLevel::WARNING => LOG_WARNING,
|
||||
LogLevel::NOTICE => LOG_NOTICE,
|
||||
LogLevel::INFO => LOG_INFO,
|
||||
LogLevel::DEBUG => LOG_DEBUG,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $ident = 'ktrix',
|
||||
private readonly int $facility = LOG_USER,
|
||||
private readonly string $channel = 'app',
|
||||
) {}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
|
||||
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
|
||||
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
|
||||
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
|
||||
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
|
||||
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
|
||||
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
|
||||
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
|
||||
|
||||
public function log($level, $message, array $context = []): void
|
||||
{
|
||||
// Extract tenant id injected by TenantAwareLogger; default to 'system'.
|
||||
$tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
|
||||
? $context['__tenant']
|
||||
: 'system';
|
||||
unset($context['__tenant']);
|
||||
|
||||
$level = strtolower((string) $level);
|
||||
$priority = self::PRIORITY[$level] ?? LOG_DEBUG;
|
||||
|
||||
$interpolated = $this->interpolate((string) $message, $context);
|
||||
$contextStr = empty($context) ? '' : ' ' . $this->encodeContext($context);
|
||||
|
||||
$entry = sprintf('[%s] [%s] %s%s', $this->channel, $tenantId, $interpolated, $contextStr);
|
||||
|
||||
openlog($this->ident, LOG_NDELAY | LOG_PID, $this->facility);
|
||||
syslog($priority, $entry);
|
||||
closelog();
|
||||
}
|
||||
|
||||
private function interpolate(string $message, array $context): string
|
||||
{
|
||||
if (!str_contains($message, '{')) {
|
||||
return $message;
|
||||
}
|
||||
$replace = [];
|
||||
foreach ($context as $key => $val) {
|
||||
if (!is_array($val) && !is_object($val)) {
|
||||
$replace['{' . $key . '}'] = (string) $val;
|
||||
}
|
||||
}
|
||||
return strtr($message, $replace);
|
||||
}
|
||||
|
||||
private function encodeContext(array $context): string
|
||||
{
|
||||
$clean = [];
|
||||
foreach ($context as $k => $v) {
|
||||
if ($v instanceof \Throwable) {
|
||||
$clean[$k] = ['type' => get_class($v), 'message' => $v->getMessage()];
|
||||
} elseif (is_resource($v)) {
|
||||
$clean[$k] = 'resource(' . get_resource_type($v) . ')';
|
||||
} elseif (is_object($v)) {
|
||||
$clean[$k] = method_exists($v, '__toString') ? (string) $v : ['object' => get_class($v)];
|
||||
} else {
|
||||
$clean[$k] = $v;
|
||||
}
|
||||
}
|
||||
return json_encode($clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
|
||||
}
|
||||
}
|
||||
112
core/lib/Logger/SystemdLogger.php
Normal file
112
core/lib/Logger/SystemdLogger.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
/**
|
||||
* PSR-3 logger that writes to stderr using the journald/systemd SD_JOURNAL_PREFIX
|
||||
* format: a syslog-priority number wrapped in angle brackets followed by the message.
|
||||
*
|
||||
* journald automatically parses the "<N>" prefix and maps it to the corresponding
|
||||
* log priority, so log entries appear in the journal with the correct severity.
|
||||
*
|
||||
* Format: <priority>LEVEL [channel] interpolated-message {"context":"key",...}
|
||||
*/
|
||||
class SystemdLogger implements LoggerInterface
|
||||
{
|
||||
/** Maps PSR-3 levels to RFC 5424 / syslog priority numbers */
|
||||
private const PRIORITY = [
|
||||
LogLevel::EMERGENCY => 0,
|
||||
LogLevel::ALERT => 1,
|
||||
LogLevel::CRITICAL => 2,
|
||||
LogLevel::ERROR => 3,
|
||||
LogLevel::WARNING => 4,
|
||||
LogLevel::NOTICE => 5,
|
||||
LogLevel::INFO => 6,
|
||||
LogLevel::DEBUG => 7,
|
||||
];
|
||||
|
||||
/** @var resource */
|
||||
private $stderr;
|
||||
|
||||
public function __construct(private readonly string $channel = 'app')
|
||||
{
|
||||
$this->stderr = fopen('php://stderr', 'w');
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
if (is_resource($this->stderr)) {
|
||||
fclose($this->stderr);
|
||||
}
|
||||
}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
|
||||
public function alert($message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
|
||||
public function critical($message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
|
||||
public function error($message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
|
||||
public function warning($message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
|
||||
public function notice($message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
|
||||
public function info($message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
|
||||
public function debug($message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
|
||||
|
||||
public function log($level, $message, array $context = []): void
|
||||
{
|
||||
// Extract tenant id injected by TenantAwareLogger; default to 'system'.
|
||||
$tenantId = isset($context['__tenant']) && is_string($context['__tenant'])
|
||||
? $context['__tenant']
|
||||
: 'system';
|
||||
unset($context['__tenant']);
|
||||
|
||||
$level = strtolower((string) $level);
|
||||
$priority = self::PRIORITY[$level] ?? 7;
|
||||
|
||||
$interpolated = $this->interpolate((string) $message, $context);
|
||||
$contextStr = empty($context) ? '' : ' ' . $this->encodeContext($context);
|
||||
|
||||
$line = sprintf(
|
||||
"<%d>%s [%s] [%s] %s%s\n",
|
||||
$priority,
|
||||
strtoupper($level),
|
||||
$this->channel,
|
||||
$tenantId,
|
||||
$interpolated,
|
||||
$contextStr,
|
||||
);
|
||||
|
||||
fwrite($this->stderr, $line);
|
||||
}
|
||||
|
||||
private function interpolate(string $message, array $context): string
|
||||
{
|
||||
if (!str_contains($message, '{')) {
|
||||
return $message;
|
||||
}
|
||||
$replace = [];
|
||||
foreach ($context as $key => $val) {
|
||||
if (!is_array($val) && !is_object($val)) {
|
||||
$replace['{' . $key . '}'] = (string) $val;
|
||||
}
|
||||
}
|
||||
return strtr($message, $replace);
|
||||
}
|
||||
|
||||
private function encodeContext(array $context): string
|
||||
{
|
||||
$clean = [];
|
||||
foreach ($context as $k => $v) {
|
||||
if ($v instanceof \Throwable) {
|
||||
$clean[$k] = ['type' => get_class($v), 'message' => $v->getMessage()];
|
||||
} elseif (is_resource($v)) {
|
||||
$clean[$k] = 'resource(' . get_resource_type($v) . ')';
|
||||
} elseif (is_object($v)) {
|
||||
$clean[$k] = method_exists($v, '__toString') ? (string) $v : ['object' => get_class($v)];
|
||||
} else {
|
||||
$clean[$k] = $v;
|
||||
}
|
||||
}
|
||||
return json_encode($clean, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{}';
|
||||
}
|
||||
}
|
||||
93
core/lib/Logger/TenantAwareLogger.php
Normal file
93
core/lib/Logger/TenantAwareLogger.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use KTXC\SessionTenant;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* PSR-3 decorator with two responsibilities:
|
||||
*
|
||||
* 1. Tenant-id injection — every log record is enriched with a `tenant` field.
|
||||
* When a tenant session is active the real tenant identifier is used; otherwise
|
||||
* the value is "system" (boot phase, CLI, bad-domain rejections, etc.).
|
||||
* The id is passed to inner loggers via the reserved `__tenant` context key,
|
||||
* which each concrete logger (FileLogger, SystemdLogger, SyslogLogger) extracts
|
||||
* and renders as a top-level field, then removes from context before output.
|
||||
*
|
||||
* 2. Per-tenant file routing (optional, controlled by $perTenant) — when enabled,
|
||||
* writes for an active tenant are routed to:
|
||||
* {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl
|
||||
* Messages that carry tenant "system" always go to the global logger.
|
||||
*
|
||||
* Both behaviours rely on a live SessionTenant reference that is populated lazily
|
||||
* by TenantMiddleware — the same pattern the cache stores use in Kernel::configureContainer().
|
||||
*/
|
||||
class TenantAwareLogger implements LoggerInterface
|
||||
{
|
||||
/** @var array<string, LoggerInterface> Per-tenant logger cache, keyed by tenant identifier. */
|
||||
private array $tenantLoggers = [];
|
||||
|
||||
/**
|
||||
* @param LoggerInterface $globalLogger Fallback logger (also used when perTenant = false).
|
||||
* @param SessionTenant $sessionTenant Live reference configured by TenantMiddleware.
|
||||
* @param string $logDir Base log directory (e.g. /var/www/app/var/log).
|
||||
* @param string $channel Log file basename (e.g. 'app' → app.jsonl).
|
||||
* @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers.
|
||||
* @param bool $perTenant When true, route tenant writes to per-tenant files.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $globalLogger,
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly string $logDir,
|
||||
private readonly string $channel = 'app',
|
||||
private readonly string $minLevel = 'debug',
|
||||
private readonly bool $perTenant = false,
|
||||
) {
|
||||
LogLevelSeverity::validate($minLevel);
|
||||
}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log('emergency', $message, $context); }
|
||||
public function alert($message, array $context = []): void { $this->log('alert', $message, $context); }
|
||||
public function critical($message, array $context = []): void { $this->log('critical', $message, $context); }
|
||||
public function error($message, array $context = []): void { $this->log('error', $message, $context); }
|
||||
public function warning($message, array $context = []): void { $this->log('warning', $message, $context); }
|
||||
public function notice($message, array $context = []): void { $this->log('notice', $message, $context); }
|
||||
public function info($message, array $context = []): void { $this->log('info', $message, $context); }
|
||||
public function debug($message, array $context = []): void { $this->log('debug', $message, $context); }
|
||||
|
||||
public function log($level, $message, array $context = []): void
|
||||
{
|
||||
// Resolve current tenant id; fall back to 'system' for CLI / boot phase.
|
||||
$tenantId = 'system';
|
||||
if ($this->sessionTenant->configured()) {
|
||||
$tenantId = $this->sessionTenant->identifier() ?? 'system';
|
||||
}
|
||||
|
||||
// Inject tenant id as a reserved context key that concrete loggers extract.
|
||||
$context['__tenant'] = $tenantId;
|
||||
|
||||
$this->resolveLogger($tenantId)->log($level, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the logger to write to for the given tenant.
|
||||
* When per-tenant routing is disabled (or tenant is "system"), always returns the global logger.
|
||||
*/
|
||||
private function resolveLogger(string $tenantId): LoggerInterface
|
||||
{
|
||||
if (!$this->perTenant || $tenantId === 'system') {
|
||||
return $this->globalLogger;
|
||||
}
|
||||
|
||||
if (!isset($this->tenantLoggers[$tenantId])) {
|
||||
$tenantLogDir = rtrim($this->logDir, '/') . '/tenant/' . $tenantId;
|
||||
$this->tenantLoggers[$tenantId] = new LevelFilterLogger(
|
||||
new FileLogger($tenantLogDir, $this->channel),
|
||||
$this->minLevel,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->tenantLoggers[$tenantId];
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
\KTXC\Console\ModuleListCommand::class,
|
||||
\KTXC\Console\ModuleEnableCommand::class,
|
||||
\KTXC\Console\ModuleDisableCommand::class,
|
||||
\KTXC\Console\ModuleInstallCommand::class,
|
||||
\KTXC\Console\ModuleUninstallCommand::class,
|
||||
\KTXC\Console\ModuleUpgradeCommand::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXC\Application;
|
||||
use KTXC\Server;
|
||||
|
||||
/**
|
||||
* Custom autoloader for modules that allows PascalCase namespaces
|
||||
@@ -73,7 +73,7 @@ class ModuleAutoloader
|
||||
}
|
||||
|
||||
// Register module namespaces with Composer ClassLoader
|
||||
$composerLoader = \KTXC\Application::getComposerLoader();
|
||||
$composerLoader = Server::getComposerLoader();
|
||||
if ($composerLoader !== null) {
|
||||
foreach ($this->namespaceMap as $namespace => $folderName) {
|
||||
$composerLoader->addPsr4(
|
||||
|
||||
@@ -31,9 +31,9 @@ class ModuleManager
|
||||
*
|
||||
* @param bool $installedOnly If true, only return modules that are in the database
|
||||
* @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly)
|
||||
* @return Module[]
|
||||
* @return ModuleObject[]
|
||||
*/
|
||||
public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection
|
||||
public function list(bool| null $installedOnly = null, bool| null $enabledOnly = null): ModuleCollection
|
||||
{
|
||||
$modules = New ModuleCollection();
|
||||
|
||||
@@ -44,12 +44,8 @@ class ModuleManager
|
||||
}
|
||||
|
||||
// load all modules from store
|
||||
$entries = $this->repository->list();
|
||||
$entries = $this->repository->list($installedOnly, $enabledOnly);
|
||||
foreach ($entries as $entry) {
|
||||
if ($enabledOnly && !$entry->getEnabled()) {
|
||||
continue; // Skip disabled modules if filtering for enabled only
|
||||
}
|
||||
// instance module
|
||||
$handle = $entry->getHandle();
|
||||
if (isset($this->moduleInstances[$entry->getHandle()])) {
|
||||
$modules[$handle] = new ModuleObject($this->moduleInstances[$handle], $entry);
|
||||
@@ -60,7 +56,7 @@ class ModuleManager
|
||||
}
|
||||
}
|
||||
// load all modules from filesystem
|
||||
if ($installedOnly === false) {
|
||||
if ($installedOnly !== true) {
|
||||
$discovered = $this->modulesDiscover();
|
||||
foreach ($discovered as $moduleInstance) {
|
||||
$handle = $moduleInstance->handle();
|
||||
@@ -72,6 +68,21 @@ class ModuleManager
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
public function fetch(string $handle): ?ModuleObject
|
||||
{
|
||||
$entry = $this->repository->fetch($handle);
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($entry->getHandle(), $entry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ModuleObject($moduleInstance, $entry);
|
||||
}
|
||||
|
||||
public function install(string $handle): void
|
||||
{
|
||||
@@ -258,7 +269,7 @@ class ModuleManager
|
||||
public function modulesBoot(): void
|
||||
{
|
||||
// Only load modules that are enabled in the database
|
||||
$modules = $this->list();
|
||||
$modules = $this->list(true, true);
|
||||
$this->logger->debug('Booting enabled modules', ['count' => count($modules)]);
|
||||
foreach ($modules as $module) {
|
||||
$handle = $module->handle();
|
||||
|
||||
@@ -9,10 +9,7 @@ use KTXF\Module\ModuleConsoleInterface;
|
||||
use KTXF\Module\ModuleInstanceInterface;
|
||||
|
||||
/**
|
||||
* Module is a unified wrapper that combines both the ModuleInterface instance
|
||||
* (from filesystem) and ModuleEntry (from database) into a single object.
|
||||
*
|
||||
* This provides a single source of truth for all module information.
|
||||
* Module is a unified wrapper that combines the filesystem module instance and the database module entry.
|
||||
*/
|
||||
class ModuleObject implements JsonSerializable
|
||||
{
|
||||
@@ -32,6 +29,9 @@ class ModuleObject implements JsonSerializable
|
||||
return [
|
||||
'id' => $this->id(),
|
||||
'handle' => $this->handle(),
|
||||
'label' => $this->label(),
|
||||
'description' => $this->description(),
|
||||
'author' => $this->author(),
|
||||
'version' => $this->version(),
|
||||
'namespace' => $this->namespace(),
|
||||
'installed' => $this->installed(),
|
||||
@@ -86,6 +86,21 @@ class ModuleObject implements JsonSerializable
|
||||
return null;
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->instance?->label() ?? '';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return $this->instance?->description() ?? '';
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return $this->instance?->author() ?? '';
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
// Prefer current version from filesystem
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace KTXC\Module\Store;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\ObjectId;
|
||||
|
||||
class ModuleStore
|
||||
{
|
||||
@@ -13,9 +14,18 @@ class ModuleStore
|
||||
protected readonly DataStore $dataStore
|
||||
) { }
|
||||
|
||||
public function list(): array
|
||||
public function list(bool|null $installed = null, bool|null $enabled = null): array
|
||||
{
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]);
|
||||
$filter = [];
|
||||
if ($installed !== null) {
|
||||
$filter['installed'] = $installed;
|
||||
}
|
||||
if ($enabled !== null) {
|
||||
$filter['enabled'] = $enabled;
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filter);
|
||||
|
||||
$modules = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$entity = new ModuleEntry();
|
||||
@@ -52,7 +62,11 @@ class ModuleStore
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return null; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
|
||||
|
||||
$data = $entry->jsonSerialize();
|
||||
unset($data['id']);
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $data]);
|
||||
return $entry;
|
||||
}
|
||||
|
||||
@@ -60,7 +74,7 @@ class ModuleStore
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Injection\Container;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Routing\Attributes\AnonymousPrefixRoute;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -19,6 +20,8 @@ class Router
|
||||
private Container $container;
|
||||
/** @var array<string,array<string,Route>> */
|
||||
private array $routes = []; // [method][path] => Route
|
||||
/** @var array<string,array<string,Route>> prefix routes [method][prefix] => Route */
|
||||
private array $prefixRoutes = [];
|
||||
private bool $initialized = false;
|
||||
private string $cacheFile;
|
||||
|
||||
@@ -40,8 +43,9 @@ class Router
|
||||
// load cached routes in production
|
||||
if ($this->environment === 'prod' && file_exists($this->cacheFile)) {
|
||||
$data = include $this->cacheFile;
|
||||
if (is_array($data)) {
|
||||
$this->routes = $data;
|
||||
if (is_array($data) && isset($data['routes'])) {
|
||||
$this->routes = $data['routes'];
|
||||
$this->prefixRoutes = $data['prefix'] ?? [];
|
||||
$this->initialized = true;
|
||||
return;
|
||||
}
|
||||
@@ -52,7 +56,8 @@ class Router
|
||||
// write cache
|
||||
$dir = dirname($this->cacheFile);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
file_put_contents($this->cacheFile, '<?php return ' . var_export($this->routes, true) . ';');
|
||||
$cache = ['routes' => $this->routes, 'prefix' => $this->prefixRoutes];
|
||||
file_put_contents($this->cacheFile, '<?php return ' . var_export($cache, true) . ';');
|
||||
}
|
||||
|
||||
|
||||
@@ -95,13 +100,17 @@ class Router
|
||||
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
|
||||
$attributes = array_merge(
|
||||
$reflectionMethod->getAttributes(AnonymousRoute::class),
|
||||
$reflectionMethod->getAttributes(AuthenticatedRoute::class)
|
||||
$reflectionMethod->getAttributes(AuthenticatedRoute::class),
|
||||
$reflectionMethod->getAttributes(AnonymousPrefixRoute::class),
|
||||
);
|
||||
foreach ($attributes as $attribute) {
|
||||
$route = $attribute->newInstance();
|
||||
$httpPath = $routePrefix . $route->path;
|
||||
$isPrefix = $route instanceof AnonymousPrefixRoute;
|
||||
$httpPath = ($isPrefix && $route->absolute)
|
||||
? $route->path
|
||||
: $routePrefix . $route->path;
|
||||
foreach ($route->methods as $httpMethod) {
|
||||
$this->routes[$httpMethod][$httpPath] = new Route(
|
||||
$routeObject = new Route(
|
||||
method: $httpMethod,
|
||||
path: $httpPath,
|
||||
name: $route->name,
|
||||
@@ -111,6 +120,11 @@ class Router
|
||||
classMethodParameters: $reflectionMethod->getParameters(),
|
||||
permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [],
|
||||
);
|
||||
if ($isPrefix) {
|
||||
$this->prefixRoutes[$httpMethod][$httpPath] = $routeObject;
|
||||
} else {
|
||||
$this->routes[$httpMethod][$httpPath] = $routeObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,7 +137,7 @@ class Router
|
||||
/**
|
||||
* Match a Request to a Route, or return null if no match.
|
||||
* Supports exact matches and simple {param} patterns.
|
||||
* Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns
|
||||
* Prioritizes: 1) exact matches, 2) specific patterns, 3) prefix routes, 4) catch-all patterns
|
||||
*/
|
||||
public function match(Request $request): ?Route
|
||||
{
|
||||
@@ -161,6 +175,20 @@ class Router
|
||||
return $routeObj->withParams($params);
|
||||
}
|
||||
}
|
||||
// Try prefix routes before catch-all — longest matching prefix wins
|
||||
$bestPrefix = null;
|
||||
$bestRoute = null;
|
||||
foreach ($this->prefixRoutes[$method] ?? [] as $prefix => $routeObj) {
|
||||
if (str_starts_with($path, $prefix) || str_starts_with($path . '/', $prefix)) {
|
||||
if ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix)) {
|
||||
$bestPrefix = $prefix;
|
||||
$bestRoute = $routeObj;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($bestRoute !== null) {
|
||||
return $bestRoute;
|
||||
}
|
||||
// Try catch-all pattern last
|
||||
if ($catchAllPattern !== null) {
|
||||
[$routePath, $routeObj] = $catchAllPattern;
|
||||
|
||||
@@ -2,91 +2,310 @@
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Injection\Container;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Module\ModuleAutoloader;
|
||||
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;
|
||||
use Symfony\Component\Console\Command\LazyCommand;
|
||||
|
||||
/**
|
||||
* Legacy Server class - now a facade to Application
|
||||
* @deprecated Use Application class directly
|
||||
* Server class - entry point for the framework
|
||||
* Handles configuration loading and kernel lifecycle
|
||||
*/
|
||||
class Server
|
||||
{
|
||||
public const ENVIRONMENT_DEV = 'dev';
|
||||
public const ENVIRONMENT_PROD = 'prod';
|
||||
private static $composerLoader = null;
|
||||
private static ?self $instance = null;
|
||||
|
||||
private Kernel $kernel;
|
||||
private array $config;
|
||||
private string $rootDir;
|
||||
|
||||
/**
|
||||
* @deprecated Use Application instead
|
||||
*/
|
||||
public static function run(): void {
|
||||
trigger_error('Server::run() is deprecated. Use Application class instead.', E_USER_DEPRECATED);
|
||||
|
||||
$projectRoot = dirname(dirname(__DIR__));
|
||||
$app = new Application($projectRoot);
|
||||
$app->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->environment()
|
||||
*/
|
||||
public static function environment(): string {
|
||||
return self::app()->environment();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->debug()
|
||||
*/
|
||||
public static function debug(): bool {
|
||||
return self::app()->debug();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->kernel()
|
||||
*/
|
||||
public static function runtimeKernel(): Kernel {
|
||||
return self::app()->kernel();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->container()
|
||||
*/
|
||||
public static function runtimeContainer(): Container {
|
||||
return self::app()->container();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->rootDir()
|
||||
*/
|
||||
public static function runtimeRootLocation(): string {
|
||||
return self::app()->rootDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getInstance()->moduleDir()
|
||||
*/
|
||||
public static function runtimeModuleLocation(): string {
|
||||
return self::app()->moduleDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::setComposerLoader()
|
||||
*/
|
||||
public static function setComposerLoader($loader): void {
|
||||
Application::setComposerLoader($loader);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use Application::getComposerLoader()
|
||||
*/
|
||||
public static function getComposerLoader() {
|
||||
return Application::getComposerLoader();
|
||||
}
|
||||
|
||||
private static function app(): Application
|
||||
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'Server class is deprecated and no longer functional. ' .
|
||||
'Update your code to use Application class with proper dependency injection. ' .
|
||||
'See the migration guide for details.'
|
||||
);
|
||||
self::$instance = $this;
|
||||
|
||||
$this->rootDir = $this->resolveProjectRoot($rootDir);
|
||||
|
||||
// Load configuration
|
||||
$this->config = $this->loadConfig();
|
||||
|
||||
// Determine environment and debug mode
|
||||
$environment = $environment ?? $this->config['environment'] ?? 'prod';
|
||||
$debug = $debug ?? $this->config['debug'] ?? false;
|
||||
|
||||
// Create kernel with configuration
|
||||
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
|
||||
|
||||
// Register module autoloader for both HTTP and CLI contexts
|
||||
$moduleAutoloader = new ModuleAutoloader($this->moduleDir());
|
||||
$moduleAutoloader->register();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application - handle incoming request and send response
|
||||
*/
|
||||
public function runHttp(): void
|
||||
{
|
||||
try {
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $this->handle($request);
|
||||
$response->send();
|
||||
$this->terminate();
|
||||
} catch (\Throwable $e) {
|
||||
// Last resort error handling for kernel initialization failures
|
||||
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
$content = $this->kernel->debug()
|
||||
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
|
||||
: 'An error occurred. Please try again later.';
|
||||
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
$response->send();
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run as a console application (CLI runtime).
|
||||
*/
|
||||
public function runConsole(): int
|
||||
{
|
||||
$this->kernel()->boot();
|
||||
$container = $this->container();
|
||||
|
||||
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
|
||||
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$instance = $module->instance();
|
||||
if (!$instance instanceof ModuleConsoleInterface) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
foreach ($instance->registerCI() as $commandClass) {
|
||||
if (!class_exists($commandClass)) {
|
||||
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
|
||||
continue;
|
||||
}
|
||||
$this->registerLazyCommand($console, $container, $commandClass);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
|
||||
return $console->run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return $this->kernel->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the application - process deferred events
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
$this->kernel->processEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the kernel instance
|
||||
*/
|
||||
public function kernel(): Kernel
|
||||
{
|
||||
return $this->kernel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container instance
|
||||
*/
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
return $this->kernel->container();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application root directory
|
||||
*/
|
||||
public function rootDir(): string
|
||||
{
|
||||
return $this->rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modules directory
|
||||
*/
|
||||
public function moduleDir(): string
|
||||
{
|
||||
return $this->rootDir . '/modules';
|
||||
}
|
||||
|
||||
public function varDir(): string
|
||||
{
|
||||
return $this->rootDir . '/var';
|
||||
}
|
||||
|
||||
public function logDir(): string
|
||||
{
|
||||
return $this->varDir() . '/logs';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
*/
|
||||
public function config(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
// Support dot notation: 'database.uri'
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->config;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment
|
||||
*/
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->kernel->environment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->kernel->debug();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from config directory
|
||||
*/
|
||||
protected function loadConfig(): array
|
||||
{
|
||||
$configFile = $this->rootDir . '/config/system.php';
|
||||
|
||||
if (!file_exists($configFile)) {
|
||||
error_log('Configuration file not found: ' . $configFile);
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException('Configuration file must return an array');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project root directory.
|
||||
*
|
||||
* Some entrypoints may pass the public/ directory or another subdirectory.
|
||||
* We walk up the directory tree until we find composer.json.
|
||||
*/
|
||||
private function resolveProjectRoot(string $startDir): string
|
||||
{
|
||||
$dir = rtrim($startDir, '/');
|
||||
if ($dir === '') {
|
||||
return $startDir;
|
||||
}
|
||||
|
||||
// If startDir is a file path, use its directory.
|
||||
if (is_file($dir)) {
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
|
||||
$current = $dir;
|
||||
while (true) {
|
||||
if (is_file($current . '/composer.json')) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$parent = dirname($current);
|
||||
if ($parent === $current) {
|
||||
// Reached filesystem root
|
||||
return $dir;
|
||||
}
|
||||
$current = $parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Composer ClassLoader instance
|
||||
*/
|
||||
public static function setComposerLoader($loader): void
|
||||
{
|
||||
self::$composerLoader = $loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Composer ClassLoader instance
|
||||
*/
|
||||
public static function getComposerLoader()
|
||||
{
|
||||
return self::$composerLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Application instance
|
||||
*/
|
||||
public static function getInstance(): ?self
|
||||
{
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a single command via lazy loading using its #[AsCommand] attribute.
|
||||
*/
|
||||
private function registerLazyCommand(
|
||||
ConsoleApplication $console,
|
||||
ContainerInterface $container,
|
||||
string $commandClass
|
||||
): void {
|
||||
try {
|
||||
$ref = new \ReflectionClass($commandClass);
|
||||
$attrs = $ref->getAttributes(AsCommand::class);
|
||||
|
||||
if (empty($attrs)) {
|
||||
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
|
||||
return;
|
||||
}
|
||||
|
||||
$attr = $attrs[0]->newInstance();
|
||||
$console->add(new LazyCommand(
|
||||
$attr->name,
|
||||
[],
|
||||
$attr->description ?? '',
|
||||
$attr->hidden ?? false,
|
||||
fn() => $container->get($commandClass)
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,4 +142,19 @@ class SecurityService
|
||||
{
|
||||
return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using a plain-text identity + password credential.
|
||||
*
|
||||
* Used by the DAV auth backend which receives credentials from the HTTP
|
||||
* Basic challenge after they have been extracted from the Authorization header.
|
||||
*
|
||||
* @param string $identity Username / e-mail
|
||||
* @param string $password Plain-text password
|
||||
* @return User|null Authenticated user, or null on failure
|
||||
*/
|
||||
public function authenticatePassword(string $identity, string $password): ?User
|
||||
{
|
||||
return $this->authenticateBasic($identity, $password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,45 @@ use KTXC\Stores\TenantStore;
|
||||
|
||||
class TenantService
|
||||
{
|
||||
public function __construct(protected readonly TenantStore $store)
|
||||
{
|
||||
}
|
||||
public function __construct(
|
||||
protected readonly TenantStore $store,
|
||||
) {}
|
||||
|
||||
public function fetchByDomain(string $domain): ?TenantObject
|
||||
{
|
||||
return $this->store->fetchByDomain($domain);
|
||||
}
|
||||
|
||||
public function fetchById(string $identifier): ?TenantObject
|
||||
{
|
||||
return $this->store->fetch($identifier);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Fetch all – or a filtered subset of – a tenant's settings.
|
||||
*
|
||||
* @param string $identifier Tenant identifier
|
||||
* @param string[] $keys Optional list of keys to return; empty = all
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function fetchSettings(string $identifier, array $keys = []): array
|
||||
{
|
||||
return $this->store->fetchSettings($identifier, $keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge-update settings for a tenant.
|
||||
*
|
||||
* @param string $identifier Tenant identifier
|
||||
* @param array<string, mixed> $settings Key-value pairs to persist
|
||||
*/
|
||||
public function storeSettings(string $identifier, array $settings): bool
|
||||
{
|
||||
return $this->store->storeSettings($identifier, $settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,9 +116,9 @@ class UserAccountsService
|
||||
// Settings Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchSettings(array $settings = []): array | null
|
||||
public function fetchSettings(array $settings = [], bool $flatten = false): array | null
|
||||
{
|
||||
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
|
||||
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings, $flatten);
|
||||
}
|
||||
|
||||
public function storeSettings(array $settings): bool
|
||||
|
||||
@@ -36,17 +36,17 @@ class SessionIdentity
|
||||
|
||||
public function mailAddress(): ?string
|
||||
{
|
||||
return $this->identityData?->getEmail();
|
||||
return $this->identityData?->getIdentity();
|
||||
}
|
||||
|
||||
public function nameFirst(): ?string
|
||||
{
|
||||
return $this->identityData?->getFirstName();
|
||||
return null;
|
||||
}
|
||||
|
||||
public function nameLast(): ?string
|
||||
{
|
||||
return $this->identityData?->getLastName();
|
||||
return null;
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
|
||||
@@ -37,6 +37,26 @@ class SessionTenant
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the tenant by its identifier (for console / CLI usage).
|
||||
*/
|
||||
public function configureById(string $identifier): void
|
||||
{
|
||||
if ($this->configured) {
|
||||
return;
|
||||
}
|
||||
$tenant = $this->tenantService->fetchById($identifier);
|
||||
if ($tenant) {
|
||||
$this->domain = $identifier;
|
||||
$this->tenant = $tenant;
|
||||
$this->configured = true;
|
||||
} else {
|
||||
$this->domain = null;
|
||||
$this->tenant = null;
|
||||
$this->configured = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the tenant configured
|
||||
*/
|
||||
|
||||
@@ -72,4 +72,57 @@ class TenantStore
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Fetch settings for a tenant, optionally filtered to specific keys.
|
||||
*
|
||||
* @param string $identifier Tenant identifier
|
||||
* @param string[] $keys Optional list of keys to return; empty = all
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function fetchSettings(string $identifier, array $keys = []): array
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(
|
||||
['identifier' => $identifier],
|
||||
['projection' => ['settings' => 1]]
|
||||
);
|
||||
|
||||
$settings = (array)($entry['settings'] ?? []);
|
||||
|
||||
if (empty($keys)) {
|
||||
return $settings;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($keys as $key) {
|
||||
$result[$key] = $settings[$key] ?? null;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge-update settings for a tenant using atomic $set on sub-fields.
|
||||
*
|
||||
* @param string $identifier Tenant identifier
|
||||
* @param array<string, mixed> $settings Key-value pairs to persist
|
||||
*/
|
||||
public function storeSettings(string $identifier, array $settings): bool
|
||||
{
|
||||
$updates = [];
|
||||
foreach ($settings as $key => $value) {
|
||||
$updates["settings.{$key}"] = $value;
|
||||
}
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(
|
||||
['identifier' => $identifier],
|
||||
['$set' => $updates]
|
||||
);
|
||||
|
||||
return $result->getMatchedCount() > 0;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -249,7 +249,7 @@ class UserAccountsStore
|
||||
// Settings Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchSettings(string $tenant, string $uid, array $settings = []): ?array
|
||||
public function fetchSettings(string $tenant, string $uid, array $settings = [], bool $flatten = false): ?array
|
||||
{
|
||||
// Only fetch the settings field from the database
|
||||
$user = $this->store->selectCollection('user_accounts')->findOne(
|
||||
@@ -264,12 +264,39 @@ class UserAccountsStore
|
||||
$userSettings = $user['settings'] ?? [];
|
||||
|
||||
if (empty($settings)) {
|
||||
return $userSettings;
|
||||
return $flatten ? $this->flattenSettings($userSettings) : $userSettings;
|
||||
}
|
||||
|
||||
// Specific keys are always returned in dot-notation (already flat)
|
||||
$result = [];
|
||||
foreach ($settings as $key) {
|
||||
$result[$key] = $userSettings[$key] ?? null;
|
||||
$parts = explode('.', $key);
|
||||
$value = $userSettings;
|
||||
foreach ($parts as $part) {
|
||||
if (!is_array($value) || !array_key_exists($part, $value)) {
|
||||
$value = null;
|
||||
break;
|
||||
}
|
||||
$value = $value[$part];
|
||||
}
|
||||
$result[$key] = $value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively flatten a nested settings array into dot-notation keys.
|
||||
*/
|
||||
private function flattenSettings(array $settings, string $prefix = ''): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($settings as $key => $value) {
|
||||
$fullKey = $prefix !== '' ? "{$prefix}.{$key}" : (string) $key;
|
||||
if (is_array($value)) {
|
||||
$result = array_merge($result, $this->flattenSettings($value, $fullKey));
|
||||
} else {
|
||||
$result[$fullKey] = $value;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
<?php
|
||||
|
||||
use KTXC\Application;
|
||||
use KTXC\Module\ModuleAutoloader;
|
||||
use KTXC\Server;
|
||||
|
||||
// Capture Composer ClassLoader instance
|
||||
// Capture Composer ClassLoader instance for compatibility
|
||||
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// Determine project root (one level up from this file)
|
||||
$projectRoot = dirname(__DIR__);
|
||||
$server = new Server(dirname(__DIR__));
|
||||
Server::setComposerLoader($composerLoader);
|
||||
|
||||
// Create and run application
|
||||
$app = new Application($projectRoot);
|
||||
|
||||
// Store composer loader for compatibility
|
||||
Application::setComposerLoader($composerLoader);
|
||||
|
||||
// Register custom module autoloader for lazy loading
|
||||
$moduleAutoloader = new ModuleAutoloader($app->moduleDir());
|
||||
$moduleAutoloader->register();
|
||||
|
||||
$app->run();
|
||||
$server->runHttp();
|
||||
@@ -1,20 +1,73 @@
|
||||
<template>
|
||||
<RouterView></RouterView>
|
||||
<SharedSnackbar />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import { onMounted } from 'vue';
|
||||
import { onMounted, watch } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
import SharedSnackbar from '@KTXC/components/shared/SharedSnackbar.vue';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { useTenantStore } from '@KTXC/stores/tenantStore';
|
||||
|
||||
const theme = useTheme();
|
||||
const layoutStore = useLayoutStore();
|
||||
const userStore = useUserStore();
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// Maps user/tenant setting keys → Vuetify color token names
|
||||
const COLOR_SETTINGS: Array<{ token: string; key: string }> = [
|
||||
{ token: 'primary', key: 'primary_color' },
|
||||
{ token: 'secondary', key: 'secondary_color' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Apply brand color overrides from stored preferences to all Vuetify theme
|
||||
* variants (light & dark). Tenant colors take priority when lock is active.
|
||||
*/
|
||||
function applyThemeColors(): void {
|
||||
const locked = tenantStore.getSetting('lock_user_colors') as boolean | null;
|
||||
|
||||
for (const { token, key } of COLOR_SETTINGS) {
|
||||
const value = locked
|
||||
? ((tenantStore.getSetting(key) as string | null) ?? (userStore.getSetting(key) as string | null))
|
||||
: ((userStore.getSetting(key) as string | null) ?? (tenantStore.getSetting(key) as string | null));
|
||||
|
||||
if (value) {
|
||||
for (const variant of Object.keys(theme.themes.value)) {
|
||||
theme.themes.value[variant].colors[token] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply font preference via CSS custom property and body style. */
|
||||
function applyFont(): void {
|
||||
const font =
|
||||
((userStore.getSetting('font') as string | null) ??
|
||||
(tenantStore.getSetting('font') as string | null));
|
||||
|
||||
if (font && font !== 'Public Sans') {
|
||||
document.documentElement.style.setProperty('--themer-font', font);
|
||||
document.body.style.fontFamily = `"${font}", sans-serif`;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply saved theme on mount
|
||||
onMounted(() => {
|
||||
// Apply saved theme mode
|
||||
if (layoutStore.theme) {
|
||||
theme.global.name.value = layoutStore.theme;
|
||||
}
|
||||
|
||||
applyThemeColors();
|
||||
applyFont();
|
||||
});
|
||||
|
||||
// Re-apply whenever tenant settings change (e.g. admin saves new brand colors)
|
||||
watch(() => tenantStore.settings, () => {
|
||||
applyThemeColors();
|
||||
applyFont();
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
29
core/src/components/shared/SharedSnackbar.vue
Normal file
29
core/src/components/shared/SharedSnackbar.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { useSnackbar } from '@KTXC/composables/useSnackbar'
|
||||
|
||||
const {
|
||||
snackbarVisible,
|
||||
snackbarMessage,
|
||||
snackbarColor,
|
||||
snackbarTimeout,
|
||||
hideSnackbar,
|
||||
} = useSnackbar()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-snackbar
|
||||
:model-value="snackbarVisible"
|
||||
:color="snackbarColor"
|
||||
:timeout="snackbarTimeout"
|
||||
location="bottom right"
|
||||
@update:model-value="value => !value && hideSnackbar()"
|
||||
>
|
||||
{{ snackbarMessage }}
|
||||
|
||||
<template #actions>
|
||||
<v-btn variant="text" @click="hideSnackbar()">
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
@@ -2,7 +2,7 @@
|
||||
* Simple snackbar/toast notification composable
|
||||
* Uses Vuetify's snackbar component
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
export interface SnackbarOptions {
|
||||
message: string
|
||||
@@ -10,21 +10,60 @@ export interface SnackbarOptions {
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
interface SnackbarState {
|
||||
message: string
|
||||
color: string
|
||||
timeout: number
|
||||
}
|
||||
|
||||
const snackbarVisible = ref(false)
|
||||
const snackbarMessage = ref('')
|
||||
const snackbarColor = ref<string>('info')
|
||||
const snackbarTimeout = ref(3000)
|
||||
const snackbarQueue = ref<SnackbarState[]>([])
|
||||
|
||||
function showNextSnackbar() {
|
||||
if (snackbarVisible.value || snackbarQueue.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextSnackbar = snackbarQueue.value.shift()
|
||||
|
||||
if (!nextSnackbar) {
|
||||
return
|
||||
}
|
||||
|
||||
snackbarMessage.value = nextSnackbar.message
|
||||
snackbarColor.value = nextSnackbar.color
|
||||
snackbarTimeout.value = nextSnackbar.timeout
|
||||
snackbarVisible.value = true
|
||||
}
|
||||
|
||||
export function useSnackbar() {
|
||||
const showSnackbar = (options: SnackbarOptions) => {
|
||||
snackbarMessage.value = options.message
|
||||
snackbarColor.value = options.color || 'info'
|
||||
snackbarTimeout.value = options.timeout || 3000
|
||||
snackbarVisible.value = true
|
||||
|
||||
const notification = {
|
||||
message: options.message,
|
||||
color: options.color || 'info',
|
||||
timeout: options.timeout || 3000,
|
||||
}
|
||||
|
||||
if (!snackbarVisible.value && snackbarQueue.value.length === 0) {
|
||||
snackbarMessage.value = notification.message
|
||||
snackbarColor.value = notification.color
|
||||
snackbarTimeout.value = notification.timeout
|
||||
snackbarVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
snackbarQueue.value = [...snackbarQueue.value, notification]
|
||||
}
|
||||
|
||||
const hideSnackbar = () => {
|
||||
snackbarVisible.value = false
|
||||
void nextTick(() => {
|
||||
showNextSnackbar()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -32,6 +71,7 @@ export function useSnackbar() {
|
||||
snackbarMessage,
|
||||
snackbarColor,
|
||||
snackbarTimeout,
|
||||
snackbarQueue,
|
||||
showSnackbar,
|
||||
hideSnackbar,
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export type ConfigProps = {
|
||||
Sidebar_drawer: boolean;
|
||||
mini_sidebar: boolean;
|
||||
actTheme: string;
|
||||
fontTheme: string;
|
||||
};
|
||||
|
||||
const config: ConfigProps = {
|
||||
Sidebar_drawer: true,
|
||||
mini_sidebar: false,
|
||||
actTheme: 'light',
|
||||
fontTheme: 'Public sans'
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -9,7 +9,7 @@
|
||||
"vue": "/vendor/vue.mjs",
|
||||
"vue-router": "/vendor/vue-router.mjs",
|
||||
"pinia": "/vendor/pinia.mjs",
|
||||
"@KTXC/utils/helpers/fetch-wrapper-core": "/js/shared-utils.js"
|
||||
"@KTXC": "/js/ktxc.mjs"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,15 +9,12 @@ import { useTenantStore } from '@KTXC/stores/tenantStore'
|
||||
import { useUserStore } from '@KTXC/stores/userStore'
|
||||
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper'
|
||||
import { initializeModules } from '@KTXC/utils/modules'
|
||||
import { createSessionMonitor } from '@KTXC/services/authManager'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import vuetify from './plugins/vuetify/index'
|
||||
import '@KTXC/scss/style.scss'
|
||||
|
||||
// Material Design Icons (Vuetify mdi icon set)
|
||||
import '@mdi/font/css/materialdesignicons.min.css'
|
||||
|
||||
// google-fonts
|
||||
import '@fontsource/public-sans/index.css'
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -26,8 +23,6 @@ app.use(pinia)
|
||||
app.use(PerfectScrollbarPlugin)
|
||||
app.use(vuetify)
|
||||
|
||||
// Note: Router is registered AFTER modules are loaded to prevent premature route matching
|
||||
|
||||
const globalWindow = window as typeof window & {
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -37,7 +32,6 @@ globalWindow.vue = Vue
|
||||
globalWindow.VueRouter = VueRouterLib
|
||||
globalWindow.Pinia = PiniaLib as unknown
|
||||
|
||||
// Bootstrap initial private UI state (modules, tenant, user) before mounting
|
||||
(async () => {
|
||||
const moduleStore = useModuleStore();
|
||||
const tenantStore = useTenantStore();
|
||||
@@ -48,23 +42,23 @@ globalWindow.Pinia = PiniaLib as unknown
|
||||
moduleStore.init(payload?.modules ?? {});
|
||||
tenantStore.init(payload?.tenant ?? null);
|
||||
userStore.init(payload?.user ?? {});
|
||||
|
||||
// Initialize auth session monitor
|
||||
const sessionMonitor = createSessionMonitor({ onLogout: () => userStore.logout() });
|
||||
sessionMonitor.start();
|
||||
|
||||
// Initialize registered modules (following reference app's bootstrap pattern)
|
||||
// Initialize registered modules
|
||||
await initializeModules(app);
|
||||
|
||||
// Add 404 catch-all route AFTER all modules are loaded
|
||||
// This ensures module routes are registered before the catch-all
|
||||
// Register a catch-all and router
|
||||
router.addRoute({
|
||||
name: 'NotFound',
|
||||
path: '/:pathMatch(.*)*',
|
||||
component: () => import('@KTXC/views/pages/maintenance/error/Error404Page.vue')
|
||||
});
|
||||
|
||||
// Register router AFTER modules are loaded
|
||||
app.use(router);
|
||||
|
||||
await router.isReady();
|
||||
// Home redirect handled by router beforeEnter
|
||||
|
||||
app.mount('#app');
|
||||
} catch (e) {
|
||||
console.error('Bootstrap failed:', e);
|
||||
|
||||
@@ -3,16 +3,10 @@ import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import vuetify from './plugins/vuetify/index';
|
||||
import '@KTXC/scss/style.scss';
|
||||
|
||||
// Material Design Icons (Vuetify mdi icon set)
|
||||
import '@mdi/font/css/materialdesignicons.min.css';
|
||||
|
||||
// google-fonts
|
||||
import '@fontsource/public-sans/400.css';
|
||||
import '@fontsource/public-sans/500.css';
|
||||
import '@fontsource/public-sans/600.css';
|
||||
import '@fontsource/public-sans/700.css';
|
||||
import '@fontsource/public-sans/index.css'
|
||||
|
||||
// The public app is served when the user has no valid server session.
|
||||
// Clear any stale identity data from localStorage to ensure the client
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
html {
|
||||
.bg-success,
|
||||
.bg-info,
|
||||
.bg-warning {
|
||||
color: white !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-row + .v-row {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
opacity: 1;
|
||||
border-color: rgb(var(--v-theme-borderLight));
|
||||
}
|
||||
|
||||
.v-table > .v-table__wrapper > table > thead > tr > th {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.border-blue-right {
|
||||
border-right: 1px solid rgba(var(--v-theme-borderLight), 0.36);
|
||||
}
|
||||
|
||||
.link-hover {
|
||||
text-decoration: unset;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.v-selection-control {
|
||||
flex: unset;
|
||||
}
|
||||
|
||||
.customizer-btn .icon {
|
||||
animation: progress-circular-rotate 1.4s linear infinite;
|
||||
transform-origin: center center;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.no-spacer {
|
||||
.v-list-item__spacer {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-circular-rotate {
|
||||
100% {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
&.v-toolbar--border {
|
||||
border-color: rgb(var(--v-theme-borderLight));
|
||||
}
|
||||
}
|
||||
|
||||
.v-toolbar {
|
||||
&.v-app-bar {
|
||||
border-bottom: 1px solid rgba(var(--v-theme-borderLight), 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.v-sheet--border {
|
||||
border: 1px solid rgba(var(--v-theme-borderLight), 0.8);
|
||||
}
|
||||
|
||||
// table css
|
||||
.v-table {
|
||||
&.v-table--hover {
|
||||
> .v-table__wrapper {
|
||||
> table {
|
||||
> tbody {
|
||||
> tr {
|
||||
&:hover {
|
||||
td {
|
||||
background: rgb(var(--v-theme-gray100));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// accordion page css
|
||||
.v-expansion-panel {
|
||||
border: 1px solid rgb(var(--v-theme-borderLight));
|
||||
&:not(:first-child) {
|
||||
margin-top: -1px;
|
||||
}
|
||||
.v-expansion-panel-text__wrapper {
|
||||
border-top: 1px solid rgb(var(--v-theme-borderLight));
|
||||
padding: 16px 24px;
|
||||
}
|
||||
&.v-expansion-panel--active {
|
||||
.v-expansion-panel-title--active {
|
||||
.v-expansion-panel-title__overlay {
|
||||
background-color: rgb(var(--v-theme-gray100));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-expansion-panel--active {
|
||||
> .v-expansion-panel-title {
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
.v-expansion-panel--disabled .v-expansion-panel-title {
|
||||
color: rgba(var(--v-theme-on-surface), 0.15);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
@use 'sass:math';
|
||||
@use 'sass:map';
|
||||
@use 'sass:meta';
|
||||
@use 'vuetify/lib/styles/tools/functions' as *;
|
||||
|
||||
// This will false all colors which is not necessory for theme
|
||||
$color-pack: false;
|
||||
|
||||
// Global font size and border radius
|
||||
$font-size-root: 1rem;
|
||||
$border-radius-root: 4px;
|
||||
$body-font-family: 'Public sans', sans-serif !default;
|
||||
$heading-font-family: $body-font-family !default;
|
||||
$btn-font-weight: 400 !default;
|
||||
$btn-letter-spacing: 0 !default;
|
||||
|
||||
// Global Radius as per breakeven point
|
||||
$rounded: () !default;
|
||||
$rounded: map-deep-merge(
|
||||
(
|
||||
0: 0,
|
||||
'sm': $border-radius-root * 0.5,
|
||||
null: $border-radius-root,
|
||||
'md': $border-radius-root * 1,
|
||||
'lg': $border-radius-root * 2,
|
||||
'xl': $border-radius-root * 6,
|
||||
'pill': 9999px,
|
||||
'circle': 50%,
|
||||
'shaped': $border-radius-root * 6 0
|
||||
),
|
||||
$rounded
|
||||
);
|
||||
// Global Typography
|
||||
$typography: () !default;
|
||||
$typography: map-deep-merge(
|
||||
(
|
||||
'h1': (
|
||||
'size': 2.375rem,
|
||||
'weight': 600,
|
||||
'line-height': 1.21,
|
||||
'font-family': inherit
|
||||
),
|
||||
'h2': (
|
||||
'size': 1.875rem,
|
||||
'weight': 600,
|
||||
'line-height': 1.27,
|
||||
'font-family': inherit
|
||||
),
|
||||
'h3': (
|
||||
'size': 1.5rem,
|
||||
'weight': 600,
|
||||
'line-height': 1.33,
|
||||
'font-family': inherit
|
||||
),
|
||||
'h4': (
|
||||
'size': 1.25rem,
|
||||
'weight': 600,
|
||||
'line-height': 1.4,
|
||||
'font-family': inherit
|
||||
),
|
||||
'h5': (
|
||||
'size': 1rem,
|
||||
'weight': 600,
|
||||
'line-height': 1.5,
|
||||
'font-family': inherit
|
||||
),
|
||||
'h6': (
|
||||
'size': 0.875rem,
|
||||
'weight': 400,
|
||||
'line-height': 1.57,
|
||||
'font-family': inherit
|
||||
),
|
||||
'subtitle-1': (
|
||||
'size': 0.875rem,
|
||||
'weight': 600,
|
||||
'line-height': 1.57,
|
||||
'font-family': inherit
|
||||
),
|
||||
'subtitle-2': (
|
||||
'size': 0.75rem,
|
||||
'weight': 500,
|
||||
'line-height': 1.66,
|
||||
'font-family': inherit
|
||||
),
|
||||
'body-1': (
|
||||
'size': 0.875rem,
|
||||
'weight': 400,
|
||||
'line-height': 1.57,
|
||||
'font-family': inherit
|
||||
),
|
||||
'body-2': (
|
||||
'size': 0.75rem,
|
||||
'weight': 400,
|
||||
'line-height': 1.66,
|
||||
'font-family': inherit
|
||||
),
|
||||
'button': (
|
||||
'size': 0.875rem,
|
||||
'weight': 500,
|
||||
'font-family': inherit,
|
||||
'text-transform': uppercase
|
||||
),
|
||||
'caption': (
|
||||
'size': 0.75rem,
|
||||
'weight': 400,
|
||||
'letter-spacing': 0,
|
||||
'font-family': inherit
|
||||
),
|
||||
'overline': (
|
||||
'size': 0.75rem,
|
||||
'weight': 500,
|
||||
'font-family': inherit,
|
||||
'line-height': 1.67,
|
||||
'letter-spacing': 0,
|
||||
'text-transform': uppercase
|
||||
)
|
||||
),
|
||||
$typography
|
||||
);
|
||||
|
||||
// Custom Variables
|
||||
// colors
|
||||
$white: #fff !default;
|
||||
|
||||
// cards
|
||||
$card-item-spacer-xy: 20px !default;
|
||||
$card-text-spacer: 20px !default;
|
||||
$card-title-size: 16px !default;
|
||||
|
||||
// Global Shadow
|
||||
$box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.08);
|
||||
|
||||
$theme-colors: (
|
||||
primary: var(--v-theme-primary),
|
||||
secondary: var(--v-theme-secondary),
|
||||
success: var(--v-theme-success),
|
||||
info: var(--v-theme-info),
|
||||
warning: var(--v-theme-warning),
|
||||
error: var(--v-theme-error)
|
||||
);
|
||||
@@ -1,37 +0,0 @@
|
||||
.single-line-alert {
|
||||
.v-alert__close,
|
||||
.v-alert__prepend {
|
||||
align-self: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-alert__prepend {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.v-alert--variant-tonal {
|
||||
&.with-border {
|
||||
@each $color, $value in $theme-colors {
|
||||
&.text-#{$color} {
|
||||
border: 1px solid rgba(#{$value}, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.single-line-alert {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.v-alert__append {
|
||||
margin-inline-start: 0px;
|
||||
}
|
||||
.v-alert__close {
|
||||
margin-left: auto;
|
||||
}
|
||||
.v-alert__content {
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.v-badge__badge {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
.v-badge--dot {
|
||||
.v-badge__badge {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
.v-breadcrumbs-item--link {
|
||||
color: rgb(var(--v-theme-lightText));
|
||||
}
|
||||
.v-breadcrumbs {
|
||||
.v-breadcrumbs-item--disabled {
|
||||
--v-disabled-opacity: 1;
|
||||
.v-breadcrumbs-item--link {
|
||||
color: rgb(var(--v-theme-darkText));
|
||||
}
|
||||
}
|
||||
.v-breadcrumbs-divider {
|
||||
color: rgb(var(--v-theme-lightText));
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-with-title {
|
||||
.v-toolbar__content {
|
||||
height: unset !important;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.v-breadcrumbs__prepend {
|
||||
svg {
|
||||
vertical-align: -3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-height {
|
||||
.v-toolbar__content {
|
||||
height: unset !important;
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
//
|
||||
// Light Buttons
|
||||
//
|
||||
|
||||
.v-btn {
|
||||
&.bg-lightprimary {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background-color: rgb(var(--v-theme-primary)) !important;
|
||||
color: $white !important;
|
||||
}
|
||||
}
|
||||
&.bg-lightsecondary {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background-color: rgb(var(--v-theme-secondary)) !important;
|
||||
color: $white !important;
|
||||
}
|
||||
}
|
||||
&.text-facebook {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background-color: rgb(var(--v-theme-facebook)) !important;
|
||||
color: $white !important;
|
||||
}
|
||||
}
|
||||
&.text-twitter {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background-color: rgb(var(--v-theme-twitter)) !important;
|
||||
color: $white !important;
|
||||
}
|
||||
}
|
||||
&.text-linkedin {
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background-color: rgb(var(--v-theme-linkedin)) !important;
|
||||
color: $white !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
text-transform: capitalize;
|
||||
letter-spacing: $btn-letter-spacing;
|
||||
font-weight: 400;
|
||||
}
|
||||
.v-btn--icon.v-btn--density-default {
|
||||
width: calc(var(--v-btn-height) + 6px);
|
||||
height: calc(var(--v-btn-height) + 6px);
|
||||
}
|
||||
|
||||
.v-btn-group .v-btn {
|
||||
height: inherit !important;
|
||||
}
|
||||
|
||||
.v-btn-group {
|
||||
border-color: rgba(var(--v-border-color), 1);
|
||||
}
|
||||
|
||||
.v-btn-group--divided .v-btn:not(:last-child) {
|
||||
border-inline-end-color: rgba(var(--v-border-color), 1);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Outline Card
|
||||
.v-card--variant-outlined {
|
||||
border-color: rgba(var(--v-theme-borderLight), 1);
|
||||
.v-divider {
|
||||
border-color: rgba(var(--v-theme-borderLight), 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.v-card-text {
|
||||
padding: $card-text-spacer;
|
||||
}
|
||||
|
||||
.v-card-actions {
|
||||
padding: 14px $card-text-spacer 14px;
|
||||
}
|
||||
|
||||
.v-card {
|
||||
overflow: visible;
|
||||
.v-card-title {
|
||||
&.text-h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.57;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-card-item {
|
||||
padding: $card-item-spacer-xy;
|
||||
}
|
||||
|
||||
.v-card-subtitle {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.title-card {
|
||||
.v-card-text {
|
||||
background-color: rgb(var(--v-theme-background));
|
||||
border: 1px solid rgba(var(--v-theme-borderLight), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.v-field--variant-outlined .v-field__outline__start.v-locale--is-ltr,
|
||||
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__start {
|
||||
border-radius: $border-radius-root 0 0 $border-radius-root;
|
||||
}
|
||||
|
||||
.v-field--variant-outlined .v-field__outline__end.v-locale--is-ltr,
|
||||
.v-locale--is-ltr .v-field--variant-outlined .v-field__outline__end {
|
||||
border-radius: 0 $border-radius-root $border-radius-root 0;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
.v-input--density-default:not(.v-autocomplete--multiple),
|
||||
.v-field--variant-solo,
|
||||
.v-field--variant-filled {
|
||||
--v-input-control-height: 39px;
|
||||
--v-input-padding-top: 2px;
|
||||
input.v-field__input {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.v-field__input {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
textarea {
|
||||
padding-top: 11px;
|
||||
}
|
||||
}
|
||||
.v-input--density-default {
|
||||
.v-field__input {
|
||||
min-height: 41px;
|
||||
}
|
||||
}
|
||||
.v-field--variant-outlined {
|
||||
&.v-field--focused {
|
||||
.v-field__outline {
|
||||
--v-field-border-width: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-input {
|
||||
.v-input__details {
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.v-input--density-comfortable {
|
||||
--v-input-control-height: 56px;
|
||||
--v-input-padding-top: 17px;
|
||||
}
|
||||
.v-label {
|
||||
font-size: 0.875rem;
|
||||
--v-medium-emphasis-opacity: 0.8;
|
||||
}
|
||||
.v-switch .v-label,
|
||||
.v-checkbox .v-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
textarea.v-field__input {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.textarea-input {
|
||||
.v-label {
|
||||
top: 15px;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
.v-list-item {
|
||||
&.v-list-item--border {
|
||||
border-color: rgb(var(--v-border-color));
|
||||
border-width: 0 0 1px 0;
|
||||
&:last-child {
|
||||
border-width: 0;
|
||||
}
|
||||
}
|
||||
&.v-list-item--variant-tonal {
|
||||
background: rgb(var(--v-theme-gray100));
|
||||
.v-list-item__underlay {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
.v-list-item__content {
|
||||
.v-divider--inset {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-list {
|
||||
&[aria-busy='true'] {
|
||||
cursor: context-menu;
|
||||
}
|
||||
}
|
||||
.v-list-group__items {
|
||||
.v-list-item {
|
||||
padding-inline-start: 40px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.v-list-item__content {
|
||||
.v-divider--inset:not(.v-divider--vertical) {
|
||||
max-width: 100%;
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.v-list--border {
|
||||
.v-list-item {
|
||||
+ .v-list-item {
|
||||
border-top: 1px solid rgb(var(--v-theme-borderLight));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.v-navigation-drawer__scrim.fade-transition-leave-to {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
.elevation-24 {
|
||||
box-shadow: $box-shadow !important;
|
||||
}
|
||||
|
||||
.v-menu {
|
||||
> .v-overlay__content {
|
||||
> .v-sheet {
|
||||
box-shadow: $box-shadow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@each $color, $value in $theme-colors {
|
||||
.#{$color}-shadow {
|
||||
box-shadow: 0 14px 12px rgba(#{$value}, 0.2);
|
||||
&:hover {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.v-text-field input {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.v-field__outline {
|
||||
color: rgb(var(--v-theme-inputBorder));
|
||||
}
|
||||
.inputWithbg {
|
||||
.v-field--variant-outlined {
|
||||
background-color: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
}
|
||||
|
||||
.v-select {
|
||||
.v-field {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
.v-textarea input {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
&::placeholder {
|
||||
color: rgba(0, 0, 0, 0.38);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
html {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.horizontalLayout {
|
||||
.page-wrapper {
|
||||
.v-container {
|
||||
padding-top: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.spacer {
|
||||
padding: 100px 0;
|
||||
@media (max-width: 1264px) {
|
||||
padding: 72px 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) {
|
||||
.spacer {
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.page-wrapper {
|
||||
background: rgb(var(--v-theme-containerBg));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 60px);
|
||||
overflow: hidden;
|
||||
|
||||
.page-content-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
@media (max-width: 1550px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
padding-inline: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-footer-container {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.v-container {
|
||||
padding: 15px;
|
||||
@media (max-width: 1550px) {
|
||||
max-width: 100%;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
padding-inline: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.maxWidth {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
$sizes: (
|
||||
'display-1': 44px,
|
||||
'display-2': 40px,
|
||||
'display-3': 30px,
|
||||
'h1': 36px,
|
||||
'h2': 30px,
|
||||
'h3': 21px,
|
||||
'h4': 18px,
|
||||
'h5': 16px,
|
||||
'h6': 14px,
|
||||
'text-8': 8px,
|
||||
'text-10': 10px,
|
||||
'text-13': 13px,
|
||||
'text-18': 18px,
|
||||
'text-20': 20px,
|
||||
'text-24': 24px,
|
||||
'body-text-1': 10px
|
||||
);
|
||||
|
||||
@each $pixel, $size in $sizes {
|
||||
.#{$pixel} {
|
||||
font-size: $size;
|
||||
line-height: $size + 10;
|
||||
}
|
||||
}
|
||||
|
||||
.customizer-btn {
|
||||
.icon {
|
||||
animation: progress-circular-rotate 1.4s linear infinite;
|
||||
transform-origin: center center;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
.fixed-width {
|
||||
max-width: 1300px;
|
||||
}
|
||||
.ga-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// font family
|
||||
body {
|
||||
font-family: 'Public Sans', sans-serif;
|
||||
.Roboto {
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
.Poppins {
|
||||
font-family: 'Poppins', sans-serif !important;
|
||||
}
|
||||
|
||||
.Inter {
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
}
|
||||
|
||||
.Public {
|
||||
font-family: 'Public sans', sans-serif !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideY {
|
||||
0%,
|
||||
50%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: rgb(var(--v-theme-lightText));
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
.v-footer {
|
||||
background: rgb(var(--v-theme-containerbg));
|
||||
padding: 24px 16px 0px;
|
||||
margin-top: auto;
|
||||
position: unset;
|
||||
a {
|
||||
text-decoration: unset;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 475px) {
|
||||
.footer {
|
||||
text-align: center;
|
||||
.v-col-6 {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
&.text-right {
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/*This is for the logo*/
|
||||
.leftSidebar {
|
||||
border: 0px;
|
||||
box-shadow: none !important;
|
||||
border-right: 1px solid rgba(var(--v-theme-borderLight), 0.8);
|
||||
.logo {
|
||||
padding-left: 7px;
|
||||
}
|
||||
}
|
||||
/*This is for the Vertical sidebar*/
|
||||
.scrollnavbar {
|
||||
height: calc(100vh - 110px);
|
||||
.smallCap {
|
||||
padding: 0px 0 0 20px !important;
|
||||
}
|
||||
.v-list {
|
||||
color: rgb(var(--v-theme-lightText));
|
||||
padding: 0;
|
||||
.v-list-item--one-line {
|
||||
&.v-list-item--active {
|
||||
border-right: 2px solid rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
.v-list-group {
|
||||
.v-list-item--one-line {
|
||||
&.v-list-item--active.v-list-item--link {
|
||||
border-right: 2px solid rgb(var(--v-theme-primary));
|
||||
}
|
||||
&.v-list-item--active.v-list-group__header {
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
.v-list-group__items {
|
||||
.v-list-item--link,
|
||||
.v-list-item {
|
||||
.v-list-item__prepend {
|
||||
margin-inline-end: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-list-item--variant-plain,
|
||||
.v-list-item--variant-outlined,
|
||||
.v-list-item--variant-text,
|
||||
.v-list-item--variant-tonal {
|
||||
color: rgb(var(--v-theme-darkText));
|
||||
}
|
||||
}
|
||||
/*General Menu css*/
|
||||
.v-list-group__items .v-list-item,
|
||||
.v-list-item {
|
||||
border-radius: 0;
|
||||
padding-inline-start: calc(20px + var(--indent-padding) / 2) !important;
|
||||
.v-list-item__prepend {
|
||||
margin-inline-end: 13px;
|
||||
}
|
||||
.v-list-item__append {
|
||||
font-size: 0.875rem;
|
||||
.v-icon {
|
||||
margin-inline-start: 13px;
|
||||
}
|
||||
> .v-icon {
|
||||
--v-medium-emphasis-opacity: 0.8;
|
||||
}
|
||||
}
|
||||
.v-list-item-title {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--v-theme-darkText));
|
||||
}
|
||||
&.v-list-item--active {
|
||||
.v-list-item-title {
|
||||
color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
}
|
||||
}
|
||||
/*This is for the dropdown*/
|
||||
.v-list {
|
||||
.v-list-item--active {
|
||||
.v-list-item-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
.sidebarchip .v-icon {
|
||||
margin-inline-start: -3px;
|
||||
}
|
||||
.v-list-group {
|
||||
.v-list-item:focus-visible > .v-list-item__overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
> .v-list-group {
|
||||
position: relative;
|
||||
> .v-list-item--active,
|
||||
> .v-list-item:hover {
|
||||
background: rgb(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-navigation-drawer--rail {
|
||||
.scrollnavbar .v-list .v-list-group__items,
|
||||
.hide-menu {
|
||||
opacity: 0;
|
||||
}
|
||||
.scrollnavbar {
|
||||
.v-list-item {
|
||||
.v-list-item__prepend {
|
||||
margin-left: 8px;
|
||||
.anticon {
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.v-list-group__items .v-list-item,
|
||||
.v-list-item {
|
||||
padding-inline-start: calc(12px + var(--indent-padding) / 2) !important;
|
||||
}
|
||||
.ExtraBox {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.sidebar-user {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leftPadding {
|
||||
margin-left: 0px;
|
||||
}
|
||||
&.leftSidebar {
|
||||
.v-list-subheader {
|
||||
display: none;
|
||||
}
|
||||
.v-navigation-drawer__content {
|
||||
.pa-5 {
|
||||
padding-left: 10px !important;
|
||||
.logo {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@media only screen and (min-width: 1170px) {
|
||||
.mini-sidebar {
|
||||
.logo {
|
||||
width: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leftSidebar:hover {
|
||||
box-shadow: $box-shadow !important;
|
||||
}
|
||||
.v-navigation-drawer--expand-on-hover:hover {
|
||||
.logo {
|
||||
width: 100%;
|
||||
}
|
||||
.v-list .v-list-group__items,
|
||||
.hide-menu {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
.profileBtn {
|
||||
height: 44px !important;
|
||||
margin: 0 20px 0 10px !important;
|
||||
padding: 0 6px;
|
||||
.v-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.profileBtn {
|
||||
min-width: 42px;
|
||||
margin: 0 12px 0 0 !important;
|
||||
.v-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.notification-dropdown {
|
||||
width: 332px !important;
|
||||
.v-list-item__content {
|
||||
.d-inline-flex {
|
||||
.text-caption {
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
@import './variables';
|
||||
@import 'vuetify/styles/main.sass';
|
||||
@import './override';
|
||||
@import './layout/container';
|
||||
@import './layout/sidebar';
|
||||
@import './layout/footer';
|
||||
@import './layout/topbar';
|
||||
|
||||
@import 'vue3-perfect-scrollbar/style.css';
|
||||
82
core/src/services/authManager.ts
Normal file
82
core/src/services/authManager.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Authentication Manager
|
||||
*/
|
||||
|
||||
const REFRESH_URL = '/auth/refresh';
|
||||
const PING_URL = '/auth/ping';
|
||||
const TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
|
||||
const PING_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
let tokenExpiresAt: number | null = null;
|
||||
let refreshPromise: Promise<boolean> | null = null;
|
||||
|
||||
export function recordExpiry(expiresIn: number | null | undefined): void {
|
||||
tokenExpiresAt = expiresIn != null ? Date.now() + expiresIn * 1000 : null;
|
||||
}
|
||||
|
||||
function isTokenExpiredOrExpiring(): boolean {
|
||||
if (tokenExpiresAt === null) return false;
|
||||
return Date.now() >= tokenExpiresAt - TOKEN_EXPIRY_BUFFER_MS;
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
refreshPromise = fetch(REFRESH_URL, { method: 'POST', credentials: 'include' })
|
||||
.then(async r => {
|
||||
if (!r.ok) return false;
|
||||
try {
|
||||
const body = await r.clone().json();
|
||||
recordExpiry(body?.expires_in);
|
||||
} catch { /* ignore parse errors */ }
|
||||
return true;
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => { refreshPromise = null; });
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the token is fresh before a request fires.
|
||||
*/
|
||||
export async function ensureFreshToken(): Promise<void> {
|
||||
if (isTokenExpiredOrExpiring()) {
|
||||
await refreshToken();
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionMonitorOptions {
|
||||
onLogout: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function createSessionMonitor(options: SessionMonitorOptions) {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function ping(): Promise<void> {
|
||||
// GET ping — no body, no CSRF needed, just confirm the session cookie is still valid
|
||||
const response = await fetch(PING_URL, { credentials: 'include' }).catch(() => null);
|
||||
|
||||
if (!response?.ok) {
|
||||
// Session may be expired — attempt a token refresh before giving up
|
||||
const refreshed = await refreshToken();
|
||||
if (!refreshed) {
|
||||
stop();
|
||||
await options.onLogout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(ping, PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
|
||||
return { start, stop };
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const authenticationService = {
|
||||
* Initialize authentication - get session and available methods
|
||||
*/
|
||||
async start(): Promise<StartResponse> {
|
||||
return fetchWrapper.get('/auth/start', undefined, { skipLogoutOnError: true });
|
||||
return fetchWrapper.get('/auth/start');
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -20,7 +20,7 @@ export const authenticationService = {
|
||||
return fetchWrapper.post('/auth/identify', {
|
||||
session,
|
||||
identity,
|
||||
}, { skipLogoutOnError: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ export const authenticationService = {
|
||||
method,
|
||||
response,
|
||||
...(identity && { identity }),
|
||||
}, { autoRetry: false, skipLogoutOnError: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -57,7 +57,7 @@ export const authenticationService = {
|
||||
session,
|
||||
method,
|
||||
return_url: returnUrl,
|
||||
}, { skipLogoutOnError: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -67,14 +67,14 @@ export const authenticationService = {
|
||||
return fetchWrapper.post('/auth/challenge', {
|
||||
session,
|
||||
method,
|
||||
}, { skipLogoutOnError: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current session status
|
||||
*/
|
||||
async getStatus(session: string): Promise<SessionStatus> {
|
||||
return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`, undefined, { skipLogoutOnError: true });
|
||||
return fetchWrapper.get(`/auth/status?session=${encodeURIComponent(session)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
29
core/src/shared/index.ts
Normal file
29
core/src/shared/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* KTXC Host SDK
|
||||
*
|
||||
* This is the single public contract between the host application and modules.
|
||||
* Modules must import from '@KTXC' (this file), never from '@KTXC/...' sub-paths.
|
||||
* At runtime, '@KTXC' resolves to '/js/ktxc.mjs' via the import map.
|
||||
*/
|
||||
|
||||
// Stores
|
||||
export { useModuleStore } from '../stores/moduleStore'
|
||||
export { useTenantStore } from '../stores/tenantStore'
|
||||
export { useUserStore } from '../stores/userStore'
|
||||
export { useIntegrationStore } from '../stores/integrationStore'
|
||||
export { useLayoutStore } from '../stores/layoutStore'
|
||||
|
||||
// Composables
|
||||
export { useUser } from '../composables/useUser'
|
||||
export { useClipboard } from '../composables/useClipboard'
|
||||
export { useSnackbar } from '../composables/useSnackbar'
|
||||
|
||||
// Services
|
||||
export { userService } from '../services/user/userService'
|
||||
|
||||
// Utilities
|
||||
export { fetchWrapper } from '../utils/helpers/fetch-wrapper'
|
||||
export { createFetchWrapper } from '../utils/helpers/fetch-wrapper-core'
|
||||
|
||||
// Types
|
||||
export type { ModuleIntegrations } from '../types/moduleTypes'
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import config from '@KTXC/config';
|
||||
import { useUserStore } from './userStore';
|
||||
|
||||
export type MenuMode = 'apps' | 'user-settings' | 'admin-settings';
|
||||
@@ -9,15 +8,15 @@ export const useLayoutStore = defineStore('layout', () => {
|
||||
// Loading state
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Sidebar state - initialize from settings or config
|
||||
// Sidebar state - initialize from settings or defaults
|
||||
const userStore = useUserStore();
|
||||
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? config.Sidebar_drawer);
|
||||
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? config.mini_sidebar);
|
||||
const sidebarDrawer = ref(userStore.getSetting('sidebar_drawer') ?? true);
|
||||
const miniSidebar = ref(userStore.getSetting('mini_sidebar') ?? false);
|
||||
const menuMode = ref<MenuMode>('apps');
|
||||
|
||||
// Theme state - initialize from settings or config
|
||||
const theme = ref(userStore.getSetting('theme') ?? config.actTheme);
|
||||
const font = ref(userStore.getSetting('font') ?? config.fontTheme);
|
||||
// Theme state - initialize from settings or defaults
|
||||
const theme = ref(userStore.getSetting('theme') ?? 'light');
|
||||
const font = ref(userStore.getSetting('font') ?? 'Public sans');
|
||||
|
||||
// Watch and sync sidebar state to settings
|
||||
watch(sidebarDrawer, (value) => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { fetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper';
|
||||
|
||||
export interface TenantState {
|
||||
id: string | null;
|
||||
@@ -6,22 +8,117 @@ export interface TenantState {
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export const useTenantStore = defineStore('tenantStore', {
|
||||
state: () => ({
|
||||
tenant: null as TenantState | null,
|
||||
}),
|
||||
actions: {
|
||||
init(tenant: Partial<TenantState> | null) {
|
||||
this.tenant = tenant
|
||||
? {
|
||||
id: tenant.id ?? null,
|
||||
domain: tenant.domain ?? null,
|
||||
label: tenant.label ?? null,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
reset() {
|
||||
this.tenant = null;
|
||||
},
|
||||
},
|
||||
export interface TenantData extends TenantState {
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Flush pending settings writes before the page unloads
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
useTenantStore().flushSettings();
|
||||
});
|
||||
}
|
||||
|
||||
export const useTenantStore = defineStore('tenantStore', () => {
|
||||
// =========================================================================
|
||||
// State
|
||||
// =========================================================================
|
||||
|
||||
const tenant = ref<TenantState | null>(null);
|
||||
const settings = ref<Record<string, unknown>>({});
|
||||
|
||||
// Pending batch for debounced writes
|
||||
let _pendingSettings: Record<string, unknown> = {};
|
||||
let _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// =========================================================================
|
||||
// Getters
|
||||
// =========================================================================
|
||||
|
||||
function getSetting(key: string): unknown {
|
||||
return settings.value[key] ?? null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings actions
|
||||
// =========================================================================
|
||||
|
||||
function setSetting(key: string, value: unknown): void {
|
||||
settings.value[key] = value;
|
||||
|
||||
// Batch writes with a 500 ms debounce — same pattern as userService
|
||||
_pendingSettings[key] = value;
|
||||
|
||||
if (_debounceTimer !== null) {
|
||||
clearTimeout(_debounceTimer);
|
||||
}
|
||||
|
||||
_debounceTimer = setTimeout(() => {
|
||||
_flush();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function _flush(): Promise<void> {
|
||||
if (Object.keys(_pendingSettings).length === 0) return;
|
||||
|
||||
const payload = { ..._pendingSettings };
|
||||
_pendingSettings = {};
|
||||
_debounceTimer = null;
|
||||
|
||||
try {
|
||||
await fetchWrapper.patch('/tenant/settings', { data: payload });
|
||||
} catch (error) {
|
||||
console.error('Failed to save tenant settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-flush any pending settings writes (called on beforeunload). */
|
||||
async function flushSettings(): Promise<void> {
|
||||
if (_debounceTimer !== null) {
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
}
|
||||
await _flush();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Initialise from /init endpoint
|
||||
// =========================================================================
|
||||
|
||||
function init(tenantData: Partial<TenantData> | null): void {
|
||||
tenant.value = tenantData
|
||||
? {
|
||||
id: tenantData.id ?? null,
|
||||
domain: tenantData.domain ?? null,
|
||||
label: tenantData.label ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
settings.value = (tenantData?.settings as Record<string, unknown>) ?? {};
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
tenant.value = null;
|
||||
settings.value = {};
|
||||
_pendingSettings = {};
|
||||
if (_debounceTimer !== null) {
|
||||
clearTimeout(_debounceTimer);
|
||||
_debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tenant,
|
||||
settings,
|
||||
|
||||
// Getters
|
||||
getSetting,
|
||||
|
||||
// Actions
|
||||
setSetting,
|
||||
flushSettings,
|
||||
init,
|
||||
reset,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { router } from '@KTXC/router';
|
||||
import { authenticationService } from '@KTXC/services/authenticationService';
|
||||
import { userService } from '@KTXC/services/user/userService';
|
||||
import type { AuthenticatedUser } from '@KTXC/types/authenticationTypes';
|
||||
@@ -80,7 +79,7 @@ export const useUserStore = defineStore('userStore', () => {
|
||||
clearAuth();
|
||||
clearProfile();
|
||||
clearSettings();
|
||||
router.push('/login');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,161 +1,92 @@
|
||||
/**
|
||||
* Core fetch wrapper - reusable across modules
|
||||
* Does not depend on stores to avoid bundling issues in library builds
|
||||
*/
|
||||
|
||||
export interface FetchWrapperOptions {
|
||||
/**
|
||||
* Optional callback to handle logout on auth failure
|
||||
* If not provided, only logs error without redirecting
|
||||
*/
|
||||
onLogout?: () => void | Promise<void>;
|
||||
/**
|
||||
* Enable automatic retry of failed requests after token refresh
|
||||
* @default true
|
||||
*/
|
||||
autoRetry?: boolean;
|
||||
/** Additional headers merged into every request */
|
||||
headers?: Record<string, string>;
|
||||
/** Override default Content-Type (default: application/json) */
|
||||
contentType?: string;
|
||||
/** Override default Accept type (default: application/json) */
|
||||
accept?: string;
|
||||
/** Called before every request */
|
||||
beforeRequest?: () => Promise<void>;
|
||||
/** Called after every successful non-stream response */
|
||||
afterResponse?: (data: any) => void;
|
||||
}
|
||||
|
||||
// Mutex to prevent multiple simultaneous refresh attempts
|
||||
class RefreshMutex {
|
||||
private promise: Promise<boolean> | null = null;
|
||||
|
||||
async acquire(): Promise<boolean> {
|
||||
if (this.promise) {
|
||||
return this.promise;
|
||||
}
|
||||
|
||||
this.promise = this.performRefresh();
|
||||
const result = await this.promise;
|
||||
this.promise = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async performRefresh(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/security/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tokenRefreshMutex = new RefreshMutex();
|
||||
|
||||
export interface RequestCallOptions {
|
||||
/**
|
||||
* Override autoRetry for this specific request
|
||||
* @default true
|
||||
*/
|
||||
autoRetry?: boolean;
|
||||
/**
|
||||
* Skip calling onLogout callback on 401/403 errors
|
||||
* Useful for authentication endpoints where 401 means invalid credentials, not session expiry
|
||||
* @default false
|
||||
*/
|
||||
skipLogoutOnError?: boolean;
|
||||
/** When set, the raw Response is forwarded to this handler instead of being JSON-parsed */
|
||||
onStream?: (response: Response) => Promise<void>;
|
||||
/** Per-call header overrides — highest priority, wins over factory-level headers */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const cookie = document.cookie.split('; ').find(r => r.startsWith('X-CSRF-TOKEN='));
|
||||
return cookie ? (cookie.split('=')[1] ?? null) : null;
|
||||
}
|
||||
|
||||
export function createFetchWrapper(options: FetchWrapperOptions = {}) {
|
||||
const { autoRetry: defaultAutoRetry = true } = options;
|
||||
|
||||
return {
|
||||
get: request('GET', options, defaultAutoRetry),
|
||||
post: request('POST', options, defaultAutoRetry),
|
||||
put: request('PUT', options, defaultAutoRetry),
|
||||
delete: request('DELETE', options, defaultAutoRetry)
|
||||
};
|
||||
}
|
||||
async function request(
|
||||
method: string,
|
||||
url: string,
|
||||
body?: object,
|
||||
callOptions?: RequestCallOptions
|
||||
): Promise<any> {
|
||||
if (options.beforeRequest) {
|
||||
await options.beforeRequest();
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body?: string;
|
||||
credentials: 'include';
|
||||
}
|
||||
|
||||
function request(method: string, options: FetchWrapperOptions, defaultAutoRetry: boolean) {
|
||||
return async (url: string, body?: object, callOptions?: RequestCallOptions): Promise<any> => {
|
||||
const autoRetry = callOptions?.autoRetry ?? defaultAutoRetry;
|
||||
|
||||
const requestOptions: RequestOptions = {
|
||||
method,
|
||||
headers: getHeaders(url),
|
||||
credentials: 'include'
|
||||
// Header priority: defaults < factory options.headers < callOptions.headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': options.contentType ?? 'application/json',
|
||||
'Accept': options.accept ?? 'application/json',
|
||||
...options.headers,
|
||||
...callOptions?.headers,
|
||||
};
|
||||
|
||||
if (body) {
|
||||
requestOptions.headers['Content-Type'] = 'application/json';
|
||||
requestOptions.body = JSON.stringify(body);
|
||||
|
||||
const csrf = getCsrfToken();
|
||||
if (csrf) headers['X-CSRF-TOKEN'] = csrf;
|
||||
|
||||
const requestOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
throw new Error(data?.message || response.statusText);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, requestOptions);
|
||||
|
||||
if (response.status === 401 && autoRetry) {
|
||||
// Try to refresh the token
|
||||
const refreshSuccess = await tokenRefreshMutex.acquire();
|
||||
|
||||
if (refreshSuccess) {
|
||||
// Retry the original request with the new token
|
||||
const retryResponse = await fetch(url, requestOptions);
|
||||
return handleResponse(retryResponse, options, callOptions?.skipLogoutOnError);
|
||||
}
|
||||
}
|
||||
|
||||
return handleResponse(response, options, callOptions?.skipLogoutOnError);
|
||||
} catch (error) {
|
||||
console.error('API error:', error);
|
||||
throw error;
|
||||
|
||||
if (callOptions?.onStream) {
|
||||
return callOptions.onStream(response);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
options.afterResponse?.(data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
get: (url: string, callOptions?: RequestCallOptions) =>
|
||||
request('GET', url, undefined, callOptions),
|
||||
post: (url: string, body?: object, callOptions?: RequestCallOptions) =>
|
||||
request('POST', url, body, callOptions),
|
||||
put: (url: string, body?: object, callOptions?: RequestCallOptions) =>
|
||||
request('PUT', url, body, callOptions),
|
||||
patch: (url: string, body?: object, callOptions?: RequestCallOptions) =>
|
||||
request('PATCH', url, body, callOptions),
|
||||
delete: (url: string, callOptions?: RequestCallOptions) =>
|
||||
request('DELETE', url, undefined, callOptions),
|
||||
};
|
||||
}
|
||||
|
||||
function getHeaders(_url: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Add CSRF token if available
|
||||
const csrfToken = getCsrfTokenFromCookie();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-TOKEN'] = csrfToken;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getCsrfTokenFromCookie(): string | null {
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
const csrfCookie = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('X-CSRF-TOKEN='));
|
||||
|
||||
return csrfCookie ? csrfCookie.split('=')[1] : null;
|
||||
}
|
||||
|
||||
async function handleResponse(response: Response, options: FetchWrapperOptions, skipLogoutOnError?: boolean): Promise<any> {
|
||||
const text = await response.text();
|
||||
const data = text && JSON.parse(text);
|
||||
|
||||
if (!response.ok) {
|
||||
if ([401, 403].includes(response.status) && !skipLogoutOnError) {
|
||||
// Call logout callback if provided
|
||||
if (options.onLogout) {
|
||||
await options.onLogout();
|
||||
} else {
|
||||
console.error('Authentication failed. Please log in again.');
|
||||
}
|
||||
}
|
||||
|
||||
const error: string = (data && data.message) || response.statusText;
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { useUserStore } from '@KTXC/stores/userStore';
|
||||
import { createFetchWrapper } from './fetch-wrapper-core';
|
||||
import { ensureFreshToken, recordExpiry } from '@KTXC/services/authManager';
|
||||
|
||||
// Create fetch wrapper with user store logout callback
|
||||
export const fetchWrapper = createFetchWrapper({
|
||||
onLogout: () => {
|
||||
const { logout } = useUserStore();
|
||||
logout();
|
||||
}
|
||||
beforeRequest: ensureFreshToken,
|
||||
afterResponse: (data) => recordExpiry(data?.expires_in),
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Shared utilities entry point for external modules
|
||||
* This file is built separately and exposed via import map
|
||||
*/
|
||||
|
||||
export { createFetchWrapper, type FetchWrapperOptions } from './fetch-wrapper-core';
|
||||
31
shared/lib/Chrono/Collection/CollectionBaseAbstract.php
Normal file
31
shared/lib/Chrono/Collection/CollectionBaseAbstract.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodeBaseAbstract;
|
||||
|
||||
/**
|
||||
* Abstract Chrono Collection Base Class
|
||||
*
|
||||
* Provides common implementation for chrono collections
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
abstract class CollectionBase extends NodeBaseAbstract implements CollectionBaseInterface {
|
||||
|
||||
protected CollectionPropertiesBaseAbstract $properties;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getProperties(): CollectionPropertiesBaseInterface {
|
||||
return $this->properties;
|
||||
}
|
||||
}
|
||||
30
shared/lib/Chrono/Collection/CollectionBaseInterface.php
Normal file
30
shared/lib/Chrono/Collection/CollectionBaseInterface.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodeBaseInterface;
|
||||
|
||||
/**
|
||||
* Collection Base Interface
|
||||
*
|
||||
* Interface represents a collection in a service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface CollectionBaseInterface extends NodeBaseInterface {
|
||||
|
||||
/**
|
||||
* Gets the collection properties
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getProperties(): CollectionPropertiesBaseInterface|CollectionPropertiesMutableInterface;
|
||||
|
||||
}
|
||||
46
shared/lib/Chrono/Collection/CollectionMutableAbstract.php
Normal file
46
shared/lib/Chrono/Collection/CollectionMutableAbstract.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodeMutableAbstract;
|
||||
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
|
||||
|
||||
/**
|
||||
* Abstract Chrono Collection Mutable Class
|
||||
*
|
||||
* Provides common implementation for mutable chrono collections
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
abstract class CollectionMutableAbstract extends NodeMutableAbstract implements CollectionMutableInterface {
|
||||
|
||||
protected CollectionPropertiesMutableAbstract $properties;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getProperties(): CollectionPropertiesMutableInterface {
|
||||
return $this->properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setProperties(NodePropertiesMutableInterface $value): static {
|
||||
if (!$value instanceof CollectionPropertiesMutableInterface) {
|
||||
throw new \InvalidArgumentException('Properties must implement CollectionPropertiesMutableInterface');
|
||||
}
|
||||
|
||||
// Copy all property values
|
||||
$this->properties->setLabel($value->getLabel());
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
32
shared/lib/Chrono/Collection/CollectionMutableInterface.php
Normal file
32
shared/lib/Chrono/Collection/CollectionMutableInterface.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodeMutableInterface;
|
||||
|
||||
/**
|
||||
* Chrono Collection Mutable Interface
|
||||
*
|
||||
* Interface for altering collection properties in a chrono service
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @method static setProperties(CollectionPropertiesMutableInterface $value)
|
||||
*/
|
||||
interface CollectionMutableInterface extends CollectionBaseInterface, NodeMutableInterface {
|
||||
|
||||
/**
|
||||
* Gets the collection properties (mutable)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getProperties(): CollectionPropertiesMutableInterface;
|
||||
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
enum CollectionPermissions: string implements JsonSerializable {
|
||||
|
||||
case View = 'view';
|
||||
case Create = 'create';
|
||||
case Modify = 'modify';
|
||||
case Destroy = 'destroy';
|
||||
case Share = 'share';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodePropertiesBaseAbstract;
|
||||
|
||||
/**
|
||||
* Abstract Chrono Collection Properties Base Class
|
||||
*
|
||||
* Provides common implementation for chrono collection properties
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
abstract class CollectionPropertiesBaseAbstract extends NodePropertiesBaseAbstract implements CollectionPropertiesBaseInterface {
|
||||
|
||||
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function content(): CollectionContent {
|
||||
$content = $this->data[self::JSON_PROPERTY_CONTENTS] ?? null;
|
||||
if ($content instanceof CollectionContent) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
if (is_string($content)) {
|
||||
return CollectionContent::tryFrom($content) ?? CollectionContent::Event;
|
||||
}
|
||||
|
||||
return CollectionContent::Event;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getLabel(): string {
|
||||
return $this->data[self::JSON_PROPERTY_LABEL] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getDescription(): ?string {
|
||||
return $this->data[self::JSON_PROPERTY_DESCRIPTION] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getPriority(): ?int {
|
||||
return $this->data[self::JSON_PROPERTY_PRIORITY] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getVisibility(): ?bool {
|
||||
return $this->data[self::JSON_PROPERTY_VISIBILITY] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getColor(): ?string {
|
||||
return $this->data[self::JSON_PROPERTY_COLOR] ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodePropertiesBaseInterface;
|
||||
|
||||
interface CollectionPropertiesBaseInterface extends NodePropertiesBaseInterface {
|
||||
|
||||
public const JSON_TYPE = 'chrono:collection';
|
||||
public const JSON_PROPERTY_CONTENTS = 'content';
|
||||
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_DESCRIPTION = 'description';
|
||||
public const JSON_PROPERTY_PRIORITY = 'priority';
|
||||
public const JSON_PROPERTY_VISIBILITY = 'visibility';
|
||||
public const JSON_PROPERTY_COLOR = 'color';
|
||||
|
||||
public function content(): CollectionContent;
|
||||
|
||||
/**
|
||||
* Gets the human friendly name of this collection (e.g. Personal Calendar)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getLabel(): string;
|
||||
|
||||
/**
|
||||
* Gets the human friendly description of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getDescription(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the priority of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getPriority(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the visibility of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getVisibility(): ?bool;
|
||||
|
||||
/**
|
||||
* Gets the color of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getColor(): ?string;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
/**
|
||||
* Abstract Chrono Collection Properties Mutable Class
|
||||
*/
|
||||
abstract class CollectionPropertiesMutableAbstract extends CollectionPropertiesBaseAbstract implements CollectionPropertiesMutableInterface {
|
||||
|
||||
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function jsonDeserialize(array|string $data): static {
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
|
||||
$this->data = $data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setLabel(string $value): static {
|
||||
$this->data[self::JSON_PROPERTY_LABEL] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setDescription(?string $value): static {
|
||||
$this->data[self::JSON_PROPERTY_DESCRIPTION] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setPriority(?int $value): static {
|
||||
$this->data[self::JSON_PROPERTY_PRIORITY] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setVisibility(?bool $value): static {
|
||||
$this->data[self::JSON_PROPERTY_VISIBILITY] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setColor(?string $value): static {
|
||||
$this->data[self::JSON_PROPERTY_COLOR] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,50 +9,45 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
|
||||
|
||||
interface ICollectionMutable extends ICollectionBase, JsonDeserializable {
|
||||
interface CollectionPropertiesMutableInterface extends CollectionPropertiesBaseInterface, NodePropertiesMutableInterface {
|
||||
|
||||
/**
|
||||
* Sets the active status of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setEnabled(bool $value): self;
|
||||
public const JSON_TYPE = CollectionPropertiesBaseInterface::JSON_TYPE;
|
||||
|
||||
/**
|
||||
* Sets the human friendly name of this collection (e.g. Personal Calendar)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setLabel(string $value): self;
|
||||
public function setLabel(string $value): static;
|
||||
|
||||
/**
|
||||
* Sets the human friendly description of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setDescription(?string $value): self;
|
||||
public function setDescription(?string $value): static;
|
||||
|
||||
/**
|
||||
* Sets the priority of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setPriority(?int $value): self;
|
||||
public function setPriority(?int $value): static;
|
||||
|
||||
/**
|
||||
* Sets the visibility of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setVisibility(?bool $value): self;
|
||||
public function setVisibility(?bool $value): static;
|
||||
|
||||
/**
|
||||
* Sets the color of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setColor(?string $value): self;
|
||||
public function setColor(?string $value): static;
|
||||
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Collection;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use JsonSerializable;
|
||||
|
||||
interface ICollectionBase extends JsonSerializable {
|
||||
|
||||
public const JSON_TYPE = 'chrono.collection';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_PROVIDER = 'provider';
|
||||
public const JSON_PROPERTY_SERVICE = 'service';
|
||||
public const JSON_PROPERTY_IN = 'in';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_DESCRIPTION = 'description';
|
||||
public const JSON_PROPERTY_PRIORITY = 'priority';
|
||||
public const JSON_PROPERTY_VISIBILITY = 'visibility';
|
||||
public const JSON_PROPERTY_COLOR = 'color';
|
||||
public const JSON_PROPERTY_CREATED = 'created';
|
||||
public const JSON_PROPERTY_MODIFIED = 'modified';
|
||||
public const JSON_PROPERTY_ENABLED = 'enabled';
|
||||
public const JSON_PROPERTY_SIGNATURE = 'signature';
|
||||
public const JSON_PROPERTY_PERMISSIONS = 'permissions';
|
||||
public const JSON_PROPERTY_ROLES = 'roles';
|
||||
public const JSON_PROPERTY_CONTENTS = 'contents';
|
||||
|
||||
/**
|
||||
* Unique identifier of the service this collection belongs to
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function in(): string|int|null;
|
||||
|
||||
/**
|
||||
* Unique arbitrary text string identifying this collection (e.g. 1 or collection1 or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Gets the creation date of this collection
|
||||
*/
|
||||
public function created(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the modification date of this collection
|
||||
*/
|
||||
public function modified(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Lists all supported attributes
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string,array<string,bool>>
|
||||
*/
|
||||
public function attributes(): array;
|
||||
|
||||
/**
|
||||
* Gets the signature of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function signature(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the role(s) of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function roles(): array;
|
||||
|
||||
/**
|
||||
* Checks if this collection supports the given role
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function role(CollectionRoles $value): bool;
|
||||
|
||||
/**
|
||||
* Gets the content types of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function contents(): array;
|
||||
|
||||
/**
|
||||
* Checks if this collection contains the given content type
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function contains(CollectionContent $value): bool;
|
||||
|
||||
/**
|
||||
* Gets the active status of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Gets the permissions of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getPermissions(): array;
|
||||
|
||||
/**
|
||||
* Checks if this collection has the given permission
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function hasPermission(CollectionPermissions $permission): bool;
|
||||
|
||||
/**
|
||||
* Gets the human friendly name of this collection (e.g. Personal Calendar)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getLabel(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the human friendly description of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getDescription(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the priority of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getPriority(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the visibility of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getVisibility(): ?bool;
|
||||
|
||||
/**
|
||||
* Gets the color of this collection
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getColor(): ?string;
|
||||
|
||||
}
|
||||
31
shared/lib/Chrono/Entity/EntityBaseAbstract.php
Normal file
31
shared/lib/Chrono/Entity/EntityBaseAbstract.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodeBaseAbstract;
|
||||
|
||||
/**
|
||||
* Abstract Chrono Entity Base Class
|
||||
*
|
||||
* Provides common implementation for chrono entities
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
abstract class EntityBaseAbstract extends NodeBaseAbstract implements EntityBaseInterface {
|
||||
|
||||
protected EntityPropertiesBaseAbstract $properties;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getProperties(): EntityPropertiesBaseInterface {
|
||||
return $this->properties;
|
||||
}
|
||||
}
|
||||
25
shared/lib/Chrono/Entity/EntityBaseInterface.php
Normal file
25
shared/lib/Chrono/Entity/EntityBaseInterface.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodeBaseInterface;
|
||||
|
||||
interface EntityBaseInterface extends NodeBaseInterface {
|
||||
|
||||
public const JSON_TYPE = 'chrono.entity';
|
||||
|
||||
/**
|
||||
* Gets the entity properties
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getProperties(): EntityPropertiesBaseInterface|EntityPropertiesMutableInterface;
|
||||
|
||||
}
|
||||
47
shared/lib/Chrono/Entity/EntityMutableAbstract.php
Normal file
47
shared/lib/Chrono/Entity/EntityMutableAbstract.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodeMutableAbstract;
|
||||
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
|
||||
|
||||
/**
|
||||
* Abstract Chrono Entity Mutable Class
|
||||
*
|
||||
* Provides common implementation for mutable chrono entities
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
abstract class EntityMutableAbstract extends NodeMutableAbstract implements EntityMutableInterface {
|
||||
|
||||
public const JSON_TYPE = EntityMutableInterface::JSON_TYPE;
|
||||
|
||||
protected EntityPropertiesMutableAbstract $properties;
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function getProperties(): EntityPropertiesMutableInterface {
|
||||
return $this->properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function setProperties(NodePropertiesMutableInterface $value): static {
|
||||
if (!$value instanceof EntityPropertiesMutableInterface) {
|
||||
throw new \InvalidArgumentException('Properties must implement EntityPropertiesMutableInterface');
|
||||
}
|
||||
|
||||
$this->properties->setDataRaw($value->getDataRaw());
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
28
shared/lib/Chrono/Entity/EntityMutableInterface.php
Normal file
28
shared/lib/Chrono/Entity/EntityMutableInterface.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodeMutableInterface;
|
||||
|
||||
/**
|
||||
* @method static setProperties(EntityPropertiesMutableInterface $value)
|
||||
*/
|
||||
interface EntityMutableInterface extends EntityBaseInterface, NodeMutableInterface {
|
||||
|
||||
public const JSON_TYPE = EntityBaseInterface::JSON_TYPE;
|
||||
|
||||
/**
|
||||
* Gets the entity properties (mutable)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getProperties(): EntityPropertiesMutableInterface;
|
||||
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
enum EntityPermissions: string implements JsonSerializable {
|
||||
|
||||
case View = 'view';
|
||||
case Modify = 'modify';
|
||||
case Delete = 'delete';
|
||||
case Share = 'share';
|
||||
|
||||
public function jsonSerialize(): string {
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
}
|
||||
21
shared/lib/Chrono/Entity/EntityPropertiesBaseAbstract.php
Normal file
21
shared/lib/Chrono/Entity/EntityPropertiesBaseAbstract.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodePropertiesBaseAbstract;
|
||||
|
||||
abstract class EntityPropertiesBaseAbstract extends NodePropertiesBaseAbstract implements EntityPropertiesBaseInterface {
|
||||
|
||||
public const JSON_TYPE = EntityPropertiesBaseInterface::JSON_TYPE;
|
||||
|
||||
public function getDataRaw(): array|string|null {
|
||||
return $this->data[self::JSON_PROPERTY_DATA] ?? null;
|
||||
}
|
||||
}
|
||||
21
shared/lib/Chrono/Entity/EntityPropertiesBaseInterface.php
Normal file
21
shared/lib/Chrono/Entity/EntityPropertiesBaseInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodePropertiesBaseInterface;
|
||||
|
||||
interface EntityPropertiesBaseInterface extends NodePropertiesBaseInterface {
|
||||
|
||||
public const JSON_TYPE = 'chrono:entity';
|
||||
public const JSON_PROPERTY_DATA = 'data';
|
||||
|
||||
public function getDataRaw(): array|string|null;
|
||||
|
||||
}
|
||||
30
shared/lib/Chrono/Entity/EntityPropertiesMutableAbstract.php
Normal file
30
shared/lib/Chrono/Entity/EntityPropertiesMutableAbstract.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
abstract class EntityPropertiesMutableAbstract extends EntityPropertiesBaseAbstract implements EntityPropertiesMutableInterface {
|
||||
|
||||
public const JSON_TYPE = EntityPropertiesBaseInterface::JSON_TYPE;
|
||||
|
||||
public function jsonDeserialize(array|string $data): static {
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
|
||||
$this->data = $data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDataRaw(array|string|null $value): static {
|
||||
$this->data[self::JSON_PROPERTY_DATA] = $value;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Resource\Provider\Node\NodePropertiesMutableInterface;
|
||||
|
||||
interface EntityPropertiesMutableInterface extends EntityPropertiesBaseInterface, NodePropertiesMutableInterface {
|
||||
|
||||
public const JSON_TYPE = EntityPropertiesBaseInterface::JSON_TYPE;
|
||||
|
||||
public function setDataRaw(array|string|null $value): static;
|
||||
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface IEntityBase extends \JsonSerializable {
|
||||
|
||||
public const JSON_TYPE = 'chrono.entity';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_IN = 'in';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_DATA = 'data';
|
||||
public const JSON_PROPERTY_CREATED = 'created';
|
||||
public const JSON_PROPERTY_MODIFIED = 'modified';
|
||||
public const JSON_PROPERTY_SIGNATURE = 'signature';
|
||||
|
||||
/**
|
||||
* Unique arbitrary text string identifying the collection this entity belongs to (e.g. 1 or Collection1 or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function in(): string|int;
|
||||
|
||||
/**
|
||||
* Unique arbitrary text string identifying this entity (e.g. 1 or Entity or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function id(): string|int;
|
||||
|
||||
/**
|
||||
* Gets the creation date of this entity
|
||||
*/
|
||||
public function created(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the modification date of this entity
|
||||
*/
|
||||
public function modified(): ?DateTimeImmutable;
|
||||
|
||||
/**
|
||||
* Gets the signature of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function signature(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the priority of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getPriority(): ?int;
|
||||
|
||||
/**
|
||||
* Gets the visibility of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getVisibility(): ?bool;
|
||||
|
||||
/**
|
||||
* Gets the color of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getColor(): ?string;
|
||||
|
||||
/**
|
||||
* Gets the object data (event, task, or journal).
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function getDataObject(): object|null;
|
||||
|
||||
/**
|
||||
* Gets the raw data as an associative array or JSON string.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array|string|null
|
||||
*/
|
||||
public function getDataJson(): array|string|null;
|
||||
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
interface IEntityMutable extends IEntityBase, JsonDeserializable {
|
||||
|
||||
/**
|
||||
* Sets the priority of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setPriority(?int $value): static;
|
||||
|
||||
/**
|
||||
* Sets the visibility of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setVisibility(?bool $value): static;
|
||||
|
||||
/**
|
||||
* Sets the color of this entity
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setColor(?string $value): static;
|
||||
|
||||
/**
|
||||
* Sets the object as a class instance.
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setDataObject(object $value): static;
|
||||
|
||||
/**
|
||||
* Sets the object data from a json string
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function setDataJson(array|string $value): static;
|
||||
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Provider;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Chrono\Service\IServiceBase;
|
||||
|
||||
interface IProviderBase extends JsonSerializable {
|
||||
|
||||
public const CAPABILITY_SERVICE_LIST = 'ServiceList';
|
||||
public const CAPABILITY_SERVICE_FETCH = 'ServiceFetch';
|
||||
public const CAPABILITY_SERVICE_EXTANT = 'ServiceExtant';
|
||||
|
||||
public const JSON_TYPE = 'chrono.provider';
|
||||
public const JSON_PROPERTY_TYPE = '@type';
|
||||
public const JSON_PROPERTY_ID = 'id';
|
||||
public const JSON_PROPERTY_LABEL = 'label';
|
||||
public const JSON_PROPERTY_CAPABILITIES = 'capabilities';
|
||||
|
||||
/**
|
||||
* Confirms if specific capability is supported (e.g. 'ServiceList')
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function capable(string $value): bool;
|
||||
|
||||
/**
|
||||
* Lists all supported capabilities
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @return array<string,bool>
|
||||
*/
|
||||
public function capabilities(): array;
|
||||
|
||||
/**
|
||||
* An arbitrary unique text string identifying this provider (e.g. UUID or 'system' or anything else)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function id(): string;
|
||||
|
||||
/**
|
||||
* The localized human friendly name of this provider (e.g. System Calendar Provider)
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function label(): string;
|
||||
|
||||
/**
|
||||
* Retrieve collection of services for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param array $filter filter criteria
|
||||
*
|
||||
* @return array<string,IServiceBase> collection of service objects
|
||||
*/
|
||||
public function serviceList(string $tenantId, string $userId, array $filter): array;
|
||||
|
||||
/**
|
||||
* Determine if any services are configured for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param int|string ...$identifiers variadic collection of service identifiers
|
||||
*
|
||||
* @return array<string,bool> collection of service identifiers with boolean values indicating if the service is available
|
||||
*/
|
||||
public function serviceExtant(string $tenantId, string $userId, int|string ...$identifiers): array;
|
||||
|
||||
/**
|
||||
* Retrieve a service with a specific identifier
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*
|
||||
* @param string $tenantId tenant identifier
|
||||
* @param string $userId user identifier
|
||||
* @param string|int $identifier service identifier
|
||||
*
|
||||
* @return IServiceBase|null returns service object or null if non found
|
||||
*/
|
||||
public function serviceFetch(string $tenantId, string $userId, string|int $identifier): ?IServiceBase;
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Provider;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
use KTXF\Chrono\Service\IServiceBase;
|
||||
|
||||
interface IProviderServiceMutate extends JsonDeserializable {
|
||||
|
||||
public const CAPABILITY_SERVICE_FRESH = 'ServiceFresh';
|
||||
public const CAPABILITY_SERVICE_CREATE = 'ServiceCreate';
|
||||
public const CAPABILITY_SERVICE_UPDATE = 'ServiceUpdate';
|
||||
public const CAPABILITY_SERVICE_DESTROY = 'ServiceDestroy';
|
||||
|
||||
/**
|
||||
* construct and new blank service instance
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function serviceFresh(string $uid = ''): IServiceBase;
|
||||
|
||||
/**
|
||||
* create a service configuration for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function serviceCreate(string $uid, IServiceBase $service): string;
|
||||
|
||||
/**
|
||||
* modify a service configuration for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function serviceModify(string $uid, IServiceBase $service): string;
|
||||
|
||||
/**
|
||||
* delete a service configuration for a specific user
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
public function serviceDestroy(string $uid, IServiceBase $service): bool;
|
||||
|
||||
}
|
||||
23
shared/lib/Chrono/Provider/ProviderBaseInterface.php
Normal file
23
shared/lib/Chrono/Provider/ProviderBaseInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Provider;
|
||||
|
||||
use KTXF\Resource\Provider\ResourceProviderBaseInterface;
|
||||
|
||||
/**
|
||||
* Chrono Provider Base Interface
|
||||
*
|
||||
* @since 2025.05.01
|
||||
*/
|
||||
interface ProviderBaseInterface extends ResourceProviderBaseInterface{
|
||||
|
||||
public const JSON_TYPE = 'chrono:provider';
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user