Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 199afd1223 |
@@ -18,24 +18,12 @@ jobs:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.5'
|
||||
tools: composer:v2
|
||||
|
||||
- name: Install Renovate
|
||||
run: |
|
||||
npm install --global --no-audit --fund=false \
|
||||
--prefix "${{ runner.temp }}/renovate-npm" \
|
||||
--cache "${{ runner.temp }}/renovate-npm-cache" \
|
||||
renovate
|
||||
"${{ runner.temp }}/renovate-npm/bin/renovate" --version
|
||||
run: npm install -g renovate
|
||||
|
||||
- name: Run Renovate
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
||||
run: |
|
||||
"${{ runner.temp }}/renovate-npm/bin/renovate" ${{ gitea.repository }}
|
||||
run: renovate ${{ gitea.repository }}
|
||||
@@ -1,23 +0,0 @@
|
||||
name: JS Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6.2.0
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm run test:unit
|
||||
@@ -1,50 +0,0 @@
|
||||
name: PHP Integration Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:8
|
||||
options: >-
|
||||
--health-cmd "mongosh --quiet --eval \"db.adminCommand('ping')\""
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 12
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1
|
||||
with:
|
||||
php-version: '8.5'
|
||||
tools: composer:v2
|
||||
extensions: ctype, iconv, mongodb
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Retrieve Server Install Action
|
||||
uses: actions/checkout@v6.0.2
|
||||
with:
|
||||
repository: Nodarx/action-server-install
|
||||
ref: main
|
||||
path: action-server-install
|
||||
github-server-url: https://git.ktrix.dev
|
||||
|
||||
- name: Configure server settings
|
||||
env:
|
||||
DATABASE_URI: 'mongodb://mongo:27017/?tls=false'
|
||||
DATABASE_NAME: 'ktrix_ci'
|
||||
APP_ENVIRONMENT: 'test'
|
||||
run: php action-server-install/scripts/configure-server.php config/system.php
|
||||
|
||||
- name: Run tests
|
||||
run: composer test:integration
|
||||
@@ -1,24 +0,0 @@
|
||||
name: PHP Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1
|
||||
with:
|
||||
php-version: '8.5'
|
||||
tools: composer:v2
|
||||
extensions: ctype, iconv, mongodb
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Run tests
|
||||
run: composer test:unit
|
||||
+5
-4
@@ -14,10 +14,11 @@ node_modules/
|
||||
|
||||
# Backend development
|
||||
/vendor/
|
||||
/config/system.php
|
||||
coverage/
|
||||
*.cache
|
||||
.phpunit.coverage
|
||||
phpunit.xml.cache
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
.phpstan.cache
|
||||
.phpactor/
|
||||
|
||||
# Editors
|
||||
@@ -33,4 +34,4 @@ logs
|
||||
# Runtime
|
||||
/modules/
|
||||
/storage/
|
||||
/var/
|
||||
/var/
|
||||
+95
-6
@@ -3,26 +3,115 @@
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Check dependencies
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
try {
|
||||
$application = Application::create(dirname(__DIR__), $composerLoader);
|
||||
exit($application->runConsole());
|
||||
// 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);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
|
||||
if (($application ?? null)?->debug()) {
|
||||
fwrite(STDERR, $e->getTraceAsString()."\n");
|
||||
fwrite(STDERR, "Fatal error: " . $e->getMessage() . "\n");
|
||||
if (isset($app) && $app->debug()) {
|
||||
fwrite(STDERR, $e->getTraceAsString() . "\n");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
+4
-7
@@ -1,20 +1,19 @@
|
||||
{
|
||||
"name": "ktxc/server",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.3",
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"mongodb/mongodb": "^2.1",
|
||||
"php-di/php-di": "*",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
"symfony/console": "^7.0"
|
||||
"symfony/console": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^12.5.31"
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -39,8 +38,6 @@
|
||||
],
|
||||
"post-update-cmd": [
|
||||
],
|
||||
"test:unit": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --colors=always --testdox",
|
||||
"test:integration": "phpunit --configuration tests/php/phpunit.xml --testsuite \"Integration Tests\" --colors=always --testdox",
|
||||
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.xml --testsuite \"Unit Tests\" --coverage-html .phpunit.coverage --coverage-text"
|
||||
"test": "phpunit --colors=always --testdox"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+409
-322
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Application Configuration
|
||||
'name' => 'Ktrix',
|
||||
'environment' => 'dev',
|
||||
'debug' => true,
|
||||
// Database Configuration
|
||||
'database' => [
|
||||
// MongoDB connection URI (include credentials if needed)
|
||||
'uri' => 'mongodb://ktrix:ktrix@127.0.0.1:27017/?authSource=ktrix&tls=false',
|
||||
'database' => 'ktrix',
|
||||
// optional driver options
|
||||
'options' => [],
|
||||
'driverOptions' => [],
|
||||
],
|
||||
|
||||
/**
|
||||
* Cache Configuration
|
||||
*
|
||||
* Set the cache store classes for different cache types.
|
||||
* Uncomment and adjust the class names as needed.
|
||||
*
|
||||
* Available Cache Stores:
|
||||
* - Ephemeral Cache: Short-lived, in-memory or file-based cache for sessions, rate limits, etc.
|
||||
* - Persistent Cache: Long-lived cache for routes, modules, compiled configs, etc.
|
||||
* - Blob Cache: Large binary objects storage.
|
||||
*
|
||||
* Predefined cache types:
|
||||
* file - File-based cache store
|
||||
* redis - Redis-based cache store
|
||||
* memcached - Memcached-based cache store
|
||||
*/
|
||||
//'cache.ephemeral' => 'file',
|
||||
//'cache.persistent' => 'file',
|
||||
//'cache.blob' => 'file',
|
||||
|
||||
// Security Configuration
|
||||
'security.salt' => 'a5418ed8c120b9d12c793ccea10571b74d0dcd4a4db7ca2f75e80fbdafb2bd9b',
|
||||
];
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Application Configuration
|
||||
'name' => 'Application Name',
|
||||
'environment' => 'dev',
|
||||
'debug' => true,
|
||||
// Database Configuration
|
||||
'database' => [
|
||||
// MongoDB connection URI (include credentials if needed)
|
||||
'uri' => 'mongodb://username:password@database-host:27017/?authSource=database&tls=true',
|
||||
'database' => 'database',
|
||||
// optional driver options
|
||||
'options' => [],
|
||||
'driverOptions' => [],
|
||||
],
|
||||
|
||||
/**
|
||||
* Cache Configuration
|
||||
*
|
||||
* Set the cache store classes for different cache types.
|
||||
* Uncomment and adjust the class names as needed.
|
||||
*
|
||||
* Available Cache Stores:
|
||||
* - Ephemeral Cache: Short-lived, in-memory or file-based cache for sessions, rate limits, etc.
|
||||
* - Persistent Cache: Long-lived cache for routes, modules, compiled configs, etc.
|
||||
* - Blob Cache: Large binary objects storage.
|
||||
*
|
||||
* Predefined cache types:
|
||||
* file - File-based cache store
|
||||
* redis - Redis-based cache store
|
||||
* memcached - Memcached-based cache store
|
||||
*/
|
||||
//'cache.ephemeral' => 'file',
|
||||
//'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/logs
|
||||
'path' => null,
|
||||
|
||||
// Log channel — used as the filename without extension.
|
||||
// 'app' → var/logs/app.jsonl (or var/logs/tenant/{id}/app.jsonl when per_tenant = true)
|
||||
'channel' => 'app',
|
||||
|
||||
// ── syslog driver options ──────────────────────────────────────────────
|
||||
// Identity tag passed to openlog(); visible in syslog and journalctl.
|
||||
'ident' => 'application',
|
||||
|
||||
// openlog() facility constant. Common values: LOG_USER, LOG_LOCAL0 … LOG_LOCAL7
|
||||
'facility' => LOG_USER,
|
||||
],
|
||||
|
||||
// Security Configuration
|
||||
// Generate a unique value for each deployment, for example: openssl rand -hex 32
|
||||
'security.salt' => 'replace-with-a-random-secret',
|
||||
];
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"header": {
|
||||
"searchPlaceholder": "Hier suchen.."
|
||||
},
|
||||
"systemMenu": {
|
||||
"administration": "Administration",
|
||||
"applications": "Anwendungen",
|
||||
"personalSettings": "Persönliche Einstellungen",
|
||||
"toggleAdmin": "Admin",
|
||||
"toggleApps": "Apps",
|
||||
"toggleSettings": "Einstellungen"
|
||||
},
|
||||
"notifications": {
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"title": "Benachrichtigungen",
|
||||
"viewAll": "Alle anzeigen"
|
||||
},
|
||||
"userMenu": {
|
||||
"darkMode": "Dunkles Design",
|
||||
"lightMode": "Helles Design",
|
||||
"logout": "Abmelden",
|
||||
"settings": "Einstellungen"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"systemMenu": {
|
||||
"adminSettings": "System",
|
||||
"apps": "Applications",
|
||||
"personalSettings": "Settings",
|
||||
"userSettings": "Settings"
|
||||
},
|
||||
"userMenu": {
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"logout": "Logout",
|
||||
"settings": "Settings",
|
||||
"systemMode": "System Mode"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"userMenu": {
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"systemMode": "System Mode"
|
||||
}
|
||||
}
|
||||
+155
-93
@@ -1,154 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use KTXC\Application\KernelOptions;
|
||||
use KTXC\Application\ProjectPaths;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Module\ModuleAutoloader;
|
||||
use KTXC\Runtime\Console\ConsoleRuntime;
|
||||
use KTXC\Runtime\Http\HttpRuntime;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
final class Application
|
||||
/**
|
||||
* Application class - entry point for the framework
|
||||
* Handles configuration loading and kernel lifecycle
|
||||
*/
|
||||
class Application
|
||||
{
|
||||
private readonly ProjectPaths $paths;
|
||||
private readonly array $config;
|
||||
private readonly Kernel $kernel;
|
||||
private readonly HttpRuntime $http;
|
||||
private readonly ConsoleRuntime $console;
|
||||
private static $composerLoader = null;
|
||||
|
||||
private Kernel $kernel;
|
||||
private array $config;
|
||||
private string $rootDir;
|
||||
|
||||
public function __construct(
|
||||
string $projectDir,
|
||||
?ClassLoader $composerLoader = null,
|
||||
?string $environment = null,
|
||||
?bool $debug = null,
|
||||
) {
|
||||
$this->paths = ProjectPaths::resolve($projectDir);
|
||||
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
|
||||
{
|
||||
$this->rootDir = $this->resolveProjectRoot($rootDir);
|
||||
|
||||
// Load configuration
|
||||
$this->config = $this->loadConfig();
|
||||
|
||||
$environment ??= $this->config['environment'] ?? 'prod';
|
||||
$debug ??= (bool) ($this->config['debug'] ?? false);
|
||||
|
||||
$this->kernel = new Kernel(
|
||||
new KernelOptions(
|
||||
$this->paths,
|
||||
$environment,
|
||||
$debug,
|
||||
),
|
||||
$this->config,
|
||||
);
|
||||
|
||||
(new ModuleAutoloader(
|
||||
$this->moduleDir(),
|
||||
$composerLoader,
|
||||
))->register();
|
||||
|
||||
$this->http = new HttpRuntime($this->kernel, $debug);
|
||||
$this->console = new ConsoleRuntime($this->kernel);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
public static function create(
|
||||
string $projectDir,
|
||||
?ClassLoader $composerLoader = null,
|
||||
?string $environment = null,
|
||||
?bool $debug = null,
|
||||
): self {
|
||||
return new self($projectDir, $composerLoader, $environment, $debug);
|
||||
}
|
||||
|
||||
public function runHttp(): void
|
||||
/**
|
||||
* Run the application - handle incoming request and send response
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
try {
|
||||
$this->http->run();
|
||||
} finally {
|
||||
$this->kernel->shutdown();
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
public function runHttpRequest(Request $request): Response
|
||||
/**
|
||||
* Handle a request
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return $this->http->run($request, send: false);
|
||||
return $this->kernel->handle($request);
|
||||
}
|
||||
|
||||
public function runConsole(): int
|
||||
/**
|
||||
* Terminate the application - process deferred events
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
try {
|
||||
return $this->console->run();
|
||||
} finally {
|
||||
$this->kernel->shutdown();
|
||||
}
|
||||
$this->kernel->processEvents();
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
$this->kernel->shutdown();
|
||||
}
|
||||
|
||||
public function kernel(): KernelInterface
|
||||
/**
|
||||
* Get the kernel instance
|
||||
*/
|
||||
public function kernel(): Kernel
|
||||
{
|
||||
return $this->kernel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container instance
|
||||
*/
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
return $this->kernel->container();
|
||||
}
|
||||
|
||||
public function environment(): string
|
||||
/**
|
||||
* Get the application root directory
|
||||
*/
|
||||
public function rootDir(): string
|
||||
{
|
||||
return $this->kernel->environment();
|
||||
}
|
||||
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->kernel->debug();
|
||||
}
|
||||
|
||||
public function projectDir(): string
|
||||
{
|
||||
return $this->paths->project;
|
||||
return $this->rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modules directory
|
||||
*/
|
||||
public function moduleDir(): string
|
||||
{
|
||||
return $this->paths->modules();
|
||||
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 (explode('.', $key) as $part) {
|
||||
if (!is_array($value) || !array_key_exists($part, $value)) {
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$part];
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function loadConfig(): array
|
||||
/**
|
||||
* Get environment
|
||||
*/
|
||||
public function environment(): string
|
||||
{
|
||||
$path = $this->paths->configuration() . '/system.php';
|
||||
if (!is_file($path)) {
|
||||
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 = require $path;
|
||||
|
||||
$config = include $configFile;
|
||||
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException("Configuration file must return an array: {$path}");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
final readonly class ExecutionContext
|
||||
{
|
||||
public function __construct(
|
||||
public RuntimeType $runtime,
|
||||
public string $executionId,
|
||||
public string $correlationId,
|
||||
public ?string $causationId = null,
|
||||
public ?string $requestId = null,
|
||||
public ?string $operationName = null,
|
||||
public array $traceMetadata = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromDescriptor(ExecutionDescriptor $descriptor): self
|
||||
{
|
||||
return new self(
|
||||
$descriptor->runtime,
|
||||
$descriptor->executionId,
|
||||
$descriptor->correlationId,
|
||||
operationName: $descriptor->operationName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
final readonly class ExecutionDescriptor
|
||||
{
|
||||
public function __construct(
|
||||
public RuntimeType $runtime,
|
||||
public string $executionId,
|
||||
public string $correlationId,
|
||||
public ?string $operationName = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function http(?string $operationName = null): self
|
||||
{
|
||||
$id = self::id();
|
||||
|
||||
return new self(RuntimeType::HTTP, $id, $id, $operationName);
|
||||
}
|
||||
|
||||
public static function cli(?string $operationName = null): self
|
||||
{
|
||||
$id = self::id();
|
||||
|
||||
return new self(RuntimeType::CLI, $id, $id, $operationName);
|
||||
}
|
||||
|
||||
private static function id(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
final readonly class ExecutionOutcome
|
||||
{
|
||||
private function __construct(
|
||||
public bool $successful,
|
||||
public mixed $result = null,
|
||||
public ?\Throwable $error = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function success(mixed $result = null): self
|
||||
{
|
||||
return new self(true, $result);
|
||||
}
|
||||
|
||||
public static function failure(\Throwable $error, mixed $result = null): self
|
||||
{
|
||||
return new self(false, $result, $error);
|
||||
}
|
||||
|
||||
public static function incomplete(): self
|
||||
{
|
||||
return new self(false);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
use KTXC\KernelInterface;
|
||||
|
||||
final readonly class ExecutionRunner implements ExecutionRunnerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private KernelInterface $kernel,
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(
|
||||
ExecutionDescriptor $descriptor,
|
||||
callable $execution,
|
||||
?callable $failure = null,
|
||||
): mixed {
|
||||
$scope = $this->kernel->beginExecution($descriptor);
|
||||
$outcome = ExecutionOutcome::incomplete();
|
||||
|
||||
try {
|
||||
$result = $execution($scope);
|
||||
$outcome = ExecutionOutcome::success($result);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $error) {
|
||||
if ($failure === null) {
|
||||
$outcome = ExecutionOutcome::failure($error);
|
||||
|
||||
throw $error;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $failure($error, $scope);
|
||||
$outcome = ExecutionOutcome::failure($error, $result);
|
||||
|
||||
return $result;
|
||||
} catch (\Throwable $failureError) {
|
||||
$outcome = ExecutionOutcome::failure($failureError);
|
||||
|
||||
throw $failureError;
|
||||
}
|
||||
} finally {
|
||||
$this->kernel->terminateExecution($scope, $outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
interface ExecutionRunnerInterface
|
||||
{
|
||||
/**
|
||||
* @template TResult
|
||||
*
|
||||
* @param callable(ExecutionScope): TResult $execution
|
||||
* @param null|callable(\Throwable, ExecutionScope): TResult $failure
|
||||
*
|
||||
* @return TResult
|
||||
*/
|
||||
public function execute(
|
||||
ExecutionDescriptor $descriptor,
|
||||
callable $execution,
|
||||
?callable $failure = null,
|
||||
): mixed;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
use KTXC\Context\IdentityContext;
|
||||
use KTXC\Context\TenantContext;
|
||||
|
||||
final class ExecutionScope
|
||||
{
|
||||
private bool $terminated = false;
|
||||
|
||||
public function __construct(
|
||||
public readonly ExecutionDescriptor $descriptor,
|
||||
public readonly ExecutionContext $context,
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly IdentityContext $identityContext,
|
||||
) {
|
||||
$this->tenantContext->clear();
|
||||
$this->identityContext->clear();
|
||||
}
|
||||
|
||||
public function markTerminated(): void
|
||||
{
|
||||
if ($this->terminated) {
|
||||
throw new \LogicException('The execution scope has already been terminated.');
|
||||
}
|
||||
|
||||
$this->terminated = true;
|
||||
}
|
||||
|
||||
public function terminated(): bool
|
||||
{
|
||||
return $this->terminated;
|
||||
}
|
||||
|
||||
public function dispose(): void
|
||||
{
|
||||
$this->identityContext->clear();
|
||||
$this->tenantContext->clear();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
enum RuntimeType: string
|
||||
{
|
||||
case HTTP = 'http';
|
||||
case CLI = 'cli';
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application\Execution;
|
||||
|
||||
final readonly class TerminationReport
|
||||
{
|
||||
/**
|
||||
* @param list<\Throwable> $failures
|
||||
*/
|
||||
public function __construct(
|
||||
public int $deferredProcessed = 0,
|
||||
public int $deferredRemaining = 0,
|
||||
public array $failures = [],
|
||||
public bool $deadlineExceeded = false,
|
||||
public bool $limitExceeded = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application;
|
||||
|
||||
final readonly class KernelOptions
|
||||
{
|
||||
public function __construct(
|
||||
public ProjectPaths $paths,
|
||||
public string $environment = 'prod',
|
||||
public bool $debug = false,
|
||||
) {
|
||||
if ($this->environment === '') {
|
||||
throw new \InvalidArgumentException('Kernel environment cannot be empty.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Application;
|
||||
|
||||
final readonly class ProjectPaths
|
||||
{
|
||||
private function __construct(
|
||||
public string $project,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function resolve(string $start): self
|
||||
{
|
||||
$directory = is_file($start) ? dirname($start) : $start;
|
||||
$directory = rtrim($directory, DIRECTORY_SEPARATOR);
|
||||
|
||||
while ($directory !== dirname($directory)) {
|
||||
if (is_file($directory . '/composer.json')) {
|
||||
return new self(realpath($directory) ?: $directory);
|
||||
}
|
||||
$directory = dirname($directory);
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Unable to resolve project root from {$start}.");
|
||||
}
|
||||
|
||||
public function configuration(): string
|
||||
{
|
||||
return $this->project . '/config';
|
||||
}
|
||||
|
||||
public function modules(): string
|
||||
{
|
||||
return $this->project . '/modules';
|
||||
}
|
||||
|
||||
public function cache(string $environment): string
|
||||
{
|
||||
return $this->project . '/var/cache/' . $environment;
|
||||
}
|
||||
|
||||
public function logs(): string
|
||||
{
|
||||
return $this->project . '/var/log';
|
||||
}
|
||||
|
||||
public function runtime(): string
|
||||
{
|
||||
return $this->project . '/var';
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Event;
|
||||
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'events:debug',
|
||||
description: 'Display the frozen event listener registry',
|
||||
)]
|
||||
final class EventsDebugCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventListenerRegistry $registry,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$rows = [];
|
||||
foreach ($this->registry->definitions() as $listener) {
|
||||
$rows[] = [
|
||||
$listener->module,
|
||||
$listener->event,
|
||||
$listener->service . '::' . $listener->method,
|
||||
$listener->delivery->value,
|
||||
$listener->priority,
|
||||
$listener->failurePolicy->value,
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(
|
||||
['Module', 'Event', 'Listener', 'Delivery', 'Priority', 'Failure'],
|
||||
$rows,
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Module;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Module;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Module;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Module;
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -54,7 +54,8 @@ class ModuleDisableCommand extends Command
|
||||
}
|
||||
|
||||
// Find the module
|
||||
$module = $this->moduleManager->fetch($handle);
|
||||
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false);
|
||||
$module = $modules[$handle] ?? null;
|
||||
|
||||
if (!$module) {
|
||||
$io->error("Module '{$handle}' not found or not installed.");
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Module;
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -48,7 +48,8 @@ class ModuleEnableCommand extends Command
|
||||
|
||||
try {
|
||||
// Find the module
|
||||
$module = $this->moduleManager->fetch($handle);
|
||||
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false);
|
||||
$module = $modules[$handle] ?? null;
|
||||
|
||||
if (!$module) {
|
||||
$io->error("Module '{$handle}' not found or not installed.");
|
||||
+5
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Module;
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
@@ -45,7 +45,10 @@ class ModuleListCommand extends Command
|
||||
$io->title('Installed Modules');
|
||||
|
||||
try {
|
||||
$modules = $this->moduleManager->list();
|
||||
$modules = $this->moduleManager->list(
|
||||
installedOnly: true,
|
||||
enabledOnly: !$showAll
|
||||
);
|
||||
|
||||
if (count($modules) === 0) {
|
||||
$io->warning('No modules found.');
|
||||
@@ -1,102 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Role;
|
||||
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Role Assign Command
|
||||
*
|
||||
* Assigns a role to a user account.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'role:assign',
|
||||
description: 'Assign a role to a user',
|
||||
)]
|
||||
class RoleAssignCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly UserRolesStore $rolesStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
|
||||
->addArgument('rid', InputArgument::REQUIRED, 'Role id to assign')
|
||||
->setHelp('This command assigns a role to a user account.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = $input->getArgument('tenant');
|
||||
$identity = $input->getArgument('identity');
|
||||
$rid = $input->getArgument('rid');
|
||||
|
||||
$io->title('Assign Role');
|
||||
|
||||
try {
|
||||
$user = $this->userStore->fetchByIdentity($tenant, $identity);
|
||||
if (!$user) {
|
||||
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$this->rolesStore->fetchByRid($tenant, $rid)) {
|
||||
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$roles = (array)($user['roles'] ?? []);
|
||||
if (in_array($rid, $roles, true)) {
|
||||
$io->text("User '{$identity}' already has role '{$rid}'.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$roles[] = $rid;
|
||||
|
||||
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => array_values($roles)])) {
|
||||
$io->error("Failed to assign role '{$rid}' to '{$identity}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->logger->info('Role assigned via console', [
|
||||
'tenant' => $tenant,
|
||||
'identity' => $identity,
|
||||
'rid' => $rid,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Role '{$rid}' assigned to '{$identity}' successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to assign role: ' . $e->getMessage());
|
||||
$this->logger->error('Role assign failed', [
|
||||
'tenant' => $tenant,
|
||||
'identity' => $identity,
|
||||
'rid' => $rid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Role;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Role Create Command
|
||||
*
|
||||
* Creates a new role within a tenant.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'role:create',
|
||||
description: 'Create a new role in a tenant',
|
||||
)]
|
||||
class RoleCreateCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly UserRolesStore $rolesStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('label', InputArgument::REQUIRED, 'Display label for the role')
|
||||
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Role description', '')
|
||||
->addOption('permission', 'p', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Permission(s) to grant (repeatable, e.g. --permission "*")', [])
|
||||
->addOption('rid', null, InputOption::VALUE_REQUIRED, 'Explicit role id (defaults to a generated UUID)')
|
||||
->addOption('system', null, InputOption::VALUE_NONE, 'Mark the role as a protected system role (cannot be updated or deleted)')
|
||||
->setHelp('This command creates a new role in a tenant. Permissions are free-form strings matched by the runtime permission checker, including wildcard suffixes like "user_manager.role.*" or the full wildcard "*".')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = $input->getArgument('tenant');
|
||||
$label = $input->getArgument('label');
|
||||
$rid = $input->getOption('rid');
|
||||
|
||||
$io->title('Create Role');
|
||||
|
||||
try {
|
||||
if (!$this->tenantService->fetchById($tenant)) {
|
||||
$io->error("Tenant '{$tenant}' not found.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($rid !== null && $this->rolesStore->fetchByRid($tenant, $rid)) {
|
||||
$io->error("Role '{$rid}' already exists in tenant '{$tenant}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$roleData = [
|
||||
'label' => $label,
|
||||
'description' => $input->getOption('description') ?? '',
|
||||
'permissions' => $input->getOption('permission'),
|
||||
'system' => $input->getOption('system'),
|
||||
];
|
||||
|
||||
if ($rid !== null) {
|
||||
$roleData['rid'] = $rid;
|
||||
}
|
||||
|
||||
$role = $this->rolesStore->createRole($tenant, $roleData);
|
||||
|
||||
$this->logger->info('Role created via console', [
|
||||
'tenant' => $tenant,
|
||||
'rid' => $role['rid'] ?? null,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Role '{$label}' created successfully!");
|
||||
$io->definitionList(
|
||||
['Rid' => $role['rid'] ?? ''],
|
||||
['Label' => $role['label'] ?? ''],
|
||||
['System' => !empty($role['system']) ? 'yes' : 'no'],
|
||||
['Permissions' => implode(', ', (array)($role['permissions'] ?? []))],
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to create role: ' . $e->getMessage());
|
||||
$this->logger->error('Role create failed', [
|
||||
'tenant' => $tenant,
|
||||
'label' => $label,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Role;
|
||||
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Role Delete Command
|
||||
*
|
||||
* Deletes a role from a tenant.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'role:delete',
|
||||
description: 'Delete a role from a tenant',
|
||||
)]
|
||||
class RoleDeleteCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRolesStore $rolesStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('rid', InputArgument::REQUIRED, 'Role id')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
|
||||
->setHelp('This command deletes a role from a tenant. System roles cannot be deleted, and roles still assigned to users cannot be deleted.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = $input->getArgument('tenant');
|
||||
$rid = $input->getArgument('rid');
|
||||
$force = $input->getOption('force');
|
||||
|
||||
$io->title('Delete Role');
|
||||
|
||||
try {
|
||||
$role = $this->rolesStore->fetchByRid($tenant, $rid);
|
||||
|
||||
if (!$role) {
|
||||
$io->error("Role '{$rid}' not found in tenant '{$tenant}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($role['system'] ?? false) {
|
||||
$io->error("Role '{$rid}' is a system role and cannot be deleted.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$userCount = $this->rolesStore->countUsersInRole($tenant, $rid);
|
||||
if ($userCount > 0) {
|
||||
$io->error("Role '{$rid}' is assigned to {$userCount} user(s) and cannot be deleted.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$force && !$io->confirm("Are you sure you want to delete role '{$rid}' from tenant '{$tenant}'?", false)) {
|
||||
$io->text('Delete cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$this->rolesStore->deleteRole($tenant, $rid)) {
|
||||
$io->error("Failed to delete role '{$rid}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->logger->info('Role deleted via console', [
|
||||
'tenant' => $tenant,
|
||||
'rid' => $rid,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Role '{$rid}' deleted successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to delete role: ' . $e->getMessage());
|
||||
$this->logger->error('Role delete failed', [
|
||||
'tenant' => $tenant,
|
||||
'rid' => $rid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Role;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Role List Command
|
||||
*
|
||||
* Lists all roles in a tenant.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'role:list',
|
||||
description: 'List roles in a tenant',
|
||||
)]
|
||||
class RoleListCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly UserRolesStore $rolesStore
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->setHelp('This command lists all roles in a tenant.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = $input->getArgument('tenant');
|
||||
|
||||
$io->title("Roles in tenant '{$tenant}'");
|
||||
|
||||
try {
|
||||
if (!$this->tenantService->fetchById($tenant)) {
|
||||
$io->error("Tenant '{$tenant}' not found.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$roles = $this->rolesStore->listRoles($tenant);
|
||||
|
||||
if (empty($roles)) {
|
||||
$io->text('No roles found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($roles as $role) {
|
||||
$rows[] = [
|
||||
$role['rid'] ?? '',
|
||||
$role['label'] ?? '',
|
||||
!empty($role['system']) ? 'yes' : 'no',
|
||||
implode(', ', (array)($role['permissions'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(['Rid', 'Label', 'System', 'Permissions'], $rows);
|
||||
$io->text(sprintf('Total: %d role(s)', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to list roles: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Role;
|
||||
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Role Revoke Command
|
||||
*
|
||||
* Revokes a role from a user account.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'role:revoke',
|
||||
description: 'Revoke a role from a user',
|
||||
)]
|
||||
class RoleRevokeCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
|
||||
->addArgument('rid', InputArgument::REQUIRED, 'Role id to revoke')
|
||||
->setHelp('This command revokes a role from a user account.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = $input->getArgument('tenant');
|
||||
$identity = $input->getArgument('identity');
|
||||
$rid = $input->getArgument('rid');
|
||||
|
||||
$io->title('Revoke Role');
|
||||
|
||||
try {
|
||||
$user = $this->userStore->fetchByIdentity($tenant, $identity);
|
||||
if (!$user) {
|
||||
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$roles = (array)($user['roles'] ?? []);
|
||||
if (!in_array($rid, $roles, true)) {
|
||||
$io->text("User '{$identity}' does not have role '{$rid}'.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$roles = array_values(array_filter($roles, fn($r) => $r !== $rid));
|
||||
|
||||
if (!$this->userStore->updateUser($tenant, $user['uid'], ['roles' => $roles])) {
|
||||
$io->error("Failed to revoke role '{$rid}' from '{$identity}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->logger->info('Role revoked via console', [
|
||||
'tenant' => $tenant,
|
||||
'identity' => $identity,
|
||||
'rid' => $rid,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Role '{$rid}' revoked from '{$identity}' successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to revoke role: ' . $e->getMessage());
|
||||
$this->logger->error('Role revoke failed', [
|
||||
'tenant' => $tenant,
|
||||
'identity' => $identity,
|
||||
'rid' => $rid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Tenant;
|
||||
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Service\TenantService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Tenant Auth Enable Command
|
||||
*
|
||||
* Enables an authentication provider for a tenant's login flow.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'tenant:auth:enable',
|
||||
description: 'Enable an authentication provider for a tenant',
|
||||
)]
|
||||
class TenantAuthEnableCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly ProviderManager $providerManager,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('provider', InputArgument::REQUIRED, 'Authentication provider id (e.g. password, oidc, totp)')
|
||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label shown for this method during login')
|
||||
->setHelp('This command enables an authentication provider for a tenant. The provider\'s module must already be installed and enabled.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenantIdentifier = $input->getArgument('tenant');
|
||||
$providerId = $input->getArgument('provider');
|
||||
|
||||
$io->title('Enable Tenant Authentication Provider');
|
||||
|
||||
try {
|
||||
$tenant = $this->tenantService->fetchById($tenantIdentifier);
|
||||
|
||||
if (!$tenant) {
|
||||
$io->error("Tenant '{$tenantIdentifier}' not found.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$this->providerManager->resolve('authentication', $providerId)) {
|
||||
$io->error("Authentication provider '{$providerId}' is not registered. Is its module installed and enabled?");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$authentication = $tenant->getConfiguration()->authentication();
|
||||
$providers = $authentication->providers();
|
||||
$providers[$providerId] = array_filter([
|
||||
'enabled' => true,
|
||||
'label' => $input->getOption('label'),
|
||||
], fn($value) => $value !== null);
|
||||
$authentication->jsonDeserialize(['providers' => $providers]);
|
||||
|
||||
$this->tenantService->deposit($tenant);
|
||||
|
||||
$this->logger->info('Tenant authentication provider enabled via console', [
|
||||
'tenant' => $tenantIdentifier,
|
||||
'provider' => $providerId,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Authentication provider '{$providerId}' enabled for tenant '{$tenantIdentifier}'.");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to enable authentication provider: ' . $e->getMessage());
|
||||
$this->logger->error('Tenant auth provider enable failed', [
|
||||
'tenant' => $tenantIdentifier,
|
||||
'provider' => $providerId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Tenant;
|
||||
|
||||
use KTXC\Models\Tenant\DomainCollection;
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
use KTXF\Utile\UUID;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Tenant Create Command
|
||||
*
|
||||
* Creates a new tenant.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'tenant:create',
|
||||
description: 'Create a new tenant',
|
||||
)]
|
||||
class TenantCreateCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly UserRolesStore $rolesStore,
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('domain', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Domain(s) served by this tenant')
|
||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the tenant')
|
||||
->addOption('description', 'd', InputOption::VALUE_REQUIRED, 'Tenant description')
|
||||
->addOption('identifier', null, InputOption::VALUE_REQUIRED, 'Explicit tenant identifier (defaults to a generated UUID)')
|
||||
->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the tenant in a disabled state')
|
||||
->addOption('admin-identity', null, InputOption::VALUE_REQUIRED, 'Identity for the bootstrap admin user', 'admin')
|
||||
->addOption('no-admin-user', null, InputOption::VALUE_NONE, 'Do not create a bootstrap admin user (the admin role is still seeded)')
|
||||
->setHelp('This command creates a new tenant with one or more domains. The tenant identifier is generated automatically unless --identifier is provided. An "admin" role with full permissions is seeded automatically, along with a bootstrap admin user unless --no-admin-user is passed.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$identifier = $input->getOption('identifier') ?? UUID::v4();
|
||||
$domains = array_values(array_unique(array_filter(array_map('trim', $input->getArgument('domain')))));
|
||||
|
||||
$io->title('Create Tenant');
|
||||
|
||||
try {
|
||||
if (empty($domains)) {
|
||||
$io->error('At least one non-empty domain is required.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Ensure identifier is unique
|
||||
if ($this->tenantService->fetchById($identifier)) {
|
||||
$io->error("Tenant '{$identifier}' already exists.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Ensure domains are not claimed by another tenant
|
||||
foreach ($domains as $domain) {
|
||||
$existing = $this->tenantService->fetchByDomain($domain);
|
||||
if ($existing) {
|
||||
$io->error("Domain '{$domain}' is already assigned to tenant '{$existing->getIdentifier()}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$tenant = new TenantObject();
|
||||
$tenant->setIdentifier($identifier);
|
||||
$tenant->setEnabled(!$input->getOption('disabled'));
|
||||
$tenant->setLabel($input->getOption('label') ?? $domains[0]);
|
||||
$tenant->setDescription($input->getOption('description') ?? '');
|
||||
$tenant->setDomains(new DomainCollection($domains));
|
||||
$tenant->setConfiguration(new TenantConfiguration());
|
||||
|
||||
$tenant = $this->tenantService->deposit($tenant);
|
||||
|
||||
if (!$tenant) {
|
||||
$io->error('Failed to create tenant.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->logger->info('Tenant created via console', [
|
||||
'identifier' => $identifier,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Tenant '{$identifier}' created successfully!");
|
||||
$io->definitionList(
|
||||
['Id' => $tenant->getId()],
|
||||
['Identifier' => $tenant->getIdentifier()],
|
||||
['Label' => $tenant->getLabel()],
|
||||
['Enabled' => $tenant->getEnabled() ? 'yes' : 'no'],
|
||||
['Domains' => implode(', ', $domains)],
|
||||
);
|
||||
|
||||
$role = $this->rolesStore->createRole($identifier, [
|
||||
'rid' => 'admin',
|
||||
'label' => 'Administrator',
|
||||
'description' => 'Full access to all tenant features',
|
||||
'permissions' => ['*'],
|
||||
'system' => true,
|
||||
]);
|
||||
|
||||
$this->logger->info('Default admin role seeded via console', [
|
||||
'tenant' => $identifier,
|
||||
'rid' => $role['rid'] ?? null,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->text("Default role 'admin' seeded with full permissions.");
|
||||
|
||||
if (!$input->getOption('no-admin-user')) {
|
||||
$adminIdentity = $input->getOption('admin-identity');
|
||||
|
||||
if ($this->userStore->fetchByIdentity($identifier, $adminIdentity)) {
|
||||
$io->warning("User '{$adminIdentity}' already exists in tenant '{$identifier}'; skipping admin user creation.");
|
||||
} else {
|
||||
$this->userStore->createUser($identifier, [
|
||||
'identity' => $adminIdentity,
|
||||
'label' => 'Administrator',
|
||||
'enabled' => true,
|
||||
'roles' => ['admin'],
|
||||
'profile' => [],
|
||||
'settings' => [],
|
||||
'provider' => null,
|
||||
'provider_subject' => null,
|
||||
'provider_managed_fields' => [],
|
||||
]);
|
||||
|
||||
$this->logger->info('Bootstrap admin user created via console', [
|
||||
'tenant' => $identifier,
|
||||
'identity' => $adminIdentity,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->text("Admin user '{$adminIdentity}' created with the 'admin' role.");
|
||||
$io->note("Set a credential for this account using the auth provider module you have installed, e.g.: php bin/console user:password {$identifier} {$adminIdentity}");
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to create tenant: ' . $e->getMessage());
|
||||
$this->logger->error('Tenant create failed', [
|
||||
'identifier' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Tenant;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Tenant Delete Command
|
||||
*
|
||||
* Deletes an existing tenant.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'tenant:delete',
|
||||
description: 'Delete a tenant',
|
||||
)]
|
||||
class TenantDeleteCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('identifier', InputArgument::REQUIRED, 'Tenant identifier to delete')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
|
||||
->setHelp('This command deletes a tenant. Deletion must be confirmed by typing the tenant\'s primary domain. User accounts and module data belonging to the tenant are not removed.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$identifier = $input->getArgument('identifier');
|
||||
$force = $input->getOption('force');
|
||||
|
||||
$io->title('Delete Tenant');
|
||||
|
||||
try {
|
||||
$tenant = $this->tenantService->fetchById($identifier);
|
||||
|
||||
if (!$tenant) {
|
||||
$io->error("Tenant '{$identifier}' not found.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$force) {
|
||||
// Confirm by typing the tenant's primary domain (identifier for
|
||||
// legacy tenants without domains)
|
||||
$domains = $tenant->getDomains()?->getArrayCopy() ?? [];
|
||||
$confirmation = $domains[0] ?? $identifier;
|
||||
|
||||
$io->definitionList(
|
||||
['Identifier' => $tenant->getIdentifier()],
|
||||
['Label' => $tenant->getLabel()],
|
||||
['Domains' => implode(', ', $domains)],
|
||||
);
|
||||
|
||||
$answer = $io->ask("Type '{$confirmation}' to confirm deletion");
|
||||
|
||||
if ($answer !== $confirmation) {
|
||||
$io->error('Confirmation did not match. Delete cancelled.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$this->tenantService->destroy($tenant);
|
||||
|
||||
$this->logger->info('Tenant deleted via console', [
|
||||
'identifier' => $identifier,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Tenant '{$identifier}' deleted successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to delete tenant: ' . $e->getMessage());
|
||||
$this->logger->error('Tenant delete failed', [
|
||||
'identifier' => $identifier,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\Tenant;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Tenant List Command
|
||||
*
|
||||
* Lists all tenants.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'tenant:list',
|
||||
description: 'List all tenants',
|
||||
)]
|
||||
class TenantListCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setHelp('This command lists all tenants.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->title('Tenants');
|
||||
|
||||
try {
|
||||
$tenants = $this->tenantService->list();
|
||||
|
||||
if (empty($tenants)) {
|
||||
$io->text('No tenants found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($tenants as $tenant) {
|
||||
$domains = $tenant->getDomains();
|
||||
$rows[] = [
|
||||
$tenant->getIdentifier(),
|
||||
$tenant->getLabel(),
|
||||
$tenant->getEnabled() ? 'yes' : 'no',
|
||||
$domains ? implode(', ', $domains->getArrayCopy()) : '',
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(['Identifier', 'Label', 'Enabled', 'Domains'], $rows);
|
||||
$io->text(sprintf('Total: %d tenant(s)', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to list tenants: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\User;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
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;
|
||||
|
||||
/**
|
||||
* User Create Command
|
||||
*
|
||||
* Creates a new user account within a tenant.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'user:create',
|
||||
description: 'Create a new user account in a tenant',
|
||||
)]
|
||||
class UserCreateCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly UserRolesStore $rolesStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
|
||||
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Display label for the user')
|
||||
->addOption('role', 'r', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Role id(s) to assign', [])
|
||||
->addOption('uid', null, InputOption::VALUE_REQUIRED, 'Explicit user id (defaults to a generated UUID)')
|
||||
->addOption('disabled', null, InputOption::VALUE_NONE, 'Create the account in a disabled state')
|
||||
->setHelp('This command creates a new user account in a tenant. Authentication credentials are managed separately by authentication provider modules.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = $input->getArgument('tenant');
|
||||
$identity = $input->getArgument('identity');
|
||||
$roles = $input->getOption('role');
|
||||
|
||||
$io->title('Create User');
|
||||
|
||||
try {
|
||||
// Ensure the tenant exists
|
||||
if (!$this->tenantService->fetchById($tenant)) {
|
||||
$io->error("Tenant '{$tenant}' not found.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Ensure identity is unique within the tenant
|
||||
if ($this->userStore->fetchByIdentity($tenant, $identity)) {
|
||||
$io->error("User '{$identity}' already exists in tenant '{$tenant}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Ensure assigned roles exist
|
||||
foreach ($roles as $role) {
|
||||
if (!$this->rolesStore->fetchByRid($tenant, $role)) {
|
||||
$io->error("Role '{$role}' not found in tenant '{$tenant}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'identity' => $identity,
|
||||
'label' => $input->getOption('label') ?? $identity,
|
||||
'enabled' => !$input->getOption('disabled'),
|
||||
'roles' => $roles,
|
||||
'profile' => [],
|
||||
'settings' => [],
|
||||
'provider' => null,
|
||||
'provider_subject' => null,
|
||||
'provider_managed_fields' => [],
|
||||
];
|
||||
|
||||
if ($input->getOption('uid')) {
|
||||
$userData['uid'] = $input->getOption('uid');
|
||||
}
|
||||
|
||||
$user = $this->userStore->createUser($tenant, $userData);
|
||||
|
||||
$this->logger->info('User created via console', [
|
||||
'tenant' => $tenant,
|
||||
'identity' => $identity,
|
||||
'uid' => $user['uid'] ?? null,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("User '{$identity}' created successfully!");
|
||||
$io->definitionList(
|
||||
['Uid' => $user['uid'] ?? ''],
|
||||
['Identity' => $user['identity'] ?? ''],
|
||||
['Label' => $user['label'] ?? ''],
|
||||
['Enabled' => !empty($user['enabled']) ? 'yes' : 'no'],
|
||||
['Roles' => implode(', ', (array)($user['roles'] ?? []))],
|
||||
);
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to create user: ' . $e->getMessage());
|
||||
$this->logger->error('User create failed', [
|
||||
'tenant' => $tenant,
|
||||
'identity' => $identity,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\User;
|
||||
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
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;
|
||||
|
||||
/**
|
||||
* User Delete Command
|
||||
*
|
||||
* Deletes a user account from a tenant.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'user:delete',
|
||||
description: 'Delete a user account from a tenant',
|
||||
)]
|
||||
class UserDeleteCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserAccountsStore $userStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->addArgument('identity', InputArgument::REQUIRED, 'User identity (email/username)')
|
||||
->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt')
|
||||
->setHelp('This command deletes a user account from a tenant. Credentials stored by authentication provider modules are not removed.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = $input->getArgument('tenant');
|
||||
$identity = $input->getArgument('identity');
|
||||
$force = $input->getOption('force');
|
||||
|
||||
$io->title('Delete User');
|
||||
|
||||
try {
|
||||
$user = $this->userStore->fetchByIdentity($tenant, $identity);
|
||||
|
||||
if (!$user) {
|
||||
$io->error("User '{$identity}' not found in tenant '{$tenant}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$force && !$io->confirm("Are you sure you want to delete user '{$identity}' from tenant '{$tenant}'?", false)) {
|
||||
$io->text('Delete cancelled.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$this->userStore->deleteUser($tenant, $user['uid'])) {
|
||||
$io->error("Failed to delete user '{$identity}'.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->logger->info('User deleted via console', [
|
||||
'tenant' => $tenant,
|
||||
'identity' => $identity,
|
||||
'uid' => $user['uid'],
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("User '{$identity}' deleted successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to delete user: ' . $e->getMessage());
|
||||
$this->logger->error('User delete failed', [
|
||||
'tenant' => $tenant,
|
||||
'identity' => $identity,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console\User;
|
||||
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
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;
|
||||
|
||||
/**
|
||||
* User List Command
|
||||
*
|
||||
* Lists all user accounts in a tenant.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'user:list',
|
||||
description: 'List user accounts in a tenant',
|
||||
)]
|
||||
class UserListCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly UserAccountsStore $userStore
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('tenant', InputArgument::REQUIRED, 'Tenant identifier')
|
||||
->setHelp('This command lists all user accounts in a tenant.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$tenant = $input->getArgument('tenant');
|
||||
|
||||
$io->title("Users in tenant '{$tenant}'");
|
||||
|
||||
try {
|
||||
if (!$this->tenantService->fetchById($tenant)) {
|
||||
$io->error("Tenant '{$tenant}' not found.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$users = $this->userStore->listUsers($tenant);
|
||||
|
||||
if (empty($users)) {
|
||||
$io->text('No users found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($users as $user) {
|
||||
$rows[] = [
|
||||
$user['uid'] ?? '',
|
||||
$user['identity'] ?? '',
|
||||
$user['label'] ?? '',
|
||||
!empty($user['enabled']) ? 'yes' : 'no',
|
||||
implode(', ', (array)($user['roles'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(['Uid', 'Identity', 'Label', 'Enabled', 'Roles'], $rows);
|
||||
$io->text(sprintf('Total: %d user(s)', count($rows)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to list users: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Context;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
|
||||
final class IdentityContext implements IdentityContextInterface
|
||||
{
|
||||
private ?User $identity = null;
|
||||
|
||||
public function initialize(User $identity): void
|
||||
{
|
||||
if ($this->identity !== null) {
|
||||
throw new \LogicException('The execution identity has already been initialized.');
|
||||
}
|
||||
|
||||
$this->identity = $identity;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->identity = null;
|
||||
}
|
||||
|
||||
public function present(): bool
|
||||
{
|
||||
return $this->identity !== null;
|
||||
}
|
||||
|
||||
public function identity(): ?User
|
||||
{
|
||||
return $this->identity;
|
||||
}
|
||||
|
||||
public function identifier(): ?string
|
||||
{
|
||||
return $this->identity?->getId();
|
||||
}
|
||||
|
||||
public function requireIdentifier(): string
|
||||
{
|
||||
return $this->identifier()
|
||||
?? throw new \LogicException('This operation requires an identity context.');
|
||||
}
|
||||
|
||||
public function label(): ?string
|
||||
{
|
||||
return $this->identity?->getLabel();
|
||||
}
|
||||
|
||||
public function mailAddress(): ?string
|
||||
{
|
||||
return $this->identity?->getIdentity();
|
||||
}
|
||||
|
||||
public function nameFirst(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function nameLast(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return $this->identity?->getPermissions() ?? [];
|
||||
}
|
||||
|
||||
public function roles(): array
|
||||
{
|
||||
return $this->identity?->getRoles() ?? [];
|
||||
}
|
||||
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
$permissions = $this->permissions();
|
||||
if (in_array($permission, $permissions, true) || in_array('*', $permissions, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($permissions as $userPermission) {
|
||||
if (str_ends_with($userPermission, '.*')) {
|
||||
$prefix = substr($userPermission, 0, -2);
|
||||
if (str_starts_with($permission, $prefix . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->roles(), true);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Context;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
|
||||
interface IdentityContextInterface
|
||||
{
|
||||
public function present(): bool;
|
||||
public function identity(): ?User;
|
||||
public function identifier(): ?string;
|
||||
public function requireIdentifier(): string;
|
||||
public function label(): ?string;
|
||||
public function mailAddress(): ?string;
|
||||
public function nameFirst(): ?string;
|
||||
public function nameLast(): ?string;
|
||||
public function permissions(): array;
|
||||
public function roles(): array;
|
||||
public function hasPermission(string $permission): bool;
|
||||
public function hasRole(string $role): bool;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Context;
|
||||
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Service\TenantService;
|
||||
|
||||
final class TenantContext implements TenantContextInterface
|
||||
{
|
||||
private ?TenantObject $tenant = null;
|
||||
private ?string $domain = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function resolveDomain(string $domain): bool
|
||||
{
|
||||
$this->clear();
|
||||
$tenant = $this->tenantService->fetchByDomain($domain);
|
||||
if ($tenant === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->domain = $domain;
|
||||
$this->tenant = $tenant;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resolveIdentifier(string $identifier): bool
|
||||
{
|
||||
$this->clear();
|
||||
$tenant = $this->tenantService->fetchById($identifier);
|
||||
if ($tenant === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->domain = $identifier;
|
||||
$this->tenant = $tenant;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->tenant = null;
|
||||
$this->domain = null;
|
||||
}
|
||||
|
||||
public function present(): bool
|
||||
{
|
||||
return $this->tenant !== null;
|
||||
}
|
||||
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->present();
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->tenant?->getEnabled() ?? false;
|
||||
}
|
||||
|
||||
public function domain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
public function identifier(): ?string
|
||||
{
|
||||
return $this->tenant?->getIdentifier();
|
||||
}
|
||||
|
||||
public function requireIdentifier(): string
|
||||
{
|
||||
return $this->identifier()
|
||||
?? throw new \LogicException('This operation requires a tenant context.');
|
||||
}
|
||||
|
||||
public function label(): ?string
|
||||
{
|
||||
return $this->tenant?->getLabel();
|
||||
}
|
||||
|
||||
public function configuration(): ?TenantConfiguration
|
||||
{
|
||||
return $this->tenant?->getConfiguration();
|
||||
}
|
||||
|
||||
public function settings(): array
|
||||
{
|
||||
return $this->tenant?->getSettings() ?? [];
|
||||
}
|
||||
|
||||
public function identityProviders(): array
|
||||
{
|
||||
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
|
||||
}
|
||||
|
||||
public function identityProviderConfig(string $providerId): ?array
|
||||
{
|
||||
return $this->identityProviders()[$providerId] ?? null;
|
||||
}
|
||||
|
||||
public function isIdentityProviderEnabled(string $providerId): bool
|
||||
{
|
||||
$config = $this->identityProviderConfig($providerId);
|
||||
|
||||
return $config !== null && ($config['enabled'] ?? false);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Context;
|
||||
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
|
||||
interface TenantContextInterface
|
||||
{
|
||||
public function present(): bool;
|
||||
public function configured(): bool;
|
||||
public function enabled(): bool;
|
||||
public function domain(): ?string;
|
||||
public function identifier(): ?string;
|
||||
public function requireIdentifier(): string;
|
||||
public function label(): ?string;
|
||||
public function configuration(): ?TenantConfiguration;
|
||||
public function settings(): array;
|
||||
public function identityProviders(): array;
|
||||
public function identityProviderConfig(string $providerId): ?array;
|
||||
public function isIdentityProviderEnabled(string $providerId): bool;
|
||||
}
|
||||
@@ -225,7 +225,7 @@ class AuthenticationController extends ControllerAbstract
|
||||
return $this->clearTokenCookies($httpResponse);
|
||||
}
|
||||
|
||||
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed', 'expires_in' => 900]);
|
||||
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']);
|
||||
|
||||
if ($response->tokens && isset($response->tokens['access'])) {
|
||||
$httpResponse->headers->setCookie(
|
||||
@@ -242,15 +242,6 @@ 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
|
||||
*/
|
||||
@@ -290,16 +281,14 @@ class AuthenticationController extends ControllerAbstract
|
||||
*/
|
||||
private function buildJsonResponse(AuthenticationResponse $response): JsonResponse
|
||||
{
|
||||
$data = $response->toArray();
|
||||
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||
|
||||
// Set token cookies and expose expires_in if present
|
||||
// Set token cookies if present
|
||||
if ($response->hasTokens()) {
|
||||
$data['expires_in'] = 900;
|
||||
$httpResponse = new JsonResponse($data, $response->httpStatus);
|
||||
return $this->setTokenCookies($httpResponse, $response->tokens, true);
|
||||
}
|
||||
|
||||
return new JsonResponse($data, $response->httpStatus);
|
||||
return $httpResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,14 +10,14 @@ use KTXC\Http\Response\RedirectResponse;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXC\Service\SecurityService;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Http\Request\Request;
|
||||
|
||||
class DefaultController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityService $securityService,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionIdentity $identity,
|
||||
#[Inject('rootDir')] private readonly string $rootDir,
|
||||
) {}
|
||||
|
||||
@@ -25,11 +25,11 @@ class DefaultController extends ControllerAbstract
|
||||
public function home(Request $request): Response
|
||||
{
|
||||
// If an authenticated identity is available, serve the private app
|
||||
if ($this->identityContext->identifier()) {
|
||||
if ($this->identity->identifier()) {
|
||||
return new FileResponse(
|
||||
$this->rootDir . '/public/private.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class DefaultController extends ControllerAbstract
|
||||
$response = new FileResponse(
|
||||
$this->rootDir . '/public/public.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
|
||||
// Clear any stale auth cookies since the user is not authenticated
|
||||
@@ -58,7 +58,7 @@ class DefaultController extends ControllerAbstract
|
||||
return new FileResponse(
|
||||
$this->rootDir . '/public/public.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,11 +116,11 @@ class DefaultController extends ControllerAbstract
|
||||
public function catchAll(Request $request, string $path = ''): Response
|
||||
{
|
||||
// If an authenticated identity is available, serve the private app
|
||||
if ($this->identityContext->identifier()) {
|
||||
if ($this->identity->identifier()) {
|
||||
return new FileResponse(
|
||||
$this->rootDir . '/public/private.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ class DefaultController extends ControllerAbstract
|
||||
$response = new FileResponse(
|
||||
$this->rootDir . '/public/public.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html', 'Cache-Control' => 'no-store']
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
|
||||
// Clear any stale auth cookies since the user is not authenticated
|
||||
|
||||
@@ -2,36 +2,34 @@
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\L10N\LocaleResolver;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXC\Security\Authorization\PermissionChecker;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class InitController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly PermissionChecker $permissionChecker,
|
||||
private readonly LocaleResolver $localeResolver,
|
||||
) {}
|
||||
|
||||
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
|
||||
public function index(Request $request): JsonResponse {
|
||||
public function index(): JsonResponse {
|
||||
|
||||
$configuration = [];
|
||||
|
||||
// modules - filter by permissions
|
||||
$configuration['modules'] = [];
|
||||
foreach ($this->moduleManager->list(true, true) as $module) {
|
||||
foreach ($this->moduleManager->list() 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();
|
||||
@@ -45,31 +43,24 @@ class InitController extends ControllerAbstract
|
||||
}
|
||||
}
|
||||
|
||||
// localization
|
||||
$configuration['l10n'] = [
|
||||
'locale' => $this->localeResolver->resolve($request),
|
||||
'fallback' => LocaleResolver::FALLBACK,
|
||||
'available' => $this->localeResolver->available(),
|
||||
];
|
||||
|
||||
// tenant
|
||||
$configuration['tenant'] = [
|
||||
'id' => $this->tenantContext->identifier(),
|
||||
'domain' => $this->tenantContext->domain(),
|
||||
'label' => $this->tenantContext->label(),
|
||||
'id' => $this->tenant->identifier(),
|
||||
'domain' => $this->tenant->domain(),
|
||||
'label' => $this->tenant->label(),
|
||||
];
|
||||
|
||||
// user
|
||||
$configuration['user'] = [
|
||||
'auth' => [
|
||||
'identifier' => $this->identityContext->identifier(),
|
||||
'identity' => $this->identityContext->identity()->getIdentity(),
|
||||
'label' => $this->identityContext->label(),
|
||||
'roles' => $this->identityContext->identity()->getRoles(),
|
||||
'permissions' => $this->identityContext->identity()->getPermissions(),
|
||||
'identifier' => $this->userIdentity->identifier(),
|
||||
'identity' => $this->userIdentity->identity()->getIdentity(),
|
||||
'label' => $this->userIdentity->label(),
|
||||
'roles' => $this->userIdentity->identity()->getRoles(),
|
||||
'permissions' => $this->userIdentity->identity()->getPermissions(),
|
||||
],
|
||||
'profile' => $this->userService->getEditableFields($this->identityContext->identifier()),
|
||||
'settings' => $this->userService->fetchSettings([], true),
|
||||
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()),
|
||||
'settings' => $this->userService->fetchSettings(),
|
||||
];
|
||||
|
||||
return new JsonResponse($configuration);
|
||||
|
||||
@@ -21,7 +21,7 @@ class ModuleController extends ControllerAbstract
|
||||
)]
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$modules = $this->moduleManager->list();
|
||||
$modules = $this->moduleManager->list(false);
|
||||
|
||||
return new JsonResponse(['modules' => $modules]);
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\TenantService;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
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 TenantContextInterface $tenantContext,
|
||||
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->tenantContext->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": {
|
||||
* "theme_default_mode": "dark",
|
||||
* "theme_palette": {"light": {"colors": {"primary": "#0284C7"}}},
|
||||
* "theme_lock": 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->tenantContext->identifier(), $data);
|
||||
|
||||
$updatedSettings = $this->tenantService->fetchSettings($this->tenantContext->identifier(), array_keys($data));
|
||||
|
||||
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use Psr\Log\LoggerInterface;
|
||||
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
|
||||
class UserAccountsController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
@@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract
|
||||
{
|
||||
try {
|
||||
// Check admin permission
|
||||
if (!$this->identityContext->hasPermission('user.admin')) {
|
||||
if (!$this->userIdentity->hasPermission('user.admin')) {
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
||||
@@ -125,7 +125,7 @@ class UserAccountsController extends ControllerAbstract
|
||||
*/
|
||||
private function userCreate(array $data): array
|
||||
{
|
||||
if (!$this->identityContext->hasPermission('user.create')) {
|
||||
if (!$this->userIdentity->hasPermission('user.create')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to create users');
|
||||
}
|
||||
|
||||
@@ -142,9 +142,9 @@ class UserAccountsController extends ControllerAbstract
|
||||
];
|
||||
|
||||
$this->logger->info('Creating user', [
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'identity' => $userData['identity'],
|
||||
'actor' => $this->identityContext->identifier()
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->createUser($userData);
|
||||
@@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract
|
||||
*/
|
||||
private function userUpdate(array $data): bool
|
||||
{
|
||||
if (!$this->identityContext->hasPermission('user.update')) {
|
||||
if (!$this->userIdentity->hasPermission('user.update')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to update users');
|
||||
}
|
||||
|
||||
@@ -186,9 +186,9 @@ class UserAccountsController extends ControllerAbstract
|
||||
}
|
||||
|
||||
$this->logger->info('Updating user', [
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->identityContext->identifier()
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->updateUser($uid, $updates);
|
||||
@@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract
|
||||
*/
|
||||
private function userDelete(array $data): bool
|
||||
{
|
||||
if (!$this->identityContext->hasPermission('user.delete')) {
|
||||
if (!$this->userIdentity->hasPermission('user.delete')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to delete users');
|
||||
}
|
||||
|
||||
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
|
||||
|
||||
// Prevent self-deletion
|
||||
if ($uid === $this->identityContext->identifier()) {
|
||||
if ($uid === $this->userIdentity->identifier()) {
|
||||
throw new \InvalidArgumentException('Cannot delete your own account');
|
||||
}
|
||||
|
||||
$this->logger->info('Deleting user', [
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->identityContext->identifier()
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->deleteUser($uid);
|
||||
@@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract
|
||||
*/
|
||||
private function userProviderUnlink(array $data): bool
|
||||
{
|
||||
if (!$this->identityContext->hasPermission('user.admin')) {
|
||||
if (!$this->userIdentity->hasPermission('user.admin')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions');
|
||||
}
|
||||
|
||||
@@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract
|
||||
];
|
||||
|
||||
$this->logger->info('Unlinking provider', [
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->identityContext->identifier()
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->updateUser($uid, $updates);
|
||||
|
||||
@@ -4,16 +4,16 @@ namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class UserProfileController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsService $userService
|
||||
) {}
|
||||
|
||||
@@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract
|
||||
)]
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
$userId = $this->identityContext->identifier();
|
||||
$userId = $this->userIdentity->identifier();
|
||||
|
||||
// Get profile with editability metadata
|
||||
$profile = $this->userService->getEditableFields($userId);
|
||||
@@ -63,7 +63,7 @@ class UserProfileController extends ControllerAbstract
|
||||
)]
|
||||
public function update(array $data): JsonResponse
|
||||
{
|
||||
$userId = $this->identityContext->identifier();
|
||||
$userId = $this->userIdentity->identifier();
|
||||
|
||||
// storeProfile automatically filters out provider-managed fields
|
||||
$this->userService->storeProfile($userId, $data);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Service\UserRolesService;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
|
||||
class UserRolesController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserRolesService $roleService,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
@@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract
|
||||
{
|
||||
try {
|
||||
// Check role admin permission
|
||||
if (!$this->identityContext->hasPermission('role.admin')) {
|
||||
if (!$this->userIdentity->hasPermission('role.admin')) {
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
||||
@@ -137,7 +137,7 @@ class UserRolesController extends ControllerAbstract
|
||||
*/
|
||||
private function roleCreate(array $data): array
|
||||
{
|
||||
if (!$this->identityContext->hasPermission('role.manage')) {
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to create roles');
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ class UserRolesController extends ControllerAbstract
|
||||
*/
|
||||
private function roleUpdate(array $data): bool
|
||||
{
|
||||
if (!$this->identityContext->hasPermission('role.manage')) {
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to update roles');
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ class UserRolesController extends ControllerAbstract
|
||||
*/
|
||||
private function roleDelete(array $data): bool
|
||||
{
|
||||
if (!$this->identityContext->hasPermission('role.manage')) {
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to delete roles');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,23 @@
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class UserSettingsController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsService $userService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve user settings, with optional filtering
|
||||
* Retrieve user settings
|
||||
* If no specific settings are requested, all settings are returned
|
||||
*
|
||||
* @return JsonResponse Settings data as key-value pairs
|
||||
@@ -30,9 +29,10 @@ class UserSettingsController extends ControllerAbstract
|
||||
methods: ['GET'],
|
||||
permissions: ['user.settings.read']
|
||||
)]
|
||||
public function read(bool $flatten = false): JsonResponse
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
$settings = $this->userService->fetchSettings(flatten: $flatten);
|
||||
// Fetch all settings (no filter)
|
||||
$settings = $this->userService->fetchSettings();
|
||||
|
||||
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
@@ -55,17 +55,17 @@ class UserSettingsController extends ControllerAbstract
|
||||
*/
|
||||
#[AuthenticatedRoute(
|
||||
'/user/settings',
|
||||
name: 'user.settings.write',
|
||||
methods: ['POST', 'PUT', 'PATCH'],
|
||||
permissions: ['user.settings.write']
|
||||
name: 'user.settings.update',
|
||||
methods: ['PUT', 'PATCH'],
|
||||
permissions: ['user.settings.update']
|
||||
)]
|
||||
public function write(array $data): JsonResponse
|
||||
public function update(array $data): JsonResponse
|
||||
{
|
||||
$this->userService->storeSettings($data);
|
||||
|
||||
// Return updated settings
|
||||
$settings = $this->userService->fetchSettings(array_keys($data));
|
||||
$updatedSettings = $this->userService->fetchSettings(array_keys($data));
|
||||
|
||||
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@ namespace KTXC\Http\Middleware;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Service\SecurityService;
|
||||
use KTXC\Context\IdentityContext;
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
@@ -22,10 +19,7 @@ class AuthenticationMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityService $securityService,
|
||||
private readonly IdentityContext $identityContext,
|
||||
private readonly EphemeralCacheInterface $ephemeralCache,
|
||||
private readonly PersistentCacheInterface $persistentCache,
|
||||
private readonly BlobCacheInterface $blobCache,
|
||||
private readonly SessionIdentity $sessionIdentity
|
||||
) {}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
@@ -35,11 +29,7 @@ class AuthenticationMiddleware implements MiddlewareInterface
|
||||
|
||||
// Initialize session identity if authentication succeeded
|
||||
if ($identity) {
|
||||
$this->identityContext->initialize($identity);
|
||||
$identityId = $this->identityContext->identifier();
|
||||
$this->ephemeralCache->setUserContext($identityId);
|
||||
$this->persistentCache->setUserContext($identityId);
|
||||
$this->blobCache->setUserContext($identityId);
|
||||
$this->sessionIdentity->initialize($identity, true);
|
||||
}
|
||||
|
||||
// Continue to next middleware (authentication is optional at this stage)
|
||||
|
||||
@@ -6,7 +6,7 @@ use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Routing\Router;
|
||||
use KTXC\Routing\Route;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Security\Authorization\PermissionChecker;
|
||||
|
||||
/**
|
||||
@@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Router $router,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionIdentity $sessionIdentity,
|
||||
private readonly PermissionChecker $permissionChecker
|
||||
) {}
|
||||
|
||||
@@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface
|
||||
}
|
||||
|
||||
// Check if route requires authentication
|
||||
if ($match->authenticated && $this->identityContext->identity() === null) {
|
||||
if ($match->authenticated && $this->sessionIdentity->identity() === null) {
|
||||
return new Response(
|
||||
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
|
||||
@@ -4,10 +4,7 @@ namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
use KTXC\SessionTenant;
|
||||
|
||||
/**
|
||||
* Tenant resolution middleware
|
||||
@@ -16,23 +13,16 @@ use KTXF\Cache\PersistentCacheInterface;
|
||||
class TenantMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenantContext,
|
||||
private readonly EphemeralCacheInterface $ephemeralCache,
|
||||
private readonly PersistentCacheInterface $persistentCache,
|
||||
private readonly BlobCacheInterface $blobCache,
|
||||
private readonly SessionTenant $sessionTenant
|
||||
) {}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
// Configure tenant from request host
|
||||
$this->tenantContext->resolveDomain($request->getHost());
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$this->ephemeralCache->setTenantContext($tenantId);
|
||||
$this->persistentCache->setTenantContext($tenantId);
|
||||
$this->blobCache->setTenantContext($tenantId);
|
||||
$this->sessionTenant->configure($request->getHost());
|
||||
|
||||
// Check if tenant is configured and enabled
|
||||
if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) {
|
||||
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) {
|
||||
return new Response(
|
||||
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+169
-178
@@ -9,28 +9,20 @@
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Application\KernelOptions;
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Application\Execution\ExecutionOutcome;
|
||||
use KTXC\Application\Execution\ExecutionRunner;
|
||||
use KTXC\Application\Execution\ExecutionRunnerInterface;
|
||||
use KTXC\Application\Execution\ExecutionScope;
|
||||
use KTXC\Application\Execution\TerminationReport;
|
||||
use KTXC\Context\IdentityContext;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContext;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Http\Middleware\MiddlewarePipeline;
|
||||
use KTXC\Http\Middleware\TenantMiddleware;
|
||||
use KTXC\Http\Middleware\FirewallMiddleware;
|
||||
use KTXC\Http\Middleware\AuthenticationMiddleware;
|
||||
use KTXC\Http\Middleware\RouterMiddleware;
|
||||
use KTXC\Injection\Builder;
|
||||
use KTXC\Injection\Container;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use KTXC\Logger\LoggerFactory;
|
||||
use KTXC\Logger\TenantAwareLogger;
|
||||
use KTXF\Event\DeferredEventProcessorInterface;
|
||||
use KTXF\Event\EventDispatcher;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
use KTXC\Logger\FileLogger;
|
||||
use KTXF\Event\EventBus;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
@@ -38,7 +30,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
|
||||
use KTXF\Cache\Store\FilePersistentCache;
|
||||
use KTXF\Cache\Store\FileBlobCache;
|
||||
|
||||
class Kernel implements KernelInterface
|
||||
class Kernel
|
||||
{
|
||||
public const VERSION = '1.0.0';
|
||||
public const VERSION_ID = 10000;
|
||||
@@ -52,17 +44,26 @@ class Kernel implements KernelInterface
|
||||
protected ?float $startTime = null;
|
||||
protected ?ContainerInterface $container = null;
|
||||
protected ?LoggerInterface $logger = null;
|
||||
private bool $errorHandlerInstalled = false;
|
||||
private ?ExecutionScope $activeScope = null;
|
||||
private ?ExecutionRunnerInterface $runner = null;
|
||||
protected ?MiddlewarePipeline $pipeline = null;
|
||||
|
||||
private string $projectDir;
|
||||
private array $config;
|
||||
|
||||
public function __construct(
|
||||
private readonly KernelOptions $options,
|
||||
protected string $environment = 'prod',
|
||||
protected bool $debug = false,
|
||||
array $config = [],
|
||||
?string $projectDir = null,
|
||||
) {
|
||||
if (!$environment) {
|
||||
throw new \InvalidArgumentException(\sprintf('Invalid environment provided to "%s": the environment cannot be empty.', get_debug_type($this)));
|
||||
}
|
||||
|
||||
$this->config = $config;
|
||||
|
||||
if ($projectDir !== null) {
|
||||
$this->projectDir = $projectDir;
|
||||
}
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
@@ -70,16 +71,15 @@ class Kernel implements KernelInterface
|
||||
$this->initialized = false;
|
||||
$this->booted = false;
|
||||
$this->container = null;
|
||||
$this->runner = null;
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
|
||||
if ($this->debug()) {
|
||||
if ($this->debug) {
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
if ($this->debug() && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
|
||||
if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
|
||||
if (\function_exists('putenv')) {
|
||||
putenv('SHELL_VERBOSITY=3');
|
||||
}
|
||||
@@ -87,11 +87,10 @@ class Kernel implements KernelInterface
|
||||
$_SERVER['SHELL_VERBOSITY'] = 3;
|
||||
}
|
||||
|
||||
// Create logger from config (driver + level-filter; per-tenant wrapping applied later in DI)
|
||||
$this->logger = LoggerFactory::create(
|
||||
$this->config,
|
||||
$this->options->paths->project,
|
||||
);
|
||||
// Create logger with config support
|
||||
$logDir = $this->config['log.directory'] ?? $this->getLogDir();
|
||||
$logChannel = $this->config['log.channel'] ?? 'app';
|
||||
$this->logger = new FileLogger($logDir, $logChannel);
|
||||
|
||||
$this->initializeErrorHandlers();
|
||||
|
||||
@@ -131,7 +130,23 @@ class Kernel implements KernelInterface
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->errorHandlerInstalled = true;
|
||||
// Handle uncaught exceptions
|
||||
set_exception_handler(function (\Throwable $exception) {
|
||||
$this->logger->error('Exception caught: ' . $exception->getMessage(), [
|
||||
'exception' => $exception,
|
||||
'file' => $exception->getFile(),
|
||||
'line' => $exception->getLine(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
]);
|
||||
|
||||
if ($this->debug) {
|
||||
echo '<pre>Uncaught Exception: ' . $exception . '</pre>';
|
||||
} else {
|
||||
echo 'An unexpected error occurred. Please try again later.';
|
||||
}
|
||||
|
||||
exit(1);
|
||||
});
|
||||
|
||||
// Handle fatal errors
|
||||
register_shutdown_function(function () {
|
||||
@@ -147,6 +162,11 @@ class Kernel implements KernelInterface
|
||||
|
||||
$this->logger->error($message, $error);
|
||||
|
||||
if ($this->debug) {
|
||||
echo '<pre>' . $message . '</pre>';
|
||||
} else {
|
||||
echo 'A fatal error occurred. Please try again later.';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -161,19 +181,14 @@ class Kernel implements KernelInterface
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $this->container->get(ModuleManager::class);
|
||||
$moduleManager->modulesBoot();
|
||||
$this->container
|
||||
->get(EventListenerRegistry::class)
|
||||
->freeze($this->container);
|
||||
|
||||
// Build middleware pipeline
|
||||
$this->pipeline = $this->buildMiddlewarePipeline();
|
||||
|
||||
$this->booted = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function executionRunner(): ExecutionRunnerInterface
|
||||
{
|
||||
return $this->runner ??= new ExecutionRunner($this);
|
||||
}
|
||||
|
||||
public function reboot(): void
|
||||
{
|
||||
$this->shutdown();
|
||||
@@ -185,108 +200,52 @@ class Kernel implements KernelInterface
|
||||
if (false === $this->initialized) {
|
||||
return;
|
||||
}
|
||||
if ($this->activeScope !== null && !$this->activeScope->terminated()) {
|
||||
throw new \LogicException('Cannot shut down the kernel while an execution scope is active.');
|
||||
}
|
||||
|
||||
$this->initialized = false;
|
||||
$this->booted = false;
|
||||
$this->container = null;
|
||||
if ($this->errorHandlerInstalled) {
|
||||
restore_error_handler();
|
||||
$this->errorHandlerInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if (!$this->booted) {
|
||||
$this->boot();
|
||||
}
|
||||
if ($this->activeScope !== null) {
|
||||
throw new \LogicException('The kernel already has an active execution scope.');
|
||||
}
|
||||
|
||||
$scope = new ExecutionScope(
|
||||
$descriptor,
|
||||
\KTXC\Application\Execution\ExecutionContext::fromDescriptor($descriptor),
|
||||
$this->container->get(TenantContext::class),
|
||||
$this->container->get(IdentityContext::class),
|
||||
);
|
||||
$this->container
|
||||
->get(DeferredEventProcessorInterface::class)
|
||||
->beginExecution($descriptor->executionId);
|
||||
$this->activeScope = $scope;
|
||||
|
||||
return $scope;
|
||||
// Use middleware pipeline to handle the request
|
||||
return $this->pipeline->handle($request);
|
||||
}
|
||||
|
||||
public function terminateExecution(
|
||||
ExecutionScope $scope,
|
||||
ExecutionOutcome $outcome,
|
||||
): TerminationReport
|
||||
/**
|
||||
* Build the middleware pipeline
|
||||
*/
|
||||
protected function buildMiddlewarePipeline(): MiddlewarePipeline
|
||||
{
|
||||
if ($scope->terminated()) {
|
||||
return new TerminationReport();
|
||||
}
|
||||
if ($this->activeScope !== $scope) {
|
||||
throw new \LogicException('Cannot terminate a scope that is not active.');
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$remaining = 0;
|
||||
$deadlineExceeded = false;
|
||||
$limitExceeded = false;
|
||||
$failures = [];
|
||||
$pipeline = new MiddlewarePipeline($this->container);
|
||||
|
||||
// Register middleware in execution order
|
||||
$pipeline->pipe(TenantMiddleware::class);
|
||||
$pipeline->pipe(FirewallMiddleware::class);
|
||||
$pipeline->pipe(AuthenticationMiddleware::class);
|
||||
$pipeline->pipe(RouterMiddleware::class);
|
||||
|
||||
return $pipeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process deferred events at the end of the request
|
||||
*/
|
||||
public function processEvents(): void
|
||||
{
|
||||
try {
|
||||
if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) {
|
||||
$result = $this->container
|
||||
->get(DeferredEventProcessorInterface::class)
|
||||
->processDeferred($scope->descriptor->executionId);
|
||||
$processed = $result->processed;
|
||||
$remaining = $result->remaining;
|
||||
$deadlineExceeded = $result->deadlineExceeded;
|
||||
$limitExceeded = $result->limitExceeded;
|
||||
if ($this->container && $this->container->has(EventBus::class)) {
|
||||
/** @var EventBus $eventBus */
|
||||
$eventBus = $this->container->get(EventBus::class);
|
||||
$eventBus->processDeferred();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failures[] = $e;
|
||||
try {
|
||||
$this->container
|
||||
?->get(DeferredEventProcessorInterface::class)
|
||||
->discardDeferred($scope->descriptor->executionId);
|
||||
} catch (\Throwable $discardError) {
|
||||
$failures[] = $discardError;
|
||||
}
|
||||
$this->logger?->error('Deferred event processing failed.', [
|
||||
'exception' => $e,
|
||||
'execution_id' => $scope->descriptor->executionId,
|
||||
'runtime' => $scope->descriptor->runtime->value,
|
||||
]);
|
||||
} finally {
|
||||
if ($this->container !== null) {
|
||||
foreach ([
|
||||
EphemeralCacheInterface::class,
|
||||
PersistentCacheInterface::class,
|
||||
BlobCacheInterface::class,
|
||||
] as $cacheType) {
|
||||
$cache = $this->container->get($cacheType);
|
||||
$cache->setUserContext(null);
|
||||
$cache->setTenantContext(null);
|
||||
}
|
||||
}
|
||||
$scope->dispose();
|
||||
$scope->markTerminated();
|
||||
$this->activeScope = null;
|
||||
error_log('Event processing error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return new TerminationReport(
|
||||
deferredProcessed: $processed,
|
||||
deferredRemaining: $remaining,
|
||||
failures: $failures,
|
||||
deadlineExceeded: $deadlineExceeded,
|
||||
limitExceeded: $limitExceeded,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,34 +255,30 @@ class Kernel implements KernelInterface
|
||||
*/
|
||||
protected function parameters(): array
|
||||
{
|
||||
$projectDir = $this->options->paths->project;
|
||||
$cacheDir = $this->options->paths->cache($this->environment());
|
||||
$logsDir = $this->options->paths->logs();
|
||||
|
||||
return [
|
||||
'kernel.project_dir' => realpath($projectDir) ?: $projectDir,
|
||||
'kernel.environment' => $this->environment(),
|
||||
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(),
|
||||
'kernel.environment' => $this->environment,
|
||||
'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%',
|
||||
'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%',
|
||||
'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%',
|
||||
'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%',
|
||||
'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%',
|
||||
'kernel.debug' => $this->debug(),
|
||||
'kernel.build_dir' => realpath($cacheDir) ?: $cacheDir,
|
||||
'kernel.cache_dir' => realpath($cacheDir) ?: $cacheDir,
|
||||
'kernel.logs_dir' => realpath($logsDir) ?: $logsDir,
|
||||
'kernel.charset' => 'UTF-8',
|
||||
'kernel.debug' => $this->debug,
|
||||
'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(),
|
||||
'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(),
|
||||
'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(),
|
||||
'kernel.charset' => $this->getCharset(),
|
||||
];
|
||||
}
|
||||
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->options->environment;
|
||||
return $this->environment;
|
||||
}
|
||||
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->options->debug;
|
||||
return $this->debug;
|
||||
}
|
||||
|
||||
public function container(): ContainerInterface
|
||||
@@ -337,7 +292,61 @@ class Kernel implements KernelInterface
|
||||
|
||||
public function getStartTime(): float
|
||||
{
|
||||
return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF;
|
||||
return $this->debug && null !== $this->startTime ? $this->startTime : -\INF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the application root dir (path of the project's composer file).
|
||||
*/
|
||||
public function folderRoot(): string
|
||||
{
|
||||
if (!isset($this->projectDir)) {
|
||||
$r = new \ReflectionObject($this);
|
||||
|
||||
if (!is_file($dir = $r->getFileName())) {
|
||||
throw new \LogicException(\sprintf('Cannot auto-detect project dir for kernel of class "%s".', $r->name));
|
||||
}
|
||||
|
||||
$dir = $rootDir = \dirname($dir);
|
||||
while (!is_file($dir.'/composer.json')) {
|
||||
if ($dir === \dirname($dir)) {
|
||||
return $this->projectDir = $rootDir;
|
||||
}
|
||||
$dir = \dirname($dir);
|
||||
}
|
||||
$this->projectDir = $dir;
|
||||
}
|
||||
|
||||
return $this->projectDir;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets the path to the configuration directory.
|
||||
*/
|
||||
private function getConfigDir(): string
|
||||
{
|
||||
return $this->folderRoot().'/config';
|
||||
}
|
||||
|
||||
public function getCacheDir(): string
|
||||
{
|
||||
return $this->folderRoot().'/var/cache/'.$this->environment;
|
||||
}
|
||||
|
||||
public function getBuildDir(): string
|
||||
{
|
||||
return $this->getCacheDir();
|
||||
}
|
||||
|
||||
public function getLogDir(): string
|
||||
{
|
||||
return $this->folderRoot().'/var/log';
|
||||
}
|
||||
|
||||
public function getCharset(): string
|
||||
{
|
||||
return 'UTF-8';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -372,9 +381,9 @@ class Kernel implements KernelInterface
|
||||
protected function configureContainer(Builder $builder): void
|
||||
{
|
||||
// Service definitions
|
||||
$projectDir = $this->options->paths->project;
|
||||
$projectDir = $this->folderRoot();
|
||||
$moduleDir = $projectDir . '/modules';
|
||||
$environment = $this->environment();
|
||||
$environment = $this->environment;
|
||||
|
||||
$builder->addDefinitions([
|
||||
|
||||
@@ -387,29 +396,11 @@ class Kernel implements KernelInterface
|
||||
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
|
||||
Container::class => \DI\get(ContainerInterface::class),
|
||||
|
||||
TenantContextInterface::class => \DI\get(TenantContext::class),
|
||||
IdentityContextInterface::class => \DI\get(IdentityContext::class),
|
||||
|
||||
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(TenantContextInterface::class),
|
||||
$logDir,
|
||||
$channel,
|
||||
$level,
|
||||
$perTenant,
|
||||
);
|
||||
},
|
||||
// Use the kernel's logger instance
|
||||
LoggerInterface::class => \DI\value($this->logger),
|
||||
|
||||
EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
|
||||
DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
|
||||
// EventBus as singleton for consistent event handling
|
||||
EventBus::class => \DI\create(EventBus::class),
|
||||
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
|
||||
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
|
||||
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
|
||||
@@ -428,13 +419,13 @@ class Kernel implements KernelInterface
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(TenantContextInterface::class)) {
|
||||
$tenantContext = $c->get(TenantContextInterface::class);
|
||||
$cache->setTenantContext($tenantContext->identifier());
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
}
|
||||
if ($c->has(IdentityContextInterface::class)) {
|
||||
$identityContext = $c->get(IdentityContextInterface::class);
|
||||
$cache->setUserContext($identityContext->identifier());
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
@@ -457,13 +448,13 @@ class Kernel implements KernelInterface
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(TenantContextInterface::class)) {
|
||||
$tenantContext = $c->get(TenantContextInterface::class);
|
||||
$cache->setTenantContext($tenantContext->identifier());
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
}
|
||||
if ($c->has(IdentityContextInterface::class)) {
|
||||
$identityContext = $c->get(IdentityContextInterface::class);
|
||||
$cache->setUserContext($identityContext->identifier());
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
@@ -486,13 +477,13 @@ class Kernel implements KernelInterface
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(TenantContextInterface::class)) {
|
||||
$tenantContext = $c->get(TenantContextInterface::class);
|
||||
$cache->setTenantContext($tenantContext->identifier());
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
}
|
||||
if ($c->has(IdentityContextInterface::class)) {
|
||||
$identityContext = $c->get(IdentityContextInterface::class);
|
||||
$cache->setUserContext($identityContext->identifier());
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Application\Execution\ExecutionOutcome;
|
||||
use KTXC\Application\Execution\ExecutionRunnerInterface;
|
||||
use KTXC\Application\Execution\ExecutionScope;
|
||||
use KTXC\Application\Execution\TerminationReport;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
interface KernelInterface
|
||||
{
|
||||
public function boot(): void;
|
||||
|
||||
public function executionRunner(): ExecutionRunnerInterface;
|
||||
|
||||
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope;
|
||||
|
||||
public function terminateExecution(
|
||||
ExecutionScope $scope,
|
||||
ExecutionOutcome $outcome,
|
||||
): TerminationReport;
|
||||
|
||||
public function shutdown(): void;
|
||||
|
||||
public function container(): ContainerInterface;
|
||||
|
||||
public function environment(): string;
|
||||
|
||||
public function debug(): bool;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\L10N;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
|
||||
/**
|
||||
* Resolves the effective UI locale for the current user.
|
||||
*
|
||||
* Resolution chain: user setting (core.locale) -> Accept-Language
|
||||
* negotiation -> fallback. Tenant default slots in between once tenant
|
||||
* settings expose one (Phase 3).
|
||||
*/
|
||||
class LocaleResolver
|
||||
{
|
||||
public const FALLBACK = 'en';
|
||||
public const SETTING_KEY = 'core.locale';
|
||||
|
||||
public function __construct(
|
||||
private readonly UserAccountsService $userService,
|
||||
#[Inject('rootDir')] private readonly string $rootDir,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Locales with a core catalog on disk (public/l10n/*.json), always
|
||||
* including the fallback. This backs the language picker, so a locale
|
||||
* is only offered when it has at least core-shell coverage.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function available(): array
|
||||
{
|
||||
$locales = [self::FALLBACK];
|
||||
foreach (glob($this->rootDir . '/public/l10n/*.json') ?: [] as $file) {
|
||||
$locales[] = basename($file, '.json');
|
||||
}
|
||||
$locales = array_values(array_unique($locales));
|
||||
sort($locales);
|
||||
return $locales;
|
||||
}
|
||||
|
||||
public function resolve(Request $request): string
|
||||
{
|
||||
$available = $this->available();
|
||||
|
||||
// 1. explicit user preference
|
||||
$settings = $this->userService->fetchSettings([self::SETTING_KEY]);
|
||||
$preference = $settings[self::SETTING_KEY] ?? null;
|
||||
if (is_string($preference) && in_array($preference, $available, true)) {
|
||||
return $preference;
|
||||
}
|
||||
|
||||
// 2. Accept-Language negotiation. getPreferredLanguage() returns the
|
||||
// first candidate when nothing matches, so the fallback leads the
|
||||
// list; it also normalizes to underscore form (pt_BR) while catalogs
|
||||
// use BCP-47 hyphens.
|
||||
$candidates = array_merge([self::FALLBACK], array_diff($available, [self::FALLBACK]));
|
||||
$negotiated = $request->getPreferredLanguage($candidates);
|
||||
if ($negotiated !== null) {
|
||||
$negotiated = str_replace('_', '-', $negotiated);
|
||||
if (in_array($negotiated, $available, true)) {
|
||||
return $negotiated;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. fallback
|
||||
return self::FALLBACK;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class FileLogger implements LoggerInterface
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0775, true);
|
||||
}
|
||||
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.jsonl';
|
||||
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log';
|
||||
}
|
||||
|
||||
public function emergency($message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
|
||||
@@ -40,18 +40,11 @@ class FileLogger implements LoggerInterface
|
||||
|
||||
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']);
|
||||
|
||||
$timestamp = $this->formatTimestamp();
|
||||
$interpolated = $this->interpolate((string) $message, $context);
|
||||
$timestamp = $this->formatTimestamp();
|
||||
$interpolated = $this->interpolate((string)$message, $context);
|
||||
$payload = [
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string) $level),
|
||||
'tenant' => $tenantId,
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string)$level),
|
||||
'channel' => $this->channel,
|
||||
'message' => $interpolated,
|
||||
'context' => $this->sanitizeContext($context),
|
||||
@@ -60,12 +53,11 @@ class FileLogger implements LoggerInterface
|
||||
if ($json === false) {
|
||||
// Fallback stringify if encoding fails (should be rare)
|
||||
$json = json_encode([
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string) $level),
|
||||
'tenant' => $tenantId,
|
||||
'channel' => $this->channel,
|
||||
'message' => $interpolated,
|
||||
'context_error' => 'failed to encode context: ' . json_last_error_msg(),
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string)$level),
|
||||
'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);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?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 TenantContextInterface 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/logs';
|
||||
}
|
||||
|
||||
return new FileLogger($path, $channel);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?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')
|
||||
{
|
||||
$this->logFile = rtrim($logDir, '/') . '/' . $channel . '.log';
|
||||
$this->ensureWritablePath();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$this->ensureWritablePath();
|
||||
|
||||
$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;
|
||||
|
||||
if (@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX) === false) {
|
||||
error_log(sprintf('Failed to write to log file: %s', $this->logFile));
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureWritablePath(): void
|
||||
{
|
||||
$logDir = dirname($this->logFile);
|
||||
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0777, true);
|
||||
}
|
||||
|
||||
if (is_dir($logDir)) {
|
||||
@chmod($logDir, 0777);
|
||||
}
|
||||
|
||||
if (!file_exists($this->logFile)) {
|
||||
@touch($this->logFile);
|
||||
}
|
||||
|
||||
clearstatcache(true, $this->logFile);
|
||||
if (file_exists($this->logFile)) {
|
||||
@chmod($this->logFile, 0666);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?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) ?: '{}';
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?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) ?: '{}';
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
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 TenantContextInterface 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 TenantContextInterface $tenantContext 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 TenantContextInterface $tenantContext,
|
||||
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->tenantContext->configured()) {
|
||||
$tenantId = $this->tenantContext->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];
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,8 @@ class TenantObject extends JsonSerializableObject
|
||||
'enabled' => $this->enabled,
|
||||
'label' => $this->label,
|
||||
'description' => $this->description,
|
||||
'domains' => $this->domains?->getArrayCopy(),
|
||||
'configuration' => $this->configuration?->jsonSerialize(),
|
||||
'domains' => $this->domains,
|
||||
'configuration' => $this->configuration,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXC\Service\FirewallService;
|
||||
use KTXF\Event\DeliveryMode;
|
||||
use KTXF\Event\EventListenerRegistry;
|
||||
use KTXF\Event\SecurityEvent;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
@@ -17,36 +13,7 @@ use KTXF\Module\ModuleInstanceAbstract;
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventListenerRegistry $events,
|
||||
) {
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->events->listen(
|
||||
'core',
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
FirewallService::class,
|
||||
'handleAuthFailure',
|
||||
priority: 100,
|
||||
);
|
||||
|
||||
foreach ([
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
SecurityEvent::ACCESS_DENIED,
|
||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||
] as $event) {
|
||||
$this->events->listen(
|
||||
'core',
|
||||
$event,
|
||||
FirewallService::class,
|
||||
'logSecurityEvent',
|
||||
DeliveryMode::Deferred,
|
||||
);
|
||||
}
|
||||
}
|
||||
public function __construct() {}
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
@@ -60,7 +27,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return 'Vallarx';
|
||||
return 'Ktrix';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
@@ -132,25 +99,9 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
|
||||
public function registerCI(): array
|
||||
{
|
||||
return [
|
||||
\KTXC\Console\Event\EventsDebugCommand::class,
|
||||
\KTXC\Console\Module\ModuleListCommand::class,
|
||||
\KTXC\Console\Module\ModuleEnableCommand::class,
|
||||
\KTXC\Console\Module\ModuleDisableCommand::class,
|
||||
\KTXC\Console\Module\ModuleInstallCommand::class,
|
||||
\KTXC\Console\Module\ModuleUninstallCommand::class,
|
||||
\KTXC\Console\Module\ModuleUpgradeCommand::class,
|
||||
\KTXC\Console\Tenant\TenantCreateCommand::class,
|
||||
\KTXC\Console\Tenant\TenantListCommand::class,
|
||||
\KTXC\Console\Tenant\TenantDeleteCommand::class,
|
||||
\KTXC\Console\Tenant\TenantAuthEnableCommand::class,
|
||||
\KTXC\Console\User\UserCreateCommand::class,
|
||||
\KTXC\Console\User\UserListCommand::class,
|
||||
\KTXC\Console\User\UserDeleteCommand::class,
|
||||
\KTXC\Console\Role\RoleCreateCommand::class,
|
||||
\KTXC\Console\Role\RoleListCommand::class,
|
||||
\KTXC\Console\Role\RoleDeleteCommand::class,
|
||||
\KTXC\Console\Role\RoleAssignCommand::class,
|
||||
\KTXC\Console\Role\RoleRevokeCommand::class,
|
||||
\KTXC\Console\ModuleListCommand::class,
|
||||
\KTXC\Console\ModuleEnableCommand::class,
|
||||
\KTXC\Console\ModuleDisableCommand::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use KTXC\Application;
|
||||
|
||||
/**
|
||||
* Custom autoloader for modules that allows PascalCase namespaces
|
||||
@@ -20,10 +20,7 @@ class ModuleAutoloader
|
||||
private array $namespaceMap = [];
|
||||
private bool $scanned = false;
|
||||
|
||||
public function __construct(
|
||||
string $modulesRoot,
|
||||
private readonly ?ClassLoader $composerLoader = null,
|
||||
)
|
||||
public function __construct(string $modulesRoot)
|
||||
{
|
||||
$this->modulesRoot = rtrim($modulesRoot, '/');
|
||||
}
|
||||
@@ -76,9 +73,10 @@ class ModuleAutoloader
|
||||
}
|
||||
|
||||
// Register module namespaces with Composer ClassLoader
|
||||
if ($this->composerLoader !== null) {
|
||||
$composerLoader = \KTXC\Application::getComposerLoader();
|
||||
if ($composerLoader !== null) {
|
||||
foreach ($this->namespaceMap as $namespace => $folderName) {
|
||||
$this->composerLoader->addPsr4(
|
||||
$composerLoader->addPsr4(
|
||||
'KTXM\\' . $namespace . '\\',
|
||||
$this->modulesRoot . '/' . $folderName . '/lib/'
|
||||
);
|
||||
|
||||
@@ -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 ModuleObject[]
|
||||
* @return Module[]
|
||||
*/
|
||||
public function list(bool| null $installedOnly = null, bool| null $enabledOnly = null): ModuleCollection
|
||||
public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection
|
||||
{
|
||||
$modules = New ModuleCollection();
|
||||
|
||||
@@ -44,8 +44,12 @@ class ModuleManager
|
||||
}
|
||||
|
||||
// load all modules from store
|
||||
$entries = $this->repository->list($installedOnly, $enabledOnly);
|
||||
$entries = $this->repository->list();
|
||||
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);
|
||||
@@ -56,7 +60,7 @@ class ModuleManager
|
||||
}
|
||||
}
|
||||
// load all modules from filesystem
|
||||
if ($installedOnly !== true) {
|
||||
if ($installedOnly === false) {
|
||||
$discovered = $this->modulesDiscover();
|
||||
foreach ($discovered as $moduleInstance) {
|
||||
$handle = $moduleInstance->handle();
|
||||
@@ -68,21 +72,6 @@ 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
|
||||
{
|
||||
@@ -269,20 +258,19 @@ class ModuleManager
|
||||
public function modulesBoot(): void
|
||||
{
|
||||
// Only load modules that are enabled in the database
|
||||
$modules = $this->list(true, true);
|
||||
$modules = $this->list();
|
||||
$this->logger->debug('Booting enabled modules', ['count' => count($modules)]);
|
||||
foreach ($modules as $module) {
|
||||
$handle = $module->handle();
|
||||
try {
|
||||
$module->boot();
|
||||
$this->logger->debug('Module booted', ['handle' => $handle]);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module boot failed: ' . $handle, [
|
||||
'exception' => $e,
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ use KTXF\Module\ModuleConsoleInterface;
|
||||
use KTXF\Module\ModuleInstanceInterface;
|
||||
|
||||
/**
|
||||
* Module is a unified wrapper that combines the filesystem module instance and the database module entry.
|
||||
* 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.
|
||||
*/
|
||||
class ModuleObject implements JsonSerializable
|
||||
{
|
||||
@@ -29,9 +32,6 @@ 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,21 +86,6 @@ 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,7 +3,6 @@
|
||||
namespace KTXC\Module\Store;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\ObjectId;
|
||||
|
||||
class ModuleStore
|
||||
{
|
||||
@@ -14,18 +13,9 @@ class ModuleStore
|
||||
protected readonly DataStore $dataStore
|
||||
) { }
|
||||
|
||||
public function list(bool|null $installed = null, bool|null $enabled = null): array
|
||||
public function list(): array
|
||||
{
|
||||
$filter = [];
|
||||
if ($installed !== null) {
|
||||
$filter['installed'] = $installed;
|
||||
}
|
||||
if ($enabled !== null) {
|
||||
$filter['enabled'] = $enabled;
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find($filter);
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]);
|
||||
$modules = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$entity = new ModuleEntry();
|
||||
@@ -62,11 +52,7 @@ class ModuleStore
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return null; }
|
||||
|
||||
$data = $entry->jsonSerialize();
|
||||
unset($data['id']);
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $data]);
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
|
||||
return $entry;
|
||||
}
|
||||
|
||||
@@ -74,7 +60,7 @@ class ModuleStore
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return; }
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($id)]);
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,11 +4,9 @@ namespace KTXC\Routing;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Request\RequestInputParameters;
|
||||
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;
|
||||
@@ -21,8 +19,6 @@ 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;
|
||||
|
||||
@@ -44,9 +40,8 @@ class Router
|
||||
// load cached routes in production
|
||||
if ($this->environment === 'prod' && file_exists($this->cacheFile)) {
|
||||
$data = include $this->cacheFile;
|
||||
if (is_array($data) && isset($data['routes'])) {
|
||||
$this->routes = $data['routes'];
|
||||
$this->prefixRoutes = $data['prefix'] ?? [];
|
||||
if (is_array($data)) {
|
||||
$this->routes = $data;
|
||||
$this->initialized = true;
|
||||
return;
|
||||
}
|
||||
@@ -57,8 +52,7 @@ class Router
|
||||
// write cache
|
||||
$dir = dirname($this->cacheFile);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
$cache = ['routes' => $this->routes, 'prefix' => $this->prefixRoutes];
|
||||
file_put_contents($this->cacheFile, '<?php return ' . var_export($cache, true) . ';');
|
||||
file_put_contents($this->cacheFile, '<?php return ' . var_export($this->routes, true) . ';');
|
||||
}
|
||||
|
||||
|
||||
@@ -101,17 +95,13 @@ class Router
|
||||
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
|
||||
$attributes = array_merge(
|
||||
$reflectionMethod->getAttributes(AnonymousRoute::class),
|
||||
$reflectionMethod->getAttributes(AuthenticatedRoute::class),
|
||||
$reflectionMethod->getAttributes(AnonymousPrefixRoute::class),
|
||||
$reflectionMethod->getAttributes(AuthenticatedRoute::class)
|
||||
);
|
||||
foreach ($attributes as $attribute) {
|
||||
$route = $attribute->newInstance();
|
||||
$isPrefix = $route instanceof AnonymousPrefixRoute;
|
||||
$httpPath = ($isPrefix && $route->absolute)
|
||||
? $route->path
|
||||
: $routePrefix . $route->path;
|
||||
$httpPath = $routePrefix . $route->path;
|
||||
foreach ($route->methods as $httpMethod) {
|
||||
$routeObject = new Route(
|
||||
$this->routes[$httpMethod][$httpPath] = new Route(
|
||||
method: $httpMethod,
|
||||
path: $httpPath,
|
||||
name: $route->name,
|
||||
@@ -121,11 +111,6 @@ class Router
|
||||
classMethodParameters: $reflectionMethod->getParameters(),
|
||||
permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [],
|
||||
);
|
||||
if ($isPrefix) {
|
||||
$this->prefixRoutes[$httpMethod][$httpPath] = $routeObject;
|
||||
} else {
|
||||
$this->routes[$httpMethod][$httpPath] = $routeObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,7 +123,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) prefix routes, 4) catch-all patterns
|
||||
* Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns
|
||||
*/
|
||||
public function match(Request $request): ?Route
|
||||
{
|
||||
@@ -176,20 +161,6 @@ 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;
|
||||
@@ -208,8 +179,7 @@ class Router
|
||||
|
||||
/**
|
||||
* Dispatch a matched route meta and return a Response (or null if controller does not return one).
|
||||
* Performs light argument resolution: Request object, route params, body fields, query params, full body for array params.
|
||||
* Precedence on name collision: route params > body fields > query params > parameter default.
|
||||
* Performs light argument resolution: Request object, route params, body fields, full body for array params.
|
||||
*/
|
||||
public function dispatch(Route $route, Request $request): ?Response
|
||||
{
|
||||
@@ -226,8 +196,7 @@ class Router
|
||||
try {
|
||||
$requestParameters = $request->getPayload();
|
||||
} catch (\Throwable) {
|
||||
// ignore payload errors, fall back to an empty parameter bag
|
||||
$requestParameters = new RequestInputParameters([]);
|
||||
// ignore payload errors
|
||||
}
|
||||
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
|
||||
$routeParams = $route->params ?? [];
|
||||
@@ -261,11 +230,6 @@ class Router
|
||||
$callArgs[] = $requestParameters->get($reflectionParameterName);
|
||||
continue;
|
||||
}
|
||||
// if method parameter matches a query string param, use that
|
||||
if ($request->query->has($reflectionParameterName)) {
|
||||
$callArgs[] = $request->query->get($reflectionParameterName);
|
||||
continue;
|
||||
}
|
||||
// if method parameter did not match, but has a default value, use that
|
||||
if ($reflectionParameter->isDefaultValueAvailable()) {
|
||||
$callArgs[] = $reflectionParameter->getDefaultValue();
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Runtime\Console;
|
||||
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Kernel;
|
||||
use KTXC\KernelInterface;
|
||||
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;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
final class ConsoleRuntime
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
|
||||
{
|
||||
return $this->kernel->executionRunner()->execute(
|
||||
ExecutionDescriptor::cli(),
|
||||
function () use ($input, $output): int {
|
||||
$container = $this->kernel->container();
|
||||
$console = new ConsoleApplication('Vallarx Console', Kernel::VERSION);
|
||||
$console->setAutoExit(false);
|
||||
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$instance = $module->instance();
|
||||
if (!$instance instanceof ModuleConsoleInterface) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($instance->registerCI() as $commandClass) {
|
||||
$this->registerCommand($console, $container, $commandClass);
|
||||
}
|
||||
}
|
||||
|
||||
return $console->run($input, $output);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string $commandClass
|
||||
*/
|
||||
private function registerCommand(ConsoleApplication $console, ContainerInterface $container, string $commandClass): void {
|
||||
if (!class_exists($commandClass)) {
|
||||
throw new \RuntimeException("Command class not found: {$commandClass}");
|
||||
}
|
||||
|
||||
$reflection = new \ReflectionClass($commandClass);
|
||||
$attributes = $reflection->getAttributes(AsCommand::class);
|
||||
if ($attributes === []) {
|
||||
throw new \RuntimeException("Command {$commandClass} is missing #[AsCommand].");
|
||||
}
|
||||
|
||||
$attribute = $attributes[0]->newInstance();
|
||||
$console->add(new LazyCommand(
|
||||
$attribute->name,
|
||||
[],
|
||||
$attribute->description ?? '',
|
||||
$attribute->hidden ?? false,
|
||||
static fn() => $container->get($commandClass),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Runtime\Http;
|
||||
|
||||
use KTXC\Application\Execution\ExecutionDescriptor;
|
||||
use KTXC\Http\Middleware\AuthenticationMiddleware;
|
||||
use KTXC\Http\Middleware\FirewallMiddleware;
|
||||
use KTXC\Http\Middleware\MiddlewarePipeline;
|
||||
use KTXC\Http\Middleware\RouterMiddleware;
|
||||
use KTXC\Http\Middleware\TenantMiddleware;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\KernelInterface;
|
||||
|
||||
final class HttpRuntime
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly bool $debug,
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(?Request $request = null, bool $send = true): Response
|
||||
{
|
||||
$request ??= Request::createFromGlobals();
|
||||
|
||||
return $this->kernel->executionRunner()->execute(
|
||||
ExecutionDescriptor::http(),
|
||||
function () use ($request, $send): Response {
|
||||
$response = $this->pipeline()->handle($request);
|
||||
if ($send) {
|
||||
$response->send();
|
||||
}
|
||||
|
||||
return $response;
|
||||
},
|
||||
function (\Throwable $error) use ($send): Response {
|
||||
$response = $this->errorResponse($error);
|
||||
if ($send) {
|
||||
$response->send();
|
||||
}
|
||||
|
||||
return $response;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private function pipeline(): MiddlewarePipeline
|
||||
{
|
||||
$pipeline = new MiddlewarePipeline($this->kernel->container());
|
||||
$pipeline->pipe(TenantMiddleware::class);
|
||||
$pipeline->pipe(FirewallMiddleware::class);
|
||||
$pipeline->pipe(AuthenticationMiddleware::class);
|
||||
$pipeline->pipe(RouterMiddleware::class);
|
||||
|
||||
return $pipeline;
|
||||
}
|
||||
|
||||
private function errorResponse(\Throwable $error): Response
|
||||
{
|
||||
error_log(sprintf(
|
||||
'Application error: %s in %s:%d',
|
||||
$error->getMessage(),
|
||||
$error->getFile(),
|
||||
$error->getLine(),
|
||||
));
|
||||
|
||||
$content = $this->debug
|
||||
? '<pre>' . htmlspecialchars((string) $error) . '</pre>'
|
||||
: 'An error occurred. Please try again later.';
|
||||
|
||||
return new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use KTXC\Security\Authentication\AuthenticationRequest;
|
||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||
use KTXC\Service\TokenService;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||
@@ -26,13 +26,13 @@ class AuthenticationManager
|
||||
private string $securityCode;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly EphemeralCacheInterface $cache,
|
||||
private readonly ProviderManager $providerManager,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly UserAccountsService $userService,
|
||||
) {
|
||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
||||
$this->securityCode = $this->tenant->configuration()->security()->code();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -75,7 +75,7 @@ class AuthenticationManager
|
||||
$methods = $this->methodsConfigured();
|
||||
|
||||
$session = AuthenticationSession::create(
|
||||
$this->tenantContext->identifier(),
|
||||
$this->tenant->identifier(),
|
||||
AuthenticationSession::STATE_FRESH
|
||||
);
|
||||
|
||||
@@ -103,7 +103,7 @@ class AuthenticationManager
|
||||
// Filter to non-redirect methods since redirects don't need identity first
|
||||
$methods = $this->methodsConfigured();
|
||||
$methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect'));
|
||||
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
|
||||
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
|
||||
|
||||
// Store identity in session without validating to prevent enumeration
|
||||
$session->setMethods(array_column($methods, 'id'), $require);
|
||||
@@ -155,13 +155,6 @@ class AuthenticationManager
|
||||
);
|
||||
}
|
||||
|
||||
if ($session->userIdentifier === null && $session->userIdentity) {
|
||||
$user = $this->userService->fetchByIdentity($session->userIdentity);
|
||||
if ($user) {
|
||||
$session->userIdentifier = $user->getId();
|
||||
}
|
||||
}
|
||||
|
||||
// Build provider context
|
||||
$context = $this->buildProviderContext($session, $method);
|
||||
|
||||
@@ -429,7 +422,7 @@ class AuthenticationManager
|
||||
$session->methodCompleted($method);
|
||||
|
||||
// Check if MFA is required
|
||||
$require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
|
||||
$require = $this->tenant->configuration()->authentication()->methodsMinimal();
|
||||
if ($require > 1) {
|
||||
$remainingMethods = $this->methodsConfigured([$method]);
|
||||
// Filter out redirect methods - they can't be used as secondary factors
|
||||
@@ -523,7 +516,7 @@ class AuthenticationManager
|
||||
|
||||
$accessToken = $this->tokenService->createToken(
|
||||
[
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenant->identifier(),
|
||||
'identifier' => $user->getId(),
|
||||
'identity' => $user->getIdentity(),
|
||||
'label' => $user->getLabel(),
|
||||
@@ -585,7 +578,7 @@ class AuthenticationManager
|
||||
*/
|
||||
private function getProviderConfig(string $method): array
|
||||
{
|
||||
$providers = $this->tenantContext->configuration()->authentication()->providers();
|
||||
$providers = $this->tenant->configuration()->authentication()->providers();
|
||||
return $providers[$method]['config'] ?? [];
|
||||
}
|
||||
|
||||
@@ -635,7 +628,7 @@ class AuthenticationManager
|
||||
*/
|
||||
private function methodsConfigured(array $methodsCompleted = []): array
|
||||
{
|
||||
$tenantProviders = $this->tenantContext->configuration()->authentication()->providers();
|
||||
$tenantProviders = $this->tenant->configuration()->authentication()->providers();
|
||||
$methods = [];
|
||||
|
||||
foreach ($tenantProviders as $providerId => $providerConfiguration) {
|
||||
@@ -669,7 +662,7 @@ class AuthenticationManager
|
||||
private function createTokens(User $user, bool $mfaVerified = false): array
|
||||
{
|
||||
$payload = [
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenant->identifier(),
|
||||
'identifier' => $user->getId(),
|
||||
'identity' => $user->getIdentity(),
|
||||
'label' => $user->getLabel(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Security\Authorization;
|
||||
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
|
||||
/**
|
||||
* Permission Checker
|
||||
@@ -11,7 +11,7 @@ use KTXC\Context\IdentityContextInterface;
|
||||
class PermissionChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IdentityContextInterface $identityContext
|
||||
private readonly SessionIdentity $sessionIdentity
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ class PermissionChecker
|
||||
*/
|
||||
public function can(string $permission, mixed $resource = null): bool
|
||||
{
|
||||
$identity = $this->identityContext->identity();
|
||||
$identity = $this->sessionIdentity->identity();
|
||||
|
||||
if (!$identity) {
|
||||
return false;
|
||||
@@ -113,7 +113,7 @@ class PermissionChecker
|
||||
*/
|
||||
public function getUserPermissions(): array
|
||||
{
|
||||
$identity = $this->identityContext->identity();
|
||||
$identity = $this->sessionIdentity->identity();
|
||||
|
||||
if (!$identity) {
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Injection\Container;
|
||||
|
||||
/**
|
||||
* Legacy Server class - now a facade to Application
|
||||
* @deprecated Use Application class directly
|
||||
*/
|
||||
class Server
|
||||
{
|
||||
public const ENVIRONMENT_DEV = 'dev';
|
||||
public const ENVIRONMENT_PROD = 'prod';
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace KTXC\Service;
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\Collection;
|
||||
use KTXC\Db\UTCDateTime;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionTenant;
|
||||
|
||||
class ConfigurationService
|
||||
{
|
||||
@@ -24,7 +24,7 @@ class ConfigurationService
|
||||
|
||||
public function __construct(
|
||||
DataStore $store,
|
||||
private readonly TenantContextInterface $tenantContext
|
||||
private readonly SessionTenant $tenant
|
||||
) {
|
||||
// DataStore provides selectCollection method
|
||||
$this->collection = $store->selectCollection(self::TABLE_NAME);
|
||||
@@ -36,10 +36,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
|
||||
{
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||
@@ -54,10 +54,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$type = $this->determineType($value);
|
||||
@@ -84,10 +84,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
|
||||
{
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$filter = ['did' => $tenant];
|
||||
@@ -116,10 +116,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function delete(string $path, string $key, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||
@@ -131,10 +131,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$filter = ['did' => $tenant];
|
||||
@@ -155,10 +155,10 @@ class ConfigurationService
|
||||
*/
|
||||
public function exists(string $path, string $key, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenantContext->configured()) {
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenantContext->identifier();
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
|
||||
|
||||
@@ -8,8 +8,8 @@ use KTXC\Http\Request\Request;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXF\Event\EventDispatcherInterface;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Event\EventBus;
|
||||
use KTXF\Event\SecurityEvent;
|
||||
use KTXF\IpUtils;
|
||||
|
||||
@@ -41,9 +41,33 @@ class FirewallService
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStore $store,
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly EventDispatcherInterface $events,
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly EventBus $eventBus
|
||||
) {
|
||||
// Listen for auth failures to detect brute force
|
||||
$this->eventBus->subscribe(
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
[$this, 'handleAuthFailure'],
|
||||
100 // High priority
|
||||
);
|
||||
|
||||
// Log all security events asynchronously
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::AUTH_FAILURE,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::ACCESS_DENIED,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
$this->eventBus->subscribeAsync(
|
||||
SecurityEvent::BRUTE_FORCE_DETECTED,
|
||||
[$this, 'logSecurityEvent']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +100,7 @@ class FirewallService
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
@@ -134,7 +158,7 @@ class FirewallService
|
||||
public function handleAuthFailure(SecurityEvent $event): void
|
||||
{
|
||||
$ipAddress = $event->getIpAddress();
|
||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||
|
||||
if (!$ipAddress || !$tenantId) {
|
||||
return;
|
||||
@@ -174,8 +198,8 @@ class FirewallService
|
||||
): void {
|
||||
// Publish brute force event
|
||||
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
||||
$event->setTenantId($this->tenantContext->identifier());
|
||||
$this->events->dispatch($event);
|
||||
$event->setTenantId($this->tenant->identifier());
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
// Auto-block the IP
|
||||
$blockDuration = $this->getConfig(
|
||||
@@ -196,7 +220,7 @@ class FirewallService
|
||||
*/
|
||||
public function logSecurityEvent(SecurityEvent $event): void
|
||||
{
|
||||
$tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
|
||||
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return;
|
||||
}
|
||||
@@ -259,8 +283,8 @@ class FirewallService
|
||||
$rule->getId(),
|
||||
$rule->getReason()
|
||||
);
|
||||
$event->setTenantId($this->tenantContext->identifier());
|
||||
$this->events->dispatch($event);
|
||||
$event->setTenantId($this->tenant->identifier());
|
||||
$this->eventBus->publish($event);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
@@ -276,7 +300,7 @@ class FirewallService
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
@@ -316,7 +340,7 @@ class FirewallService
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
@@ -329,7 +353,7 @@ class FirewallService
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
@@ -352,7 +376,7 @@ class FirewallService
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
@@ -365,7 +389,7 @@ class FirewallService
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
@@ -395,7 +419,7 @@ class FirewallService
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
@@ -424,7 +448,7 @@ class FirewallService
|
||||
$event->setDeviceFingerprint($fingerprint)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->events->dispatch($event);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
@@ -440,7 +464,7 @@ class FirewallService
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
||||
if ($rule->getTenantId() !== $this->tenant->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -461,7 +485,7 @@ class FirewallService
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
|
||||
if ($rule->getTenantId() !== $this->tenant->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -477,7 +501,7 @@ class FirewallService
|
||||
*/
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
@@ -494,7 +518,7 @@ class FirewallService
|
||||
?string $result = null,
|
||||
int $limit = 100
|
||||
): array {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
@@ -507,7 +531,7 @@ class FirewallService
|
||||
*/
|
||||
public function getBlockedCount(?\DateTimeImmutable $since = null): int
|
||||
{
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return 0;
|
||||
}
|
||||
@@ -532,7 +556,7 @@ class FirewallService
|
||||
*/
|
||||
private function getConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$config = $this->tenantContext->configuration();
|
||||
$config = $this->tenant->configuration();
|
||||
$parts = explode('.', $key);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
@@ -552,7 +576,7 @@ class FirewallService
|
||||
private function getActiveRules(): array
|
||||
{
|
||||
if ($this->rulesCache === null) {
|
||||
$tenantId = $this->tenantContext->identifier();
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$this->rulesCache = $tenantId
|
||||
? $this->store->listRules($tenantId, true)
|
||||
: [];
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace KTXC\Service;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Models\Identity\User;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||
|
||||
/**
|
||||
@@ -23,12 +23,12 @@ class SecurityService
|
||||
private string $securityCode;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly ProviderManager $providerManager,
|
||||
) {
|
||||
$this->securityCode = $this->tenantContext->configuration()->security()->code();
|
||||
$this->securityCode = $this->sessionTenant->configuration()->security()->code();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,7 +118,7 @@ class SecurityService
|
||||
continue;
|
||||
}
|
||||
$context = new \KTXF\Security\Authentication\ProviderContext(
|
||||
tenantId: $this->tenantContext->identifier(),
|
||||
tenantId: $this->sessionTenant->identifier(),
|
||||
userIdentity: $identity,
|
||||
);
|
||||
$result = $provider->verify($context, $credentials);
|
||||
@@ -142,19 +142,4 @@ 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,70 +7,13 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tenants keyed by id
|
||||
*
|
||||
* @return array<string, TenantObject>
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
return $this->store->list();
|
||||
}
|
||||
|
||||
public function deposit(TenantObject $tenant): ?TenantObject
|
||||
{
|
||||
$domains = $tenant->getDomains();
|
||||
if ($domains === null || count($domains) === 0) {
|
||||
throw new \InvalidArgumentException('A tenant must have at least one domain.');
|
||||
}
|
||||
|
||||
return $this->store->deposit($tenant);
|
||||
}
|
||||
|
||||
public function destroy(TenantObject $tenant): void
|
||||
{
|
||||
$this->store->destroy($tenant);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
|
||||
@@ -26,7 +26,7 @@ class TokenService
|
||||
private string $algorithm = 'HS256';
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly EphemeralCacheInterface $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
use KTXC\Context\IdentityContextInterface;
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
|
||||
class UserAccountsService
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly IdentityContextInterface $identityContext,
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsStore $userStore
|
||||
) {
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class UserAccountsService
|
||||
*/
|
||||
public function listUsers(array $filters = []): array
|
||||
{
|
||||
$users = $this->userStore->listUsers($this->tenantContext->identifier(), $filters);
|
||||
$users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters);
|
||||
|
||||
// Remove sensitive data
|
||||
foreach ($users as &$user) {
|
||||
@@ -38,7 +38,7 @@ class UserAccountsService
|
||||
|
||||
public function fetchByIdentity(string $identifier): User | null
|
||||
{
|
||||
$data = $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
|
||||
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||
if (!$data) {
|
||||
return null;
|
||||
}
|
||||
@@ -50,32 +50,32 @@ class UserAccountsService
|
||||
|
||||
public function fetchByIdentifier(string $identifier): array | null
|
||||
{
|
||||
return $this->userStore->fetchByIdentifier($this->tenantContext->identifier(), $identifier);
|
||||
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
|
||||
}
|
||||
|
||||
public function fetchByIdentityRaw(string $identifier): array | null
|
||||
{
|
||||
return $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier);
|
||||
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||
}
|
||||
|
||||
public function fetchByProviderSubject(string $provider, string $subject): ?array
|
||||
{
|
||||
return $this->userStore->fetchByProviderSubject($this->tenantContext->identifier(), $provider, $subject);
|
||||
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
|
||||
}
|
||||
|
||||
public function createUser(array $userData): array
|
||||
{
|
||||
return $this->userStore->createUser($this->tenantContext->identifier(), $userData);
|
||||
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
|
||||
}
|
||||
|
||||
public function updateUser(string $uid, array $updates): bool
|
||||
{
|
||||
return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates);
|
||||
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
|
||||
}
|
||||
|
||||
public function deleteUser(string $uid): bool
|
||||
{
|
||||
return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid);
|
||||
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -84,7 +84,7 @@ class UserAccountsService
|
||||
|
||||
public function fetchProfile(string $uid): ?array
|
||||
{
|
||||
return $this->userStore->fetchProfile($this->tenantContext->identifier(), $uid);
|
||||
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
|
||||
}
|
||||
|
||||
public function storeProfile(string $uid, array $profileFields): bool
|
||||
@@ -109,21 +109,21 @@ class UserAccountsService
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->userStore->storeProfile($this->tenantContext->identifier(), $uid, $editableFields);
|
||||
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchSettings(array $settings = [], bool $flatten = false): array | null
|
||||
public function fetchSettings(array $settings = []): array | null
|
||||
{
|
||||
return $this->userStore->fetchSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings, $flatten);
|
||||
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
|
||||
}
|
||||
|
||||
public function storeSettings(array $settings): bool
|
||||
{
|
||||
return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings);
|
||||
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Context\TenantContextInterface;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface;
|
||||
class UserRolesService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContextInterface $tenantContext,
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly UserRolesStore $roleStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
@@ -26,7 +26,7 @@ class UserRolesService
|
||||
*/
|
||||
public function listRoles(): array
|
||||
{
|
||||
return $this->roleStore->listRoles($this->tenantContext->identifier());
|
||||
return $this->roleStore->listRoles($this->tenantIdentity->identifier());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ class UserRolesService
|
||||
*/
|
||||
public function getRole(string $rid): ?array
|
||||
{
|
||||
return $this->roleStore->fetchByRid($this->tenantContext->identifier(), $rid);
|
||||
return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,11 +45,11 @@ class UserRolesService
|
||||
$this->validateRoleData($roleData);
|
||||
|
||||
$this->logger->info('Creating role', [
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'label' => $roleData['label'] ?? 'Unnamed'
|
||||
]);
|
||||
|
||||
return $this->roleStore->createRole($this->tenantContext->identifier(), $roleData);
|
||||
return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,11 +70,11 @@ class UserRolesService
|
||||
$this->validateRoleData($updates, false);
|
||||
|
||||
$this->logger->info('Updating role', [
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'rid' => $rid
|
||||
]);
|
||||
|
||||
return $this->roleStore->updateRole($this->tenantContext->identifier(), $rid, $updates);
|
||||
return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,17 +93,17 @@ class UserRolesService
|
||||
}
|
||||
|
||||
// Check if role is assigned to users
|
||||
$userCount = $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
|
||||
$userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
|
||||
if ($userCount > 0) {
|
||||
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
|
||||
}
|
||||
|
||||
$this->logger->info('Deleting role', [
|
||||
'tenant' => $this->tenantContext->identifier(),
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'rid' => $rid
|
||||
]);
|
||||
|
||||
return $this->roleStore->deleteRole($this->tenantContext->identifier(), $rid);
|
||||
return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +111,7 @@ class UserRolesService
|
||||
*/
|
||||
public function getRoleUserCount(string $rid): int
|
||||
{
|
||||
return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
|
||||
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
|
||||
class SessionIdentity
|
||||
{
|
||||
private bool $identityLock = false;
|
||||
private ?User $identityData = null;
|
||||
|
||||
public function initialize(User $identity, bool $lock = true): void
|
||||
{
|
||||
if ($this->identityLock) {
|
||||
throw new \RuntimeException('Identity is already locked and cannot be changed.');
|
||||
}
|
||||
|
||||
$this->identityData = $identity;
|
||||
$this->identityLock = $lock;
|
||||
}
|
||||
|
||||
public function identity(): ?User
|
||||
{
|
||||
return $this->identityData;
|
||||
}
|
||||
|
||||
public function identifier(): ?string
|
||||
{
|
||||
return $this->identityData?->getId();
|
||||
}
|
||||
|
||||
public function label(): ?string
|
||||
{
|
||||
return $this->identityData?->getLabel();
|
||||
}
|
||||
|
||||
public function mailAddress(): ?string
|
||||
{
|
||||
return $this->identityData?->getEmail();
|
||||
}
|
||||
|
||||
public function nameFirst(): ?string
|
||||
{
|
||||
return $this->identityData?->getFirstName();
|
||||
}
|
||||
|
||||
public function nameLast(): ?string
|
||||
{
|
||||
return $this->identityData?->getLastName();
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return $this->identityData?->getPermissions() ?? [];
|
||||
}
|
||||
|
||||
public function roles(): array
|
||||
{
|
||||
return $this->identityData?->getRoles() ?? [];
|
||||
}
|
||||
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
$permissions = $this->permissions();
|
||||
|
||||
// Exact match
|
||||
if (in_array($permission, $permissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard match
|
||||
foreach ($permissions as $userPerm) {
|
||||
if (str_ends_with($userPerm, '.*')) {
|
||||
$prefix = substr($userPerm, 0, -2);
|
||||
if (str_starts_with($permission, $prefix . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full wildcard
|
||||
if (in_array('*', $permissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasRole(string $role): bool
|
||||
{
|
||||
return in_array($role, $this->roles());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Models\Tenant\TenantConfiguration;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Service\TenantService;
|
||||
|
||||
class SessionTenant
|
||||
{
|
||||
private ?TenantObject $tenant = null;
|
||||
private ?string $domain = null;
|
||||
private bool $configured = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Configure the tenant information
|
||||
* This method is called by the SecurityMiddleware after validation
|
||||
*/
|
||||
public function configure(string $domain): void
|
||||
{
|
||||
if ($this->configured) {
|
||||
return;
|
||||
}
|
||||
$tenant = $this->tenantService->fetchByDomain($domain);
|
||||
if ($tenant) {
|
||||
$this->domain = $domain;
|
||||
$this->tenant = $tenant;
|
||||
$this->configured = true;
|
||||
} else {
|
||||
$this->domain = null;
|
||||
$this->tenant = null;
|
||||
$this->configured = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the tenant configured
|
||||
*/
|
||||
public function configured(): bool
|
||||
{
|
||||
return $this->configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the tenant enabled
|
||||
*/
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->tenant?->getEnabled() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant domain
|
||||
*/
|
||||
public function domain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant identifier
|
||||
*/
|
||||
public function identifier(): ?string
|
||||
{
|
||||
return $this->tenant?->getIdentifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant label
|
||||
*/
|
||||
public function label(): ?string
|
||||
{
|
||||
return $this->tenant?->getLabel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant configuration
|
||||
*/
|
||||
public function configuration(): TenantConfiguration
|
||||
{
|
||||
return $this->tenant?->getConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Current tenant settings
|
||||
*/
|
||||
public function settings(): array
|
||||
{
|
||||
return $this->tenant?->getSettings() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all identity providers configuration for this tenant
|
||||
* @return array<string, array> Map of provider ID to provider config
|
||||
*/
|
||||
public function identityProviders(): array
|
||||
{
|
||||
return $this->tenant?->getConfiguration()['identity']['providers'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration for a specific identity provider
|
||||
*
|
||||
* @param string $providerId Provider identifier (e.g., 'default', 'oidc')
|
||||
* @return array|null Provider configuration or null if not found
|
||||
*/
|
||||
public function identityProviderConfig(string $providerId): ?array
|
||||
{
|
||||
$providers = $this->identityProviders();
|
||||
return $providers[$providerId] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an identity provider is enabled for this tenant
|
||||
*/
|
||||
public function isIdentityProviderEnabled(string $providerId): bool
|
||||
{
|
||||
$config = $this->identityProviderConfig($providerId);
|
||||
return $config !== null && ($config['enabled'] ?? false);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\ObjectId;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
|
||||
class TenantStore
|
||||
@@ -53,9 +52,7 @@ class TenantStore
|
||||
|
||||
private function create(TenantObject $entry): ?TenantObject
|
||||
{
|
||||
$document = $entry->jsonSerialize();
|
||||
unset($document['id']);
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($document);
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
|
||||
$entry->setId((string)$result->getInsertedId());
|
||||
return $entry;
|
||||
}
|
||||
@@ -64,9 +61,7 @@ class TenantStore
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return null; }
|
||||
$document = $entry->jsonSerialize();
|
||||
unset($document['id']);
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => new ObjectId($id)], ['$set' => $document]);
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
|
||||
return $entry;
|
||||
}
|
||||
|
||||
@@ -74,60 +69,7 @@ class TenantStore
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne(['_id' => new ObjectId($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;
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -177,8 +177,8 @@ class UserAccountsStore
|
||||
$userData['uid'] = $userData['uid'] ?? UUID::v4();
|
||||
$userData['enabled'] = $userData['enabled'] ?? true;
|
||||
$userData['roles'] = $userData['roles'] ?? [];
|
||||
$userData['profile'] = (object) ($userData['profile'] ?? []);
|
||||
$userData['settings'] = (object) ($userData['settings'] ?? []);
|
||||
$userData['profile'] = $userData['profile'] ?? [];
|
||||
$userData['settings'] = $userData['settings'] ?? [];
|
||||
|
||||
$this->store->selectCollection('user_accounts')->insertOne($userData);
|
||||
|
||||
@@ -232,25 +232,16 @@ class UserAccountsStore
|
||||
return false;
|
||||
}
|
||||
|
||||
$collection = $this->store->selectCollection('user_accounts');
|
||||
|
||||
// Repair legacy documents where 'profile' was stored as an empty BSON array:
|
||||
// dot-notation $set below cannot add a named field to an array.
|
||||
$collection->updateOne(
|
||||
['tid' => $tenant, 'uid' => $uid, 'profile' => []],
|
||||
['$set' => ['profile' => (object) []]]
|
||||
);
|
||||
|
||||
$updates = [];
|
||||
foreach ($profileFields as $key => $value) {
|
||||
$updates["profile.{$key}"] = $value;
|
||||
}
|
||||
|
||||
$result = $collection->updateOne(
|
||||
$result = $this->store->selectCollection('user_accounts')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $uid],
|
||||
['$set' => $updates]
|
||||
);
|
||||
|
||||
|
||||
return $result->getModifiedCount() > 0;
|
||||
}
|
||||
|
||||
@@ -258,7 +249,7 @@ class UserAccountsStore
|
||||
// Settings Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchSettings(string $tenant, string $uid, array $settings = [], bool $flatten = false): ?array
|
||||
public function fetchSettings(string $tenant, string $uid, array $settings = []): ?array
|
||||
{
|
||||
// Only fetch the settings field from the database
|
||||
$user = $this->store->selectCollection('user_accounts')->findOne(
|
||||
@@ -273,39 +264,12 @@ class UserAccountsStore
|
||||
$userSettings = $user['settings'] ?? [];
|
||||
|
||||
if (empty($settings)) {
|
||||
return $flatten ? $this->flattenSettings($userSettings) : $userSettings;
|
||||
return $userSettings;
|
||||
}
|
||||
|
||||
// Specific keys are always returned in dot-notation (already flat)
|
||||
$result = [];
|
||||
foreach ($settings as $key) {
|
||||
$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;
|
||||
}
|
||||
$result[$key] = $userSettings[$key] ?? null;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
@@ -316,25 +280,16 @@ class UserAccountsStore
|
||||
return false;
|
||||
}
|
||||
|
||||
$collection = $this->store->selectCollection('user_accounts');
|
||||
|
||||
// Repair legacy documents where 'settings' was stored as an empty BSON array:
|
||||
// dot-notation $set below cannot add a named field to an array.
|
||||
$collection->updateOne(
|
||||
['tid' => $tenant, 'uid' => $uid, 'settings' => []],
|
||||
['$set' => ['settings' => (object) []]]
|
||||
);
|
||||
|
||||
$updates = [];
|
||||
foreach ($settings as $key => $value) {
|
||||
$updates["settings.{$key}"] = $value;
|
||||
}
|
||||
|
||||
$result = $collection->updateOne(
|
||||
$result = $this->store->selectCollection('user_accounts')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $uid],
|
||||
['$set' => $updates]
|
||||
);
|
||||
|
||||
|
||||
// Return true if document was matched (exists), even if not modified
|
||||
return $result->getMatchedCount() > 0;
|
||||
}
|
||||
|
||||
+16
-3
@@ -1,9 +1,22 @@
|
||||
<?php
|
||||
|
||||
use KTXC\Application;
|
||||
use KTXC\Module\ModuleAutoloader;
|
||||
|
||||
// Capture Composer ClassLoader instance for compatibility
|
||||
// Capture Composer ClassLoader instance
|
||||
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$application = Application::create(dirname(__DIR__), $composerLoader);
|
||||
$application->runHttp();
|
||||
// Determine project root (one level up from this file)
|
||||
$projectRoot = dirname(__DIR__);
|
||||
|
||||
// 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();
|
||||
+13
-2
@@ -1,9 +1,20 @@
|
||||
<template>
|
||||
<RouterView></RouterView>
|
||||
<SharedSnackbar />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import SharedSnackbar from '@KTXC/components/shared/SharedSnackbar.vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useTheme } from 'vuetify';
|
||||
import { useLayoutStore } from '@KTXC/stores/layoutStore';
|
||||
|
||||
const theme = useTheme();
|
||||
const layoutStore = useLayoutStore();
|
||||
|
||||
// Apply saved theme on mount
|
||||
onMounted(() => {
|
||||
if (layoutStore.theme) {
|
||||
theme.global.name.value = layoutStore.theme;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path d="M3 5H10V10H14V5H22V10H26V5H33V19L18 33L3 19Z" fill="#818CF8"/><path d="M11 14L18 21L25 14V19.5L18 26.5L11 19.5Z" fill="#4F46E5"/></svg>
|
||||
|
Before Width: | Height: | Size: 205 B |
@@ -1,29 +0,0 @@
|
||||
<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>
|
||||
@@ -3,6 +3,4 @@
|
||||
*/
|
||||
|
||||
export { useClipboard } from './useClipboard'
|
||||
export { useL10n, t } from './useL10n'
|
||||
export type { L10N, TranslateParams } from './useL10n'
|
||||
export { useUser } from './useUser'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user