Initial Version
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# Frontend development
|
||||
node_modules/
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
.cache/
|
||||
.vite/
|
||||
.temp/
|
||||
.tmp/
|
||||
|
||||
# Frontend build
|
||||
/public/
|
||||
/static/
|
||||
|
||||
# Backend development
|
||||
/vendor/
|
||||
coverage/
|
||||
phpunit.xml.cache
|
||||
.phpunit.result.cache
|
||||
.php-cs-fixer.cache
|
||||
.phpstan.cache
|
||||
.phpactor/
|
||||
|
||||
# Editors
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
*.log*
|
||||
|
||||
# Runtime
|
||||
/modules/
|
||||
/storage/
|
||||
/var/
|
||||
143
.stubs/mongodb.stub.php
Normal file
143
.stubs/mongodb.stub.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
/**
|
||||
* Stub file for MongoDB extension types
|
||||
* This file provides type information for the PHP language server
|
||||
* It should never be executed - it's only for IDE/static analysis
|
||||
*/
|
||||
|
||||
namespace MongoDB\BSON {
|
||||
|
||||
/**
|
||||
* BSON type for the "ObjectId" type
|
||||
*/
|
||||
class ObjectId implements \Stringable, \JsonSerializable, \MongoDB\BSON\Type
|
||||
{
|
||||
/**
|
||||
* Construct a new ObjectId
|
||||
* @param string|null $id A 24-character hexadecimal string. If not provided, the driver will generate an ObjectId.
|
||||
*/
|
||||
final public function __construct(?string $id = null) {}
|
||||
|
||||
/**
|
||||
* Returns the hexadecimal representation of this ObjectId
|
||||
*/
|
||||
final public function __toString(): string {}
|
||||
|
||||
/**
|
||||
* Returns the timestamp component of this ObjectId
|
||||
*/
|
||||
final public function getTimestamp(): int {}
|
||||
|
||||
/**
|
||||
* Returns a representation that can be converted to JSON
|
||||
*/
|
||||
final public function jsonSerialize(): mixed {}
|
||||
|
||||
/**
|
||||
* Checks if a value is a valid ObjectId
|
||||
*/
|
||||
final public static function isValid(string $id): bool {}
|
||||
}
|
||||
|
||||
/**
|
||||
* BSON type for the "UTCDateTime" type
|
||||
*/
|
||||
class UTCDateTime implements \JsonSerializable, \MongoDB\BSON\Type
|
||||
{
|
||||
/**
|
||||
* Construct a new UTCDateTime
|
||||
* @param int|\DateTimeInterface|null $milliseconds Number of milliseconds since the Unix epoch (1970-01-01 00:00:00 UTC)
|
||||
*/
|
||||
final public function __construct(int|\DateTimeInterface|null $milliseconds = null) {}
|
||||
|
||||
/**
|
||||
* Returns the string representation of this UTCDateTime
|
||||
*/
|
||||
final public function __toString(): string {}
|
||||
|
||||
/**
|
||||
* Returns the DateTime representation of this UTCDateTime
|
||||
*/
|
||||
final public function toDateTime(): \DateTime {}
|
||||
|
||||
/**
|
||||
* Returns a representation that can be converted to JSON
|
||||
*/
|
||||
final public function jsonSerialize(): mixed {}
|
||||
}
|
||||
|
||||
/**
|
||||
* This interface is implemented by BSON types
|
||||
*/
|
||||
interface Type {}
|
||||
}
|
||||
|
||||
namespace MongoDB\Driver {
|
||||
|
||||
/**
|
||||
* The MongoDB\Driver\CursorInterface interface
|
||||
*/
|
||||
interface CursorInterface extends \Traversable
|
||||
{
|
||||
/**
|
||||
* Returns the MongoDB\Driver\CursorId associated with this cursor
|
||||
*/
|
||||
public function getId(): CursorId;
|
||||
|
||||
/**
|
||||
* Returns the MongoDB\Driver\Server associated with this cursor
|
||||
*/
|
||||
public function getServer(): Server;
|
||||
|
||||
/**
|
||||
* Checks if the cursor may have additional results
|
||||
*/
|
||||
public function isDead(): bool;
|
||||
|
||||
/**
|
||||
* Sets a type map to use for BSON unserialization
|
||||
*/
|
||||
public function setTypeMap(array $typemap): void;
|
||||
|
||||
/**
|
||||
* Returns an array containing all results for this cursor
|
||||
*/
|
||||
public function toArray(): array;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MongoDB\Driver\Cursor class
|
||||
*/
|
||||
final class Cursor implements CursorInterface, \Iterator
|
||||
{
|
||||
private function __construct() {}
|
||||
|
||||
public function current(): array|object|null {}
|
||||
public function getId(): CursorId {}
|
||||
public function getServer(): Server {}
|
||||
public function isDead(): bool {}
|
||||
public function key(): ?int {}
|
||||
public function next(): void {}
|
||||
public function rewind(): void {}
|
||||
public function setTypeMap(array $typemap): void {}
|
||||
public function toArray(): array {}
|
||||
public function valid(): bool {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The MongoDB\Driver\CursorId class
|
||||
*/
|
||||
final class CursorId
|
||||
{
|
||||
private function __construct() {}
|
||||
public function __toString(): string {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The MongoDB\Driver\Server class
|
||||
*/
|
||||
final class Server
|
||||
{
|
||||
private function __construct() {}
|
||||
}
|
||||
}
|
||||
117
bin/console
Executable file
117
bin/console
Executable file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
try {
|
||||
// Bootstrap the application
|
||||
$projectRoot = dirname(__DIR__);
|
||||
$app = new Application($projectRoot);
|
||||
|
||||
// Boot kernel to initialize container and modules
|
||||
$app->kernel()->boot();
|
||||
|
||||
// Get the container
|
||||
$container = $app->container();
|
||||
|
||||
// Create Symfony Console Application
|
||||
$console = new ConsoleApplication('Ktrix Console', Kernel::VERSION);
|
||||
|
||||
// Collect all command classes
|
||||
$commandClasses = [];
|
||||
|
||||
// Collect commands from modules
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $container->get(ModuleManager::class);
|
||||
|
||||
foreach ($moduleManager->list() as $module) {
|
||||
$moduleInstance = $module->instance();
|
||||
|
||||
// Skip if module instance is not available
|
||||
if ($moduleInstance === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if module implements console command provider
|
||||
if ($moduleInstance instanceof ModuleConsoleInterface) {
|
||||
try {
|
||||
$commands = $moduleInstance->registerCI();
|
||||
|
||||
foreach ($commands as $commandClass) {
|
||||
if (!class_exists($commandClass)) {
|
||||
fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n");
|
||||
continue;
|
||||
}
|
||||
$commandClasses[] = $commandClass;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register commands using lazy loading
|
||||
foreach ($commandClasses as $commandClass) {
|
||||
try {
|
||||
// Use reflection to read #[AsCommand] attribute without instantiation
|
||||
$reflection = new \ReflectionClass($commandClass);
|
||||
$attributes = $reflection->getAttributes(\Symfony\Component\Console\Attribute\AsCommand::class);
|
||||
|
||||
if (empty($attributes)) {
|
||||
fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get attribute instance
|
||||
/** @var \Symfony\Component\Console\Attribute\AsCommand $commandAttr */
|
||||
$commandAttr = $attributes[0]->newInstance();
|
||||
|
||||
// Create lazy command wrapper that defers instantiation
|
||||
$lazyCommand = new LazyCommand(
|
||||
$commandAttr->name,
|
||||
[],
|
||||
$commandAttr->description ?? '',
|
||||
$commandAttr->hidden ?? false,
|
||||
fn() => $container->get($commandClass) // Only instantiate when executed
|
||||
);
|
||||
|
||||
$console->add($lazyCommand);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Run the console application
|
||||
$exitCode = $console->run();
|
||||
exit($exitCode);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
fwrite(STDERR, "Fatal error: " . $e->getMessage() . "\n");
|
||||
if (isset($app) && $app->debug()) {
|
||||
fwrite(STDERR, $e->getTraceAsString() . "\n");
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
4
bin/phpunit
Executable file
4
bin/phpunit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||
43
composer.json
Normal file
43
composer.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"mongodb/mongodb": "^2.1",
|
||||
"php-di/php-di": "*",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
"symfony/console": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true
|
||||
},
|
||||
"bump-after-update": true,
|
||||
"sort-packages": true
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"KTXC\\": "core/lib/",
|
||||
"KTXF\\": "shared/lib/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"KTXT\\": "tests/php/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"post-install-cmd": [
|
||||
],
|
||||
"post-update-cmd": [
|
||||
],
|
||||
"test": "phpunit --colors=always --testdox"
|
||||
}
|
||||
}
|
||||
3148
composer.lock
generated
Normal file
3148
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
config/system.php
Normal file
40
config/system.php
Normal file
@@ -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',
|
||||
];
|
||||
216
core/lib/Application.php
Normal file
216
core/lib/Application.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Application class - entry point for the framework
|
||||
* Handles configuration loading and kernel lifecycle
|
||||
*/
|
||||
class Application
|
||||
{
|
||||
private static $composerLoader = null;
|
||||
|
||||
private Kernel $kernel;
|
||||
private array $config;
|
||||
private string $rootDir;
|
||||
|
||||
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
|
||||
{
|
||||
$this->rootDir = $this->resolveProjectRoot($rootDir);
|
||||
|
||||
// Load configuration
|
||||
$this->config = $this->loadConfig();
|
||||
|
||||
// Determine environment and debug mode
|
||||
$environment = $environment ?? $this->config['environment'] ?? 'prod';
|
||||
$debug = $debug ?? $this->config['debug'] ?? false;
|
||||
|
||||
// Create kernel with configuration
|
||||
$this->kernel = new Kernel($environment, $debug, $this->config, $rootDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the application - handle incoming request and send response
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
try {
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $this->handle($request);
|
||||
$response->send();
|
||||
$this->terminate();
|
||||
} catch (\Throwable $e) {
|
||||
// Last resort error handling for kernel initialization failures
|
||||
error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
$content = $this->kernel->debug()
|
||||
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
|
||||
: 'An error occurred. Please try again later.';
|
||||
$response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
]);
|
||||
$response->send();
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a request
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return $this->kernel->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the application - process deferred events
|
||||
*/
|
||||
public function terminate(): void
|
||||
{
|
||||
$this->kernel->processEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the kernel instance
|
||||
*/
|
||||
public function kernel(): Kernel
|
||||
{
|
||||
return $this->kernel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the container instance
|
||||
*/
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
return $this->kernel->container();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application root directory
|
||||
*/
|
||||
public function rootDir(): string
|
||||
{
|
||||
return $this->rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the modules directory
|
||||
*/
|
||||
public function moduleDir(): string
|
||||
{
|
||||
return $this->rootDir . '/modules';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
*/
|
||||
public function config(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
// Support dot notation: 'database.uri'
|
||||
$keys = explode('.', $key);
|
||||
$value = $this->config;
|
||||
|
||||
foreach ($keys as $k) {
|
||||
if (!is_array($value) || !array_key_exists($k, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$k];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment
|
||||
*/
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->kernel->environment();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if debug mode is enabled
|
||||
*/
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->kernel->debug();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from config directory
|
||||
*/
|
||||
protected function loadConfig(): array
|
||||
{
|
||||
$configFile = $this->rootDir . '/config/system.php';
|
||||
|
||||
if (!file_exists($configFile)) {
|
||||
error_log('Configuration file not found: ' . $configFile);
|
||||
return [];
|
||||
}
|
||||
|
||||
$config = include $configFile;
|
||||
|
||||
if (!is_array($config)) {
|
||||
throw new \RuntimeException('Configuration file must return an array');
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project root directory.
|
||||
*
|
||||
* Some entrypoints may pass the public/ directory or another subdirectory.
|
||||
* We walk up the directory tree until we find composer.json.
|
||||
*/
|
||||
private function resolveProjectRoot(string $startDir): string
|
||||
{
|
||||
$dir = rtrim($startDir, '/');
|
||||
if ($dir === '') {
|
||||
return $startDir;
|
||||
}
|
||||
|
||||
// If startDir is a file path, use its directory.
|
||||
if (is_file($dir)) {
|
||||
$dir = dirname($dir);
|
||||
}
|
||||
|
||||
$current = $dir;
|
||||
while (true) {
|
||||
if (is_file($current . '/composer.json')) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$parent = dirname($current);
|
||||
if ($parent === $current) {
|
||||
// Reached filesystem root
|
||||
return $dir;
|
||||
}
|
||||
$current = $parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Composer ClassLoader instance
|
||||
*/
|
||||
public static function setComposerLoader($loader): void
|
||||
{
|
||||
self::$composerLoader = $loader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Composer ClassLoader instance
|
||||
*/
|
||||
public static function getComposerLoader()
|
||||
{
|
||||
return self::$composerLoader;
|
||||
}
|
||||
}
|
||||
92
core/lib/Console/ModuleDisableCommand.php
Normal file
92
core/lib/Console/ModuleDisableCommand.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Module Disable Command
|
||||
*
|
||||
* Disables an enabled module.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'module:disable',
|
||||
description: 'Disable a module',
|
||||
)]
|
||||
class ModuleDisableCommand 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 disable')
|
||||
->setHelp('This command disables an enabled module without uninstalling it.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$handle = $input->getArgument('handle');
|
||||
|
||||
$io->title('Disable Module');
|
||||
|
||||
try {
|
||||
// Prevent disabling core module
|
||||
if ($handle === 'core') {
|
||||
$io->error('Cannot disable the core module.');
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Find the module
|
||||
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false);
|
||||
$module = $modules[$handle] ?? null;
|
||||
|
||||
if (!$module) {
|
||||
$io->error("Module '{$handle}' not found or not installed.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (!$module->enabled()) {
|
||||
$io->warning("Module '{$handle}' is already disabled.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Disable the module
|
||||
$io->text("Disabling module '{$handle}'...");
|
||||
$this->moduleManager->disable($handle);
|
||||
|
||||
$this->logger->info('Module disabled via console', [
|
||||
'handle' => $handle,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Module '{$handle}' disabled successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to disable module: ' . $e->getMessage());
|
||||
$this->logger->error('Module disable failed', [
|
||||
'handle' => $handle,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
core/lib/Console/ModuleEnableCommand.php
Normal file
86
core/lib/Console/ModuleEnableCommand.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* Module Enable Command
|
||||
*
|
||||
* Enables a disabled module.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'module:enable',
|
||||
description: 'Enable a module',
|
||||
)]
|
||||
class ModuleEnableCommand 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 enable')
|
||||
->setHelp('This command enables a previously disabled module.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$handle = $input->getArgument('handle');
|
||||
|
||||
$io->title('Enable Module');
|
||||
|
||||
try {
|
||||
// Find the module
|
||||
$modules = $this->moduleManager->list(installedOnly: true, enabledOnly: false);
|
||||
$module = $modules[$handle] ?? null;
|
||||
|
||||
if (!$module) {
|
||||
$io->error("Module '{$handle}' not found or not installed.");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($module->enabled()) {
|
||||
$io->warning("Module '{$handle}' is already enabled.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Enable the module
|
||||
$io->text("Enabling module '{$handle}'...");
|
||||
$this->moduleManager->enable($handle);
|
||||
|
||||
$this->logger->info('Module enabled via console', [
|
||||
'handle' => $handle,
|
||||
'command' => $this->getName(),
|
||||
]);
|
||||
|
||||
$io->success("Module '{$handle}' enabled successfully!");
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to enable module: ' . $e->getMessage());
|
||||
$this->logger->error('Module enable failed', [
|
||||
'handle' => $handle,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
core/lib/Console/ModuleListCommand.php
Normal file
86
core/lib/Console/ModuleListCommand.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Console;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
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 List Command
|
||||
*
|
||||
* Lists all modules with their status and version information.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'module:list',
|
||||
description: 'List all modules with their status and versions',
|
||||
)]
|
||||
class ModuleListCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleManager $moduleManager
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('all', 'a', InputOption::VALUE_NONE, 'Show all modules including disabled ones')
|
||||
->setHelp('This command lists all installed modules with their status and version information.')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$showAll = $input->getOption('all');
|
||||
|
||||
$io->title('Installed Modules');
|
||||
|
||||
try {
|
||||
$modules = $this->moduleManager->list(
|
||||
installedOnly: true,
|
||||
enabledOnly: !$showAll
|
||||
);
|
||||
|
||||
if (count($modules) === 0) {
|
||||
$io->warning('No modules found.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($modules as $module) {
|
||||
$status = $module->enabled() ? '<fg=green>Enabled</>' : '<fg=yellow>Disabled</>';
|
||||
$upgrade = $module->needsUpgrade() ? '<fg=red>Yes</>' : '';
|
||||
|
||||
$rows[] = [
|
||||
$module->handle(),
|
||||
$module->version(),
|
||||
$status,
|
||||
$upgrade,
|
||||
$module->namespace() ?? 'N/A',
|
||||
];
|
||||
}
|
||||
|
||||
$io->table(
|
||||
['Handle', 'Version', 'Status', 'Needs Upgrade', 'Namespace'],
|
||||
$rows
|
||||
);
|
||||
|
||||
$io->success(sprintf('Found %d module(s).', count($modules)));
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$io->error('Failed to list modules: ' . $e->getMessage());
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
361
core/lib/Controllers/AuthenticationController.php
Normal file
361
core/lib/Controllers/AuthenticationController.php
Normal file
@@ -0,0 +1,361 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Cookie;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Http\Response\RedirectResponse;
|
||||
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||
use KTXC\Security\AuthenticationManager;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
*/
|
||||
class AuthenticationController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuthenticationManager $authManager
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Authentication Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Start authentication session
|
||||
*/
|
||||
#[AnonymousRoute('/auth/start', name: 'auth.start', methods: ['GET'])]
|
||||
public function start(): JsonResponse
|
||||
{
|
||||
$request = AuthenticationRequest::start();
|
||||
$response = $this->authManager->handle($request);
|
||||
|
||||
return $this->buildJsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify user for identity-first login flow
|
||||
*/
|
||||
#[AnonymousRoute('/auth/identify', name: 'auth.identify', methods: ['POST'])]
|
||||
public function identify(string $session, string $identity): JsonResponse
|
||||
{
|
||||
if (empty($session) || empty($identity)) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Session and identity are required', 'error_code' => 'invalid_request'],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$request = AuthenticationRequest::identify($session, trim($identity));
|
||||
$response = $this->authManager->handle($request);
|
||||
|
||||
return $this->buildJsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a challenge for methods that require it (SMS, email, TOTP)
|
||||
*/
|
||||
#[AnonymousRoute('/auth/challenge', name: 'auth.challenge', methods: ['POST'])]
|
||||
public function challenge(string $session, string $method): JsonResponse
|
||||
{
|
||||
if (empty($session) || empty($method)) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$request = AuthenticationRequest::challenge($session, $method);
|
||||
$response = $this->authManager->handle($request);
|
||||
|
||||
return $this->buildJsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a credential or challenge response
|
||||
*/
|
||||
#[AnonymousRoute('/auth/verify', name: 'auth.verify', methods: ['POST'])]
|
||||
public function verify(string $session, string $method, string $response): JsonResponse
|
||||
{
|
||||
if (empty($session) || empty($method)) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$request = AuthenticationRequest::verify($session, $method, $response);
|
||||
$authResponse = $this->authManager->handle($request);
|
||||
|
||||
return $this->buildJsonResponse($authResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin redirect-based authentication (OIDC/SAML)
|
||||
*/
|
||||
#[AnonymousRoute('/auth/redirect', name: 'auth.redirect', methods: ['POST'])]
|
||||
public function redirect(Request $request): JsonResponse
|
||||
{
|
||||
$data = $this->getRequestData($request);
|
||||
|
||||
$sessionId = $data['session'] ?? '';
|
||||
$method = $data['method'] ?? '';
|
||||
$returnUrl = $data['return_url'] ?? '/';
|
||||
|
||||
if (empty($sessionId) || empty($method)) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Session ID and method are required', 'error_code' => 'invalid_request'],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$scheme = $request->isSecure() ? 'https' : 'http';
|
||||
$host = $request->getHost();
|
||||
$callbackUrl = "{$scheme}://{$host}/auth/callback/{$method}";
|
||||
|
||||
$authRequest = AuthenticationRequest::redirect($sessionId, $method, $callbackUrl, $returnUrl);
|
||||
$response = $this->authManager->handle($authRequest);
|
||||
|
||||
return $this->buildJsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle callback from identity provider (OIDC/SAML)
|
||||
*/
|
||||
#[AnonymousRoute('/auth/callback/{provider}', name: 'auth.callback', methods: ['GET', 'POST'])]
|
||||
public function callback(Request $request, string $provider): JsonResponse|RedirectResponse
|
||||
{
|
||||
$params = $request->isMethod('POST')
|
||||
? $request->request->all()
|
||||
: $request->query->all();
|
||||
|
||||
$sessionId = $params['state'] ?? null;
|
||||
|
||||
if (!$sessionId) {
|
||||
return $this->redirectWithError('Missing state parameter');
|
||||
}
|
||||
|
||||
$authRequest = AuthenticationRequest::callback($sessionId, $provider, $params);
|
||||
$response = $this->authManager->handle($authRequest);
|
||||
|
||||
if ($response->isSuccess()) {
|
||||
$returnUrl = $response->returnUrl ?? '/';
|
||||
$httpResponse = new RedirectResponse($returnUrl);
|
||||
|
||||
if ($response->hasTokens()) {
|
||||
return $this->setTokenCookies($httpResponse, $response->tokens, $request->isSecure());
|
||||
}
|
||||
|
||||
return $httpResponse;
|
||||
}
|
||||
|
||||
if ($response->isPending()) {
|
||||
return new RedirectResponse('/login/mfa?session=' . urlencode($response->sessionId));
|
||||
}
|
||||
|
||||
return $this->redirectWithError($response->errorMessage ?? 'Authentication failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session status
|
||||
*/
|
||||
#[AnonymousRoute('/auth/status', name: 'auth.status', methods: ['GET'])]
|
||||
public function status(Request $request): JsonResponse
|
||||
{
|
||||
$sessionId = $request->query->get('session', '');
|
||||
|
||||
if (empty($sessionId)) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Session ID is required', 'error_code' => 'invalid_request'],
|
||||
JsonResponse::HTTP_BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
$authRequest = AuthenticationRequest::status($sessionId);
|
||||
$response = $this->authManager->handle($authRequest);
|
||||
|
||||
return $this->buildJsonResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel authentication session
|
||||
*/
|
||||
#[AnonymousRoute('/auth/session', name: 'auth.session.cancel', methods: ['DELETE'])]
|
||||
public function cancel(Request $request): JsonResponse
|
||||
{
|
||||
$sessionId = $request->query->get('session', '');
|
||||
|
||||
$authRequest = AuthenticationRequest::cancel($sessionId);
|
||||
$this->authManager->handle($authRequest);
|
||||
|
||||
return new JsonResponse(['status' => 'cancelled', 'message' => 'Session cancelled']);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
#[AnonymousRoute('/auth/refresh', name: 'auth.refresh', methods: ['POST'])]
|
||||
public function refresh(Request $request): JsonResponse
|
||||
{
|
||||
$refreshToken = $request->cookies->get('refreshToken');
|
||||
|
||||
if (!$refreshToken) {
|
||||
return new JsonResponse(
|
||||
['error' => 'Refresh token required', 'error_code' => 'missing_token'],
|
||||
JsonResponse::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
$authRequest = AuthenticationRequest::refresh($refreshToken);
|
||||
$response = $this->authManager->handle($authRequest);
|
||||
|
||||
if ($response->isFailed()) {
|
||||
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||
return $this->clearTokenCookies($httpResponse);
|
||||
}
|
||||
|
||||
$httpResponse = new JsonResponse(['status' => 'success', 'message' => 'Token refreshed']);
|
||||
|
||||
if ($response->tokens && isset($response->tokens['access'])) {
|
||||
$httpResponse->headers->setCookie(
|
||||
Cookie::create('accessToken')
|
||||
->withValue($response->tokens['access'])
|
||||
->withExpires(time() + 900)
|
||||
->withPath('/')
|
||||
->withSecure($request->isSecure())
|
||||
->withHttpOnly(true)
|
||||
->withSameSite(Cookie::SAMESITE_STRICT)
|
||||
);
|
||||
}
|
||||
|
||||
return $httpResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current device
|
||||
*/
|
||||
#[AuthenticatedRoute('/auth/logout', name: 'auth.logout', methods: ['POST'])]
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$token = $request->cookies->get('accessToken');
|
||||
|
||||
$authRequest = AuthenticationRequest::logout($token, false);
|
||||
$this->authManager->handle($authRequest);
|
||||
|
||||
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out successfully']);
|
||||
return $this->clearTokenCookies($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout all devices
|
||||
*/
|
||||
#[AuthenticatedRoute('/auth/logout-all', name: 'auth.logout.all', methods: ['POST'])]
|
||||
public function logoutAll(Request $request): JsonResponse
|
||||
{
|
||||
$token = $request->cookies->get('accessToken');
|
||||
|
||||
$authRequest = AuthenticationRequest::logout($token, true);
|
||||
$this->authManager->handle($authRequest);
|
||||
|
||||
$response = new JsonResponse(['status' => 'success', 'message' => 'Logged out from all devices']);
|
||||
return $this->clearTokenCookies($response);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Response Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Build JSON response from AuthenticationResponse
|
||||
*/
|
||||
private function buildJsonResponse(AuthenticationResponse $response): JsonResponse
|
||||
{
|
||||
$httpResponse = new JsonResponse($response->toArray(), $response->httpStatus);
|
||||
|
||||
// Set token cookies if present
|
||||
if ($response->hasTokens()) {
|
||||
return $this->setTokenCookies($httpResponse, $response->tokens, true);
|
||||
}
|
||||
|
||||
return $httpResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set authentication token cookies
|
||||
*/
|
||||
private function setTokenCookies(JsonResponse|RedirectResponse $response, array $tokens, bool $secure = true): JsonResponse|RedirectResponse
|
||||
{
|
||||
if (isset($tokens['access'])) {
|
||||
$response->headers->setCookie(
|
||||
Cookie::create('accessToken')
|
||||
->withValue($tokens['access'])
|
||||
->withExpires(time() + 900)
|
||||
->withPath('/')
|
||||
->withSecure($secure)
|
||||
->withHttpOnly(true)
|
||||
->withSameSite(Cookie::SAMESITE_STRICT)
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($tokens['refresh'])) {
|
||||
$response->headers->setCookie(
|
||||
Cookie::create('refreshToken')
|
||||
->withValue($tokens['refresh'])
|
||||
->withExpires(time() + 604800)
|
||||
->withPath('/auth/refresh')
|
||||
->withSecure($secure)
|
||||
->withHttpOnly(true)
|
||||
->withSameSite(Cookie::SAMESITE_STRICT)
|
||||
);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication token cookies
|
||||
*/
|
||||
private function clearTokenCookies(JsonResponse $response): JsonResponse
|
||||
{
|
||||
$response->headers->clearCookie('accessToken', '/');
|
||||
$response->headers->clearCookie('refreshToken', '/auth/refresh');
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect with error message
|
||||
*/
|
||||
private function redirectWithError(string $error): RedirectResponse
|
||||
{
|
||||
return new RedirectResponse('/login?error=' . urlencode($error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request data from JSON body or form data
|
||||
*/
|
||||
private function getRequestData(Request $request): array
|
||||
{
|
||||
$contentType = $request->headers->get('Content-Type', '');
|
||||
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
try {
|
||||
return $request->toArray();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return $request->request->all();
|
||||
}
|
||||
}
|
||||
144
core/lib/Controllers/DefaultController.php
Normal file
144
core/lib/Controllers/DefaultController.php
Normal file
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Http\Response\FileResponse;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Http\Response\RedirectResponse;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXC\Service\SecurityService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Http\Request\Request;
|
||||
|
||||
class DefaultController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityService $securityService,
|
||||
private readonly SessionIdentity $identity,
|
||||
#[Inject('rootDir')] private readonly string $rootDir,
|
||||
) {}
|
||||
|
||||
#[AnonymousRoute('/', name: 'root', methods: ['GET'])]
|
||||
public function home(Request $request): Response
|
||||
{
|
||||
// If an authenticated identity is available, serve the private app
|
||||
if ($this->identity->identifier()) {
|
||||
return new FileResponse(
|
||||
$this->rootDir . '/public/private.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
// User is not authenticated - serve the public app
|
||||
// If there's an accessToken cookie present but invalid, clear it
|
||||
$response = new FileResponse(
|
||||
$this->rootDir . '/public/public.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
|
||||
// Clear any stale auth cookies since the user is not authenticated
|
||||
if ($request->cookies->has('accessToken')) {
|
||||
$response->headers->clearCookie('accessToken', '/');
|
||||
}
|
||||
if ($request->cookies->has('refreshToken')) {
|
||||
$response->headers->clearCookie('refreshToken', '/security/refresh');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[AnonymousRoute('/login', name: 'login', methods: ['GET'])]
|
||||
public function login(): Response
|
||||
{
|
||||
return new FileResponse(
|
||||
$this->rootDir . '/public/public.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
#[AnonymousRoute('/logout', name: 'logout_get', methods: ['GET'])]
|
||||
public function logoutGet(Request $request): Response
|
||||
{
|
||||
// Blacklist the current access token if present
|
||||
$accessToken = $request->cookies->get('accessToken');
|
||||
if ($accessToken) {
|
||||
$claims = $this->securityService->extractTokenClaims($accessToken);
|
||||
if ($claims && isset($claims['jti'])) {
|
||||
$this->securityService->logout($claims['jti'], $claims['exp'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
$response = new RedirectResponse(
|
||||
'/login',
|
||||
Response::HTTP_SEE_OTHER
|
||||
);
|
||||
|
||||
// Clear both authentication cookies
|
||||
$response->headers->clearCookie('accessToken', '/');
|
||||
$response->headers->clearCookie('refreshToken', '/security/refresh');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[AnonymousRoute('/logout', name: 'logout_post', methods: ['POST'])]
|
||||
public function logoutPost(Request $request): Response
|
||||
{
|
||||
// Blacklist the current access token if present
|
||||
$accessToken = $request->cookies->get('accessToken');
|
||||
if ($accessToken) {
|
||||
$claims = $this->securityService->extractTokenClaims($accessToken);
|
||||
if ($claims && isset($claims['jti'])) {
|
||||
$this->securityService->logout($claims['jti'], $claims['exp'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
$response = new JsonResponse(['message' => 'Logged out successfully']);
|
||||
|
||||
// Clear both authentication cookies
|
||||
$response->headers->clearCookie('accessToken', '/');
|
||||
$response->headers->clearCookie('refreshToken', '/security/refresh');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all route for SPA routing.
|
||||
* Serves the appropriate HTML based on authentication status,
|
||||
* allowing client-side routing to handle the actual path.
|
||||
*/
|
||||
#[AnonymousRoute('/{path}', name: 'spa_catchall', methods: ['GET'])]
|
||||
public function catchAll(Request $request, string $path = ''): Response
|
||||
{
|
||||
// If an authenticated identity is available, serve the private app
|
||||
if ($this->identity->identifier()) {
|
||||
return new FileResponse(
|
||||
$this->rootDir . '/public/private.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
}
|
||||
|
||||
// User is not authenticated - serve the public app
|
||||
$response = new FileResponse(
|
||||
$this->rootDir . '/public/public.html',
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'text/html']
|
||||
);
|
||||
|
||||
// Clear any stale auth cookies since the user is not authenticated
|
||||
if ($request->cookies->has('accessToken')) {
|
||||
$response->headers->clearCookie('accessToken', '/');
|
||||
}
|
||||
if ($request->cookies->has('refreshToken')) {
|
||||
$response->headers->clearCookie('refreshToken', '/security/refresh');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
95
core/lib/Controllers/InitController.php
Normal file
95
core/lib/Controllers/InitController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXC\Security\Authorization\PermissionChecker;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class InitController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly ModuleManager $moduleManager,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly PermissionChecker $permissionChecker,
|
||||
) {}
|
||||
|
||||
#[AuthenticatedRoute('/init', name: 'init', methods: ['GET'])]
|
||||
public function index(): JsonResponse {
|
||||
|
||||
$configuration = [];
|
||||
|
||||
// modules - filter by permissions
|
||||
$configuration['modules'] = [];
|
||||
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();
|
||||
if (!$this->hasModuleViewPermission($handle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$integrations = $module->registerBI();
|
||||
if ($integrations !== null) {
|
||||
$configuration['modules'][$handle] = $integrations;
|
||||
}
|
||||
}
|
||||
|
||||
// tenant
|
||||
$configuration['tenant'] = [
|
||||
'id' => $this->tenant->identifier(),
|
||||
'domain' => $this->tenant->domain(),
|
||||
'label' => $this->tenant->label(),
|
||||
];
|
||||
|
||||
// user
|
||||
$configuration['user'] = [
|
||||
'auth' => [
|
||||
'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->userIdentity->identifier()),
|
||||
'settings' => $this->userService->fetchSettings(),
|
||||
];
|
||||
|
||||
return new JsonResponse($configuration);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has permission to view a module
|
||||
*
|
||||
* Checks for the following permissions (in order):
|
||||
* 1. {module_handle} - module access permission
|
||||
* 2. {module_handle}.* - wildcard for the module
|
||||
* 3. * - global wildcard
|
||||
*
|
||||
* @param string $moduleHandle The module handle to check
|
||||
* @return bool
|
||||
*/
|
||||
private function hasModuleViewPermission(string $moduleHandle): bool
|
||||
{
|
||||
// Core module is always accessible to authenticated users
|
||||
if ($moduleHandle === 'core') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific module permission or wildcard permissions
|
||||
return $this->permissionChecker->canAny([
|
||||
"{$moduleHandle}",
|
||||
"{$moduleHandle}.*",
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
68
core/lib/Controllers/ModuleController.php
Normal file
68
core/lib/Controllers/ModuleController.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
|
||||
class ModuleController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleManager $moduleManager
|
||||
) { }
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/modules/list',
|
||||
name: 'modules.index',
|
||||
methods: ['GET'],
|
||||
permissions: ['module_manager.modules.view']
|
||||
)]
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$modules = $this->moduleManager->list(false);
|
||||
|
||||
return new JsonResponse(['modules' => $modules]);
|
||||
}
|
||||
|
||||
#[AuthenticatedRoute(
|
||||
'/modules/manage',
|
||||
name: 'modules.manage',
|
||||
methods: ['POST'],
|
||||
permissions: ['module_manager.modules.manage']
|
||||
)]
|
||||
public function manage(string $handle, string $action): JsonResponse
|
||||
{
|
||||
// Verify module exists
|
||||
$moduleInstance = $this->moduleManager->moduleInstance($handle, null);
|
||||
if (!$moduleInstance) {
|
||||
return new JsonResponse(['error' => 'Module "' . $handle . '" not found.'], 404);
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
case 'install':
|
||||
$this->moduleManager->install($handle);
|
||||
return new JsonResponse(['message' => 'Module "' . $handle . '" installed successfully.']);
|
||||
|
||||
case 'uninstall':
|
||||
$this->moduleManager->uninstall($handle);
|
||||
return new JsonResponse(['message' => 'Module "' . $handle . '" uninstalled successfully.']);
|
||||
|
||||
case 'enable':
|
||||
$this->moduleManager->enable($handle);
|
||||
return new JsonResponse(['message' => 'Module "' . $handle . '" enabled successfully.']);
|
||||
|
||||
case 'disable':
|
||||
$this->moduleManager->disable($handle);
|
||||
return new JsonResponse(['message' => 'Module "' . $handle . '" disabled successfully.']);
|
||||
|
||||
case 'upgrade':
|
||||
$this->moduleManager->upgrade($handle);
|
||||
return new JsonResponse(['message' => 'Module "' . $handle . '" upgraded successfully.']);
|
||||
|
||||
default:
|
||||
return new JsonResponse(['error' => 'Invalid action.'], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
251
core/lib/Controllers/UserAccountsController.php
Normal file
251
core/lib/Controllers/UserAccountsController.php
Normal file
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* User Accounts Controller
|
||||
* Core administrative user management operations
|
||||
*/
|
||||
class UserAccountsController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Main versioned endpoint for user management
|
||||
*/
|
||||
#[AuthenticatedRoute('/user/accounts/v1', name: 'user.accounts.v1', methods: ['POST'])]
|
||||
public function index(int $version, string $transaction, string $operation, array $data = []): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Check admin permission
|
||||
if (!$this->userIdentity->hasPermission('user.admin')) {
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
||||
], JsonResponse::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$result = $this->process($operation, $data);
|
||||
|
||||
return new JsonResponse([
|
||||
'version' => $version,
|
||||
'transaction' => $transaction,
|
||||
'operation' => $operation,
|
||||
'status' => 'success',
|
||||
'data' => $result,
|
||||
], JsonResponse::HTTP_OK);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return new JsonResponse([
|
||||
'version' => $version,
|
||||
'transaction' => $transaction,
|
||||
'operation' => $operation,
|
||||
'status' => 'error',
|
||||
'data' => ['code' => 400, 'message' => $e->getMessage()]
|
||||
], JsonResponse::HTTP_BAD_REQUEST);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('User manager operation failed', [
|
||||
'operation' => $operation,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'version' => $version,
|
||||
'transaction' => $transaction,
|
||||
'operation' => $operation,
|
||||
'status' => 'error',
|
||||
'data' => ['code' => $e->getCode(), 'message' => $e->getMessage()]
|
||||
], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process operation
|
||||
*/
|
||||
private function process(string $operation, array $data): mixed
|
||||
{
|
||||
return match ($operation) {
|
||||
'user.list' => $this->userList($data),
|
||||
'user.fetch' => $this->userFetch($data),
|
||||
'user.create' => $this->userCreate($data),
|
||||
'user.update' => $this->userUpdate($data),
|
||||
'user.delete' => $this->userDelete($data),
|
||||
'user.provider.unlink' => $this->userProviderUnlink($data),
|
||||
default => throw new \InvalidArgumentException("Invalid operation: {$operation}"),
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// User Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List all users for tenant
|
||||
*/
|
||||
private function userList(array $data): array
|
||||
{
|
||||
return $this->userService->listUsers($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single user by UID
|
||||
*/
|
||||
private function userFetch(array $data): array
|
||||
{
|
||||
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
|
||||
|
||||
$user = $this->userService->fetchByIdentifier($uid);
|
||||
if (!$user) {
|
||||
throw new \InvalidArgumentException('User not found');
|
||||
}
|
||||
|
||||
// Get editable fields for profile
|
||||
$editableFields = $this->userService->getEditableFields($uid);
|
||||
$user['profile_editable'] = $editableFields;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user
|
||||
*/
|
||||
private function userCreate(array $data): array
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('user.create')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to create users');
|
||||
}
|
||||
|
||||
$userData = [
|
||||
'identity' => $data['identity'] ?? throw new \InvalidArgumentException('Identity required'),
|
||||
'label' => $data['label'] ?? $data['identity'],
|
||||
'enabled' => $data['enabled'] ?? true,
|
||||
'roles' => $data['roles'] ?? [],
|
||||
'profile' => $data['profile'] ?? [],
|
||||
'settings' => [],
|
||||
'provider' => null,
|
||||
'provider_subject' => null,
|
||||
'provider_managed_fields' => []
|
||||
];
|
||||
|
||||
$this->logger->info('Creating user', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'identity' => $userData['identity'],
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->createUser($userData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing user
|
||||
*/
|
||||
private function userUpdate(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('user.update')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to update users');
|
||||
}
|
||||
|
||||
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
|
||||
|
||||
// Build updates (exclude sensitive fields)
|
||||
$updates = [];
|
||||
$allowedFields = ['label', 'enabled', 'roles', 'profile'];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$updates[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
throw new \InvalidArgumentException('No valid fields to update');
|
||||
}
|
||||
|
||||
// Special handling for profile updates (respect managed fields)
|
||||
if (isset($updates['profile'])) {
|
||||
$user = $this->userService->fetchByIdentifier($uid);
|
||||
$managedFields = $user['provider_managed_fields'] ?? [];
|
||||
|
||||
foreach ($managedFields as $field) {
|
||||
unset($updates['profile'][$field]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->info('Updating user', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->updateUser($uid, $updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user
|
||||
*/
|
||||
private function userDelete(array $data): bool
|
||||
{
|
||||
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->userIdentity->identifier()) {
|
||||
throw new \InvalidArgumentException('Cannot delete your own account');
|
||||
}
|
||||
|
||||
$this->logger->info('Deleting user', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->deleteUser($uid);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Security Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Unlink external provider
|
||||
*/
|
||||
private function userProviderUnlink(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('user.admin')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions');
|
||||
}
|
||||
|
||||
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
|
||||
|
||||
$updates = [
|
||||
'provider' => null,
|
||||
'provider_subject' => null,
|
||||
'provider_managed_fields' => []
|
||||
];
|
||||
|
||||
$this->logger->info('Unlinking provider', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'uid' => $uid,
|
||||
'actor' => $this->userIdentity->identifier()
|
||||
]);
|
||||
|
||||
return $this->userService->updateUser($uid, $updates);
|
||||
}
|
||||
}
|
||||
76
core/lib/Controllers/UserProfileController.php
Normal file
76
core/lib/Controllers/UserProfileController.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class UserProfileController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsService $userService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve user profile
|
||||
*
|
||||
* @return JsonResponse Profile data with editability metadata
|
||||
*/
|
||||
#[AuthenticatedRoute(
|
||||
'/user/profile',
|
||||
name: 'user.profile.read',
|
||||
methods: ['GET'],
|
||||
permissions: ['user.profile.read']
|
||||
)]
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
$userId = $this->userIdentity->identifier();
|
||||
|
||||
// Get profile with editability metadata
|
||||
$profile = $this->userService->getEditableFields($userId);
|
||||
|
||||
return new JsonResponse($profile, JsonResponse::HTTP_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user profile fields
|
||||
* Only editable fields can be updated. Provider-managed fields are automatically filtered out.
|
||||
*
|
||||
* @param array $data Key-value pairs of profile fields to update
|
||||
*
|
||||
* @example request body:
|
||||
* {
|
||||
* "data": {
|
||||
* "name_given": "John",
|
||||
* "name_family": "Doe",
|
||||
* "phone": "+1234567890"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @return JsonResponse Updated profile data
|
||||
*/
|
||||
#[AuthenticatedRoute(
|
||||
'/user/profile',
|
||||
name: 'user.profile.update',
|
||||
methods: ['PUT', 'PATCH'],
|
||||
permissions: ['user.profile.update']
|
||||
)]
|
||||
public function update(array $data): JsonResponse
|
||||
{
|
||||
$userId = $this->userIdentity->identifier();
|
||||
|
||||
// storeProfile automatically filters out provider-managed fields
|
||||
$this->userService->storeProfile($userId, $data);
|
||||
|
||||
// Return updated profile with metadata
|
||||
$updatedProfile = $this->userService->getEditableFields($userId);
|
||||
|
||||
return new JsonResponse($updatedProfile, JsonResponse::HTTP_OK);
|
||||
}
|
||||
}
|
||||
201
core/lib/Controllers/UserRolesController.php
Normal file
201
core/lib/Controllers/UserRolesController.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Service\UserRolesService;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* User Roles Controller
|
||||
* Core administrative role management operations
|
||||
*/
|
||||
class UserRolesController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserRolesService $roleService,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Main versioned endpoint for role management
|
||||
*/
|
||||
#[AuthenticatedRoute('/user/roles/v1', name: 'user.roles.v1', methods: ['POST'])]
|
||||
public function index(int $version, string $transaction, string $operation, array $data = []): JsonResponse
|
||||
{
|
||||
try {
|
||||
// Check role admin permission
|
||||
if (!$this->userIdentity->hasPermission('role.admin')) {
|
||||
return new JsonResponse([
|
||||
'status' => 'error',
|
||||
'data' => ['code' => 403, 'message' => 'Insufficient permissions']
|
||||
], JsonResponse::HTTP_FORBIDDEN);
|
||||
}
|
||||
|
||||
$result = $this->process($operation, $data);
|
||||
|
||||
return new JsonResponse([
|
||||
'version' => $version,
|
||||
'transaction' => $transaction,
|
||||
'operation' => $operation,
|
||||
'status' => 'success',
|
||||
'data' => $result,
|
||||
], JsonResponse::HTTP_OK);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->logger->error('Role manager validation error', [
|
||||
'operation' => $operation,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'version' => $version,
|
||||
'transaction' => $transaction,
|
||||
'operation' => $operation,
|
||||
'status' => 'error',
|
||||
'data' => ['code' => 400, 'message' => $e->getMessage()]
|
||||
], JsonResponse::HTTP_BAD_REQUEST);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Role manager operation failed', [
|
||||
'operation' => $operation,
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString()
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'version' => $version,
|
||||
'transaction' => $transaction,
|
||||
'operation' => $operation,
|
||||
'status' => 'error',
|
||||
'data' => ['code' => $e->getCode(), 'message' => $e->getMessage()]
|
||||
], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process operation
|
||||
*/
|
||||
private function process(string $operation, array $data): mixed
|
||||
{
|
||||
return match ($operation) {
|
||||
'role.list' => $this->roleList($data),
|
||||
'role.fetch' => $this->roleFetch($data),
|
||||
'role.create' => $this->roleCreate($data),
|
||||
'role.update' => $this->roleUpdate($data),
|
||||
'role.delete' => $this->roleDelete($data),
|
||||
'permissions.list' => $this->permissionsList($data),
|
||||
default => throw new \InvalidArgumentException("Invalid operation: {$operation}"),
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Role Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List all roles
|
||||
*/
|
||||
private function roleList(array $data): array
|
||||
{
|
||||
$roles = $this->roleService->listRoles();
|
||||
|
||||
// Add user count to each role
|
||||
foreach ($roles as &$role) {
|
||||
$role['user_count'] = $this->roleService->getRoleUserCount($role['rid']);
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single role
|
||||
*/
|
||||
private function roleFetch(array $data): array
|
||||
{
|
||||
$rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required');
|
||||
|
||||
$role = $this->roleService->getRole($rid);
|
||||
if (!$role) {
|
||||
throw new \InvalidArgumentException('Role not found');
|
||||
}
|
||||
|
||||
$role['user_count'] = $this->roleService->getRoleUserCount($rid);
|
||||
|
||||
return $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new role
|
||||
*/
|
||||
private function roleCreate(array $data): array
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to create roles');
|
||||
}
|
||||
|
||||
$roleData = [
|
||||
'label' => $data['label'] ?? throw new \InvalidArgumentException('Role label required'),
|
||||
'description' => $data['description'] ?? '',
|
||||
'permissions' => $data['permissions'] ?? []
|
||||
];
|
||||
|
||||
return $this->roleService->createRole($roleData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing role
|
||||
*/
|
||||
private function roleUpdate(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to update roles');
|
||||
}
|
||||
|
||||
$rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required');
|
||||
|
||||
$updates = [];
|
||||
$allowedFields = ['label', 'description', 'permissions'];
|
||||
|
||||
foreach ($allowedFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$updates[$field] = $data[$field];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($updates)) {
|
||||
throw new \InvalidArgumentException('No valid fields to update');
|
||||
}
|
||||
|
||||
return $this->roleService->updateRole($rid, $updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete role
|
||||
*/
|
||||
private function roleDelete(array $data): bool
|
||||
{
|
||||
if (!$this->userIdentity->hasPermission('role.manage')) {
|
||||
throw new \InvalidArgumentException('Insufficient permissions to delete roles');
|
||||
}
|
||||
|
||||
$rid = $data['rid'] ?? throw new \InvalidArgumentException('Role ID required');
|
||||
|
||||
return $this->roleService->deleteRole($rid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available permissions
|
||||
*/
|
||||
private function permissionsList(array $data): array
|
||||
{
|
||||
return $this->roleService->availablePermissions();
|
||||
}
|
||||
}
|
||||
71
core/lib/Controllers/UserSettingsController.php
Normal file
71
core/lib/Controllers/UserSettingsController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Controllers;
|
||||
|
||||
use KTXC\Http\Response\JsonResponse;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Controller\ControllerAbstract;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
|
||||
class UserSettingsController extends ControllerAbstract
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsService $userService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve user settings
|
||||
* If no specific settings are requested, all settings are returned
|
||||
*
|
||||
* @return JsonResponse Settings data as key-value pairs
|
||||
*/
|
||||
#[AuthenticatedRoute(
|
||||
'/user/settings',
|
||||
name: 'user.settings.read',
|
||||
methods: ['GET'],
|
||||
permissions: ['user.settings.read']
|
||||
)]
|
||||
public function read(): JsonResponse
|
||||
{
|
||||
// Fetch all settings (no filter)
|
||||
$settings = $this->userService->fetchSettings();
|
||||
|
||||
return new JsonResponse($settings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user settings
|
||||
*
|
||||
* @param array $data Key-value pairs of settings to update
|
||||
*
|
||||
* @example request body:
|
||||
* {
|
||||
* "data": {
|
||||
* "theme": "dark",
|
||||
* "language": "en",
|
||||
* "notifications": true
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @return JsonResponse Updated settings data
|
||||
*/
|
||||
#[AuthenticatedRoute(
|
||||
'/user/settings',
|
||||
name: 'user.settings.update',
|
||||
methods: ['PUT', 'PATCH'],
|
||||
permissions: ['user.settings.update']
|
||||
)]
|
||||
public function update(array $data): JsonResponse
|
||||
{
|
||||
$this->userService->storeSettings($data);
|
||||
|
||||
// Return updated settings
|
||||
$updatedSettings = $this->userService->fetchSettings(array_keys($data));
|
||||
|
||||
return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
|
||||
}
|
||||
}
|
||||
76
core/lib/Db/Client.php
Normal file
76
core/lib/Db/Client.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Db;
|
||||
|
||||
use MongoDB\Client as MongoClient;
|
||||
|
||||
/**
|
||||
* Wrapper for MongoDB\Client
|
||||
* Provides abstraction layer for MongoDB client operations
|
||||
*/
|
||||
class Client
|
||||
{
|
||||
private MongoClient $client;
|
||||
|
||||
/**
|
||||
* Create a new MongoDB client
|
||||
*
|
||||
* @param string $uri Connection URI
|
||||
* @param array $uriOptions URI options
|
||||
* @param array $driverOptions Driver options
|
||||
*/
|
||||
public function __construct(string $uri = 'mongodb://localhost:27017', array $uriOptions = [], array $driverOptions = [])
|
||||
{
|
||||
$this->client = new MongoClient($uri, $uriOptions, $driverOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a database
|
||||
*
|
||||
* @param string $databaseName Database name
|
||||
* @param array $options Database options
|
||||
* @return Database
|
||||
*/
|
||||
public function selectDatabase(string $databaseName, array $options = []): Database
|
||||
{
|
||||
$mongoDatabase = $this->client->selectDatabase($databaseName, $options);
|
||||
return new Database($mongoDatabase);
|
||||
}
|
||||
|
||||
/**
|
||||
* List databases
|
||||
*/
|
||||
public function listDatabases(array $options = []): array
|
||||
{
|
||||
$databases = [];
|
||||
foreach ($this->client->listDatabases($options) as $databaseInfo) {
|
||||
$databases[] = $databaseInfo;
|
||||
}
|
||||
return $databases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a database
|
||||
*/
|
||||
public function dropDatabase(string $databaseName, array $options = []): array|object|null
|
||||
{
|
||||
return $this->client->dropDatabase($databaseName, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying MongoDB Client
|
||||
* Use sparingly - prefer using wrapper methods
|
||||
*/
|
||||
public function getMongoClient(): MongoClient
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method to access database as property
|
||||
*/
|
||||
public function __get(string $databaseName): Database
|
||||
{
|
||||
return $this->selectDatabase($databaseName);
|
||||
}
|
||||
}
|
||||
295
core/lib/Db/Collection.php
Normal file
295
core/lib/Db/Collection.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Db;
|
||||
|
||||
use MongoDB\Collection as MongoCollection;
|
||||
use MongoDB\InsertOneResult;
|
||||
use MongoDB\UpdateResult;
|
||||
use MongoDB\DeleteResult;
|
||||
|
||||
/**
|
||||
* Wrapper for MongoDB\Collection
|
||||
* Provides abstraction layer for MongoDB collection operations
|
||||
*/
|
||||
class Collection
|
||||
{
|
||||
private MongoCollection $collection;
|
||||
|
||||
public function __construct(MongoCollection $collection)
|
||||
{
|
||||
$this->collection = $collection;
|
||||
|
||||
// Set type map to return plain arrays instead of objects
|
||||
// This converts BSON types to PHP native types
|
||||
$this->collection = $collection->withOptions([
|
||||
'typeMap' => [
|
||||
'root' => 'array',
|
||||
'document' => 'array',
|
||||
'array' => 'array'
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find documents in the collection
|
||||
*
|
||||
* @param array $filter Query filter
|
||||
* @param array $options Query options
|
||||
* @return Cursor
|
||||
*/
|
||||
public function find(array $filter = [], array $options = []): Cursor
|
||||
{
|
||||
$filter = $this->convertFilter($filter);
|
||||
/** @var \Iterator $cursor */
|
||||
$cursor = $this->collection->find($filter, $options);
|
||||
return new Cursor($cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single document
|
||||
*
|
||||
* @param array $filter Query filter
|
||||
* @param array $options Query options
|
||||
* @return array|null Returns array with _id as string
|
||||
*/
|
||||
public function findOne(array $filter = [], array $options = []): ?array
|
||||
{
|
||||
$filter = $this->convertFilter($filter);
|
||||
$result = $this->collection->findOne($filter, $options);
|
||||
|
||||
if ($result === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to array if it's an object
|
||||
if (is_object($result)) {
|
||||
$result = (array) $result;
|
||||
}
|
||||
|
||||
return $this->convertBsonToNative($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a single document
|
||||
*
|
||||
* @param array|object $document Document to insert
|
||||
* @param array $options Insert options
|
||||
* @return InsertOneResult
|
||||
*/
|
||||
public function insertOne(array|object $document, array $options = []): InsertOneResult
|
||||
{
|
||||
$document = $this->convertDocument($document);
|
||||
return $this->collection->insertOne($document, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert multiple documents
|
||||
*
|
||||
* @param array $documents Documents to insert
|
||||
* @param array $options Insert options
|
||||
*/
|
||||
public function insertMany(array $documents, array $options = []): mixed
|
||||
{
|
||||
$documents = array_map(fn($doc) => $this->convertDocument($doc), $documents);
|
||||
return $this->collection->insertMany($documents, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single document
|
||||
*
|
||||
* @param array $filter Query filter
|
||||
* @param array $update Update operations
|
||||
* @param array $options Update options
|
||||
* @return UpdateResult
|
||||
*/
|
||||
public function updateOne(array $filter, array $update, array $options = []): UpdateResult
|
||||
{
|
||||
$filter = $this->convertFilter($filter);
|
||||
$update = $this->convertDocument($update);
|
||||
return $this->collection->updateOne($filter, $update, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update multiple documents
|
||||
*
|
||||
* @param array $filter Query filter
|
||||
* @param array $update Update operations
|
||||
* @param array $options Update options
|
||||
* @return UpdateResult
|
||||
*/
|
||||
public function updateMany(array $filter, array $update, array $options = []): UpdateResult
|
||||
{
|
||||
$filter = $this->convertFilter($filter);
|
||||
$update = $this->convertDocument($update);
|
||||
return $this->collection->updateMany($filter, $update, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single document
|
||||
*
|
||||
* @param array $filter Query filter
|
||||
* @param array $options Delete options
|
||||
* @return DeleteResult
|
||||
*/
|
||||
public function deleteOne(array $filter, array $options = []): DeleteResult
|
||||
{
|
||||
$filter = $this->convertFilter($filter);
|
||||
return $this->collection->deleteOne($filter, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple documents
|
||||
*
|
||||
* @param array $filter Query filter
|
||||
* @param array $options Delete options
|
||||
* @return DeleteResult
|
||||
*/
|
||||
public function deleteMany(array $filter, array $options = []): DeleteResult
|
||||
{
|
||||
$filter = $this->convertFilter($filter);
|
||||
return $this->collection->deleteMany($filter, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count documents matching filter
|
||||
*
|
||||
* @param array $filter Query filter
|
||||
* @param array $options Count options
|
||||
* @return int
|
||||
*/
|
||||
public function countDocuments(array $filter = [], array $options = []): int
|
||||
{
|
||||
$filter = $this->convertFilter($filter);
|
||||
return $this->collection->countDocuments($filter, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute aggregation pipeline
|
||||
*
|
||||
* @param array $pipeline Aggregation pipeline
|
||||
* @param array $options Aggregation options
|
||||
* @return Cursor
|
||||
*/
|
||||
public function aggregate(array $pipeline, array $options = []): Cursor
|
||||
{
|
||||
/** @var \Iterator $cursor */
|
||||
$cursor = $this->collection->aggregate($pipeline, $options);
|
||||
return new Cursor($cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an index
|
||||
*
|
||||
* @param array $key Index specification
|
||||
* @param array $options Index options
|
||||
* @return string Index name
|
||||
*/
|
||||
public function createIndex(array $key, array $options = []): string
|
||||
{
|
||||
return $this->collection->createIndex($key, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the collection
|
||||
*/
|
||||
public function drop(): array|object|null
|
||||
{
|
||||
return $this->collection->drop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection name
|
||||
*/
|
||||
public function getCollectionName(): string
|
||||
{
|
||||
return $this->collection->getCollectionName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database name
|
||||
*/
|
||||
public function getDatabaseName(): string
|
||||
{
|
||||
return $this->collection->getDatabaseName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ObjectId instances in filter to MongoDB ObjectId
|
||||
*/
|
||||
private function convertFilter(array $filter): array
|
||||
{
|
||||
return $this->convertArray($filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ObjectId instances in document to MongoDB ObjectId
|
||||
*/
|
||||
private function convertDocument(array|object $document): array|object
|
||||
{
|
||||
if (is_array($document)) {
|
||||
return $this->convertArray($document);
|
||||
}
|
||||
return $document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively convert ObjectId and UTCDateTime instances
|
||||
*/
|
||||
private function convertArray(array $data): array
|
||||
{
|
||||
foreach ($data as $key => $value) {
|
||||
if ($value instanceof ObjectId) {
|
||||
$data[$key] = $value->toBSON();
|
||||
} elseif ($value instanceof UTCDateTime) {
|
||||
$data[$key] = $value->toBSON();
|
||||
} elseif (is_array($value)) {
|
||||
$data[$key] = $this->convertArray($value);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying MongoDB Collection
|
||||
* Use sparingly - prefer using wrapper methods
|
||||
*/
|
||||
public function getMongoCollection(): MongoCollection
|
||||
{
|
||||
return $this->collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert BSON objects to native PHP types
|
||||
* Handles ObjectId, UTCDateTime, and other BSON types
|
||||
*/
|
||||
private function convertBsonToNative(mixed $data): mixed
|
||||
{
|
||||
if (is_array($data)) {
|
||||
foreach ($data as $key => $value) {
|
||||
$data[$key] = $this->convertBsonToNative($value);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
if (is_object($data)) {
|
||||
// Convert MongoDB BSON ObjectId to string
|
||||
if ($data instanceof \MongoDB\BSON\ObjectId) {
|
||||
return (string) $data;
|
||||
}
|
||||
|
||||
// Convert MongoDB BSON UTCDateTime to string or DateTime
|
||||
if ($data instanceof \MongoDB\BSON\UTCDateTime) {
|
||||
return (string) $data->toDateTime()->format('c');
|
||||
}
|
||||
|
||||
// Convert other objects to arrays recursively
|
||||
if (method_exists($data, 'bsonSerialize')) {
|
||||
return $this->convertBsonToNative($data->bsonSerialize());
|
||||
}
|
||||
|
||||
return (array) $data;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
86
core/lib/Db/Cursor.php
Normal file
86
core/lib/Db/Cursor.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Db;
|
||||
|
||||
use Iterator;
|
||||
use IteratorAggregate;
|
||||
use Traversable;
|
||||
|
||||
/**
|
||||
* Wrapper for MongoDB Cursor
|
||||
* Provides abstraction layer for MongoDB cursor operations
|
||||
* Automatically converts BSON types to native PHP types
|
||||
*/
|
||||
class Cursor implements IteratorAggregate
|
||||
{
|
||||
private Iterator $cursor;
|
||||
|
||||
public function __construct(Iterator $cursor)
|
||||
{
|
||||
$this->cursor = $cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert cursor to array with BSON types converted to native PHP types
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = iterator_to_array($this->cursor);
|
||||
return $this->convertBsonToNative($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get iterator for foreach loops
|
||||
* Note: Items will be returned as-is (may contain BSON objects)
|
||||
* Use toArray() if you need full conversion
|
||||
*/
|
||||
public function getIterator(): Traversable
|
||||
{
|
||||
return $this->cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get underlying MongoDB cursor
|
||||
*/
|
||||
public function getMongoCursor(): Iterator
|
||||
{
|
||||
return $this->cursor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert BSON objects to native PHP types
|
||||
* Handles ObjectId, UTCDateTime, and other BSON types
|
||||
*/
|
||||
private function convertBsonToNative(mixed $data): mixed
|
||||
{
|
||||
if (is_array($data)) {
|
||||
foreach ($data as $key => $value) {
|
||||
$data[$key] = $this->convertBsonToNative($value);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
if (is_object($data)) {
|
||||
// Convert MongoDB BSON ObjectId to string
|
||||
if ($data instanceof \MongoDB\BSON\ObjectId) {
|
||||
return (string) $data;
|
||||
}
|
||||
|
||||
// Convert MongoDB BSON UTCDateTime to ISO8601 string
|
||||
if ($data instanceof \MongoDB\BSON\UTCDateTime) {
|
||||
return $data->toDateTime()->format('c');
|
||||
}
|
||||
|
||||
// Convert other objects to arrays recursively
|
||||
if (method_exists($data, 'bsonSerialize')) {
|
||||
return $this->convertBsonToNative($data->bsonSerialize());
|
||||
}
|
||||
|
||||
// Convert stdClass and other objects to array
|
||||
$array = (array) $data;
|
||||
return $this->convertBsonToNative($array);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
97
core/lib/Db/DataStore.php
Normal file
97
core/lib/Db/DataStore.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Db;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
|
||||
/**
|
||||
* DataStore provides access to MongoDB database operations
|
||||
* Uses composition pattern with Database wrapper
|
||||
*/
|
||||
class DataStore
|
||||
{
|
||||
protected array $configuration;
|
||||
protected Client $client;
|
||||
protected Database $database;
|
||||
|
||||
public function __construct(#[Inject('database')] array $configuration = [])
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
|
||||
$uri = $configuration['uri'];
|
||||
$databaseName = $configuration['database'];
|
||||
$options = $configuration['options'] ?? [];
|
||||
$driverOptions = $configuration['driverOptions'] ?? [];
|
||||
|
||||
$this->client = new Client($uri, $options, $driverOptions);
|
||||
$this->database = $this->client->selectDatabase($databaseName, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a collection from the database
|
||||
*
|
||||
* @param string $collectionName Collection name
|
||||
* @param array $options Collection options
|
||||
* @return Collection
|
||||
*/
|
||||
public function selectCollection(string $collectionName, array $options = []): Collection
|
||||
{
|
||||
return $this->database->selectCollection($collectionName, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying Database instance
|
||||
*/
|
||||
public function getDatabase(): Database
|
||||
{
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Client instance
|
||||
*/
|
||||
public function getClient(): Client
|
||||
{
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all collections
|
||||
*/
|
||||
public function listCollections(array $options = []): array
|
||||
{
|
||||
return $this->database->listCollections($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collection
|
||||
*/
|
||||
public function createCollection(string $collectionName, array $options = []): Collection
|
||||
{
|
||||
return $this->database->createCollection($collectionName, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a collection
|
||||
*/
|
||||
public function dropCollection(string $collectionName, array $options = []): array|object
|
||||
{
|
||||
return $this->database->dropCollection($collectionName, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database name
|
||||
*/
|
||||
public function getDatabaseName(): string
|
||||
{
|
||||
return $this->database->getDatabaseName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method to access collection as property
|
||||
*/
|
||||
public function __get(string $collectionName): Collection
|
||||
{
|
||||
return $this->selectCollection($collectionName);
|
||||
}
|
||||
}
|
||||
104
core/lib/Db/Database.php
Normal file
104
core/lib/Db/Database.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Db;
|
||||
|
||||
use MongoDB\Database as MongoDatabase;
|
||||
|
||||
/**
|
||||
* Wrapper for MongoDB\Database
|
||||
* Provides abstraction layer for MongoDB database operations
|
||||
*/
|
||||
class Database
|
||||
{
|
||||
private MongoDatabase $database;
|
||||
|
||||
public function __construct(MongoDatabase $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a collection
|
||||
*
|
||||
* @param string $collectionName Collection name
|
||||
* @param array $options Collection options
|
||||
* @return Collection
|
||||
*/
|
||||
public function selectCollection(string $collectionName, array $options = []): Collection
|
||||
{
|
||||
$mongoCollection = $this->database->selectCollection($collectionName, $options);
|
||||
return new Collection($mongoCollection);
|
||||
}
|
||||
|
||||
/**
|
||||
* List collections
|
||||
*/
|
||||
public function listCollections(array $options = []): array
|
||||
{
|
||||
$collections = [];
|
||||
foreach ($this->database->listCollections($options) as $collectionInfo) {
|
||||
$collections[] = $collectionInfo;
|
||||
}
|
||||
return $collections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the database
|
||||
*/
|
||||
public function drop(array $options = []): array|object|null
|
||||
{
|
||||
return $this->database->drop($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database name
|
||||
*/
|
||||
public function getDatabaseName(): string
|
||||
{
|
||||
return $this->database->getDatabaseName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a collection
|
||||
*/
|
||||
public function createCollection(string $collectionName, array $options = []): Collection|null
|
||||
{
|
||||
$mongoCollection = $this->database->createCollection($collectionName, $options);
|
||||
return $mongoCollection ? new Collection($mongoCollection) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a collection
|
||||
*/
|
||||
public function dropCollection(string $collectionName, array $options = []): array|object|null
|
||||
{
|
||||
return $this->database->dropCollection($collectionName, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a database command
|
||||
*/
|
||||
public function command(array|object $command, array $options = []): Cursor
|
||||
{
|
||||
/** @var \Iterator $cursor */
|
||||
$cursor = $this->database->command($command, $options);
|
||||
return new Cursor($cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying MongoDB Database
|
||||
* Use sparingly - prefer using wrapper methods
|
||||
*/
|
||||
public function getMongoDatabase(): MongoDatabase
|
||||
{
|
||||
return $this->database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic method to access collection as property
|
||||
*/
|
||||
public function __get(string $collectionName): Collection
|
||||
{
|
||||
return $this->selectCollection($collectionName);
|
||||
}
|
||||
}
|
||||
71
core/lib/Db/ObjectId.php
Normal file
71
core/lib/Db/ObjectId.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Db;
|
||||
|
||||
use MongoDB\BSON\ObjectId as MongoObjectId;
|
||||
|
||||
/**
|
||||
* Wrapper for MongoDB\BSON\ObjectId
|
||||
* Provides abstraction layer for MongoDB ObjectId handling
|
||||
*/
|
||||
class ObjectId
|
||||
{
|
||||
private MongoObjectId $objectId;
|
||||
|
||||
/**
|
||||
* Create a new ObjectId
|
||||
*
|
||||
* @param string|MongoObjectId|null $id Optional ID string or MongoDB ObjectId
|
||||
*/
|
||||
public function __construct(string|MongoObjectId|null $id = null)
|
||||
{
|
||||
if ($id instanceof MongoObjectId) {
|
||||
$this->objectId = $id;
|
||||
} elseif (is_string($id)) {
|
||||
$this->objectId = new MongoObjectId($id);
|
||||
} else {
|
||||
$this->objectId = new MongoObjectId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string representation of the ObjectId
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return (string) $this->objectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying MongoDB ObjectId
|
||||
* Used internally when interacting with MongoDB driver
|
||||
*/
|
||||
public function toBSON(): MongoObjectId
|
||||
{
|
||||
return $this->objectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp from the ObjectId
|
||||
*/
|
||||
public function getTimestamp(): int
|
||||
{
|
||||
return $this->objectId->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ObjectId from string
|
||||
*/
|
||||
public static function fromString(string $id): self
|
||||
{
|
||||
return new self($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid ObjectId
|
||||
*/
|
||||
public static function isValid(string $id): bool
|
||||
{
|
||||
return MongoObjectId::isValid($id);
|
||||
}
|
||||
}
|
||||
89
core/lib/Db/UTCDateTime.php
Normal file
89
core/lib/Db/UTCDateTime.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Db;
|
||||
|
||||
use MongoDB\BSON\UTCDateTime as MongoUTCDateTime;
|
||||
use DateTimeInterface;
|
||||
|
||||
/**
|
||||
* Wrapper for MongoDB\BSON\UTCDateTime
|
||||
* Provides abstraction layer for MongoDB datetime handling
|
||||
*/
|
||||
class UTCDateTime
|
||||
{
|
||||
private MongoUTCDateTime|string $dateTime;
|
||||
|
||||
/**
|
||||
* Create a new UTCDateTime
|
||||
*
|
||||
* @param int|DateTimeInterface|null $milliseconds Milliseconds since epoch, or DateTime object
|
||||
*/
|
||||
public function __construct(int|DateTimeInterface|null $milliseconds = null)
|
||||
{
|
||||
// Check if MongoDB extension is loaded
|
||||
if (class_exists(MongoUTCDateTime::class)) {
|
||||
$this->dateTime = new MongoUTCDateTime($milliseconds);
|
||||
} else {
|
||||
// Fallback for environments without MongoDB extension (testing, linting)
|
||||
$this->dateTime = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(DATE_ATOM);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string representation
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
if ($this->dateTime instanceof MongoUTCDateTime) {
|
||||
return $this->dateTime->toDateTime()->format(DATE_ATOM);
|
||||
}
|
||||
return $this->dateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying MongoDB UTCDateTime or fallback string
|
||||
* Used internally when interacting with MongoDB driver
|
||||
*/
|
||||
public function toBSON(): MongoUTCDateTime|string
|
||||
{
|
||||
return $this->dateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to PHP DateTime
|
||||
*/
|
||||
public function toDateTime(): \DateTimeImmutable
|
||||
{
|
||||
if ($this->dateTime instanceof MongoUTCDateTime) {
|
||||
return \DateTimeImmutable::createFromMutable($this->dateTime->toDateTime());
|
||||
}
|
||||
return new \DateTimeImmutable($this->dateTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get milliseconds since epoch
|
||||
*/
|
||||
public function toMilliseconds(): int
|
||||
{
|
||||
if ($this->dateTime instanceof MongoUTCDateTime) {
|
||||
return (int) $this->dateTime;
|
||||
}
|
||||
return (int) ((new \DateTimeImmutable($this->dateTime))->getTimestamp() * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from DateTime
|
||||
*/
|
||||
public static function fromDateTime(DateTimeInterface $dateTime): self
|
||||
{
|
||||
return new self($dateTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create current timestamp
|
||||
*/
|
||||
public static function now(): self
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
}
|
||||
407
core/lib/Http/Cookie.php
Normal file
407
core/lib/Http/Cookie.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http;
|
||||
|
||||
/**
|
||||
* Represents a cookie.
|
||||
*
|
||||
* @author Johannes M. Schmitt <schmittjoh@gmail.com>
|
||||
*/
|
||||
class Cookie
|
||||
{
|
||||
public const SAMESITE_NONE = 'none';
|
||||
public const SAMESITE_LAX = 'lax';
|
||||
public const SAMESITE_STRICT = 'strict';
|
||||
|
||||
protected int $expire;
|
||||
protected string $path;
|
||||
|
||||
private ?string $sameSite = null;
|
||||
private bool $secureDefault = false;
|
||||
|
||||
private const RESERVED_CHARS_LIST = "=,; \t\r\n\v\f";
|
||||
private const RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"];
|
||||
private const RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C'];
|
||||
|
||||
/**
|
||||
* Creates cookie from raw header string.
|
||||
*/
|
||||
public static function fromString(string $cookie, bool $decode = false): static
|
||||
{
|
||||
$data = [
|
||||
'expires' => 0,
|
||||
'path' => '/',
|
||||
'domain' => null,
|
||||
'secure' => false,
|
||||
'httponly' => false,
|
||||
'raw' => !$decode,
|
||||
'samesite' => null,
|
||||
'partitioned' => false,
|
||||
];
|
||||
|
||||
$parts = HeaderUtils::split($cookie, ';=');
|
||||
$part = array_shift($parts);
|
||||
|
||||
$name = $decode ? urldecode($part[0]) : $part[0];
|
||||
$value = isset($part[1]) ? ($decode ? urldecode($part[1]) : $part[1]) : null;
|
||||
|
||||
$data = HeaderUtils::combine($parts) + $data;
|
||||
$data['expires'] = self::expiresTimestamp($data['expires']);
|
||||
|
||||
if (isset($data['max-age']) && ($data['max-age'] > 0 || $data['expires'] > time())) {
|
||||
$data['expires'] = time() + (int) $data['max-age'];
|
||||
}
|
||||
|
||||
return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite'], $data['partitioned']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see self::__construct
|
||||
*
|
||||
* @param self::SAMESITE_*|''|null $sameSite
|
||||
*/
|
||||
public static function create(string $name, ?string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX, bool $partitioned = false): self
|
||||
{
|
||||
return new self($name, $value, $expire, $path, $domain, $secure, $httpOnly, $raw, $sameSite, $partitioned);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name The name of the cookie
|
||||
* @param string|null $value The value of the cookie
|
||||
* @param int|string|\DateTimeInterface $expire The time the cookie expires
|
||||
* @param string|null $path The path on the server in which the cookie will be available on
|
||||
* @param string|null $domain The domain that the cookie is available to
|
||||
* @param bool|null $secure Whether the client should send back the cookie only over HTTPS or null to auto-enable this when the request is already using HTTPS
|
||||
* @param bool $httpOnly Whether the cookie will be made accessible only through the HTTP protocol
|
||||
* @param bool $raw Whether the cookie value should be sent with no url encoding
|
||||
* @param self::SAMESITE_*|''|null $sameSite Whether the cookie will be available for cross-site requests
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
protected string $name,
|
||||
protected ?string $value = null,
|
||||
int|string|\DateTimeInterface $expire = 0,
|
||||
?string $path = '/',
|
||||
protected ?string $domain = null,
|
||||
protected ?bool $secure = null,
|
||||
protected bool $httpOnly = true,
|
||||
private bool $raw = false,
|
||||
?string $sameSite = self::SAMESITE_LAX,
|
||||
private bool $partitioned = false,
|
||||
) {
|
||||
// from PHP source code
|
||||
if ($raw && false !== strpbrk($name, self::RESERVED_CHARS_LIST)) {
|
||||
throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $name));
|
||||
}
|
||||
|
||||
if (!$name) {
|
||||
throw new \InvalidArgumentException('The cookie name cannot be empty.');
|
||||
}
|
||||
|
||||
$this->expire = self::expiresTimestamp($expire);
|
||||
$this->path = $path ?: '/';
|
||||
$this->sameSite = $this->withSameSite($sameSite)->sameSite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy with a new value.
|
||||
*/
|
||||
public function withValue(?string $value): static
|
||||
{
|
||||
$cookie = clone $this;
|
||||
$cookie->value = $value;
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy with a new domain that the cookie is available to.
|
||||
*/
|
||||
public function withDomain(?string $domain): static
|
||||
{
|
||||
$cookie = clone $this;
|
||||
$cookie->domain = $domain;
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy with a new time the cookie expires.
|
||||
*/
|
||||
public function withExpires(int|string|\DateTimeInterface $expire = 0): static
|
||||
{
|
||||
$cookie = clone $this;
|
||||
$cookie->expire = self::expiresTimestamp($expire);
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts expires formats to a unix timestamp.
|
||||
*/
|
||||
private static function expiresTimestamp(int|string|\DateTimeInterface $expire = 0): int
|
||||
{
|
||||
// convert expiration time to a Unix timestamp
|
||||
if ($expire instanceof \DateTimeInterface) {
|
||||
$expire = $expire->format('U');
|
||||
} elseif (!is_numeric($expire)) {
|
||||
$expire = strtotime($expire);
|
||||
|
||||
if (false === $expire) {
|
||||
throw new \InvalidArgumentException('The cookie expiration time is not valid.');
|
||||
}
|
||||
}
|
||||
|
||||
return 0 < $expire ? (int) $expire : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy with a new path on the server in which the cookie will be available on.
|
||||
*/
|
||||
public function withPath(string $path): static
|
||||
{
|
||||
$cookie = clone $this;
|
||||
$cookie->path = '' === $path ? '/' : $path;
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client.
|
||||
*/
|
||||
public function withSecure(bool $secure = true): static
|
||||
{
|
||||
$cookie = clone $this;
|
||||
$cookie->secure = $secure;
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy that be accessible only through the HTTP protocol.
|
||||
*/
|
||||
public function withHttpOnly(bool $httpOnly = true): static
|
||||
{
|
||||
$cookie = clone $this;
|
||||
$cookie->httpOnly = $httpOnly;
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy that uses no url encoding.
|
||||
*/
|
||||
public function withRaw(bool $raw = true): static
|
||||
{
|
||||
if ($raw && false !== strpbrk($this->name, self::RESERVED_CHARS_LIST)) {
|
||||
throw new \InvalidArgumentException(\sprintf('The cookie name "%s" contains invalid characters.', $this->name));
|
||||
}
|
||||
|
||||
$cookie = clone $this;
|
||||
$cookie->raw = $raw;
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy with SameSite attribute.
|
||||
*
|
||||
* @param self::SAMESITE_*|''|null $sameSite
|
||||
*/
|
||||
public function withSameSite(?string $sameSite): static
|
||||
{
|
||||
if ('' === $sameSite) {
|
||||
$sameSite = null;
|
||||
} elseif (null !== $sameSite) {
|
||||
$sameSite = strtolower($sameSite);
|
||||
}
|
||||
|
||||
if (!\in_array($sameSite, [self::SAMESITE_LAX, self::SAMESITE_STRICT, self::SAMESITE_NONE, null], true)) {
|
||||
throw new \InvalidArgumentException('The "sameSite" parameter value is not valid.');
|
||||
}
|
||||
|
||||
$cookie = clone $this;
|
||||
$cookie->sameSite = $sameSite;
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a cookie copy that is tied to the top-level site in cross-site context.
|
||||
*/
|
||||
public function withPartitioned(bool $partitioned = true): static
|
||||
{
|
||||
$cookie = clone $this;
|
||||
$cookie->partitioned = $partitioned;
|
||||
|
||||
return $cookie;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cookie as a string.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
if ($this->isRaw()) {
|
||||
$str = $this->getName();
|
||||
} else {
|
||||
$str = str_replace(self::RESERVED_CHARS_FROM, self::RESERVED_CHARS_TO, $this->getName());
|
||||
}
|
||||
|
||||
$str .= '=';
|
||||
|
||||
if ('' === (string) $this->getValue()) {
|
||||
$str .= 'deleted; expires='.gmdate('D, d M Y H:i:s T', time() - 31536001).'; Max-Age=0';
|
||||
} else {
|
||||
$str .= $this->isRaw() ? $this->getValue() : rawurlencode($this->getValue());
|
||||
|
||||
if (0 !== $this->getExpiresTime()) {
|
||||
$str .= '; expires='.gmdate('D, d M Y H:i:s T', $this->getExpiresTime()).'; Max-Age='.$this->getMaxAge();
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->getPath()) {
|
||||
$str .= '; path='.$this->getPath();
|
||||
}
|
||||
|
||||
if ($this->getDomain()) {
|
||||
$str .= '; domain='.$this->getDomain();
|
||||
}
|
||||
|
||||
if ($this->isSecure()) {
|
||||
$str .= '; secure';
|
||||
}
|
||||
|
||||
if ($this->isHttpOnly()) {
|
||||
$str .= '; httponly';
|
||||
}
|
||||
|
||||
if (null !== $this->getSameSite()) {
|
||||
$str .= '; samesite='.$this->getSameSite();
|
||||
}
|
||||
|
||||
if ($this->isPartitioned()) {
|
||||
$str .= '; partitioned';
|
||||
}
|
||||
|
||||
return $str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the name of the cookie.
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the value of the cookie.
|
||||
*/
|
||||
public function getValue(): ?string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the domain that the cookie is available to.
|
||||
*/
|
||||
public function getDomain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the time the cookie expires.
|
||||
*/
|
||||
public function getExpiresTime(): int
|
||||
{
|
||||
return $this->expire;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the max-age attribute.
|
||||
*/
|
||||
public function getMaxAge(): int
|
||||
{
|
||||
$maxAge = $this->expire - time();
|
||||
|
||||
return max(0, $maxAge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path on the server in which the cookie will be available on.
|
||||
*/
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client.
|
||||
*/
|
||||
public function isSecure(): bool
|
||||
{
|
||||
return $this->secure ?? $this->secureDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the cookie will be made accessible only through the HTTP protocol.
|
||||
*/
|
||||
public function isHttpOnly(): bool
|
||||
{
|
||||
return $this->httpOnly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this cookie is about to be cleared.
|
||||
*/
|
||||
public function isCleared(): bool
|
||||
{
|
||||
return 0 !== $this->expire && $this->expire < time();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the cookie value should be sent with no url encoding.
|
||||
*/
|
||||
public function isRaw(): bool
|
||||
{
|
||||
return $this->raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the cookie should be tied to the top-level site in cross-site context.
|
||||
*/
|
||||
public function isPartitioned(): bool
|
||||
{
|
||||
return $this->partitioned;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self::SAMESITE_*|null
|
||||
*/
|
||||
public function getSameSite(): ?string
|
||||
{
|
||||
return $this->sameSite;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $default The default value of the "secure" flag when it is set to null
|
||||
*/
|
||||
public function setSecureDefault(bool $default): void
|
||||
{
|
||||
$this->secureDefault = $default;
|
||||
}
|
||||
}
|
||||
11
core/lib/Http/Exception/BadRequestException.php
Normal file
11
core/lib/Http/Exception/BadRequestException.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace KTXC\Http\Exception;
|
||||
|
||||
use KTXF\Exception\BaseException;
|
||||
|
||||
class BadRequestException extends BaseException
|
||||
{
|
||||
}
|
||||
12
core/lib/Http/Exception/ConflictingHeadersException.php
Normal file
12
core/lib/Http/Exception/ConflictingHeadersException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Http\Exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when request headers conflict with each other.
|
||||
*/
|
||||
class ConflictingHeadersException extends \UnexpectedValueException
|
||||
{
|
||||
}
|
||||
12
core/lib/Http/Exception/JsonException.php
Normal file
12
core/lib/Http/Exception/JsonException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Http\Exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when JSON decoding/encoding fails in HTTP context.
|
||||
*/
|
||||
class JsonException extends \UnexpectedValueException
|
||||
{
|
||||
}
|
||||
12
core/lib/Http/Exception/SessionNotFoundException.php
Normal file
12
core/lib/Http/Exception/SessionNotFoundException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Http\Exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a session is expected but not available.
|
||||
*/
|
||||
class SessionNotFoundException extends \LogicException
|
||||
{
|
||||
}
|
||||
12
core/lib/Http/Exception/SuspiciousOperationException.php
Normal file
12
core/lib/Http/Exception/SuspiciousOperationException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Http\Exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a suspicious operation is detected (e.g., invalid host).
|
||||
*/
|
||||
class SuspiciousOperationException extends \UnexpectedValueException
|
||||
{
|
||||
}
|
||||
9
core/lib/Http/Exception/UnexpectedValueException.php
Normal file
9
core/lib/Http/Exception/UnexpectedValueException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace KTXC\Http\Exception;
|
||||
|
||||
use KTXF\Exception\RuntimeException;
|
||||
|
||||
class UnexpectedValueException extends RuntimeException {}
|
||||
288
core/lib/Http/File/UploadedFile.php
Normal file
288
core/lib/Http/File/UploadedFile.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Http\File;
|
||||
|
||||
/**
|
||||
* Represents a file uploaded through an HTTP request.
|
||||
*/
|
||||
class UploadedFile extends \SplFileInfo
|
||||
{
|
||||
private string $originalName;
|
||||
private ?string $mimeType;
|
||||
private int $error;
|
||||
private bool $test;
|
||||
|
||||
/**
|
||||
* Accepts the information of the uploaded file as provided by the PHP global $_FILES.
|
||||
*
|
||||
* @param string $path The full temporary path to the file
|
||||
* @param string $originalName The original file name of the uploaded file
|
||||
* @param string|null $mimeType The type of the file as provided by PHP; null defaults to application/octet-stream
|
||||
* @param int|null $error The error constant of the upload (one of PHP's UPLOAD_ERR_XXX constants); null defaults to UPLOAD_ERR_OK
|
||||
* @param bool $test Whether the test mode is active (used for testing)
|
||||
*
|
||||
* @throws \InvalidArgumentException If the file is not readable
|
||||
*/
|
||||
public function __construct(
|
||||
string $path,
|
||||
string $originalName,
|
||||
?string $mimeType = null,
|
||||
?int $error = null,
|
||||
bool $test = false
|
||||
) {
|
||||
$this->originalName = $this->getName($originalName);
|
||||
$this->mimeType = $mimeType ?? 'application/octet-stream';
|
||||
$this->error = $error ?? \UPLOAD_ERR_OK;
|
||||
$this->test = $test;
|
||||
|
||||
parent::__construct($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original file name.
|
||||
*
|
||||
* It is extracted from the request from which the file has been uploaded.
|
||||
* This should not be considered as a safe value to use for a file name on your servers.
|
||||
*
|
||||
* @return string The original name
|
||||
*/
|
||||
public function getClientOriginalName(): string
|
||||
{
|
||||
return $this->originalName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the original file extension.
|
||||
*
|
||||
* It is extracted from the original file name that was uploaded.
|
||||
* This should not be considered as a safe value to use for a file name on your servers.
|
||||
*
|
||||
* @return string The extension
|
||||
*/
|
||||
public function getClientOriginalExtension(): string
|
||||
{
|
||||
return pathinfo($this->originalName, \PATHINFO_EXTENSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file mime type.
|
||||
*
|
||||
* The client mime type is extracted from the request from which the file was uploaded,
|
||||
* so it should not be considered as a safe value.
|
||||
*
|
||||
* @return string The mime type
|
||||
*/
|
||||
public function getClientMimeType(): string
|
||||
{
|
||||
return $this->mimeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the extension based on the client mime type.
|
||||
*
|
||||
* If the mime type is unknown, returns null.
|
||||
*
|
||||
* This method uses a built-in list of mime type / extension pairs.
|
||||
*
|
||||
* @return string|null The guessed extension or null if it cannot be guessed
|
||||
*/
|
||||
public function guessClientExtension(): ?string
|
||||
{
|
||||
return self::mimeToExtension($this->mimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the upload error.
|
||||
*
|
||||
* If the upload was successful, the constant UPLOAD_ERR_OK is returned.
|
||||
* Otherwise one of the other UPLOAD_ERR_XXX constants is returned.
|
||||
*
|
||||
* @return int The upload error
|
||||
*/
|
||||
public function getError(): int
|
||||
{
|
||||
return $this->error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the file has been uploaded with HTTP and no error occurred.
|
||||
*
|
||||
* @return bool True if the file is valid, false otherwise
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
$isOk = \UPLOAD_ERR_OK === $this->error;
|
||||
|
||||
return $this->test ? $isOk : $isOk && is_uploaded_file($this->getPathname());
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the file to a new location.
|
||||
*
|
||||
* @param string $directory The destination folder
|
||||
* @param string|null $name The new file name
|
||||
*
|
||||
* @return \SplFileInfo A SplFileInfo object for the new file
|
||||
*
|
||||
* @throws \RuntimeException if the file cannot be moved
|
||||
*/
|
||||
public function move(string $directory, ?string $name = null): \SplFileInfo
|
||||
{
|
||||
if ($this->isValid()) {
|
||||
if ($this->test) {
|
||||
return $this->doMove($directory, $name);
|
||||
}
|
||||
|
||||
$target = $this->getTargetFile($directory, $name);
|
||||
|
||||
if (!@move_uploaded_file($this->getPathname(), $target)) {
|
||||
$error = error_get_last();
|
||||
throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error')));
|
||||
}
|
||||
|
||||
@chmod($target, 0666 & ~umask());
|
||||
|
||||
return new \SplFileInfo($target);
|
||||
}
|
||||
|
||||
throw new \RuntimeException($this->getErrorMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum size of an uploaded file as configured in php.ini.
|
||||
*
|
||||
* @return int|float The maximum size of an uploaded file in bytes (returns float on 32-bit for large values)
|
||||
*/
|
||||
public static function getMaxFilesize(): int|float
|
||||
{
|
||||
$sizePostMax = self::parseFilesize(\ini_get('post_max_size'));
|
||||
$sizeUploadMax = self::parseFilesize(\ini_get('upload_max_filesize'));
|
||||
|
||||
return min($sizePostMax ?: \PHP_INT_MAX, $sizeUploadMax ?: \PHP_INT_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an informative upload error message.
|
||||
*
|
||||
* @return string The error message regarding the specified error code
|
||||
*/
|
||||
public function getErrorMessage(): string
|
||||
{
|
||||
return match ($this->error) {
|
||||
\UPLOAD_ERR_INI_SIZE => 'The file "%s" exceeds your upload_max_filesize ini directive.',
|
||||
\UPLOAD_ERR_FORM_SIZE => 'The file "%s" exceeds the upload limit defined in your form.',
|
||||
\UPLOAD_ERR_PARTIAL => 'The file "%s" was only partially uploaded.',
|
||||
\UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
|
||||
\UPLOAD_ERR_CANT_WRITE => 'The file "%s" could not be written on disk.',
|
||||
\UPLOAD_ERR_NO_TMP_DIR => 'File could not be uploaded: missing temporary directory.',
|
||||
\UPLOAD_ERR_EXTENSION => 'File upload was stopped by a PHP extension.',
|
||||
default => 'The file "%s" was not uploaded due to an unknown error.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns locale independent base name of the given path.
|
||||
*
|
||||
* @param string $name The new file name
|
||||
*
|
||||
* @return string The base name
|
||||
*/
|
||||
protected function getName(string $name): string
|
||||
{
|
||||
$originalName = str_replace('\\', '/', $name);
|
||||
$pos = strrpos($originalName, '/');
|
||||
$originalName = false === $pos ? $originalName : substr($originalName, $pos + 1);
|
||||
|
||||
return $originalName;
|
||||
}
|
||||
|
||||
protected function getTargetFile(string $directory, ?string $name = null): string
|
||||
{
|
||||
if (!is_dir($directory)) {
|
||||
if (false === @mkdir($directory, 0777, true) && !is_dir($directory)) {
|
||||
throw new \RuntimeException(sprintf('Unable to create the "%s" directory.', $directory));
|
||||
}
|
||||
} elseif (!is_writable($directory)) {
|
||||
throw new \RuntimeException(sprintf('Unable to write in the "%s" directory.', $directory));
|
||||
}
|
||||
|
||||
$target = rtrim($directory, '/\\') . \DIRECTORY_SEPARATOR . (null === $name ? $this->getBasename() : $this->getName($name));
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the file to a new location (used in test mode).
|
||||
*/
|
||||
protected function doMove(string $directory, ?string $name = null): \SplFileInfo
|
||||
{
|
||||
$target = $this->getTargetFile($directory, $name);
|
||||
|
||||
if (!@rename($this->getPathname(), $target)) {
|
||||
$error = error_get_last();
|
||||
throw new \RuntimeException(sprintf('Could not move the file "%s" to "%s" (%s).', $this->getPathname(), $target, strip_tags($error['message'] ?? 'unknown error')));
|
||||
}
|
||||
|
||||
@chmod($target, 0666 & ~umask());
|
||||
|
||||
return new \SplFileInfo($target);
|
||||
}
|
||||
|
||||
private static function parseFilesize(string $size): int|float
|
||||
{
|
||||
if ('' === $size) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$size = strtolower($size);
|
||||
|
||||
$max = ltrim($size, '+');
|
||||
if (str_starts_with($max, '0x')) {
|
||||
$max = \intval($max, 16);
|
||||
} elseif (str_starts_with($max, '0')) {
|
||||
$max = \intval($max, 8);
|
||||
} else {
|
||||
$max = (int) $max;
|
||||
}
|
||||
|
||||
switch (substr($size, -1)) {
|
||||
case 't': $max *= 1024;
|
||||
// no break
|
||||
case 'g': $max *= 1024;
|
||||
// no break
|
||||
case 'm': $max *= 1024;
|
||||
// no break
|
||||
case 'k': $max *= 1024;
|
||||
}
|
||||
|
||||
return $max;
|
||||
}
|
||||
|
||||
private static function mimeToExtension(string $mimeType): ?string
|
||||
{
|
||||
$map = [
|
||||
'application/pdf' => 'pdf',
|
||||
'application/zip' => 'zip',
|
||||
'application/json' => 'json',
|
||||
'application/xml' => 'xml',
|
||||
'application/octet-stream' => 'bin',
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
'image/svg+xml' => 'svg',
|
||||
'text/plain' => 'txt',
|
||||
'text/html' => 'html',
|
||||
'text/css' => 'css',
|
||||
'text/javascript' => 'js',
|
||||
'audio/mpeg' => 'mp3',
|
||||
'audio/wav' => 'wav',
|
||||
'video/mp4' => 'mp4',
|
||||
'video/webm' => 'webm',
|
||||
];
|
||||
|
||||
return $map[$mimeType] ?? null;
|
||||
}
|
||||
}
|
||||
275
core/lib/Http/HeaderParameters.php
Normal file
275
core/lib/Http/HeaderParameters.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http;
|
||||
|
||||
/**
|
||||
* HeaderBag is a container for HTTP headers.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @implements \IteratorAggregate<string, list<string|null>>
|
||||
*/
|
||||
class HeaderParameters implements \IteratorAggregate, \Countable, \Stringable
|
||||
{
|
||||
protected const UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
protected const LOWER = '-abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/**
|
||||
* @var array<string, list<string|null>>
|
||||
*/
|
||||
protected array $headers = [];
|
||||
protected array $cacheControl = [];
|
||||
|
||||
public function __construct(array $headers = [])
|
||||
{
|
||||
foreach ($headers as $key => $values) {
|
||||
$this->set($key, $values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the headers as a string.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
if (!$headers = $this->all()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ksort($headers);
|
||||
$max = max(array_map('strlen', array_keys($headers))) + 1;
|
||||
$content = '';
|
||||
foreach ($headers as $name => $values) {
|
||||
$name = ucwords($name, '-');
|
||||
foreach ($values as $value) {
|
||||
$content .= \sprintf("%-{$max}s %s\r\n", $name.':', $value);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the headers.
|
||||
*
|
||||
* @param string|null $key The name of the headers to return or null to get them all
|
||||
*
|
||||
* @return ($key is null ? array<string, list<string|null>> : list<string|null>)
|
||||
*/
|
||||
public function all(?string $key = null): array
|
||||
{
|
||||
if (null !== $key) {
|
||||
return $this->headers[strtr($key, self::UPPER, self::LOWER)] ?? [];
|
||||
}
|
||||
|
||||
return $this->headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter keys.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
return array_keys($this->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current HTTP headers by a new set.
|
||||
*/
|
||||
public function replace(array $headers = []): void
|
||||
{
|
||||
$this->headers = [];
|
||||
$this->add($headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds new headers the current HTTP headers set.
|
||||
*/
|
||||
public function add(array $headers): void
|
||||
{
|
||||
foreach ($headers as $key => $values) {
|
||||
$this->set($key, $values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first header by name or the default one.
|
||||
*/
|
||||
public function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
$headers = $this->all($key);
|
||||
|
||||
if (!$headers) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (null === $headers[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $headers[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a header by name.
|
||||
*
|
||||
* @param string|string[]|null $values The value or an array of values
|
||||
* @param bool $replace Whether to replace the actual value or not (true by default)
|
||||
*/
|
||||
public function set(string $key, string|array|null $values, bool $replace = true): void
|
||||
{
|
||||
$key = strtr($key, self::UPPER, self::LOWER);
|
||||
|
||||
if (\is_array($values)) {
|
||||
$values = array_values($values);
|
||||
|
||||
if (true === $replace || !isset($this->headers[$key])) {
|
||||
$this->headers[$key] = $values;
|
||||
} else {
|
||||
$this->headers[$key] = array_merge($this->headers[$key], $values);
|
||||
}
|
||||
} else {
|
||||
if (true === $replace || !isset($this->headers[$key])) {
|
||||
$this->headers[$key] = [$values];
|
||||
} else {
|
||||
$this->headers[$key][] = $values;
|
||||
}
|
||||
}
|
||||
|
||||
if ('cache-control' === $key) {
|
||||
$this->cacheControl = $this->parseCacheControl(implode(', ', $this->headers[$key]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the HTTP header is defined.
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return \array_key_exists(strtr($key, self::UPPER, self::LOWER), $this->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given HTTP header contains the given value.
|
||||
*/
|
||||
public function contains(string $key, string $value): bool
|
||||
{
|
||||
return \in_array($value, $this->all($key), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a header.
|
||||
*/
|
||||
public function remove(string $key): void
|
||||
{
|
||||
$key = strtr($key, self::UPPER, self::LOWER);
|
||||
|
||||
unset($this->headers[$key]);
|
||||
|
||||
if ('cache-control' === $key) {
|
||||
$this->cacheControl = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTTP header value converted to a date.
|
||||
*
|
||||
* @throws \RuntimeException When the HTTP header is not parseable
|
||||
*/
|
||||
public function getDate(string $key, ?\DateTimeInterface $default = null): ?\DateTimeImmutable
|
||||
{
|
||||
if (null === $value = $this->get($key)) {
|
||||
return null !== $default ? \DateTimeImmutable::createFromInterface($default) : null;
|
||||
}
|
||||
|
||||
if (false === $date = \DateTimeImmutable::createFromFormat(\DATE_RFC2822, $value)) {
|
||||
throw new \RuntimeException(\sprintf('The "%s" HTTP header is not parseable (%s).', $key, $value));
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom Cache-Control directive.
|
||||
*/
|
||||
public function addCacheControlDirective(string $key, bool|string $value = true): void
|
||||
{
|
||||
$this->cacheControl[$key] = $value;
|
||||
|
||||
$this->set('Cache-Control', $this->getCacheControlHeader());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the Cache-Control directive is defined.
|
||||
*/
|
||||
public function hasCacheControlDirective(string $key): bool
|
||||
{
|
||||
return \array_key_exists($key, $this->cacheControl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Cache-Control directive value by name.
|
||||
*/
|
||||
public function getCacheControlDirective(string $key): bool|string|null
|
||||
{
|
||||
return $this->cacheControl[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a Cache-Control directive.
|
||||
*/
|
||||
public function removeCacheControlDirective(string $key): void
|
||||
{
|
||||
unset($this->cacheControl[$key]);
|
||||
|
||||
$this->set('Cache-Control', $this->getCacheControlHeader());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator for headers.
|
||||
*
|
||||
* @return \ArrayIterator<string, list<string|null>>
|
||||
*/
|
||||
public function getIterator(): \ArrayIterator
|
||||
{
|
||||
return new \ArrayIterator($this->headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of headers.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return \count($this->headers);
|
||||
}
|
||||
|
||||
protected function getCacheControlHeader(): string
|
||||
{
|
||||
ksort($this->cacheControl);
|
||||
|
||||
return HeaderUtils::toString($this->cacheControl, ',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Cache-Control HTTP header.
|
||||
*/
|
||||
protected function parseCacheControl(string $header): array
|
||||
{
|
||||
$parts = HeaderUtils::split($header, ',=');
|
||||
|
||||
return HeaderUtils::combine($parts);
|
||||
}
|
||||
}
|
||||
298
core/lib/Http/HeaderUtils.php
Normal file
298
core/lib/Http/HeaderUtils.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http;
|
||||
|
||||
/**
|
||||
* HTTP header utility functions.
|
||||
*
|
||||
* @author Christian Schmidt <github@chsc.dk>
|
||||
*/
|
||||
class HeaderUtils
|
||||
{
|
||||
public const DISPOSITION_ATTACHMENT = 'attachment';
|
||||
public const DISPOSITION_INLINE = 'inline';
|
||||
|
||||
/**
|
||||
* This class should not be instantiated.
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits an HTTP header by one or more separators.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* HeaderUtils::split('da, en-gb;q=0.8', ',;')
|
||||
* # returns [['da'], ['en-gb', 'q=0.8']]
|
||||
*
|
||||
* @param string $separators List of characters to split on, ordered by
|
||||
* precedence, e.g. ',', ';=', or ',;='
|
||||
*
|
||||
* @return array Nested array with as many levels as there are characters in
|
||||
* $separators
|
||||
*/
|
||||
public static function split(string $header, string $separators): array
|
||||
{
|
||||
if ('' === $separators) {
|
||||
throw new \InvalidArgumentException('At least one separator must be specified.');
|
||||
}
|
||||
|
||||
$quotedSeparators = preg_quote($separators, '/');
|
||||
|
||||
preg_match_all('
|
||||
/
|
||||
(?!\s)
|
||||
(?:
|
||||
# quoted-string
|
||||
"(?:[^"\\\\]|\\\\.)*(?:"|\\\\|$)
|
||||
|
|
||||
# token
|
||||
[^"'.$quotedSeparators.']+
|
||||
)+
|
||||
(?<!\s)
|
||||
|
|
||||
# separator
|
||||
\s*
|
||||
(?<separator>['.$quotedSeparators.'])
|
||||
\s*
|
||||
/x', trim($header), $matches, \PREG_SET_ORDER);
|
||||
|
||||
return self::groupParts($matches, $separators);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines an array of arrays into one associative array.
|
||||
*
|
||||
* Each of the nested arrays should have one or two elements. The first
|
||||
* value will be used as the keys in the associative array, and the second
|
||||
* will be used as the values, or true if the nested array only contains one
|
||||
* element. Array keys are lowercased.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* HeaderUtils::combine([['foo', 'abc'], ['bar']])
|
||||
* // => ['foo' => 'abc', 'bar' => true]
|
||||
*/
|
||||
public static function combine(array $parts): array
|
||||
{
|
||||
$assoc = [];
|
||||
foreach ($parts as $part) {
|
||||
$name = strtolower($part[0]);
|
||||
$value = $part[1] ?? true;
|
||||
$assoc[$name] = $value;
|
||||
}
|
||||
|
||||
return $assoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins an associative array into a string for use in an HTTP header.
|
||||
*
|
||||
* The key and value of each entry are joined with '=', and all entries
|
||||
* are joined with the specified separator and an additional space (for
|
||||
* readability). Values are quoted if necessary.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* HeaderUtils::toString(['foo' => 'abc', 'bar' => true, 'baz' => 'a b c'], ',')
|
||||
* // => 'foo=abc, bar, baz="a b c"'
|
||||
*/
|
||||
public static function toString(array $assoc, string $separator): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($assoc as $name => $value) {
|
||||
if (true === $value) {
|
||||
$parts[] = $name;
|
||||
} else {
|
||||
$parts[] = $name.'='.self::quote($value);
|
||||
}
|
||||
}
|
||||
|
||||
return implode($separator.' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a string as a quoted string, if necessary.
|
||||
*
|
||||
* If a string contains characters not allowed by the "token" construct in
|
||||
* the HTTP specification, it is backslash-escaped and enclosed in quotes
|
||||
* to match the "quoted-string" construct.
|
||||
*/
|
||||
public static function quote(string $s): string
|
||||
{
|
||||
if (preg_match('/^[a-z0-9!#$%&\'*.^_`|~-]+$/i', $s)) {
|
||||
return $s;
|
||||
}
|
||||
|
||||
return '"'.addcslashes($s, '"\\"').'"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a quoted string.
|
||||
*
|
||||
* If passed an unquoted string that matches the "token" construct (as
|
||||
* defined in the HTTP specification), it is passed through verbatim.
|
||||
*/
|
||||
public static function unquote(string $s): string
|
||||
{
|
||||
return preg_replace('/\\\\(.)|"/', '$1', $s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an HTTP Content-Disposition field-value.
|
||||
*
|
||||
* @param string $disposition One of "inline" or "attachment"
|
||||
* @param string $filename A unicode string
|
||||
* @param string $filenameFallback A string containing only ASCII characters that
|
||||
* is semantically equivalent to $filename. If the filename is already ASCII,
|
||||
* it can be omitted, or just copied from $filename
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @see RFC 6266
|
||||
*/
|
||||
public static function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string
|
||||
{
|
||||
if (!\in_array($disposition, [self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE])) {
|
||||
throw new \InvalidArgumentException(\sprintf('The disposition must be either "%s" or "%s".', self::DISPOSITION_ATTACHMENT, self::DISPOSITION_INLINE));
|
||||
}
|
||||
|
||||
if ('' === $filenameFallback) {
|
||||
$filenameFallback = $filename;
|
||||
}
|
||||
|
||||
// filenameFallback is not ASCII.
|
||||
if (!preg_match('/^[\x20-\x7e]*$/', $filenameFallback)) {
|
||||
throw new \InvalidArgumentException('The filename fallback must only contain ASCII characters.');
|
||||
}
|
||||
|
||||
// percent characters aren't safe in fallback.
|
||||
if (str_contains($filenameFallback, '%')) {
|
||||
throw new \InvalidArgumentException('The filename fallback cannot contain the "%" character.');
|
||||
}
|
||||
|
||||
// path separators aren't allowed in either.
|
||||
if (str_contains($filename, '/') || str_contains($filename, '\\') || str_contains($filenameFallback, '/') || str_contains($filenameFallback, '\\')) {
|
||||
throw new \InvalidArgumentException('The filename and the fallback cannot contain the "/" and "\\" characters.');
|
||||
}
|
||||
|
||||
$params = ['filename' => $filenameFallback];
|
||||
if ($filename !== $filenameFallback) {
|
||||
$params['filename*'] = "utf-8''".rawurlencode($filename);
|
||||
}
|
||||
|
||||
return $disposition.'; '.self::toString($params, ';');
|
||||
}
|
||||
|
||||
/**
|
||||
* Like parse_str(), but preserves dots in variable names.
|
||||
*/
|
||||
public static function parseQuery(string $query, bool $ignoreBrackets = false, string $separator = '&'): array
|
||||
{
|
||||
$q = [];
|
||||
|
||||
foreach (explode($separator, $query) as $v) {
|
||||
if (false !== $i = strpos($v, "\0")) {
|
||||
$v = substr($v, 0, $i);
|
||||
}
|
||||
|
||||
if (false === $i = strpos($v, '=')) {
|
||||
$k = urldecode($v);
|
||||
$v = '';
|
||||
} else {
|
||||
$k = urldecode(substr($v, 0, $i));
|
||||
$v = substr($v, $i);
|
||||
}
|
||||
|
||||
if (false !== $i = strpos($k, "\0")) {
|
||||
$k = substr($k, 0, $i);
|
||||
}
|
||||
|
||||
$k = ltrim($k, ' ');
|
||||
|
||||
if ($ignoreBrackets) {
|
||||
$q[$k][] = urldecode(substr($v, 1));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (false === $i = strpos($k, '[')) {
|
||||
$q[] = bin2hex($k).$v;
|
||||
} else {
|
||||
$q[] = bin2hex(substr($k, 0, $i)).rawurlencode(substr($k, $i)).$v;
|
||||
}
|
||||
}
|
||||
|
||||
if ($ignoreBrackets) {
|
||||
return $q;
|
||||
}
|
||||
|
||||
parse_str(implode('&', $q), $q);
|
||||
|
||||
$query = [];
|
||||
|
||||
foreach ($q as $k => $v) {
|
||||
if (false !== $i = strpos($k, '_')) {
|
||||
$query[substr_replace($k, hex2bin(substr($k, 0, $i)).'[', 0, 1 + $i)] = $v;
|
||||
} else {
|
||||
$query[hex2bin($k)] = $v;
|
||||
}
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private static function groupParts(array $matches, string $separators, bool $first = true): array
|
||||
{
|
||||
$separator = $separators[0];
|
||||
$separators = substr($separators, 1) ?: '';
|
||||
$i = 0;
|
||||
|
||||
if ('' === $separators && !$first) {
|
||||
$parts = [''];
|
||||
|
||||
foreach ($matches as $match) {
|
||||
if (!$i && isset($match['separator'])) {
|
||||
$i = 1;
|
||||
$parts[1] = '';
|
||||
} else {
|
||||
$parts[$i] .= self::unquote($match[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
$partMatches = [];
|
||||
|
||||
foreach ($matches as $match) {
|
||||
if (($match['separator'] ?? null) === $separator) {
|
||||
++$i;
|
||||
} else {
|
||||
$partMatches[$i][] = $match;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($partMatches as $matches) {
|
||||
if ('' === $separators && '' !== $unquoted = self::unquote($matches[0][0])) {
|
||||
$parts[] = $unquoted;
|
||||
} elseif ($groupedParts = self::groupParts($matches, $separators, false)) {
|
||||
$parts[] = $groupedParts;
|
||||
}
|
||||
}
|
||||
|
||||
return $parts;
|
||||
}
|
||||
}
|
||||
38
core/lib/Http/Middleware/AuthenticationMiddleware.php
Normal file
38
core/lib/Http/Middleware/AuthenticationMiddleware.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Service\SecurityService;
|
||||
use KTXC\SessionIdentity;
|
||||
|
||||
/**
|
||||
* Authentication middleware
|
||||
* Authenticates the request and initializes session identity
|
||||
*
|
||||
* Note: This middleware does NOT enforce authentication.
|
||||
* It only attempts to authenticate if credentials are present.
|
||||
* Route-level authentication is enforced by RouterMiddleware.
|
||||
*/
|
||||
class AuthenticationMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityService $securityService,
|
||||
private readonly SessionIdentity $sessionIdentity
|
||||
) {}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
// Attempt to authenticate the request
|
||||
$identity = $this->securityService->authenticate($request);
|
||||
|
||||
// Initialize session identity if authentication succeeded
|
||||
if ($identity) {
|
||||
$this->sessionIdentity->initialize($identity, true);
|
||||
}
|
||||
|
||||
// Continue to next middleware (authentication is optional at this stage)
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
32
core/lib/Http/Middleware/FirewallMiddleware.php
Normal file
32
core/lib/Http/Middleware/FirewallMiddleware.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Service\FirewallService;
|
||||
|
||||
/**
|
||||
* Firewall middleware
|
||||
* Checks if the request is authorized to proceed
|
||||
*/
|
||||
class FirewallMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FirewallService $firewall
|
||||
) {}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
// Check firewall authorization
|
||||
if (!$this->firewall->authorized($request)) {
|
||||
return new Response(
|
||||
Response::$statusTexts[Response::HTTP_FORBIDDEN],
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
// Continue to next middleware
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
21
core/lib/Http/Middleware/MiddlewareInterface.php
Normal file
21
core/lib/Http/Middleware/MiddlewareInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
|
||||
/**
|
||||
* PSR-15 style middleware interface
|
||||
*/
|
||||
interface MiddlewareInterface
|
||||
{
|
||||
/**
|
||||
* Process an incoming server request.
|
||||
*
|
||||
* @param Request $request The request to process
|
||||
* @param RequestHandlerInterface $handler The next handler in the pipeline
|
||||
* @return Response The response from processing
|
||||
*/
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response;
|
||||
}
|
||||
112
core/lib/Http/Middleware/MiddlewarePipeline.php
Normal file
112
core/lib/Http/Middleware/MiddlewarePipeline.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use Psr\Container\ContainerInterface;
|
||||
|
||||
/**
|
||||
* Middleware pipeline - processes request through a stack of middleware
|
||||
*/
|
||||
class MiddlewarePipeline implements RequestHandlerInterface
|
||||
{
|
||||
/** @var array<string|MiddlewareInterface> */
|
||||
private array $middleware = [];
|
||||
|
||||
private ?ContainerInterface $container = null;
|
||||
|
||||
public function __construct(?ContainerInterface $container = null)
|
||||
{
|
||||
$this->container = $container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add middleware to the pipeline
|
||||
*
|
||||
* @param string|MiddlewareInterface $middleware Middleware class name or instance
|
||||
* @return self
|
||||
*/
|
||||
public function pipe(string|MiddlewareInterface $middleware): self
|
||||
{
|
||||
$this->middleware[] = $middleware;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the request through the middleware pipeline
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Create a handler for the pipeline
|
||||
$handler = $this->createHandler(0);
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a handler for a specific position in the pipeline
|
||||
*
|
||||
* @param int $index Current position in the middleware stack
|
||||
* @return RequestHandlerInterface
|
||||
*/
|
||||
public function createHandler(int $index): RequestHandlerInterface
|
||||
{
|
||||
// If we've reached the end of the pipeline, return a default handler
|
||||
if (!isset($this->middleware[$index])) {
|
||||
return new class implements RequestHandlerInterface {
|
||||
public function handle(Request $request): Response {
|
||||
return new Response(Response::$statusTexts[Response::HTTP_NOT_FOUND], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return new class($this->middleware[$index], $this, $index, $this->container) implements RequestHandlerInterface {
|
||||
private string|MiddlewareInterface $middleware;
|
||||
private MiddlewarePipeline $pipeline;
|
||||
private int $index;
|
||||
private ?ContainerInterface $container;
|
||||
|
||||
public function __construct(
|
||||
string|MiddlewareInterface $middleware,
|
||||
MiddlewarePipeline $pipeline,
|
||||
int $index,
|
||||
?ContainerInterface $container
|
||||
) {
|
||||
$this->middleware = $middleware;
|
||||
$this->pipeline = $pipeline;
|
||||
$this->index = $index;
|
||||
$this->container = $container;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
// Resolve middleware instance if it's a class name
|
||||
$middleware = $this->middleware;
|
||||
|
||||
if (is_string($middleware)) {
|
||||
if ($this->container && $this->container->has($middleware)) {
|
||||
$middleware = $this->container->get($middleware);
|
||||
} else {
|
||||
$middleware = new $middleware();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$middleware instanceof MiddlewareInterface) {
|
||||
throw new \RuntimeException(
|
||||
sprintf('Middleware must implement %s', MiddlewareInterface::class)
|
||||
);
|
||||
}
|
||||
|
||||
// Create the next handler in the chain
|
||||
$next = $this->pipeline->createHandler($this->index + 1);
|
||||
|
||||
// Process this middleware
|
||||
return $middleware->process($request, $next);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
20
core/lib/Http/Middleware/RequestHandlerInterface.php
Normal file
20
core/lib/Http/Middleware/RequestHandlerInterface.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
|
||||
/**
|
||||
* PSR-15 style request handler interface
|
||||
*/
|
||||
interface RequestHandlerInterface
|
||||
{
|
||||
/**
|
||||
* Handle the request and return a response.
|
||||
*
|
||||
* @param Request $request The request to handle
|
||||
* @return Response The response from handling the request
|
||||
*/
|
||||
public function handle(Request $request): Response;
|
||||
}
|
||||
62
core/lib/Http/Middleware/RouterMiddleware.php
Normal file
62
core/lib/Http/Middleware/RouterMiddleware.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Routing\Router;
|
||||
use KTXC\Routing\Route;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\Security\Authorization\PermissionChecker;
|
||||
|
||||
/**
|
||||
* Router middleware
|
||||
* Matches routes and dispatches to controllers
|
||||
*/
|
||||
class RouterMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Router $router,
|
||||
private readonly SessionIdentity $sessionIdentity,
|
||||
private readonly PermissionChecker $permissionChecker
|
||||
) {}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
// Attempt to match the route
|
||||
$match = $this->router->match($request);
|
||||
|
||||
if (!$match instanceof Route) {
|
||||
// No route matched, continue to next handler (will return 404)
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
// Check if route requires authentication
|
||||
if ($match->authenticated && $this->sessionIdentity->identity() === null) {
|
||||
return new Response(
|
||||
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
// Check permissions (if any specified)
|
||||
if ($match->authenticated && !empty($match->permissions)) {
|
||||
if (!$this->permissionChecker->canAny($match->permissions)) {
|
||||
return new Response(
|
||||
Response::$statusTexts[Response::HTTP_FORBIDDEN],
|
||||
Response::HTTP_FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch to the controller
|
||||
$response = $this->router->dispatch($match, $request);
|
||||
|
||||
if ($response instanceof Response) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// If dispatch didn't return a response, continue to next handler
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
35
core/lib/Http/Middleware/TenantMiddleware.php
Normal file
35
core/lib/Http/Middleware/TenantMiddleware.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Http\Middleware;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\SessionTenant;
|
||||
|
||||
/**
|
||||
* Tenant resolution middleware
|
||||
* Configures the tenant based on the request host
|
||||
*/
|
||||
class TenantMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $sessionTenant
|
||||
) {}
|
||||
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
// Configure tenant from request host
|
||||
$this->sessionTenant->configure($request->getHost());
|
||||
|
||||
// Check if tenant is configured and enabled
|
||||
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) {
|
||||
return new Response(
|
||||
Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
// Continue to next middleware
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
2107
core/lib/Http/Request/Request.php
Normal file
2107
core/lib/Http/Request/Request.php
Normal file
File diff suppressed because it is too large
Load Diff
129
core/lib/Http/Request/RequestFileCollection.php
Normal file
129
core/lib/Http/Request/RequestFileCollection.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Request;
|
||||
|
||||
use KTXC\Http\File\UploadedFile;
|
||||
|
||||
/**
|
||||
* FileBag is a container for uploaded files.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Bulat Shakirzyanov <mallluhuct@gmail.com>
|
||||
*/
|
||||
class RequestFileCollection extends RequestParameters
|
||||
{
|
||||
private const FILE_KEYS = ['error', 'full_path', 'name', 'size', 'tmp_name', 'type'];
|
||||
|
||||
/**
|
||||
* @param array|UploadedFile[] $parameters An array of HTTP files
|
||||
*/
|
||||
public function __construct(array $parameters = [])
|
||||
{
|
||||
$this->replace($parameters);
|
||||
}
|
||||
|
||||
public function replace(array $files = []): void
|
||||
{
|
||||
$this->parameters = [];
|
||||
$this->add($files);
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
if (!\is_array($value) && !$value instanceof UploadedFile) {
|
||||
throw new \InvalidArgumentException('An uploaded file must be an array or an instance of UploadedFile.');
|
||||
}
|
||||
|
||||
parent::set($key, $this->convertFileInformation($value));
|
||||
}
|
||||
|
||||
public function add(array $files = []): void
|
||||
{
|
||||
foreach ($files as $key => $file) {
|
||||
$this->set($key, $file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts uploaded files to UploadedFile instances.
|
||||
*
|
||||
* @return UploadedFile[]|UploadedFile|null
|
||||
*/
|
||||
protected function convertFileInformation(array|UploadedFile $file): array|UploadedFile|null
|
||||
{
|
||||
if ($file instanceof UploadedFile) {
|
||||
return $file;
|
||||
}
|
||||
|
||||
$file = $this->fixPhpFilesArray($file);
|
||||
$keys = array_keys($file + ['full_path' => null]);
|
||||
sort($keys);
|
||||
|
||||
if (self::FILE_KEYS === $keys) {
|
||||
if (\UPLOAD_ERR_NO_FILE === $file['error']) {
|
||||
$file = null;
|
||||
} else {
|
||||
$file = new UploadedFile($file['tmp_name'], $file['full_path'] ?? $file['name'], $file['type'], $file['error'], false);
|
||||
}
|
||||
} else {
|
||||
$file = array_map(fn ($v) => $v instanceof UploadedFile || \is_array($v) ? $this->convertFileInformation($v) : $v, $file);
|
||||
if (array_is_list($file)) {
|
||||
$file = array_filter($file);
|
||||
}
|
||||
}
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes a malformed PHP $_FILES array.
|
||||
*
|
||||
* PHP has a bug that the format of the $_FILES array differs, depending on
|
||||
* whether the uploaded file fields had normal field names or array-like
|
||||
* field names ("normal" vs. "parent[child]").
|
||||
*
|
||||
* This method fixes the array to look like the "normal" $_FILES array.
|
||||
*
|
||||
* It's safe to pass an already converted array, in which case this method
|
||||
* just returns the original array unmodified.
|
||||
*/
|
||||
protected function fixPhpFilesArray(array $data): array
|
||||
{
|
||||
$keys = array_keys($data + ['full_path' => null]);
|
||||
sort($keys);
|
||||
|
||||
if (self::FILE_KEYS !== $keys || !isset($data['name']) || !\is_array($data['name'])) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$files = $data;
|
||||
foreach (self::FILE_KEYS as $k) {
|
||||
unset($files[$k]);
|
||||
}
|
||||
|
||||
foreach ($data['name'] as $key => $name) {
|
||||
$files[$key] = $this->fixPhpFilesArray([
|
||||
'error' => $data['error'][$key],
|
||||
'name' => $name,
|
||||
'type' => $data['type'][$key],
|
||||
'tmp_name' => $data['tmp_name'][$key],
|
||||
'size' => $data['size'][$key],
|
||||
] + (isset($data['full_path'][$key]) ? [
|
||||
'full_path' => $data['full_path'][$key],
|
||||
] : []));
|
||||
}
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
154
core/lib/Http/Request/RequestHeaderAccept.php
Normal file
154
core/lib/Http/Request/RequestHeaderAccept.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Request;
|
||||
|
||||
use KTXC\Http\HeaderUtils;
|
||||
|
||||
// Help opcache.preload discover always-needed symbols
|
||||
class_exists(RequestHeaderAcceptItem::class);
|
||||
|
||||
/**
|
||||
* Represents an Accept-* header.
|
||||
*
|
||||
* An accept header is compound with a list of items,
|
||||
* sorted by descending quality.
|
||||
*
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*/
|
||||
class RequestHeaderAccept
|
||||
{
|
||||
/**
|
||||
* @var RequestHeaderAcceptItem[]
|
||||
*/
|
||||
private array $items = [];
|
||||
|
||||
private bool $sorted = true;
|
||||
|
||||
/**
|
||||
* @param RequestHeaderAcceptItem[] $items
|
||||
*/
|
||||
public function __construct(array $items)
|
||||
{
|
||||
foreach ($items as $item) {
|
||||
$this->add($item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AcceptHeader instance from a string.
|
||||
*/
|
||||
public static function fromString(?string $headerValue): self
|
||||
{
|
||||
$parts = HeaderUtils::split($headerValue ?? '', ',;=');
|
||||
|
||||
return new self(array_map(function ($subParts) {
|
||||
static $index = 0;
|
||||
$part = array_shift($subParts);
|
||||
$attributes = HeaderUtils::combine($subParts);
|
||||
|
||||
$item = new RequestHeaderAcceptItem($part[0], $attributes);
|
||||
$item->setIndex($index++);
|
||||
|
||||
return $item;
|
||||
}, $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns header value's string representation.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return implode(',', $this->items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if header has given value.
|
||||
*/
|
||||
public function has(string $value): bool
|
||||
{
|
||||
return isset($this->items[$value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns given value's item, if exists.
|
||||
*/
|
||||
public function get(string $value): ?RequestHeaderAcceptItem
|
||||
{
|
||||
return $this->items[$value] ?? $this->items[explode('/', $value)[0].'/*'] ?? $this->items['*/*'] ?? $this->items['*'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an item.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function add(RequestHeaderAcceptItem $item): static
|
||||
{
|
||||
$this->items[$item->getValue()] = $item;
|
||||
$this->sorted = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all items.
|
||||
*
|
||||
* @return RequestHeaderAcceptItem[]
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$this->sort();
|
||||
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters items on their value using given regex.
|
||||
*/
|
||||
public function filter(string $pattern): self
|
||||
{
|
||||
return new self(array_filter($this->items, fn (RequestHeaderAcceptItem $item) => preg_match($pattern, $item->getValue())));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns first item.
|
||||
*/
|
||||
public function first(): ?RequestHeaderAcceptItem
|
||||
{
|
||||
$this->sort();
|
||||
|
||||
return $this->items ? reset($this->items) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts items by descending quality.
|
||||
*/
|
||||
private function sort(): void
|
||||
{
|
||||
if (!$this->sorted) {
|
||||
uasort($this->items, function (RequestHeaderAcceptItem $a, RequestHeaderAcceptItem $b) {
|
||||
$qA = $a->getQuality();
|
||||
$qB = $b->getQuality();
|
||||
|
||||
if ($qA === $qB) {
|
||||
return $a->getIndex() > $b->getIndex() ? 1 : -1;
|
||||
}
|
||||
|
||||
return $qA > $qB ? -1 : 1;
|
||||
});
|
||||
|
||||
$this->sorted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
163
core/lib/Http/Request/RequestHeaderAcceptItem.php
Normal file
163
core/lib/Http/Request/RequestHeaderAcceptItem.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Request;
|
||||
|
||||
use KTXC\Http\HeaderUtils;
|
||||
|
||||
/**
|
||||
* Represents an Accept-* header item.
|
||||
*
|
||||
* @author Jean-François Simon <contact@jfsimon.fr>
|
||||
*/
|
||||
class RequestHeaderAcceptItem
|
||||
{
|
||||
private float $quality = 1.0;
|
||||
private int $index = 0;
|
||||
private array $attributes = [];
|
||||
|
||||
public function __construct(
|
||||
private string $value,
|
||||
array $attributes = [],
|
||||
) {
|
||||
foreach ($attributes as $name => $value) {
|
||||
$this->setAttribute($name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AcceptHeaderInstance instance from a string.
|
||||
*/
|
||||
public static function fromString(?string $itemValue): self
|
||||
{
|
||||
$parts = HeaderUtils::split($itemValue ?? '', ';=');
|
||||
|
||||
$part = array_shift($parts);
|
||||
$attributes = HeaderUtils::combine($parts);
|
||||
|
||||
return new self($part[0], $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns header value's string representation.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
$string = $this->value.($this->quality < 1 ? ';q='.$this->quality : '');
|
||||
if (\count($this->attributes) > 0) {
|
||||
$string .= '; '.HeaderUtils::toString($this->attributes, ';');
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the item value.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setValue(string $value): static
|
||||
{
|
||||
$this->value = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item value.
|
||||
*/
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the item quality.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setQuality(float $quality): static
|
||||
{
|
||||
$this->quality = $quality;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item quality.
|
||||
*/
|
||||
public function getQuality(): float
|
||||
{
|
||||
return $this->quality;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the item index.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setIndex(int $index): static
|
||||
{
|
||||
$this->index = $index;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item index.
|
||||
*/
|
||||
public function getIndex(): int
|
||||
{
|
||||
return $this->index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if an attribute exists.
|
||||
*/
|
||||
public function hasAttribute(string $name): bool
|
||||
{
|
||||
return isset($this->attributes[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an attribute by its name.
|
||||
*/
|
||||
public function getAttribute(string $name, mixed $default = null): mixed
|
||||
{
|
||||
return $this->attributes[$name] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all attributes.
|
||||
*/
|
||||
public function getAttributes(): array
|
||||
{
|
||||
return $this->attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an attribute.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setAttribute(string $name, string $value): static
|
||||
{
|
||||
if ('q' === $name) {
|
||||
$this->quality = (float) $value;
|
||||
} else {
|
||||
$this->attributes[$name] = $value;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
12
core/lib/Http/Request/RequestHeaderParameters.php
Normal file
12
core/lib/Http/Request/RequestHeaderParameters.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace KTXC\Http\Request;
|
||||
|
||||
use KTXC\Http\HeaderParameters;
|
||||
|
||||
/**
|
||||
* HeaderBag is a container for HTTP headers.
|
||||
*/
|
||||
class RequestHeaderParameters extends HeaderParameters {}
|
||||
155
core/lib/Http/Request/RequestInputParameters.php
Normal file
155
core/lib/Http/Request/RequestInputParameters.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Request;
|
||||
|
||||
use KTXC\Http\Exception\BadRequestException;
|
||||
use KTXC\Http\Exception\UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* InputBag is a container for user input values such as $_GET, $_POST, $_REQUEST, and $_COOKIE.
|
||||
*
|
||||
* @author Saif Eddin Gmati <azjezz@protonmail.com>
|
||||
*/
|
||||
final class RequestInputParameters extends RequestParameters
|
||||
{
|
||||
/**
|
||||
* Returns an input value by name (scalar, Stringable, or array).
|
||||
*
|
||||
* Arrays are now allowed. (Previously only scalar values were permitted.)
|
||||
* No deep validation of array contents is performed here; callers should
|
||||
* sanitize nested values as needed.
|
||||
*
|
||||
* @param string|int|float|bool|array|null $default The default value if the key does not exist
|
||||
*
|
||||
* @return string|int|float|bool|array|null
|
||||
*
|
||||
* @throws BadRequestException if the stored input value is of an unsupported type
|
||||
* @throws \InvalidArgumentException if the provided default is of an unsupported type
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): string|int|float|bool|array|null
|
||||
{
|
||||
if (null !== $default && !\is_scalar($default) && !$default instanceof \Stringable && !\is_array($default)) {
|
||||
throw new \InvalidArgumentException(\sprintf('Expected a scalar or array value as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($default)));
|
||||
}
|
||||
|
||||
$value = parent::get($key, $this);
|
||||
|
||||
if (null !== $value && $this !== $value && !\is_scalar($value) && !$value instanceof \Stringable && !\is_array($value)) {
|
||||
throw new BadRequestException(\sprintf('Input value "%s" contains an invalid (non-scalar, non-array, non-Stringable) value.', $key));
|
||||
}
|
||||
|
||||
return $this === $value ? $default : $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current input values by a new set.
|
||||
*/
|
||||
public function replace(array $inputs = []): void
|
||||
{
|
||||
$this->parameters = [];
|
||||
$this->add($inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds input values.
|
||||
*/
|
||||
public function add(array $inputs = []): void
|
||||
{
|
||||
foreach ($inputs as $input => $value) {
|
||||
$this->set($input, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an input by name.
|
||||
*
|
||||
* @param string|int|float|bool|array|null $value
|
||||
*/
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
if (null !== $value && !\is_scalar($value) && !\is_array($value) && !$value instanceof \Stringable) {
|
||||
throw new \InvalidArgumentException(\sprintf('Expected a scalar, or an array as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($value)));
|
||||
}
|
||||
|
||||
$this->parameters[$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter value converted to an enum.
|
||||
*
|
||||
* @template T of \BackedEnum
|
||||
*
|
||||
* @param class-string<T> $class
|
||||
* @param ?T $default
|
||||
*
|
||||
* @return ?T
|
||||
*
|
||||
* @psalm-return ($default is null ? T|null : T)
|
||||
*
|
||||
* @throws BadRequestException if the input cannot be converted to an enum
|
||||
*/
|
||||
public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum
|
||||
{
|
||||
try {
|
||||
return parent::getEnum($key, $class, $default);
|
||||
} catch (UnexpectedValueException $e) {
|
||||
throw new BadRequestException($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter value converted to string.
|
||||
*
|
||||
* @throws BadRequestException if the input contains a non-scalar value
|
||||
*/
|
||||
public function getString(string $key, string $default = ''): string
|
||||
{
|
||||
// Shortcuts the parent method because the validation on scalar is already done in get().
|
||||
return (string) $this->get($key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException if the input value is an array and \FILTER_REQUIRE_ARRAY or \FILTER_FORCE_ARRAY is not set
|
||||
* @throws BadRequestException if the input value is invalid and \FILTER_NULL_ON_FAILURE is not set
|
||||
*/
|
||||
public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed
|
||||
{
|
||||
$value = $this->has($key) ? $this->all()[$key] : $default;
|
||||
|
||||
// Always turn $options into an array - this allows filter_var option shortcuts.
|
||||
if (!\is_array($options) && $options) {
|
||||
$options = ['flags' => $options];
|
||||
}
|
||||
|
||||
if (\is_array($value) && !(($options['flags'] ?? 0) & (\FILTER_REQUIRE_ARRAY | \FILTER_FORCE_ARRAY))) {
|
||||
throw new BadRequestException(\sprintf('Input value "%s" contains an array, but "FILTER_REQUIRE_ARRAY" or "FILTER_FORCE_ARRAY" flags were not set.', $key));
|
||||
}
|
||||
|
||||
if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) {
|
||||
throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null)));
|
||||
}
|
||||
|
||||
$options['flags'] ??= 0;
|
||||
$nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE;
|
||||
$options['flags'] |= \FILTER_NULL_ON_FAILURE;
|
||||
|
||||
$value = filter_var($value, $filter, $options);
|
||||
|
||||
if (null !== $value || $nullOnFailure) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
throw new BadRequestException(\sprintf('Input value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key));
|
||||
}
|
||||
}
|
||||
260
core/lib/Http/Request/RequestParameters.php
Normal file
260
core/lib/Http/Request/RequestParameters.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Request;
|
||||
|
||||
use KTXC\Http\Exception\BadRequestException;
|
||||
use KTXC\Http\Exception\UnexpectedValueException;
|
||||
|
||||
/**
|
||||
* ParameterBag is a container for key/value pairs.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* @implements \IteratorAggregate<string, mixed>
|
||||
*/
|
||||
class RequestParameters implements \IteratorAggregate, \Countable
|
||||
{
|
||||
public function __construct(
|
||||
protected array $parameters = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameters.
|
||||
*
|
||||
* @param string|null $key The name of the parameter to return or null to get them all
|
||||
*
|
||||
* @throws BadRequestException if the value is not an array
|
||||
*/
|
||||
public function all(?string $key = null): array
|
||||
{
|
||||
if (null === $key) {
|
||||
return $this->parameters;
|
||||
}
|
||||
|
||||
if (!\is_array($value = $this->parameters[$key] ?? [])) {
|
||||
throw new BadRequestException(\sprintf('Unexpected value for parameter "%s": expecting "array", got "%s".', $key, get_debug_type($value)));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter keys.
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
return array_keys($this->parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the current parameters by a new set.
|
||||
*/
|
||||
public function replace(array $parameters = []): void
|
||||
{
|
||||
$this->parameters = $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds parameters.
|
||||
*/
|
||||
public function add(array $parameters = []): void
|
||||
{
|
||||
$this->parameters = array_replace($this->parameters, $parameters);
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default;
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value): void
|
||||
{
|
||||
$this->parameters[$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the parameter is defined.
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return \array_key_exists($key, $this->parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a parameter.
|
||||
*/
|
||||
public function remove(string $key): void
|
||||
{
|
||||
unset($this->parameters[$key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the alphabetic characters of the parameter value.
|
||||
*
|
||||
* @throws UnexpectedValueException if the value cannot be converted to string
|
||||
*/
|
||||
public function getAlpha(string $key, string $default = ''): string
|
||||
{
|
||||
return preg_replace('/[^[:alpha:]]/', '', $this->getString($key, $default));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the alphabetic characters and digits of the parameter value.
|
||||
*
|
||||
* @throws UnexpectedValueException if the value cannot be converted to string
|
||||
*/
|
||||
public function getAlnum(string $key, string $default = ''): string
|
||||
{
|
||||
return preg_replace('/[^[:alnum:]]/', '', $this->getString($key, $default));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the digits of the parameter value.
|
||||
*
|
||||
* @throws UnexpectedValueException if the value cannot be converted to string
|
||||
*/
|
||||
public function getDigits(string $key, string $default = ''): string
|
||||
{
|
||||
return preg_replace('/[^[:digit:]]/', '', $this->getString($key, $default));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter as string.
|
||||
*
|
||||
* @throws UnexpectedValueException if the value cannot be converted to string
|
||||
*/
|
||||
public function getString(string $key, string $default = ''): string
|
||||
{
|
||||
$value = $this->get($key, $default);
|
||||
if (!\is_scalar($value) && !$value instanceof \Stringable) {
|
||||
throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be converted to "string".', $key));
|
||||
}
|
||||
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter value converted to integer.
|
||||
*
|
||||
* @throws UnexpectedValueException if the value cannot be converted to integer
|
||||
*/
|
||||
public function getInt(string $key, int $default = 0): int
|
||||
{
|
||||
return $this->filter($key, $default, \FILTER_VALIDATE_INT, ['flags' => \FILTER_REQUIRE_SCALAR]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter value converted to boolean.
|
||||
*
|
||||
* @throws UnexpectedValueException if the value cannot be converted to a boolean
|
||||
*/
|
||||
public function getBoolean(string $key, bool $default = false): bool
|
||||
{
|
||||
return $this->filter($key, $default, \FILTER_VALIDATE_BOOL, ['flags' => \FILTER_REQUIRE_SCALAR]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameter value converted to an enum.
|
||||
*
|
||||
* @template T of \BackedEnum
|
||||
*
|
||||
* @param class-string<T> $class
|
||||
* @param ?T $default
|
||||
*
|
||||
* @return ?T
|
||||
*
|
||||
* @psalm-return ($default is null ? T|null : T)
|
||||
*
|
||||
* @throws UnexpectedValueException if the parameter value cannot be converted to an enum
|
||||
*/
|
||||
public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum
|
||||
{
|
||||
$value = $this->get($key);
|
||||
|
||||
if (null === $value) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
return $class::from($value);
|
||||
} catch (\ValueError|\TypeError $e) {
|
||||
throw new UnexpectedValueException(\sprintf('Parameter "%s" cannot be converted to enum: %s.', $key, $e->getMessage()), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter key.
|
||||
*
|
||||
* @param int $filter FILTER_* constant
|
||||
* @param int|array{flags?: int, options?: array} $options Flags from FILTER_* constants
|
||||
*
|
||||
* @see https://php.net/filter-var
|
||||
*
|
||||
* @throws UnexpectedValueException if the parameter value is a non-stringable object
|
||||
* @throws UnexpectedValueException if the parameter value is invalid and \FILTER_NULL_ON_FAILURE is not set
|
||||
*/
|
||||
public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed
|
||||
{
|
||||
$value = $this->get($key, $default);
|
||||
|
||||
// Always turn $options into an array - this allows filter_var option shortcuts.
|
||||
if (!\is_array($options) && $options) {
|
||||
$options = ['flags' => $options];
|
||||
}
|
||||
|
||||
// Add a convenience check for arrays.
|
||||
if (\is_array($value) && !isset($options['flags'])) {
|
||||
$options['flags'] = \FILTER_REQUIRE_ARRAY;
|
||||
}
|
||||
|
||||
if (\is_object($value) && !$value instanceof \Stringable) {
|
||||
throw new UnexpectedValueException(\sprintf('Parameter value "%s" cannot be filtered.', $key));
|
||||
}
|
||||
|
||||
if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) {
|
||||
throw new \InvalidArgumentException(\sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null)));
|
||||
}
|
||||
|
||||
$options['flags'] ??= 0;
|
||||
$nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE;
|
||||
$options['flags'] |= \FILTER_NULL_ON_FAILURE;
|
||||
|
||||
$value = filter_var($value, $filter, $options);
|
||||
|
||||
if (null !== $value || $nullOnFailure) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
throw new \UnexpectedValueException(\sprintf('Parameter value "%s" is invalid and flag "FILTER_NULL_ON_FAILURE" was not set.', $key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator for parameters.
|
||||
*
|
||||
* @return \ArrayIterator<string, mixed>
|
||||
*/
|
||||
public function getIterator(): \ArrayIterator
|
||||
{
|
||||
return new \ArrayIterator($this->parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of parameters.
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return \count($this->parameters);
|
||||
}
|
||||
}
|
||||
99
core/lib/Http/Request/RequestServerParameters.php
Normal file
99
core/lib/Http/Request/RequestServerParameters.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Request;
|
||||
|
||||
/**
|
||||
* ServerBag is a container for HTTP headers from the $_SERVER variable.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
* @author Bulat Shakirzyanov <mallluhuct@gmail.com>
|
||||
* @author Robert Kiss <kepten@gmail.com>
|
||||
*/
|
||||
class RequestServerParameters extends RequestParameters
|
||||
{
|
||||
/**
|
||||
* Gets the HTTP headers.
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($this->parameters as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
$headers[substr($key, 5)] = $value;
|
||||
} elseif (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true) && '' !== $value) {
|
||||
$headers[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($this->parameters['PHP_AUTH_USER'])) {
|
||||
$headers['PHP_AUTH_USER'] = $this->parameters['PHP_AUTH_USER'];
|
||||
$headers['PHP_AUTH_PW'] = $this->parameters['PHP_AUTH_PW'] ?? '';
|
||||
} else {
|
||||
/*
|
||||
* php-cgi under Apache does not pass HTTP Basic user/pass to PHP by default
|
||||
* For this workaround to work, add these lines to your .htaccess file:
|
||||
* RewriteCond %{HTTP:Authorization} .+
|
||||
* RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
|
||||
*
|
||||
* A sample .htaccess file:
|
||||
* RewriteEngine On
|
||||
* RewriteCond %{HTTP:Authorization} .+
|
||||
* RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
|
||||
* RewriteCond %{REQUEST_FILENAME} !-f
|
||||
* RewriteRule ^(.*)$ index.php [QSA,L]
|
||||
*/
|
||||
|
||||
$authorizationHeader = null;
|
||||
if (isset($this->parameters['HTTP_AUTHORIZATION'])) {
|
||||
$authorizationHeader = $this->parameters['HTTP_AUTHORIZATION'];
|
||||
} elseif (isset($this->parameters['REDIRECT_HTTP_AUTHORIZATION'])) {
|
||||
$authorizationHeader = $this->parameters['REDIRECT_HTTP_AUTHORIZATION'];
|
||||
}
|
||||
|
||||
if (null !== $authorizationHeader) {
|
||||
if (0 === stripos($authorizationHeader, 'basic ')) {
|
||||
// Decode AUTHORIZATION header into PHP_AUTH_USER and PHP_AUTH_PW when authorization header is basic
|
||||
$exploded = explode(':', base64_decode(substr($authorizationHeader, 6)), 2);
|
||||
if (2 == \count($exploded)) {
|
||||
[$headers['PHP_AUTH_USER'], $headers['PHP_AUTH_PW']] = $exploded;
|
||||
}
|
||||
} elseif (empty($this->parameters['PHP_AUTH_DIGEST']) && (0 === stripos($authorizationHeader, 'digest '))) {
|
||||
// In some circumstances PHP_AUTH_DIGEST needs to be set
|
||||
$headers['PHP_AUTH_DIGEST'] = $authorizationHeader;
|
||||
$this->parameters['PHP_AUTH_DIGEST'] = $authorizationHeader;
|
||||
} elseif (0 === stripos($authorizationHeader, 'bearer ')) {
|
||||
/*
|
||||
* XXX: Since there is no PHP_AUTH_BEARER in PHP predefined variables,
|
||||
* I'll just set $headers['AUTHORIZATION'] here.
|
||||
* https://php.net/reserved.variables.server
|
||||
*/
|
||||
$headers['AUTHORIZATION'] = $authorizationHeader;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($headers['AUTHORIZATION'])) {
|
||||
return $headers;
|
||||
}
|
||||
|
||||
// PHP_AUTH_USER/PHP_AUTH_PW
|
||||
if (isset($headers['PHP_AUTH_USER'])) {
|
||||
$headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.($headers['PHP_AUTH_PW'] ?? ''));
|
||||
} elseif (isset($headers['PHP_AUTH_DIGEST'])) {
|
||||
$headers['AUTHORIZATION'] = $headers['PHP_AUTH_DIGEST'];
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
}
|
||||
78
core/lib/Http/Response/FileResponse.php
Normal file
78
core/lib/Http/Response/FileResponse.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
namespace KTXC\Http\Response;
|
||||
|
||||
/**
|
||||
* Simple file response that reads a file from disk and serves it.
|
||||
*
|
||||
* Only supports sending full file contents (no range / streaming for now).
|
||||
*/
|
||||
class FileResponse extends Response
|
||||
{
|
||||
private string $filePath;
|
||||
|
||||
public function __construct(string $filePath, int $status = 200, array $headers = [])
|
||||
{
|
||||
if (!is_file($filePath) || !is_readable($filePath)) {
|
||||
throw new \InvalidArgumentException(sprintf('FileResponse: file not found or not readable: %s', $filePath));
|
||||
}
|
||||
|
||||
$this->filePath = $filePath;
|
||||
|
||||
// Determine content type (very small helper; rely on common extensions)
|
||||
$mime = self::guessMimeType($filePath) ?? 'application/octet-stream';
|
||||
$headers['Content-Type'] = $headers['Content-Type'] ?? $mime;
|
||||
$headers['Content-Length'] = (string) filesize($filePath);
|
||||
$headers['Last-Modified'] = gmdate('D, d M Y H:i:s', filemtime($filePath)) . ' GMT';
|
||||
$headers['Cache-Control'] = $headers['Cache-Control'] ?? 'public, max-age=60';
|
||||
|
||||
parent::__construct('', $status, $headers);
|
||||
|
||||
// Defer reading file until sendContent to avoid memory usage.
|
||||
}
|
||||
|
||||
public function getFilePath(): string
|
||||
{
|
||||
return $this->filePath;
|
||||
}
|
||||
|
||||
public function sendContent(): static
|
||||
{
|
||||
// Output file contents directly
|
||||
readfile($this->filePath);
|
||||
return $this;
|
||||
}
|
||||
|
||||
private static function guessMimeType(string $filePath): ?string
|
||||
{
|
||||
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
return match ($ext) {
|
||||
'html', 'htm' => 'text/html; charset=UTF-8',
|
||||
'css' => 'text/css; charset=UTF-8',
|
||||
'js' => 'application/javascript; charset=UTF-8',
|
||||
'json' => 'application/json; charset=UTF-8',
|
||||
'png' => 'image/png',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'svg' => 'image/svg+xml',
|
||||
'txt' => 'text/plain; charset=UTF-8',
|
||||
'xml' => 'application/xml; charset=UTF-8',
|
||||
default => self::finfoMime($filePath),
|
||||
};
|
||||
}
|
||||
|
||||
private static function finfoMime(string $filePath): ?string
|
||||
{
|
||||
if (function_exists('finfo_open')) {
|
||||
$f = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($f) {
|
||||
$mime = finfo_file($f, $filePath) ?: null;
|
||||
finfo_close($f);
|
||||
return $mime;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
189
core/lib/Http/Response/JsonResponse.php
Normal file
189
core/lib/Http/Response/JsonResponse.php
Normal file
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Response;
|
||||
|
||||
/**
|
||||
* Response represents an HTTP response in JSON format.
|
||||
*
|
||||
* Note that this class does not force the returned JSON content to be an
|
||||
* object. It is however recommended that you do return an object as it
|
||||
* protects yourself against XSSI and JSON-JavaScript Hijacking.
|
||||
*
|
||||
* @see https://github.com/OWASP/CheatSheetSeries/blob/master/cheatsheets/AJAX_Security_Cheat_Sheet.md#always-return-json-with-an-object-on-the-outside
|
||||
*
|
||||
* @author Igor Wiedler <igor@wiedler.ch>
|
||||
*/
|
||||
class JsonResponse extends Response
|
||||
{
|
||||
protected mixed $data;
|
||||
protected ?string $callback = null;
|
||||
|
||||
// Encode <, >, ', &, and " characters in the JSON, making it also safe to be embedded into HTML.
|
||||
// 15 === JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT
|
||||
public const DEFAULT_ENCODING_OPTIONS = 15;
|
||||
|
||||
protected int $encodingOptions = self::DEFAULT_ENCODING_OPTIONS;
|
||||
|
||||
/**
|
||||
* @param bool $json If the data is already a JSON string
|
||||
*/
|
||||
public function __construct(mixed $data = null, int $status = 200, array $headers = [], bool $json = false)
|
||||
{
|
||||
parent::__construct('', $status, $headers);
|
||||
|
||||
if ($json && !\is_string($data) && !is_numeric($data) && !$data instanceof \Stringable) {
|
||||
throw new \TypeError(\sprintf('"%s": If $json is set to true, argument $data must be a string or object implementing __toString(), "%s" given.', __METHOD__, get_debug_type($data)));
|
||||
}
|
||||
|
||||
$data ??= new \ArrayObject();
|
||||
|
||||
$json ? $this->setJson($data) : $this->setData($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for chainability.
|
||||
*
|
||||
* Example:
|
||||
*
|
||||
* return JsonResponse::fromJsonString('{"key": "value"}')
|
||||
* ->setSharedMaxAge(300);
|
||||
*
|
||||
* @param string $data The JSON response string
|
||||
* @param int $status The response status code (200 "OK" by default)
|
||||
* @param array $headers An array of response headers
|
||||
*/
|
||||
public static function fromJsonString(string $data, int $status = 200, array $headers = []): static
|
||||
{
|
||||
return new static($data, $status, $headers, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the JSONP callback.
|
||||
*
|
||||
* @param string|null $callback The JSONP callback or null to use none
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws \InvalidArgumentException When the callback name is not valid
|
||||
*/
|
||||
public function setCallback(?string $callback): static
|
||||
{
|
||||
if (null !== $callback) {
|
||||
// partially taken from https://geekality.net/2011/08/03/valid-javascript-identifier/
|
||||
// partially taken from https://github.com/willdurand/JsonpCallbackValidator
|
||||
// JsonpCallbackValidator is released under the MIT License. See https://github.com/willdurand/JsonpCallbackValidator/blob/v1.1.0/LICENSE for details.
|
||||
// (c) William Durand <william.durand1@gmail.com>
|
||||
$pattern = '/^[$_\p{L}][$_\p{L}\p{Mn}\p{Mc}\p{Nd}\p{Pc}\x{200C}\x{200D}]*(?:\[(?:"(?:\\\.|[^"\\\])*"|\'(?:\\\.|[^\'\\\])*\'|\d+)\])*?$/u';
|
||||
$reserved = [
|
||||
'break', 'do', 'instanceof', 'typeof', 'case', 'else', 'new', 'var', 'catch', 'finally', 'return', 'void', 'continue', 'for', 'switch', 'while',
|
||||
'debugger', 'function', 'this', 'with', 'default', 'if', 'throw', 'delete', 'in', 'try', 'class', 'enum', 'extends', 'super', 'const', 'export',
|
||||
'import', 'implements', 'let', 'private', 'public', 'yield', 'interface', 'package', 'protected', 'static', 'null', 'true', 'false',
|
||||
];
|
||||
$parts = explode('.', $callback);
|
||||
foreach ($parts as $part) {
|
||||
if (!preg_match($pattern, $part) || \in_array($part, $reserved, true)) {
|
||||
throw new \InvalidArgumentException('The callback name is not valid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->callback = $callback;
|
||||
|
||||
return $this->update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a raw string containing a JSON document to be sent.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setJson(string $json): static
|
||||
{
|
||||
$this->data = $json;
|
||||
|
||||
return $this->update();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the data to be sent as JSON.
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function setData(mixed $data = []): static
|
||||
{
|
||||
try {
|
||||
$data = json_encode($data, $this->encodingOptions);
|
||||
} catch (\Exception $e) {
|
||||
if ('Exception' === $e::class && str_starts_with($e->getMessage(), 'Failed calling ')) {
|
||||
throw $e->getPrevious() ?: $e;
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if (\JSON_THROW_ON_ERROR & $this->encodingOptions) {
|
||||
return $this->setJson($data);
|
||||
}
|
||||
|
||||
if (\JSON_ERROR_NONE !== json_last_error()) {
|
||||
throw new \InvalidArgumentException(json_last_error_msg());
|
||||
}
|
||||
|
||||
return $this->setJson($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns options used while encoding data to JSON.
|
||||
*/
|
||||
public function getEncodingOptions(): int
|
||||
{
|
||||
return $this->encodingOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets options used while encoding data to JSON.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setEncodingOptions(int $encodingOptions): static
|
||||
{
|
||||
$this->encodingOptions = $encodingOptions;
|
||||
|
||||
return $this->setData(json_decode($this->data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the content and headers according to the JSON data and callback.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function update(): static
|
||||
{
|
||||
if (null !== $this->callback) {
|
||||
// Not using application/javascript for compatibility reasons with older browsers.
|
||||
$this->headers->set('Content-Type', 'text/javascript');
|
||||
|
||||
return $this->setContent(\sprintf('/**/%s(%s);', $this->callback, $this->data));
|
||||
}
|
||||
|
||||
// Only set the header when there is none or when it equals 'text/javascript' (from a previous update with callback)
|
||||
// in order to not overwrite a custom definition.
|
||||
if (!$this->headers->has('Content-Type') || 'text/javascript' === $this->headers->get('Content-Type')) {
|
||||
$this->headers->set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
return $this->setContent($this->data);
|
||||
}
|
||||
}
|
||||
94
core/lib/Http/Response/RedirectResponse.php
Normal file
94
core/lib/Http/Response/RedirectResponse.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Response;
|
||||
|
||||
/**
|
||||
* RedirectResponse represents an HTTP response doing a redirect.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class RedirectResponse extends Response
|
||||
{
|
||||
protected string $targetUrl;
|
||||
|
||||
/**
|
||||
* Creates a redirect response so that it conforms to the rules defined for a redirect status code.
|
||||
*
|
||||
* @param string $url The URL to redirect to. The URL should be a full URL, with schema etc.,
|
||||
* but practically every browser redirects on paths only as well
|
||||
* @param int $status The HTTP status code (302 "Found" by default)
|
||||
* @param array $headers The headers (Location is always set to the given URL)
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*
|
||||
* @see https://tools.ietf.org/html/rfc2616#section-10.3
|
||||
*/
|
||||
public function __construct(string $url, int $status = 302, array $headers = [])
|
||||
{
|
||||
parent::__construct('', $status, $headers);
|
||||
|
||||
$this->setTargetUrl($url);
|
||||
|
||||
if (!$this->isRedirect()) {
|
||||
throw new \InvalidArgumentException(\sprintf('The HTTP status code is not a redirect ("%s" given).', $status));
|
||||
}
|
||||
|
||||
if (301 == $status && !\array_key_exists('cache-control', array_change_key_case($headers, \CASE_LOWER))) {
|
||||
$this->headers->remove('cache-control');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the target URL.
|
||||
*/
|
||||
public function getTargetUrl(): string
|
||||
{
|
||||
return $this->targetUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the redirect target of this response.
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
*/
|
||||
public function setTargetUrl(string $url): static
|
||||
{
|
||||
if ('' === $url) {
|
||||
throw new \InvalidArgumentException('Cannot redirect to an empty URL.');
|
||||
}
|
||||
|
||||
$this->targetUrl = $url;
|
||||
|
||||
$this->setContent(
|
||||
\sprintf('<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="refresh" content="0;url=\'%1$s\'" />
|
||||
|
||||
<title>Redirecting to %1$s</title>
|
||||
</head>
|
||||
<body>
|
||||
Redirecting to <a href="%1$s">%1$s</a>.
|
||||
</body>
|
||||
</html>', htmlspecialchars($url, \ENT_QUOTES, 'UTF-8')));
|
||||
|
||||
$this->headers->set('Location', $url);
|
||||
$this->headers->set('Content-Type', 'text/html; charset=utf-8');
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
1336
core/lib/Http/Response/Response.php
Normal file
1336
core/lib/Http/Response/Response.php
Normal file
File diff suppressed because it is too large
Load Diff
275
core/lib/Http/Response/ResponseHeaderParameters.php
Normal file
275
core/lib/Http/Response/ResponseHeaderParameters.php
Normal file
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Response;
|
||||
|
||||
use KTXC\Http\Cookie;
|
||||
use KTXC\Http\HeaderParameters;
|
||||
use KTXC\Http\HeaderUtils;
|
||||
|
||||
/**
|
||||
* ResponseHeaderBag is a container for Response HTTP headers.
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class ResponseHeaderParameters extends HeaderParameters
|
||||
{
|
||||
public const COOKIES_FLAT = 'flat';
|
||||
public const COOKIES_ARRAY = 'array';
|
||||
|
||||
public const DISPOSITION_ATTACHMENT = 'attachment';
|
||||
public const DISPOSITION_INLINE = 'inline';
|
||||
|
||||
protected array $computedCacheControl = [];
|
||||
protected array $cookies = [];
|
||||
protected array $headerNames = [];
|
||||
|
||||
public function __construct(array $headers = [])
|
||||
{
|
||||
parent::__construct($headers);
|
||||
|
||||
if (!isset($this->headers['cache-control'])) {
|
||||
$this->set('Cache-Control', '');
|
||||
}
|
||||
|
||||
/* RFC2616 - 14.18 says all Responses need to have a Date */
|
||||
if (!isset($this->headers['date'])) {
|
||||
$this->initDate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the headers, with original capitalizations.
|
||||
*/
|
||||
public function allPreserveCase(): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($this->all() as $name => $value) {
|
||||
$headers[$this->headerNames[$name] ?? $name] = $value;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public function allPreserveCaseWithoutCookies(): array
|
||||
{
|
||||
$headers = $this->allPreserveCase();
|
||||
if (isset($this->headerNames['set-cookie'])) {
|
||||
unset($headers[$this->headerNames['set-cookie']]);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public function replace(array $headers = []): void
|
||||
{
|
||||
$this->headerNames = [];
|
||||
|
||||
parent::replace($headers);
|
||||
|
||||
if (!isset($this->headers['cache-control'])) {
|
||||
$this->set('Cache-Control', '');
|
||||
}
|
||||
|
||||
if (!isset($this->headers['date'])) {
|
||||
$this->initDate();
|
||||
}
|
||||
}
|
||||
|
||||
public function all(?string $key = null): array
|
||||
{
|
||||
$headers = parent::all();
|
||||
|
||||
if (null !== $key) {
|
||||
$key = strtr($key, self::UPPER, self::LOWER);
|
||||
|
||||
return 'set-cookie' !== $key ? $headers[$key] ?? [] : array_map('strval', $this->getCookies());
|
||||
}
|
||||
|
||||
foreach ($this->getCookies() as $cookie) {
|
||||
$headers['set-cookie'][] = (string) $cookie;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public function set(string $key, string|array|null $values, bool $replace = true): void
|
||||
{
|
||||
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
|
||||
|
||||
if ('set-cookie' === $uniqueKey) {
|
||||
if ($replace) {
|
||||
$this->cookies = [];
|
||||
}
|
||||
foreach ((array) $values as $cookie) {
|
||||
$this->setCookie(Cookie::fromString($cookie));
|
||||
}
|
||||
$this->headerNames[$uniqueKey] = $key;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->headerNames[$uniqueKey] = $key;
|
||||
|
||||
parent::set($key, $values, $replace);
|
||||
|
||||
// ensure the cache-control header has sensible defaults
|
||||
if (\in_array($uniqueKey, ['cache-control', 'etag', 'last-modified', 'expires'], true) && '' !== $computed = $this->computeCacheControlValue()) {
|
||||
$this->headers['cache-control'] = [$computed];
|
||||
$this->headerNames['cache-control'] = 'Cache-Control';
|
||||
$this->computedCacheControl = $this->parseCacheControl($computed);
|
||||
}
|
||||
}
|
||||
|
||||
public function remove(string $key): void
|
||||
{
|
||||
$uniqueKey = strtr($key, self::UPPER, self::LOWER);
|
||||
unset($this->headerNames[$uniqueKey]);
|
||||
|
||||
if ('set-cookie' === $uniqueKey) {
|
||||
$this->cookies = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
parent::remove($key);
|
||||
|
||||
if ('cache-control' === $uniqueKey) {
|
||||
$this->computedCacheControl = [];
|
||||
}
|
||||
|
||||
if ('date' === $uniqueKey) {
|
||||
$this->initDate();
|
||||
}
|
||||
}
|
||||
|
||||
public function hasCacheControlDirective(string $key): bool
|
||||
{
|
||||
return \array_key_exists($key, $this->computedCacheControl);
|
||||
}
|
||||
|
||||
public function getCacheControlDirective(string $key): bool|string|null
|
||||
{
|
||||
return $this->computedCacheControl[$key] ?? null;
|
||||
}
|
||||
|
||||
public function setCookie(Cookie $cookie): void
|
||||
{
|
||||
$this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie;
|
||||
$this->headerNames['set-cookie'] = 'Set-Cookie';
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a cookie from the array, but does not unset it in the browser.
|
||||
*/
|
||||
public function removeCookie(string $name, ?string $path = '/', ?string $domain = null): void
|
||||
{
|
||||
$path ??= '/';
|
||||
|
||||
unset($this->cookies[$domain][$path][$name]);
|
||||
|
||||
if (empty($this->cookies[$domain][$path])) {
|
||||
unset($this->cookies[$domain][$path]);
|
||||
|
||||
if (empty($this->cookies[$domain])) {
|
||||
unset($this->cookies[$domain]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->cookies) {
|
||||
unset($this->headerNames['set-cookie']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all cookies.
|
||||
*
|
||||
* @return Cookie[]
|
||||
*
|
||||
* @throws \InvalidArgumentException When the $format is invalid
|
||||
*/
|
||||
public function getCookies(string $format = self::COOKIES_FLAT): array
|
||||
{
|
||||
if (!\in_array($format, [self::COOKIES_FLAT, self::COOKIES_ARRAY])) {
|
||||
throw new \InvalidArgumentException(\sprintf('Format "%s" invalid (%s).', $format, implode(', ', [self::COOKIES_FLAT, self::COOKIES_ARRAY])));
|
||||
}
|
||||
|
||||
if (self::COOKIES_ARRAY === $format) {
|
||||
return $this->cookies;
|
||||
}
|
||||
|
||||
$flattenedCookies = [];
|
||||
foreach ($this->cookies as $path) {
|
||||
foreach ($path as $cookies) {
|
||||
foreach ($cookies as $cookie) {
|
||||
$flattenedCookies[] = $cookie;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $flattenedCookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears a cookie in the browser.
|
||||
*
|
||||
* @param bool $partitioned
|
||||
*/
|
||||
public function clearCookie(string $name, ?string $path = '/', ?string $domain = null, bool $secure = false, bool $httpOnly = true, ?string $sameSite = null /* , bool $partitioned = false */): void
|
||||
{
|
||||
$partitioned = 6 < \func_num_args() ? func_get_arg(6) : false;
|
||||
|
||||
$this->setCookie(new Cookie($name, null, 1, $path, $domain, $secure, $httpOnly, false, $sameSite, $partitioned));
|
||||
}
|
||||
|
||||
/**
|
||||
* @see HeaderUtils::makeDisposition()
|
||||
*/
|
||||
public function makeDisposition(string $disposition, string $filename, string $filenameFallback = ''): string
|
||||
{
|
||||
return HeaderUtils::makeDisposition($disposition, $filename, $filenameFallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the calculated value of the cache-control header.
|
||||
*
|
||||
* This considers several other headers and calculates or modifies the
|
||||
* cache-control header to a sensible, conservative value.
|
||||
*/
|
||||
protected function computeCacheControlValue(): string
|
||||
{
|
||||
if (!$this->cacheControl) {
|
||||
if ($this->has('Last-Modified') || $this->has('Expires')) {
|
||||
return 'private, must-revalidate'; // allows for heuristic expiration (RFC 7234 Section 4.2.2) in the case of "Last-Modified"
|
||||
}
|
||||
|
||||
// conservative by default
|
||||
return 'no-cache, private';
|
||||
}
|
||||
|
||||
$header = $this->getCacheControlHeader();
|
||||
if (isset($this->cacheControl['public']) || isset($this->cacheControl['private'])) {
|
||||
return $header;
|
||||
}
|
||||
|
||||
// public if s-maxage is defined, private otherwise
|
||||
if (!isset($this->cacheControl['s-maxage'])) {
|
||||
return $header.', private';
|
||||
}
|
||||
|
||||
return $header;
|
||||
}
|
||||
|
||||
private function initDate(): void
|
||||
{
|
||||
$this->set('Date', gmdate('D, d M Y H:i:s').' GMT');
|
||||
}
|
||||
}
|
||||
164
core/lib/Http/Response/StreamedJsonResponse.php
Normal file
164
core/lib/Http/Response/StreamedJsonResponse.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Response;
|
||||
|
||||
/**
|
||||
* StreamedJsonResponse represents a streamed HTTP response for JSON.
|
||||
*
|
||||
* A StreamedJsonResponse uses a structure and generics to create an
|
||||
* efficient resource-saving JSON response.
|
||||
*
|
||||
* It is recommended to use flush() function after a specific number of items to directly stream the data.
|
||||
*
|
||||
* @see flush()
|
||||
*
|
||||
* @author Alexander Schranz <alexander@sulu.io>
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* function loadArticles(): \Generator
|
||||
* // some streamed loading
|
||||
* yield ['title' => 'Article 1'];
|
||||
* yield ['title' => 'Article 2'];
|
||||
* yield ['title' => 'Article 3'];
|
||||
* // recommended to use flush() after every specific number of items
|
||||
* }),
|
||||
*
|
||||
* $response = new StreamedJsonResponse(
|
||||
* // json structure with generators in which will be streamed
|
||||
* [
|
||||
* '_embedded' => [
|
||||
* 'articles' => loadArticles(), // any generator which you want to stream as list of data
|
||||
* ],
|
||||
* ],
|
||||
* );
|
||||
*/
|
||||
class StreamedJsonResponse extends StreamedResponse
|
||||
{
|
||||
private const PLACEHOLDER = '__symfony_json__';
|
||||
|
||||
/**
|
||||
* @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator
|
||||
* @param int $status The HTTP status code (200 "OK" by default)
|
||||
* @param array<string, string|string[]> $headers An array of HTTP headers
|
||||
* @param int $encodingOptions Flags for the json_encode() function
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly iterable $data,
|
||||
int $status = 200,
|
||||
array $headers = [],
|
||||
private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS,
|
||||
) {
|
||||
parent::__construct($this->stream(...), $status, $headers);
|
||||
|
||||
if (!$this->headers->get('Content-Type')) {
|
||||
$this->headers->set('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
|
||||
private function stream(): void
|
||||
{
|
||||
$jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions;
|
||||
$keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK;
|
||||
|
||||
$this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions);
|
||||
}
|
||||
|
||||
private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
|
||||
{
|
||||
if (\is_array($data)) {
|
||||
$this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_iterable($data) && !$data instanceof \JsonSerializable) {
|
||||
$this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode($data, $jsonEncodingOptions);
|
||||
}
|
||||
|
||||
private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void
|
||||
{
|
||||
$generators = [];
|
||||
|
||||
array_walk_recursive($data, function (&$item, $key) use (&$generators) {
|
||||
if (self::PLACEHOLDER === $key) {
|
||||
// if the placeholder is already in the structure it should be replaced with a new one that explode
|
||||
// works like expected for the structure
|
||||
$generators[] = $key;
|
||||
}
|
||||
|
||||
// generators should be used but for better DX all kind of Traversable and objects are supported
|
||||
if (\is_object($item)) {
|
||||
$generators[] = $item;
|
||||
$item = self::PLACEHOLDER;
|
||||
} elseif (self::PLACEHOLDER === $item) {
|
||||
// if the placeholder is already in the structure it should be replaced with a new one that explode
|
||||
// works like expected for the structure
|
||||
$generators[] = $item;
|
||||
}
|
||||
});
|
||||
|
||||
$jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions));
|
||||
|
||||
foreach ($generators as $index => $generator) {
|
||||
// send first and between parts of the structure
|
||||
echo $jsonParts[$index];
|
||||
|
||||
$this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions);
|
||||
}
|
||||
|
||||
// send last part of the structure
|
||||
echo $jsonParts[array_key_last($jsonParts)];
|
||||
}
|
||||
|
||||
private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void
|
||||
{
|
||||
$isFirstItem = true;
|
||||
$startTag = '[';
|
||||
|
||||
foreach ($iterable as $key => $item) {
|
||||
if ($isFirstItem) {
|
||||
$isFirstItem = false;
|
||||
// depending on the first elements key the generator is detected as a list or map
|
||||
// we can not check for a whole list or map because that would hurt the performance
|
||||
// of the streamed response which is the main goal of this response class
|
||||
if (0 !== $key) {
|
||||
$startTag = '{';
|
||||
}
|
||||
|
||||
echo $startTag;
|
||||
} else {
|
||||
// if not first element of the generic, a separator is required between the elements
|
||||
echo ',';
|
||||
}
|
||||
|
||||
if ('{' === $startTag) {
|
||||
echo json_encode((string) $key, $keyEncodingOptions).':';
|
||||
}
|
||||
|
||||
$this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions);
|
||||
}
|
||||
|
||||
if ($isFirstItem) { // indicates that the generator was empty
|
||||
echo '[';
|
||||
}
|
||||
|
||||
echo '[' === $startTag ? ']' : '}';
|
||||
}
|
||||
}
|
||||
152
core/lib/Http/Response/StreamedResponse.php
Normal file
152
core/lib/Http/Response/StreamedResponse.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types = 1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC\Http\Response;
|
||||
|
||||
/**
|
||||
* StreamedResponse represents a streamed HTTP response.
|
||||
*
|
||||
* A StreamedResponse uses a callback or an iterable of strings for its content.
|
||||
*
|
||||
* The callback should use the standard PHP functions like echo
|
||||
* to stream the response back to the client. The flush() function
|
||||
* can also be used if needed.
|
||||
*
|
||||
* @see flush()
|
||||
*
|
||||
* @author Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
class StreamedResponse extends Response
|
||||
{
|
||||
protected ?\Closure $callback = null;
|
||||
protected bool $streamed = false;
|
||||
|
||||
private bool $headersSent = false;
|
||||
|
||||
/**
|
||||
* @param callable|iterable<string>|null $callbackOrChunks
|
||||
* @param int $status The HTTP status code (200 "OK" by default)
|
||||
*/
|
||||
public function __construct(callable|iterable|null $callbackOrChunks = null, int $status = 200, array $headers = [])
|
||||
{
|
||||
parent::__construct(null, $status, $headers);
|
||||
|
||||
if (\is_callable($callbackOrChunks)) {
|
||||
$this->setCallback($callbackOrChunks);
|
||||
} elseif ($callbackOrChunks) {
|
||||
$this->setChunks($callbackOrChunks);
|
||||
}
|
||||
$this->streamed = false;
|
||||
$this->headersSent = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<string> $chunks
|
||||
*/
|
||||
public function setChunks(iterable $chunks): static
|
||||
{
|
||||
$this->callback = static function () use ($chunks): void {
|
||||
foreach ($chunks as $chunk) {
|
||||
echo $chunk;
|
||||
@ob_flush();
|
||||
flush();
|
||||
}
|
||||
};
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the PHP callback associated with this Response.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setCallback(callable $callback): static
|
||||
{
|
||||
$this->callback = $callback(...);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCallback(): ?\Closure
|
||||
{
|
||||
if (!isset($this->callback)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($this->callback)(...);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method only sends the headers once.
|
||||
*
|
||||
* @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function sendHeaders(?int $statusCode = null): static
|
||||
{
|
||||
if ($this->headersSent) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
if ($statusCode < 100 || $statusCode >= 200) {
|
||||
$this->headersSent = true;
|
||||
}
|
||||
|
||||
return parent::sendHeaders($statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method only sends the content once.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function sendContent(): static
|
||||
{
|
||||
if ($this->streamed) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->streamed = true;
|
||||
|
||||
if (!isset($this->callback)) {
|
||||
throw new \LogicException('The Response callback must be set.');
|
||||
}
|
||||
|
||||
($this->callback)();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*
|
||||
* @throws \LogicException when the content is not null
|
||||
*/
|
||||
public function setContent(?string $content): static
|
||||
{
|
||||
if (null !== $content) {
|
||||
throw new \LogicException('The content cannot be set on a StreamedResponse instance.');
|
||||
}
|
||||
|
||||
$this->streamed = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContent(): string|false
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
148
core/lib/Http/Session/SessionInterface.php
Normal file
148
core/lib/Http/Session/SessionInterface.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Http\Session;
|
||||
|
||||
/**
|
||||
* Interface for session storage.
|
||||
*/
|
||||
interface SessionInterface
|
||||
{
|
||||
/**
|
||||
* Starts the session storage.
|
||||
*
|
||||
* @return bool True if session started
|
||||
*
|
||||
* @throws \RuntimeException if session fails to start
|
||||
*/
|
||||
public function start(): bool;
|
||||
|
||||
/**
|
||||
* Returns the session ID.
|
||||
*
|
||||
* @return string The session ID
|
||||
*/
|
||||
public function getId(): string;
|
||||
|
||||
/**
|
||||
* Sets the session ID.
|
||||
*
|
||||
* @param string $id The session ID
|
||||
*/
|
||||
public function setId(string $id): void;
|
||||
|
||||
/**
|
||||
* Returns the session name.
|
||||
*
|
||||
* @return string The session name
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Sets the session name.
|
||||
*
|
||||
* @param string $name The session name
|
||||
*/
|
||||
public function setName(string $name): void;
|
||||
|
||||
/**
|
||||
* Invalidates the current session.
|
||||
*
|
||||
* Clears all session attributes and flashes and regenerates the
|
||||
* session and deletes the old session from persistence.
|
||||
*
|
||||
* @param int|null $lifetime Sets the cookie lifetime for the session cookie.
|
||||
* A null value will leave the system settings unchanged,
|
||||
* 0 sets the cookie to expire with browser session.
|
||||
* Time is in seconds, and is not a Unix timestamp.
|
||||
*
|
||||
* @return bool True if session invalidated, false if error
|
||||
*/
|
||||
public function invalidate(?int $lifetime = null): bool;
|
||||
|
||||
/**
|
||||
* Migrates the current session to a new session id while maintaining all
|
||||
* session attributes.
|
||||
*
|
||||
* @param bool $destroy Whether to delete the old session or leave it to garbage collection
|
||||
* @param int|null $lifetime Sets the cookie lifetime for the session cookie.
|
||||
* A null value will leave the system settings unchanged,
|
||||
* 0 sets the cookie to expire with browser session.
|
||||
* Time is in seconds, and is not a Unix timestamp.
|
||||
*
|
||||
* @return bool True if session migrated, false if error
|
||||
*/
|
||||
public function migrate(bool $destroy = false, ?int $lifetime = null): bool;
|
||||
|
||||
/**
|
||||
* Force the session to be saved and closed.
|
||||
*
|
||||
* This method is generally not required for real sessions as
|
||||
* the session will be automatically saved at the end of
|
||||
* code execution.
|
||||
*/
|
||||
public function save(): void;
|
||||
|
||||
/**
|
||||
* Checks if an attribute is defined.
|
||||
*
|
||||
* @param string $name The attribute name
|
||||
*
|
||||
* @return bool True if the attribute is defined, false otherwise
|
||||
*/
|
||||
public function has(string $name): bool;
|
||||
|
||||
/**
|
||||
* Returns an attribute.
|
||||
*
|
||||
* @param string $name The attribute name
|
||||
* @param mixed $default The default value if not found
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function get(string $name, mixed $default = null): mixed;
|
||||
|
||||
/**
|
||||
* Sets an attribute.
|
||||
*
|
||||
* @param string $name The attribute name
|
||||
* @param mixed $value The attribute value
|
||||
*/
|
||||
public function set(string $name, mixed $value): void;
|
||||
|
||||
/**
|
||||
* Returns attributes.
|
||||
*
|
||||
* @return array<string, mixed> Attributes
|
||||
*/
|
||||
public function all(): array;
|
||||
|
||||
/**
|
||||
* Sets attributes.
|
||||
*
|
||||
* @param array<string, mixed> $attributes Attributes
|
||||
*/
|
||||
public function replace(array $attributes): void;
|
||||
|
||||
/**
|
||||
* Removes an attribute.
|
||||
*
|
||||
* @param string $name The attribute name
|
||||
*
|
||||
* @return mixed The removed value or null when it does not exist
|
||||
*/
|
||||
public function remove(string $name): mixed;
|
||||
|
||||
/**
|
||||
* Clears all attributes.
|
||||
*/
|
||||
public function clear(): void;
|
||||
|
||||
/**
|
||||
* Checks if the session was started.
|
||||
*
|
||||
* @return bool True if started, false otherwise
|
||||
*/
|
||||
public function isStarted(): bool;
|
||||
}
|
||||
5
core/lib/Injection/Builder.php
Normal file
5
core/lib/Injection/Builder.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Injection;
|
||||
|
||||
class Builder extends \DI\ContainerBuilder {}
|
||||
5
core/lib/Injection/Container.php
Normal file
5
core/lib/Injection/Container.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Injection;
|
||||
|
||||
class Container extends \DI\Container {}
|
||||
494
core/lib/Kernel.php
Normal file
494
core/lib/Kernel.php
Normal file
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace KTXC;
|
||||
|
||||
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\FileLogger;
|
||||
use KTXF\Event\EventBus;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Cache\PersistentCacheInterface;
|
||||
use KTXF\Cache\BlobCacheInterface;
|
||||
use KTXF\Cache\Store\FileEphemeralCache;
|
||||
use KTXF\Cache\Store\FilePersistentCache;
|
||||
use KTXF\Cache\Store\FileBlobCache;
|
||||
|
||||
class Kernel
|
||||
{
|
||||
public const VERSION = '1.0.0';
|
||||
public const VERSION_ID = 10000;
|
||||
public const MAJOR_VERSION = 1;
|
||||
public const MINOR_VERSION = 0;
|
||||
public const RELEASE_VERSION = 0;
|
||||
public const EXTRA_VERSION = '';
|
||||
|
||||
protected bool $initialized = false;
|
||||
protected bool $booted = false;
|
||||
protected ?float $startTime = null;
|
||||
protected ?ContainerInterface $container = null;
|
||||
protected ?LoggerInterface $logger = null;
|
||||
protected ?MiddlewarePipeline $pipeline = null;
|
||||
|
||||
private string $projectDir;
|
||||
private array $config;
|
||||
|
||||
public function __construct(
|
||||
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()
|
||||
{
|
||||
$this->initialized = false;
|
||||
$this->booted = false;
|
||||
$this->container = null;
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
|
||||
if ($this->debug) {
|
||||
$this->startTime = microtime(true);
|
||||
}
|
||||
if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) {
|
||||
if (\function_exists('putenv')) {
|
||||
putenv('SHELL_VERBOSITY=3');
|
||||
}
|
||||
$_ENV['SHELL_VERBOSITY'] = 3;
|
||||
$_SERVER['SHELL_VERBOSITY'] = 3;
|
||||
}
|
||||
|
||||
// Create logger with config support
|
||||
$logDir = $this->config['log.directory'] ?? $this->getLogDir();
|
||||
$logChannel = $this->config['log.channel'] ?? 'app';
|
||||
$this->logger = new FileLogger($logDir, $logChannel);
|
||||
|
||||
$this->initializeErrorHandlers();
|
||||
|
||||
$container = $this->initializeContainer();
|
||||
|
||||
$this->container = $container;
|
||||
$this->initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up global error and exception handlers
|
||||
*/
|
||||
protected function initializeErrorHandlers(): void
|
||||
{
|
||||
// Convert PHP errors to exceptions
|
||||
set_error_handler(function ($errno, $errstr, $errfile, $errline) {
|
||||
// Don't throw exception if error reporting is turned off
|
||||
if (!(error_reporting() & $errno)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = sprintf(
|
||||
"PHP Error [%d]: %s in %s:%d",
|
||||
$errno,
|
||||
$errstr,
|
||||
$errfile,
|
||||
$errline
|
||||
);
|
||||
|
||||
$this->logger->error($message, ['errno' => $errno, 'file' => $errfile, 'line' => $errline]);
|
||||
|
||||
// Throw exception for fatal errors
|
||||
if ($errno === E_ERROR || $errno === E_CORE_ERROR || $errno === E_COMPILE_ERROR || $errno === E_USER_ERROR) {
|
||||
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
|
||||
}
|
||||
|
||||
return 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 () {
|
||||
$error = error_get_last();
|
||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
|
||||
$message = sprintf(
|
||||
"Fatal Error [%d]: %s in %s:%d",
|
||||
$error['type'],
|
||||
$error['message'],
|
||||
$error['file'],
|
||||
$error['line']
|
||||
);
|
||||
|
||||
$this->logger->error($message, $error);
|
||||
|
||||
if ($this->debug) {
|
||||
echo '<pre>' . $message . '</pre>';
|
||||
} else {
|
||||
echo 'A fatal error occurred. Please try again later.';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initialize();
|
||||
}
|
||||
|
||||
if (!$this->booted) {
|
||||
/** @var ModuleManager $moduleManager */
|
||||
$moduleManager = $this->container->get(ModuleManager::class);
|
||||
$moduleManager->modulesBoot();
|
||||
|
||||
// Build middleware pipeline
|
||||
$this->pipeline = $this->buildMiddlewarePipeline();
|
||||
|
||||
$this->booted = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function reboot(): void
|
||||
{
|
||||
$this->shutdown();
|
||||
$this->boot();
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
{
|
||||
if (false === $this->initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->initialized = false;
|
||||
$this->booted = false;
|
||||
$this->container = null;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
if (!$this->booted) {
|
||||
$this->boot();
|
||||
}
|
||||
|
||||
// Use middleware pipeline to handle the request
|
||||
return $this->pipeline->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the middleware pipeline
|
||||
*/
|
||||
protected function buildMiddlewarePipeline(): MiddlewarePipeline
|
||||
{
|
||||
$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(EventBus::class)) {
|
||||
/** @var EventBus $eventBus */
|
||||
$eventBus = $this->container->get(EventBus::class);
|
||||
$eventBus->processDeferred();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Event processing error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the kernel parameters.
|
||||
*
|
||||
* @return array<string, array|bool|string|int|float|\UnitEnum|null>
|
||||
*/
|
||||
protected function parameters(): array
|
||||
{
|
||||
return [
|
||||
'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($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->environment;
|
||||
}
|
||||
|
||||
public function debug(): bool
|
||||
{
|
||||
return $this->debug;
|
||||
}
|
||||
|
||||
public function container(): ContainerInterface
|
||||
{
|
||||
if (!$this->container) {
|
||||
throw new \LogicException('Cannot retrieve the container from a non-booted kernel.');
|
||||
}
|
||||
|
||||
return $this->container;
|
||||
}
|
||||
|
||||
public function getStartTime(): float
|
||||
{
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the service container
|
||||
*/
|
||||
protected function initializeContainer(): Container
|
||||
{
|
||||
$container = $this->buildContainer();
|
||||
$container->set('kernel', $this);
|
||||
|
||||
return $container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the service container.
|
||||
*
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
protected function buildContainer(): Container
|
||||
{
|
||||
$builder = new Builder(Container::class);
|
||||
$builder->useAutowiring(true);
|
||||
$builder->useAttributes(true);
|
||||
$builder->addDefinitions($this->parameters());
|
||||
$builder->addDefinitions($this->config);
|
||||
|
||||
$this->configureContainer($builder);
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
|
||||
protected function configureContainer(Builder $builder): void
|
||||
{
|
||||
// Service definitions
|
||||
$projectDir = $this->folderRoot();
|
||||
$moduleDir = $projectDir . '/modules';
|
||||
$environment = $this->environment;
|
||||
|
||||
$builder->addDefinitions([
|
||||
|
||||
// Provide primitives for injection
|
||||
'rootDir' => \DI\value($projectDir),
|
||||
'moduleDir' => \DI\value($moduleDir),
|
||||
'environment' => \DI\value($environment),
|
||||
|
||||
// IMPORTANT: ensure Container::class resolves to the *current* container instance.
|
||||
// Without this alias, PHP-DI will happily autowire a new empty Container when asked
|
||||
Container::class => \DI\get(ContainerInterface::class),
|
||||
|
||||
// Use the kernel's logger instance
|
||||
LoggerInterface::class => \DI\value($this->logger),
|
||||
|
||||
// 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';
|
||||
|
||||
$storeMap = [
|
||||
'file' => FileEphemeralCache::class,
|
||||
// 'redis' => RedisEphemeralCache::class,
|
||||
];
|
||||
|
||||
$storeClass = $storeMap[$storeType] ?? $storeType;
|
||||
|
||||
if (!class_exists($storeClass)) {
|
||||
throw new \RuntimeException("Ephemeral cache store not found: {$storeClass}");
|
||||
}
|
||||
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
}
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
},
|
||||
// Persistent Cache - for long-lived data (routes, modules, compiled configs)
|
||||
PersistentCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
|
||||
$storeType = $c->has('cache.persistent') ? $c->get('cache.persistent') : 'file';
|
||||
|
||||
$storeMap = [
|
||||
'file' => FilePersistentCache::class,
|
||||
// 'database' => DatabasePersistentCache::class,
|
||||
];
|
||||
|
||||
$storeClass = $storeMap[$storeType] ?? $storeType;
|
||||
|
||||
if (!class_exists($storeClass)) {
|
||||
throw new \RuntimeException("Persistent cache store not found: {$storeClass}");
|
||||
}
|
||||
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
}
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
},
|
||||
// Blob Cache - for binary/media data (previews, thumbnails)
|
||||
BlobCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
|
||||
$storeType = $c->has('cache.blob') ? $c->get('cache.blob') : 'file';
|
||||
|
||||
$storeMap = [
|
||||
'file' => FileBlobCache::class,
|
||||
// 's3' => S3BlobCache::class,
|
||||
];
|
||||
|
||||
$storeClass = $storeMap[$storeType] ?? $storeType;
|
||||
|
||||
if (!class_exists($storeClass)) {
|
||||
throw new \RuntimeException("Blob cache store not found: {$storeClass}");
|
||||
}
|
||||
|
||||
$cache = new $storeClass($projectDir);
|
||||
|
||||
// Set tenant/user context if available
|
||||
if ($c->has(SessionTenant::class)) {
|
||||
$tenant = $c->get(SessionTenant::class);
|
||||
$cache->setTenantContext($tenant->identifier());
|
||||
}
|
||||
if ($c->has(SessionIdentity::class)) {
|
||||
$identity = $c->get(SessionIdentity::class);
|
||||
$cache->setUserContext($identity->identifier());
|
||||
}
|
||||
|
||||
return $cache;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
125
core/lib/Logger/FileLogger.php
Normal file
125
core/lib/Logger/FileLogger.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Logger;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
|
||||
/**
|
||||
* Simple file-based PSR-3 logger.
|
||||
*/
|
||||
class FileLogger implements LoggerInterface
|
||||
{
|
||||
private string $logFile;
|
||||
private bool $useMicroseconds;
|
||||
private string $channel;
|
||||
|
||||
/**
|
||||
* @param string $logDir Directory where log files are written
|
||||
* @param string $channel Logical channel name (used in filename)
|
||||
* @param bool $useMicroseconds Whether to include microseconds in timestamp
|
||||
*/
|
||||
public function __construct(string $logDir, string $channel = 'app', bool $useMicroseconds = true)
|
||||
{
|
||||
$this->useMicroseconds = $useMicroseconds;
|
||||
$this->channel = $channel;
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0775, true);
|
||||
}
|
||||
$this->logFile = rtrim($logDir, '/').'/'.$channel.'.log';
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$timestamp = $this->formatTimestamp();
|
||||
$interpolated = $this->interpolate((string)$message, $context);
|
||||
$payload = [
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string)$level),
|
||||
'channel' => $this->channel,
|
||||
'message' => $interpolated,
|
||||
'context' => $this->sanitizeContext($context),
|
||||
];
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
// Fallback stringify if encoding fails (should be rare)
|
||||
$json = json_encode([
|
||||
'time' => $timestamp,
|
||||
'level' => strtolower((string)$level),
|
||||
'channel' => $this->channel,
|
||||
'message' => $interpolated,
|
||||
'context_error' => 'failed to encode context: '.json_last_error_msg(),
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '{"error":"logging failure"}';
|
||||
}
|
||||
$this->write($json);
|
||||
}
|
||||
|
||||
private function formatTimestamp(): string
|
||||
{
|
||||
if ($this->useMicroseconds) {
|
||||
$dt = \DateTimeImmutable::createFromFormat('U.u', sprintf('%.6F', microtime(true)));
|
||||
return $dt?->format('Y-m-d H:i:s.u') ?? date('Y-m-d H:i:s');
|
||||
}
|
||||
return date('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
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)) {
|
||||
continue; // don't inline complex values
|
||||
}
|
||||
$replace['{'.$key.'}'] = (string)$val;
|
||||
}
|
||||
return strtr($message, $replace);
|
||||
}
|
||||
|
||||
private function sanitizeContext(array $context): array
|
||||
{
|
||||
if (empty($context)) { return []; }
|
||||
$clean = [];
|
||||
foreach ($context as $k => $v) {
|
||||
if ($v instanceof \Throwable) {
|
||||
$clean[$k] = [
|
||||
'type' => get_class($v),
|
||||
'message' => $v->getMessage(),
|
||||
'code' => $v->getCode(),
|
||||
'file' => $v->getFile(),
|
||||
'line' => $v->getLine(),
|
||||
'trace' => explode("\n", $v->getTraceAsString()),
|
||||
];
|
||||
} elseif (is_resource($v)) {
|
||||
$clean[$k] = 'resource('.get_resource_type($v).')';
|
||||
} elseif (is_object($v)) {
|
||||
// Try to extract serializable data
|
||||
if (method_exists($v, '__toString')) {
|
||||
$clean[$k] = (string)$v;
|
||||
} else {
|
||||
$clean[$k] = ['object' => get_class($v)];
|
||||
}
|
||||
} else {
|
||||
$clean[$k] = $v;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
private function write(string $line): void
|
||||
{
|
||||
$line = rtrim($line)."\n"; // newline-delimited JSON (JSONL)
|
||||
@file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
}
|
||||
255
core/lib/Models/Firewall/FirewallLogObject.php
Normal file
255
core/lib/Models/Firewall/FirewallLogObject.php
Normal file
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Firewall;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
/**
|
||||
* Represents a firewall access log entry for tracking blocked/allowed requests
|
||||
*/
|
||||
class FirewallLogObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
public const RESULT_ALLOWED = 'allowed';
|
||||
public const RESULT_BLOCKED = 'blocked';
|
||||
|
||||
public const EVENT_AUTH_FAILURE = 'auth_failure';
|
||||
public const EVENT_RATE_LIMIT = 'rate_limit';
|
||||
public const EVENT_BRUTE_FORCE = 'brute_force';
|
||||
public const EVENT_SUSPICIOUS = 'suspicious';
|
||||
public const EVENT_RULE_MATCH = 'rule_match';
|
||||
public const EVENT_ACCESS_CHECK = 'access_check';
|
||||
|
||||
private ?string $id = null;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $ipAddress = null;
|
||||
private ?string $deviceFingerprint = null;
|
||||
private ?string $userAgent = null;
|
||||
private ?string $requestPath = null;
|
||||
private ?string $requestMethod = null;
|
||||
private ?string $eventType = null;
|
||||
private ?string $result = null; // allowed, blocked
|
||||
private ?string $ruleId = null; // Which rule triggered (if any)
|
||||
private ?string $identityId = null; // User ID if authenticated
|
||||
private ?\DateTimeImmutable $timestamp = null;
|
||||
private ?array $metadata = null; // Additional context
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
|
||||
if (array_key_exists('_id', $data)) {
|
||||
$this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||
} elseif (array_key_exists('id', $data)) {
|
||||
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||
}
|
||||
|
||||
if (array_key_exists('tenantId', $data)) {
|
||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||
}
|
||||
if (array_key_exists('ipAddress', $data)) {
|
||||
$this->ipAddress = $data['ipAddress'] !== null ? (string)$data['ipAddress'] : null;
|
||||
}
|
||||
if (array_key_exists('deviceFingerprint', $data)) {
|
||||
$this->deviceFingerprint = $data['deviceFingerprint'] !== null ? (string)$data['deviceFingerprint'] : null;
|
||||
}
|
||||
if (array_key_exists('userAgent', $data)) {
|
||||
$this->userAgent = $data['userAgent'] !== null ? (string)$data['userAgent'] : null;
|
||||
}
|
||||
if (array_key_exists('requestPath', $data)) {
|
||||
$this->requestPath = $data['requestPath'] !== null ? (string)$data['requestPath'] : null;
|
||||
}
|
||||
if (array_key_exists('requestMethod', $data)) {
|
||||
$this->requestMethod = $data['requestMethod'] !== null ? (string)$data['requestMethod'] : null;
|
||||
}
|
||||
if (array_key_exists('eventType', $data)) {
|
||||
$this->eventType = $data['eventType'] !== null ? (string)$data['eventType'] : null;
|
||||
}
|
||||
if (array_key_exists('result', $data)) {
|
||||
$this->result = $data['result'] !== null ? (string)$data['result'] : null;
|
||||
}
|
||||
if (array_key_exists('ruleId', $data)) {
|
||||
$this->ruleId = $data['ruleId'] !== null ? (string)$data['ruleId'] : null;
|
||||
}
|
||||
if (array_key_exists('identityId', $data)) {
|
||||
$this->identityId = $data['identityId'] !== null ? (string)$data['identityId'] : null;
|
||||
}
|
||||
if (array_key_exists('timestamp', $data)) {
|
||||
$this->timestamp = $data['timestamp'] !== null
|
||||
? new \DateTimeImmutable($data['timestamp'])
|
||||
: null;
|
||||
}
|
||||
if (array_key_exists('metadata', $data)) {
|
||||
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenantId' => $this->tenantId,
|
||||
'ipAddress' => $this->ipAddress,
|
||||
'deviceFingerprint' => $this->deviceFingerprint,
|
||||
'userAgent' => $this->userAgent,
|
||||
'requestPath' => $this->requestPath,
|
||||
'requestMethod' => $this->requestMethod,
|
||||
'eventType' => $this->eventType,
|
||||
'result' => $this->result,
|
||||
'ruleId' => $this->ruleId,
|
||||
'identityId' => $this->identityId,
|
||||
'timestamp' => $this->timestamp?->format(\DateTimeInterface::ATOM),
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(?string $id): self
|
||||
{
|
||||
$this->id = $id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTenantId(): ?string
|
||||
{
|
||||
return $this->tenantId;
|
||||
}
|
||||
|
||||
public function setTenantId(?string $tenantId): self
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIpAddress(): ?string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function setIpAddress(?string $ipAddress): self
|
||||
{
|
||||
$this->ipAddress = $ipAddress;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceFingerprint(): ?string
|
||||
{
|
||||
return $this->deviceFingerprint;
|
||||
}
|
||||
|
||||
public function setDeviceFingerprint(?string $deviceFingerprint): self
|
||||
{
|
||||
$this->deviceFingerprint = $deviceFingerprint;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function setUserAgent(?string $userAgent): self
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequestPath(): ?string
|
||||
{
|
||||
return $this->requestPath;
|
||||
}
|
||||
|
||||
public function setRequestPath(?string $requestPath): self
|
||||
{
|
||||
$this->requestPath = $requestPath;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRequestMethod(): ?string
|
||||
{
|
||||
return $this->requestMethod;
|
||||
}
|
||||
|
||||
public function setRequestMethod(?string $requestMethod): self
|
||||
{
|
||||
$this->requestMethod = $requestMethod;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEventType(): ?string
|
||||
{
|
||||
return $this->eventType;
|
||||
}
|
||||
|
||||
public function setEventType(?string $eventType): self
|
||||
{
|
||||
$this->eventType = $eventType;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResult(): ?string
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
|
||||
public function setResult(?string $result): self
|
||||
{
|
||||
$this->result = $result;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRuleId(): ?string
|
||||
{
|
||||
return $this->ruleId;
|
||||
}
|
||||
|
||||
public function setRuleId(?string $ruleId): self
|
||||
{
|
||||
$this->ruleId = $ruleId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIdentityId(): ?string
|
||||
{
|
||||
return $this->identityId;
|
||||
}
|
||||
|
||||
public function setIdentityId(?string $identityId): self
|
||||
{
|
||||
$this->identityId = $identityId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTimestamp(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
public function setTimestamp(?\DateTimeImmutable $timestamp): self
|
||||
{
|
||||
$this->timestamp = $timestamp;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMetadata(): ?array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
public function setMetadata(?array $metadata): self
|
||||
{
|
||||
$this->metadata = $metadata;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
241
core/lib/Models/Firewall/FirewallRuleObject.php
Normal file
241
core/lib/Models/Firewall/FirewallRuleObject.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Models\Firewall;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
/**
|
||||
* Represents a firewall rule for IP/device access control
|
||||
*/
|
||||
class FirewallRuleObject implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
public const TYPE_IP = 'ip';
|
||||
public const TYPE_IP_RANGE = 'ip_range';
|
||||
public const TYPE_DEVICE = 'device';
|
||||
|
||||
public const ACTION_ALLOW = 'allow';
|
||||
public const ACTION_BLOCK = 'block';
|
||||
|
||||
private ?string $id = null;
|
||||
private ?string $tenantId = null;
|
||||
private ?string $type = null; // ip, ip_range, device
|
||||
private ?string $action = null; // allow, block
|
||||
private ?string $value = null; // IP address, CIDR range, or device fingerprint
|
||||
private ?string $reason = null; // Why this rule was created
|
||||
private ?string $createdBy = null; // User ID who created the rule
|
||||
private ?\DateTimeImmutable $createdAt = null;
|
||||
private ?\DateTimeImmutable $expiresAt = null; // null = permanent
|
||||
private bool $enabled = true;
|
||||
private ?array $metadata = null; // Additional context (user agent, country, etc.)
|
||||
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
|
||||
if (array_key_exists('_id', $data)) {
|
||||
$this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||
} elseif (array_key_exists('id', $data)) {
|
||||
$this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||
}
|
||||
|
||||
if (array_key_exists('tenantId', $data)) {
|
||||
$this->tenantId = $data['tenantId'] !== null ? (string)$data['tenantId'] : null;
|
||||
}
|
||||
if (array_key_exists('type', $data)) {
|
||||
$this->type = $data['type'] !== null ? (string)$data['type'] : null;
|
||||
}
|
||||
if (array_key_exists('action', $data)) {
|
||||
$this->action = $data['action'] !== null ? (string)$data['action'] : null;
|
||||
}
|
||||
if (array_key_exists('value', $data)) {
|
||||
$this->value = $data['value'] !== null ? (string)$data['value'] : null;
|
||||
}
|
||||
if (array_key_exists('reason', $data)) {
|
||||
$this->reason = $data['reason'] !== null ? (string)$data['reason'] : null;
|
||||
}
|
||||
if (array_key_exists('createdBy', $data)) {
|
||||
$this->createdBy = $data['createdBy'] !== null ? (string)$data['createdBy'] : null;
|
||||
}
|
||||
if (array_key_exists('createdAt', $data)) {
|
||||
$this->createdAt = $data['createdAt'] !== null
|
||||
? new \DateTimeImmutable($data['createdAt'])
|
||||
: null;
|
||||
}
|
||||
if (array_key_exists('expiresAt', $data)) {
|
||||
$this->expiresAt = $data['expiresAt'] !== null
|
||||
? new \DateTimeImmutable($data['expiresAt'])
|
||||
: null;
|
||||
}
|
||||
if (array_key_exists('enabled', $data)) {
|
||||
$this->enabled = (bool)$data['enabled'];
|
||||
}
|
||||
if (array_key_exists('metadata', $data)) {
|
||||
$this->metadata = $data['metadata'] !== null ? (array)$data['metadata'] : null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'tenantId' => $this->tenantId,
|
||||
'type' => $this->type,
|
||||
'action' => $this->action,
|
||||
'value' => $this->value,
|
||||
'reason' => $this->reason,
|
||||
'createdBy' => $this->createdBy,
|
||||
'createdAt' => $this->createdAt?->format(\DateTimeInterface::ATOM),
|
||||
'expiresAt' => $this->expiresAt?->format(\DateTimeInterface::ATOM),
|
||||
'enabled' => $this->enabled,
|
||||
'metadata' => $this->metadata,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this rule has expired
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
if ($this->expiresAt === null) {
|
||||
return false;
|
||||
}
|
||||
return $this->expiresAt < new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this rule is currently active (enabled and not expired)
|
||||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->enabled && !$this->isExpired();
|
||||
}
|
||||
|
||||
// Getters and setters
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(?string $id): self
|
||||
{
|
||||
$this->id = $id;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTenantId(): ?string
|
||||
{
|
||||
return $this->tenantId;
|
||||
}
|
||||
|
||||
public function setTenantId(?string $tenantId): self
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): ?string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(?string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAction(): ?string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
public function setAction(?string $action): self
|
||||
{
|
||||
$this->action = $action;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getValue(): ?string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function setValue(?string $value): self
|
||||
{
|
||||
$this->value = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getReason(): ?string
|
||||
{
|
||||
return $this->reason;
|
||||
}
|
||||
|
||||
public function setReason(?string $reason): self
|
||||
{
|
||||
$this->reason = $reason;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedBy(): ?string
|
||||
{
|
||||
return $this->createdBy;
|
||||
}
|
||||
|
||||
public function setCreatedBy(?string $createdBy): self
|
||||
{
|
||||
$this->createdBy = $createdBy;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(?\DateTimeImmutable $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExpiresAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->expiresAt;
|
||||
}
|
||||
|
||||
public function setExpiresAt(?\DateTimeImmutable $expiresAt): self
|
||||
{
|
||||
$this->expiresAt = $expiresAt;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): self
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMetadata(): ?array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
public function setMetadata(?array $metadata): self
|
||||
{
|
||||
$this->metadata = $metadata;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
156
core/lib/Models/Identity/User.php
Normal file
156
core/lib/Models/Identity/User.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Models\Identity;
|
||||
|
||||
class User
|
||||
{
|
||||
private ?string $id = null;
|
||||
private ?string $identity = null;
|
||||
private ?string $label = null;
|
||||
private ?array $roles = [];
|
||||
private array $permissions = [];
|
||||
private ?bool $enabled = null;
|
||||
private ?string $provider = null;
|
||||
private ?string $externalSubject = null;
|
||||
private ?int $initialLogin = null;
|
||||
private ?int $recentLogin = null;
|
||||
|
||||
public function populate(array $data, string $source): void
|
||||
{
|
||||
if ($source === 'users') {
|
||||
$this->id = $data['uid'] ?? null; // 'uid' maps to 'id'
|
||||
$this->identity = $data['identity'] ?? null;
|
||||
$this->label = $data['label'] ?? null;
|
||||
$this->roles = (array)($data['roles'] ?? []);
|
||||
$this->enabled = $data['enabled'] ?? null;
|
||||
$this->provider = $data['provider'] ?? null;
|
||||
$this->externalSubject = $data['external_subject'] ?? null;
|
||||
$this->initialLogin = $data['initial_login'] ?? null;
|
||||
$this->recentLogin = $data['recent_login'] ?? null;
|
||||
$this->permissions = (array)($data['permissions'] ?? []);
|
||||
}
|
||||
|
||||
if ($source === 'jwt') {
|
||||
$this->id = $data['identifier'] ?? null;
|
||||
$this->identity = $data['identity'] ?? null;
|
||||
$this->label = $data['label'] ?? null;
|
||||
$this->roles = (array)($data['role'] ?? []);
|
||||
$this->permissions = (array)($data['permissions'] ?? []);
|
||||
$this->enabled = true;
|
||||
}
|
||||
|
||||
if ($source === 'external') {
|
||||
$this->identity = $data['identity'] ?? null;
|
||||
$this->label = $data['label'] ?? null;
|
||||
$this->externalSubject = $data['external_subject'] ?? null;
|
||||
$this->provider = $data['provider'] ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $value): void
|
||||
{
|
||||
$this->id = $value;
|
||||
}
|
||||
|
||||
public function getIdentity(): ?string
|
||||
{
|
||||
return $this->identity;
|
||||
}
|
||||
|
||||
public function setIdentity(string $value): void
|
||||
{
|
||||
$this->identity = $value;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(?string $value): void
|
||||
{
|
||||
$this->label = $value;
|
||||
}
|
||||
|
||||
public function getRoles(): array
|
||||
{
|
||||
return $this->roles;
|
||||
}
|
||||
|
||||
public function setRoles(array $values): void
|
||||
{
|
||||
$this->roles = $values;
|
||||
}
|
||||
|
||||
public function getEnabled(): ?bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(?bool $value): void
|
||||
{
|
||||
$this->enabled = $value;
|
||||
}
|
||||
|
||||
public function getProvider(): ?string
|
||||
{
|
||||
return $this->provider;
|
||||
}
|
||||
|
||||
public function setProvider(?string $value): void
|
||||
{
|
||||
$this->provider = $value;
|
||||
}
|
||||
|
||||
public function getExternalSubject(): ?string
|
||||
{
|
||||
return $this->externalSubject;
|
||||
}
|
||||
|
||||
public function setExternalSubject(?string $value): void
|
||||
{
|
||||
$this->externalSubject = $value;
|
||||
}
|
||||
|
||||
public function getInitialLogin(): ?int
|
||||
{
|
||||
return $this->initialLogin;
|
||||
}
|
||||
|
||||
public function setInitialLogin(?int $value): void
|
||||
{
|
||||
$this->initialLogin = $value;
|
||||
}
|
||||
|
||||
public function getRecentLogin(): ?int
|
||||
{
|
||||
return $this->recentLogin;
|
||||
}
|
||||
|
||||
public function setRecentLogin(?int $value): void
|
||||
{
|
||||
$this->recentLogin = $value;
|
||||
}
|
||||
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return $this->permissions;
|
||||
}
|
||||
|
||||
public function setPermissions(array $permissions): void
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
}
|
||||
|
||||
public function hasPermission(string $permission): bool
|
||||
{
|
||||
return in_array($permission, $this->permissions, true);
|
||||
}
|
||||
|
||||
}
|
||||
13
core/lib/Models/Tenant/DomainCollection.php
Normal file
13
core/lib/Models/Tenant/DomainCollection.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use KTXF\Utile\Collection\CollectionAbstract;
|
||||
|
||||
class DomainCollection extends CollectionAbstract
|
||||
{
|
||||
public function __construct(array $items = [])
|
||||
{
|
||||
parent::__construct($items, CollectionAbstract::TYPE_STRING);
|
||||
}
|
||||
}
|
||||
22
core/lib/Models/Tenant/TenantAuthentication.php
Normal file
22
core/lib/Models/Tenant/TenantAuthentication.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
/**
|
||||
* Tenant Configuration
|
||||
*/
|
||||
class TenantAuthentication extends JsonSerializableObject
|
||||
{
|
||||
protected array $providers = [];
|
||||
protected int $methodsMinimal = 1;
|
||||
|
||||
public function providers(): array {
|
||||
return $this->providers;
|
||||
}
|
||||
|
||||
public function methodsMinimal(): int {
|
||||
return $this->methodsMinimal;
|
||||
}
|
||||
}
|
||||
13
core/lib/Models/Tenant/TenantCollection.php
Normal file
13
core/lib/Models/Tenant/TenantCollection.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use KTXF\Utile\Collection\CollectionAbstract;
|
||||
|
||||
class TenantCollection extends CollectionAbstract
|
||||
{
|
||||
public function __construct(array $items = [])
|
||||
{
|
||||
parent::__construct($items, TenantObject::class, CollectionAbstract::TYPE_STRING);
|
||||
}
|
||||
}
|
||||
29
core/lib/Models/Tenant/TenantConfiguration.php
Normal file
29
core/lib/Models/Tenant/TenantConfiguration.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
/**
|
||||
* Tenant Configuration
|
||||
*/
|
||||
class TenantConfiguration extends JsonSerializableObject
|
||||
{
|
||||
protected TenantAuthentication $authentication;
|
||||
protected TenantSecurity $security;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->authentication = new TenantAuthentication();
|
||||
$this->security = new TenantSecurity();
|
||||
}
|
||||
|
||||
public function authentication(): TenantAuthentication {
|
||||
return $this->authentication;
|
||||
}
|
||||
|
||||
public function security(): TenantSecurity {
|
||||
return $this->security;
|
||||
}
|
||||
|
||||
}
|
||||
148
core/lib/Models/Tenant/TenantObject.php
Normal file
148
core/lib/Models/Tenant/TenantObject.php
Normal file
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
/**
|
||||
* Tenant entity representing a tenant
|
||||
*/
|
||||
class TenantObject extends JsonSerializableObject
|
||||
{
|
||||
private ?string $id = null;
|
||||
private ?string $identifier = null;
|
||||
private bool $enabled = false;
|
||||
private ?string $label = null;
|
||||
private ?string $description = null;
|
||||
private ?DomainCollection $domains = null;
|
||||
private ?TenantConfiguration $configuration = null;
|
||||
|
||||
/**
|
||||
* Deserialize from associative array.
|
||||
*/
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
// Map only if key exists to avoid notices and allow partial input
|
||||
if (array_key_exists('_id', $data)) $this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||
elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||
if (array_key_exists('identifier', $data)) $this->identifier = $data['identifier'] !== null ? (string)$data['identifier'] : null;
|
||||
if (array_key_exists('enabled', $data)) $this->enabled = $data['enabled'] !== null ? (bool)$data['enabled'] : null;
|
||||
if (array_key_exists('label', $data)) $this->label = $data['label'] !== null ? (string)$data['label'] : null;
|
||||
if (array_key_exists('description', $data)) $this->description = $data['description'] !== null ? (string)$data['description'] : null;
|
||||
if (array_key_exists('domains', $data)) {
|
||||
$this->domains = (new DomainCollection((array)$data['domains']));
|
||||
}
|
||||
if (array_key_exists('configuration', $data)) {
|
||||
$this->configuration = (new TenantConfiguration)->jsonDeserialize($data['configuration']);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to JSON-friendly structure.
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'identifier' => $this->identifier,
|
||||
'enabled' => $this->enabled,
|
||||
'label' => $this->label,
|
||||
'description' => $this->description,
|
||||
'domains' => $this->domains,
|
||||
'configuration' => $this->configuration,
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $value): self
|
||||
{
|
||||
$this->id = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIdentifier(): ?string
|
||||
{
|
||||
return $this->identifier;
|
||||
}
|
||||
|
||||
public function setIdentifier(string $value): self
|
||||
{
|
||||
$this->identifier = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $value): self
|
||||
{
|
||||
$this->enabled = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(string $value): self
|
||||
{
|
||||
$this->label = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(string $value): self
|
||||
{
|
||||
$this->description = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDomains(): ?DomainCollection
|
||||
{
|
||||
return $this->domains;
|
||||
}
|
||||
|
||||
public function setDomains(DomainCollection $value): self
|
||||
{
|
||||
$this->domains = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConfiguration(): TenantConfiguration
|
||||
{
|
||||
return $this->configuration;
|
||||
}
|
||||
|
||||
public function setConfiguration(TenantConfiguration $value): self
|
||||
{
|
||||
$this->configuration = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSettings(): array
|
||||
{
|
||||
return $this->configuration['settings'] ?? [];
|
||||
}
|
||||
|
||||
public function setSettings(array $value): self
|
||||
{
|
||||
$this->configuration['settings'] = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
}
|
||||
22
core/lib/Models/Tenant/TenantSecurity.php
Normal file
22
core/lib/Models/Tenant/TenantSecurity.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Models\Tenant;
|
||||
|
||||
use KTXF\Json\JsonSerializableObject;
|
||||
|
||||
/**
|
||||
* Tenant Configuration
|
||||
*/
|
||||
class TenantSecurity extends JsonSerializableObject
|
||||
{
|
||||
protected string $code = '';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->code = uniqid();
|
||||
}
|
||||
|
||||
public function code(): string {
|
||||
return $this->code;
|
||||
}
|
||||
}
|
||||
112
core/lib/Module/Module.php
Normal file
112
core/lib/Module/Module.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use KTXF\Module\ModuleInstanceAbstract;
|
||||
|
||||
/**
|
||||
* Core Module
|
||||
*
|
||||
* Provides core system functionality and permissions
|
||||
*/
|
||||
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
return 'core';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return 'Core System';
|
||||
}
|
||||
|
||||
public function author(): string
|
||||
{
|
||||
return 'Ktrix';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Core system functionality and user features';
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
return '0.0.1';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return [
|
||||
// Core User Permissions
|
||||
'user.profile.read' => [
|
||||
'label' => 'Read Own Profile',
|
||||
'description' => 'View own user profile information',
|
||||
'group' => 'User Profile'
|
||||
],
|
||||
'user.profile.update' => [
|
||||
'label' => 'Update Own Profile',
|
||||
'description' => 'Edit own user profile information',
|
||||
'group' => 'User Profile'
|
||||
],
|
||||
'user.settings.read' => [
|
||||
'label' => 'Read Own Settings',
|
||||
'description' => 'View own user settings',
|
||||
'group' => 'User Settings'
|
||||
],
|
||||
'user.settings.update' => [
|
||||
'label' => 'Update Own Settings',
|
||||
'description' => 'Edit own user settings',
|
||||
'group' => 'User Settings'
|
||||
],
|
||||
|
||||
// Module Management
|
||||
'module_manager.modules.view' => [
|
||||
'label' => 'View Modules',
|
||||
'description' => 'View list of installed and available modules',
|
||||
'group' => 'Module Management'
|
||||
],
|
||||
'module_manager.modules.manage' => [
|
||||
'label' => 'Manage Modules',
|
||||
'description' => 'Install, uninstall, enable, and disable modules',
|
||||
'group' => 'Module Management'
|
||||
],
|
||||
'module_manager.modules.*' => [
|
||||
'label' => 'Full Module Management',
|
||||
'description' => 'All module management operations',
|
||||
'group' => 'Module Management'
|
||||
],
|
||||
|
||||
// System Administration
|
||||
'system.admin' => [
|
||||
'label' => 'System Administrator',
|
||||
'description' => 'Full system access (superuser)',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
'*' => [
|
||||
'label' => 'All Permissions',
|
||||
'description' => 'Grants access to all features and operations',
|
||||
'group' => 'System Administration'
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function registerCI(): array
|
||||
{
|
||||
return [
|
||||
\KTXC\Console\ModuleListCommand::class,
|
||||
\KTXC\Console\ModuleEnableCommand::class,
|
||||
\KTXC\Console\ModuleDisableCommand::class,
|
||||
];
|
||||
}
|
||||
|
||||
public function registerBI(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
195
core/lib/Module/ModuleAutoloader.php
Normal file
195
core/lib/Module/ModuleAutoloader.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use KTXC\Application;
|
||||
|
||||
/**
|
||||
* Custom autoloader for modules that allows PascalCase namespaces
|
||||
* with lowercase folder names.
|
||||
*
|
||||
* This breaks from PSR-4 convention to allow:
|
||||
* - Folder: modules/contacts_manager/
|
||||
* - Namespace: KTXM\ContactsManager\
|
||||
*
|
||||
* The autoloader scans lazily - only when a KTXM class is first requested.
|
||||
*/
|
||||
class ModuleAutoloader
|
||||
{
|
||||
private string $modulesRoot;
|
||||
private array $namespaceMap = [];
|
||||
private bool $scanned = false;
|
||||
|
||||
public function __construct(string $modulesRoot)
|
||||
{
|
||||
$this->modulesRoot = rtrim($modulesRoot, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the autoloader
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
spl_autoload_register([$this, 'loadClass']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the autoloader
|
||||
*/
|
||||
public function unregister(): void
|
||||
{
|
||||
spl_autoload_unregister([$this, 'loadClass']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the modules directory and build a map of namespaces to folder paths
|
||||
* This is called lazily on the first KTXM class request
|
||||
*/
|
||||
private function scan(): void
|
||||
{
|
||||
if ($this->scanned) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->namespaceMap = [];
|
||||
|
||||
if (!is_dir($this->modulesRoot)) {
|
||||
$this->scanned = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleDirs = glob($this->modulesRoot . '/*', GLOB_ONLYDIR);
|
||||
foreach ($moduleDirs as $moduleDir) {
|
||||
$moduleFile = $moduleDir . '/lib/Module.php';
|
||||
if (!file_exists($moduleFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract the namespace from Module.php
|
||||
$namespace = $this->extractNamespace($moduleFile);
|
||||
if ($namespace) {
|
||||
$this->namespaceMap[$namespace] = basename($moduleDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Register module namespaces with Composer ClassLoader
|
||||
$composerLoader = \KTXC\Application::getComposerLoader();
|
||||
if ($composerLoader !== null) {
|
||||
foreach ($this->namespaceMap as $namespace => $folderName) {
|
||||
$composerLoader->addPsr4(
|
||||
'KTXM\\' . $namespace . '\\',
|
||||
$this->modulesRoot . '/' . $folderName . '/lib/'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->scanned = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a class by its fully qualified name
|
||||
*
|
||||
* @param string $className Fully qualified class name (e.g., KTXM\ContactsManager\Module)
|
||||
* @return bool True if the class was loaded, false otherwise
|
||||
*/
|
||||
public function loadClass(string $className): bool
|
||||
{
|
||||
try {
|
||||
// Only handle classes in the KTXM namespace
|
||||
if (!str_starts_with($className, 'KTXM\\')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the namespace segment (e.g., ContactsManager from KTXM\ContactsManager\Module)
|
||||
$parts = explode('\\', $className);
|
||||
if (count($parts) < 2) {
|
||||
$this->logError("Invalid class name format: $className (expected at least 2 namespace parts)");
|
||||
return false;
|
||||
}
|
||||
|
||||
$namespaceSegment = $parts[1];
|
||||
|
||||
// Check if we already have a mapping for this namespace
|
||||
if (!isset($this->namespaceMap[$namespaceSegment])) {
|
||||
// Scan only if we haven't scanned yet (this happens once, on first module access)
|
||||
if (!$this->scanned) {
|
||||
$this->scan();
|
||||
|
||||
// Check again after scanning
|
||||
if (!isset($this->namespaceMap[$namespaceSegment])) {
|
||||
$this->logError("No module found for namespace segment: $namespaceSegment (class: $className)");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
$this->logError("Module not found after scan: $namespaceSegment (class: $className)");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$folderName = $this->namespaceMap[$namespaceSegment];
|
||||
|
||||
// Reconstruct the relative path
|
||||
// KTXM\ContactsManager\Module -> contacts_manager/lib/Module.php
|
||||
// KTXM\ContactsManager\Something -> contacts_manager/lib/Something.php
|
||||
$relativePath = 'lib/' . implode('/', array_slice($parts, 2)) . '.php';
|
||||
$filePath = $this->modulesRoot . '/' . $folderName . '/' . $relativePath;
|
||||
|
||||
if (file_exists($filePath)) {
|
||||
require_once $filePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->logError("File not found for class $className at path: $filePath");
|
||||
return false;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->logError("Exception in ModuleAutoloader while loading $className: " . $e->getMessage(), [
|
||||
'exception' => $e,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error from the autoloader
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @param array $context Additional context
|
||||
*/
|
||||
private function logError(string $message, array $context = []): void
|
||||
{
|
||||
// Log to PHP error log
|
||||
error_log('[ModuleAutoloader] ' . $message);
|
||||
|
||||
if (!empty($context)) {
|
||||
error_log('[ModuleAutoloader Context] ' . json_encode($context));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract namespace from a Module.php file
|
||||
*
|
||||
* @param string $filePath Path to the Module.php file (at modules/{handle}/lib/Module.php)
|
||||
* @return string|null The namespace segment (e.g., 'ContactsManager')
|
||||
*/
|
||||
private function extractNamespace(string $filePath): ?string
|
||||
{
|
||||
if (!file_exists($filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match namespace declaration: namespace KTXM\<Namespace>;
|
||||
if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
23
core/lib/Module/ModuleCollection.php
Normal file
23
core/lib/Module/ModuleCollection.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXF\Utile\Collection\CollectionAbstract;
|
||||
|
||||
class ModuleCollection extends CollectionAbstract implements JsonSerializable
|
||||
{
|
||||
public function __construct(array $items = [])
|
||||
{
|
||||
parent::__construct($items, ModuleObject::class, CollectionAbstract::TYPE_STRING);
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this as $key => $item) {
|
||||
$result[$key] = $item;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
595
core/lib/Module/ModuleManager.php
Normal file
595
core/lib/Module/ModuleManager.php
Normal file
@@ -0,0 +1,595 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use Exception;
|
||||
use DI\Attribute\Inject;
|
||||
use KTXC\Module\Store\ModuleStore;
|
||||
use KTXC\Module\Store\ModuleEntry;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use KTXF\Module\ModuleInstanceInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
|
||||
class ModuleManager
|
||||
{
|
||||
private string $serverRoot = '';
|
||||
private array $moduleInstances = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ModuleStore $repository,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ContainerInterface $container,
|
||||
#[Inject('rootDir')] private readonly string $rootDir
|
||||
) {
|
||||
// Initialize server root path
|
||||
$this->serverRoot = $rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all modules as unified Module objects
|
||||
*
|
||||
* @param bool $installedOnly If true, only return modules that are in the database
|
||||
* @param bool $enabledOnly If true, only return modules that are enabled (implies installedOnly)
|
||||
* @return Module[]
|
||||
*/
|
||||
public function list(bool $installedOnly = true, $enabledOnly = true): ModuleCollection
|
||||
{
|
||||
$modules = New ModuleCollection();
|
||||
|
||||
// Always include core module
|
||||
$coreModule = $this->coreModule();
|
||||
if ($coreModule) {
|
||||
$modules['core'] = new ModuleObject($coreModule, null);
|
||||
}
|
||||
|
||||
// load all modules from store
|
||||
$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);
|
||||
} else {
|
||||
$moduleInstance = $this->moduleInstance($handle, $entry->getNamespace());
|
||||
$modules[$handle] = new ModuleObject($moduleInstance, $entry);
|
||||
$this->moduleInstances[$handle] = $moduleInstance;
|
||||
}
|
||||
}
|
||||
// load all modules from filesystem
|
||||
if ($installedOnly === false) {
|
||||
$discovered = $this->modulesDiscover();
|
||||
foreach ($discovered as $moduleInstance) {
|
||||
$handle = $moduleInstance->handle();
|
||||
if (!isset($modules[$handle])) {
|
||||
$modules[$handle] = new ModuleObject($moduleInstance, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
public function install(string $handle): void
|
||||
{
|
||||
// First, try to find the module by scanning the filesystem
|
||||
$modulesDir = $this->serverRoot . '/modules';
|
||||
$namespace = null;
|
||||
|
||||
// Scan for the module by checking if handle matches any folder or module's handle() method
|
||||
if (is_dir($modulesDir)) {
|
||||
$moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
|
||||
foreach ($moduleDirs as $moduleDir) {
|
||||
$testModuleFile = $moduleDir . '/lib/Module.php';
|
||||
if (!file_exists($testModuleFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract namespace from the Module.php file
|
||||
$testNamespace = $this->extractNamespaceFromFile($testModuleFile);
|
||||
if (!$testNamespace) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to instantiate with a temporary handle to check if it matches
|
||||
$folderName = basename($moduleDir);
|
||||
$testInstance = $this->moduleInstance($folderName, $testNamespace);
|
||||
|
||||
if ($testInstance && $testInstance->handle() === $handle) {
|
||||
$namespace = $testNamespace;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$namespace) {
|
||||
$this->logger->error('Module not found for installation', ['handle' => $handle]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($handle, $namespace);
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->install();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module installation failed: ' . $handle, [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$module = new ModuleEntry();
|
||||
$module->setHandle($handle);
|
||||
$module->setVersion($moduleInstance->version());
|
||||
$module->setEnabled(false);
|
||||
$module->setInstalled(true);
|
||||
// Store the namespace we found
|
||||
$module->setNamespace($namespace);
|
||||
$this->repository->deposit($module);
|
||||
}
|
||||
|
||||
public function uninstall(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->uninstall();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module uninstallation failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->repository->destroy($moduleEntry);
|
||||
}
|
||||
|
||||
public function enable(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->enable();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module enabling failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setEnabled(true);
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
public function disable(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->disable();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module disabling failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setEnabled(false);
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
public function upgrade(string $handle): void
|
||||
{
|
||||
$moduleEntry = $this->repository->fetch($handle);
|
||||
if (!$moduleEntry || !$moduleEntry->getInstalled()) {
|
||||
$this->logger->warning('Attempted to uninstall non-installed module: ' . $handle);
|
||||
throw new Exception('Module not installed: ' . $handle);
|
||||
}
|
||||
|
||||
$moduleInstance = $this->moduleInstance($moduleEntry->getHandle(), $moduleEntry->getNamespace());
|
||||
if (!$moduleInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$moduleInstance->upgrade();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Module upgrade failed: ' . $moduleEntry->getHandle(), [
|
||||
'exception' => [
|
||||
'code' => $e->getCode(),
|
||||
'message' => $e->getMessage(),
|
||||
]
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleEntry->setVersion($moduleInstance->version());
|
||||
$this->repository->deposit($moduleEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot all enabled modules (must be called after container is ready).
|
||||
*/
|
||||
public function modulesBoot(): void
|
||||
{
|
||||
// Only load modules that are enabled in the database
|
||||
$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 (Exception $e) {
|
||||
$this->logger->error('Module boot failed: ' . $handle, [
|
||||
'exception' => $e,
|
||||
'message' => $e->getMessage(),
|
||||
'code' => $e->getCode(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan filesystem for module directories and return module instances
|
||||
*
|
||||
* @return array<string, ModuleInstanceInterface> Map of handle => ModuleInstanceInterface
|
||||
*/
|
||||
private function modulesDiscover(): array
|
||||
{
|
||||
$modules = [];
|
||||
$modulesDir = $this->serverRoot . '/modules';
|
||||
|
||||
if (!is_dir($modulesDir)) {
|
||||
return $modules;
|
||||
}
|
||||
|
||||
// Get list of installed module handles to skip
|
||||
$installedHandles = [];
|
||||
foreach ($this->repository->list() as $entry) {
|
||||
$installedHandles[] = $entry->getHandle();
|
||||
}
|
||||
|
||||
// Scan for module directories
|
||||
$moduleDirs = glob($modulesDir . '/*', GLOB_ONLYDIR);
|
||||
foreach ($moduleDirs as $moduleDir) {
|
||||
$moduleFile = $moduleDir . '/lib/Module.php';
|
||||
if (!file_exists($moduleFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract namespace from the Module.php file
|
||||
$namespace = $this->extractNamespaceFromFile($moduleFile);
|
||||
if (!$namespace) {
|
||||
$this->logger->warning('Could not extract namespace from Module.php', [
|
||||
'file' => $moduleFile
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the folder name as a temporary handle to instantiate the module
|
||||
$folderName = basename($moduleDir);
|
||||
$moduleInstance = $this->moduleInstance($folderName, $namespace);
|
||||
if (!$moduleInstance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the actual handle from the module instance
|
||||
$handle = $moduleInstance->handle();
|
||||
|
||||
// Skip if already installed
|
||||
if (in_array($handle, $installedHandles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-cache with the correct handle if different from folder name
|
||||
if ($handle !== $folderName) {
|
||||
unset($this->moduleInstances[$folderName]);
|
||||
$this->moduleInstances[$handle] = $moduleInstance;
|
||||
}
|
||||
|
||||
$modules[$handle] = $moduleInstance;
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
public function moduleInstance(string $handle, ?string $namespace = null): ?ModuleInstanceInterface
|
||||
{
|
||||
// Load module's vendor autoloader if it exists
|
||||
$this->loadModuleVendor($handle);
|
||||
|
||||
// Return from cache if already instantiated
|
||||
if (isset($this->moduleInstances[$handle])) {
|
||||
return $this->moduleInstances[$handle];
|
||||
}
|
||||
|
||||
// Determine the namespace segment
|
||||
// If namespace is provided, use it; otherwise derive from handle
|
||||
$nsSegment = $namespace ?: $this->studly($handle);
|
||||
|
||||
$className = 'KTXM\\' . $nsSegment . '\\Module';
|
||||
|
||||
if (!class_exists($className)) {
|
||||
$this->logger->error('Module class not found', [
|
||||
'handle' => $handle,
|
||||
'namespace' => $namespace,
|
||||
'resolved' => $className
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!in_array(ModuleInstanceInterface::class, class_implements($className))) {
|
||||
$this->logger->error('Module class does not implement ModuleInstanceInterface', [
|
||||
'class' => $className
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$module = $this->moduleLoad($className);
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Failed to lazily create module instance', [
|
||||
'handle' => $handle,
|
||||
'namespace' => $namespace,
|
||||
'exception' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache by handle
|
||||
if ($module) {
|
||||
$this->moduleInstances[$handle] = $module;
|
||||
}
|
||||
|
||||
return $module;
|
||||
}
|
||||
|
||||
private function moduleLoad(string $className): ?ModuleInstanceInterface
|
||||
{
|
||||
try {
|
||||
// Use reflection to check constructor requirements
|
||||
$reflectionClass = new ReflectionClass($className);
|
||||
$constructor = $reflectionClass->getConstructor();
|
||||
|
||||
if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) {
|
||||
// Simple instantiation for modules without dependencies
|
||||
return new $className();
|
||||
}
|
||||
|
||||
// For modules with dependencies, try to resolve them from the container
|
||||
$parameters = $constructor->getParameters();
|
||||
$args = [];
|
||||
|
||||
foreach ($parameters as $parameter) {
|
||||
$type = $parameter->getType();
|
||||
if ($type && !$type->isBuiltin()) {
|
||||
$typeName = $type->getName();
|
||||
|
||||
// Try to get service from container
|
||||
if ($this->container->has($typeName)) {
|
||||
$args[] = $this->container->get($typeName);
|
||||
} elseif ($parameter->isDefaultValueAvailable()) {
|
||||
$args[] = $parameter->getDefaultValue();
|
||||
} else {
|
||||
// Cannot resolve dependency
|
||||
$this->logger->warning('Cannot resolve dependency for module: ' . $className, [
|
||||
'dependency' => $typeName
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
} elseif ($parameter->isDefaultValueAvailable()) {
|
||||
$args[] = $parameter->getDefaultValue();
|
||||
} else {
|
||||
// Cannot resolve primitive dependency
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $reflectionClass->newInstanceArgs($args);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Failed to instantiate module: ' . $className, [
|
||||
'exception' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a module's vendor autoloader if it has dependencies
|
||||
*
|
||||
* @param string $handle Module handle
|
||||
* @throws Exception If module has dependencies but vendor directory is missing
|
||||
*/
|
||||
private function loadModuleVendor(string $handle): void
|
||||
{
|
||||
$moduleDir = $this->serverRoot . '/modules/' . $handle;
|
||||
$composerJson = $moduleDir . '/composer.json';
|
||||
$vendorAutoload = $moduleDir . '/lib/vendor/autoload.php';
|
||||
|
||||
// Check if module has a composer.json with dependencies
|
||||
if (file_exists($composerJson)) {
|
||||
$composerData = json_decode(file_get_contents($composerJson), true);
|
||||
$hasDependencies = !empty($composerData['require']) && count($composerData['require']) > 1; // More than just PHP
|
||||
|
||||
if ($hasDependencies) {
|
||||
if (file_exists($vendorAutoload)) {
|
||||
require_once $vendorAutoload;
|
||||
$this->logger->debug("Loaded vendor autoloader for module: {$handle}");
|
||||
} else {
|
||||
throw new Exception(
|
||||
"Module '{$handle}' declares dependencies in composer.json but vendor directory is missing. "
|
||||
. "Run 'composer install' in {$moduleDir}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function studly(string $value): string
|
||||
{
|
||||
$value = str_replace(['-', '_'], ' ', strtolower($value));
|
||||
$value = ucwords($value);
|
||||
return str_replace(' ', '', $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the PHP namespace from a Module.php file by parsing its contents
|
||||
*
|
||||
* @param string $moduleFilePath Absolute path to the Module.php file (located at /modules/{handle}/lib/Module.php)
|
||||
* @return string|null The namespace segment (e.g., 'ContactsManager' from 'KTXM\ContactsManager')
|
||||
*/
|
||||
private function extractNamespaceFromFile(string $moduleFilePath): ?string
|
||||
{
|
||||
if (!file_exists($moduleFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($moduleFilePath);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match namespace declaration: namespace KTXM\<Namespace>;
|
||||
if (preg_match('/^\s*namespace\s+KTXM\\\\([a-zA-Z0-9_]+)\s*;/m', $content, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available permissions from all modules
|
||||
*
|
||||
* @return array Grouped permissions with metadata
|
||||
*/
|
||||
public function availablePermissions(): array
|
||||
{
|
||||
$permissions = [];
|
||||
|
||||
foreach ($this->list() as $module) {
|
||||
$modulePermissions = $module->permissions();
|
||||
|
||||
foreach ($modulePermissions as $permission => $meta) {
|
||||
$permissions[$permission] = array_merge($meta, [
|
||||
'module' => $module->handle()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Group by category
|
||||
$grouped = [];
|
||||
foreach ($permissions as $permission => $meta) {
|
||||
$group = $meta['group'] ?? 'Other';
|
||||
|
||||
if (!isset($grouped[$group])) {
|
||||
$grouped[$group] = [];
|
||||
}
|
||||
|
||||
$grouped[$group][$permission] = $meta;
|
||||
}
|
||||
|
||||
// Sort groups alphabetically
|
||||
ksort($grouped);
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a permission exists
|
||||
*/
|
||||
public function permissionExists(string $permission): bool
|
||||
{
|
||||
foreach ($this->list() as $module) {
|
||||
$modulePermissions = $module->permissions();
|
||||
|
||||
// Exact match
|
||||
if (isset($modulePermissions[$permission])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard match (e.g., user_manager.users.create matches user_manager.users.*)
|
||||
foreach (array_keys($modulePermissions) as $registered) {
|
||||
if (str_ends_with($registered, '.*')) {
|
||||
$prefix = substr($registered, 0, -2);
|
||||
if (str_starts_with($permission, $prefix . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Get the core module instance
|
||||
*/
|
||||
private function coreModule(): ?\KTXF\Module\ModuleInstanceInterface
|
||||
{
|
||||
if (isset($this->moduleInstances['core'])) {
|
||||
return $this->moduleInstances['core'];
|
||||
}
|
||||
|
||||
try {
|
||||
$coreModuleClass = \KTXC\Module\Module::class;
|
||||
if (!class_exists($coreModuleClass)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$instance = $this->container->get($coreModuleClass);
|
||||
$this->moduleInstances['core'] = $instance;
|
||||
return $instance;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Failed to load core module', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
179
core/lib/Module/ModuleObject.php
Normal file
179
core/lib/Module/ModuleObject.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module;
|
||||
|
||||
use JsonSerializable;
|
||||
use KTXC\Module\Store\ModuleEntry;
|
||||
use KTXF\Module\ModuleBrowserInterface;
|
||||
use KTXF\Module\ModuleConsoleInterface;
|
||||
use KTXF\Module\ModuleInstanceInterface;
|
||||
|
||||
/**
|
||||
* Module is a unified wrapper that combines both the ModuleInterface instance
|
||||
* (from filesystem) and ModuleEntry (from database) into a single object.
|
||||
*
|
||||
* This provides a single source of truth for all module information.
|
||||
*/
|
||||
class ModuleObject implements JsonSerializable
|
||||
{
|
||||
private ?ModuleInstanceInterface $instance = null;
|
||||
private ?ModuleEntry $entry = null;
|
||||
|
||||
public function __construct(?ModuleInstanceInterface $instance = null, ?ModuleEntry $entry = null)
|
||||
{
|
||||
$this->instance = $instance;
|
||||
$this->entry = $entry;
|
||||
}
|
||||
|
||||
// ===== Serialization =====
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id(),
|
||||
'handle' => $this->handle(),
|
||||
'version' => $this->version(),
|
||||
'namespace' => $this->namespace(),
|
||||
'installed' => $this->installed(),
|
||||
'enabled' => $this->enabled(),
|
||||
'needsUpgrade' => $this->needsUpgrade(),
|
||||
];
|
||||
}
|
||||
|
||||
// ===== State from ModuleEntry (database) =====
|
||||
|
||||
public function id(): ?string
|
||||
{
|
||||
return $this->entry?->getId();
|
||||
}
|
||||
|
||||
public function installed(): bool
|
||||
{
|
||||
return $this->entry?->getInstalled() ?? false;
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->entry?->getEnabled() ?? false;
|
||||
}
|
||||
|
||||
// ===== Information from ModuleInterface (filesystem) =====
|
||||
|
||||
public function handle(): string
|
||||
{
|
||||
if ($this->instance) {
|
||||
return $this->instance->handle();
|
||||
}
|
||||
if ($this->entry) {
|
||||
return $this->entry->getHandle();
|
||||
}
|
||||
throw new \RuntimeException('Module has neither instance nor entry');
|
||||
}
|
||||
|
||||
public function namespace(): ?string
|
||||
{
|
||||
if ($this->entry) {
|
||||
return $this->entry->getNamespace();
|
||||
}
|
||||
if ($this->instance) {
|
||||
// Extract namespace from class name
|
||||
$className = get_class($this->instance);
|
||||
$parts = explode('\\', $className);
|
||||
if (count($parts) >= 2 && $parts[0] === 'KTXM') {
|
||||
return $parts[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function version(): string
|
||||
{
|
||||
// Prefer current version from filesystem
|
||||
if ($this->instance) {
|
||||
return $this->instance->version();
|
||||
}
|
||||
// Fallback to stored version
|
||||
if ($this->entry) {
|
||||
return $this->entry->getVersion();
|
||||
}
|
||||
return '0.0.0';
|
||||
}
|
||||
|
||||
public function permissions(): array
|
||||
{
|
||||
return $this->instance?->permissions() ?? [];
|
||||
}
|
||||
|
||||
// ===== Computed properties =====
|
||||
|
||||
public function needsUpgrade(): bool
|
||||
{
|
||||
if (!$this->instance || !$this->entry || !$this->installed()) {
|
||||
return false;
|
||||
}
|
||||
$currentVersion = $this->instance->version();
|
||||
$storedVersion = $this->entry->getVersion();
|
||||
return version_compare($currentVersion, $storedVersion, '>');
|
||||
}
|
||||
|
||||
// ===== Access to underlying objects =====
|
||||
|
||||
public function instance(): ?ModuleInstanceInterface
|
||||
{
|
||||
return $this->instance;
|
||||
}
|
||||
|
||||
public function entry(): ?ModuleEntry
|
||||
{
|
||||
return $this->entry;
|
||||
}
|
||||
|
||||
// ===== Lifecycle methods (delegate to instance) =====
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->instance?->boot();
|
||||
}
|
||||
|
||||
public function install(): void
|
||||
{
|
||||
$this->instance?->install();
|
||||
}
|
||||
|
||||
public function uninstall(): void
|
||||
{
|
||||
$this->instance?->uninstall();
|
||||
}
|
||||
|
||||
public function enable(): void
|
||||
{
|
||||
$this->instance?->enable();
|
||||
}
|
||||
|
||||
public function disable(): void
|
||||
{
|
||||
$this->instance?->disable();
|
||||
}
|
||||
|
||||
public function upgrade(): void
|
||||
{
|
||||
$this->instance?->upgrade();
|
||||
}
|
||||
|
||||
public function registerBI(): array | null
|
||||
{
|
||||
if ($this->instance instanceof ModuleBrowserInterface) {
|
||||
return $this->instance->registerBI();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function registerCI(): array | null
|
||||
{
|
||||
if ($this->instance instanceof ModuleConsoleInterface) {
|
||||
return $this->instance->registerCI();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
119
core/lib/Module/Store/ModuleEntry.php
Normal file
119
core/lib/Module/Store/ModuleEntry.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module\Store;
|
||||
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
/**
|
||||
* Module entity representing an installed module
|
||||
*/
|
||||
class ModuleEntry implements \JsonSerializable, JsonDeserializable
|
||||
{
|
||||
private ?string $id = null;
|
||||
private ?string $namespace = null;
|
||||
private ?string $handle = null;
|
||||
private bool $installed = false;
|
||||
private bool $enabled = false;
|
||||
private string $version = '0.0.1';
|
||||
|
||||
/**
|
||||
* Deserialize from associative array.
|
||||
*/
|
||||
public function jsonDeserialize(array|string $data): static
|
||||
{
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
// Map only if key exists to avoid notices and allow partial input
|
||||
if (array_key_exists('_id', $data)) $this->id = $data['_id'] !== null ? (string)$data['_id'] : null;
|
||||
elseif (array_key_exists('id', $data)) $this->id = $data['id'] !== null ? (string)$data['id'] : null;
|
||||
if (array_key_exists('namespace', $data)) $this->namespace = $data['namespace'] !== null ? (string)$data['namespace'] : null;
|
||||
if (array_key_exists('handle', $data)) $this->handle = $data['handle'] !== null ? (string)$data['handle'] : null;
|
||||
if (array_key_exists('installed', $data)) $this->installed = (bool)$data['installed'];
|
||||
if (array_key_exists('enabled', $data)) $this->enabled = (bool)$data['enabled'];
|
||||
if (array_key_exists('version', $data)) $this->version = (string)$data['version'];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to JSON-friendly structure.
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'namespace' => $this->namespace,
|
||||
'handle' => $this->handle,
|
||||
'installed' => $this->installed,
|
||||
'enabled' => $this->enabled,
|
||||
'version' => $this->version,
|
||||
];
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $value): self
|
||||
{
|
||||
$this->id = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNamespace(): ?string
|
||||
{
|
||||
return $this->namespace;
|
||||
}
|
||||
|
||||
public function setNamespace(string $value): self
|
||||
{
|
||||
$this->namespace = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHandle(): ?string
|
||||
{
|
||||
return $this->handle;
|
||||
}
|
||||
|
||||
public function setHandle(string $value): self
|
||||
{
|
||||
$this->handle = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getInstalled(): bool
|
||||
{
|
||||
return $this->installed;
|
||||
}
|
||||
|
||||
public function setInstalled(bool $value): self
|
||||
{
|
||||
$this->installed = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $value): self
|
||||
{
|
||||
$this->enabled = $value;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function setVersion(string $value): self
|
||||
{
|
||||
$this->version = $value;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
66
core/lib/Module/Store/ModuleStore.php
Normal file
66
core/lib/Module/Store/ModuleStore.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Module\Store;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
|
||||
class ModuleStore
|
||||
{
|
||||
|
||||
protected const COLLECTION_NAME = 'modules';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
) { }
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find(['enabled' => true, 'installed' => true]);
|
||||
$modules = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$entity = new ModuleEntry();
|
||||
$entity->jsonDeserialize((array)$entry);
|
||||
$modules[$entity->getId()] = $entity;
|
||||
}
|
||||
return $modules;
|
||||
}
|
||||
|
||||
public function fetch(string $handle): ?ModuleEntry
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['handle' => $handle]);
|
||||
if (!$entry) { return null; }
|
||||
return (new ModuleEntry())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
public function deposit(ModuleEntry $entry): ?ModuleEntry
|
||||
{
|
||||
if ($entry->getId()) {
|
||||
return $this->update($entry);
|
||||
} else {
|
||||
return $this->create($entry);
|
||||
}
|
||||
}
|
||||
|
||||
private function create(ModuleEntry $entry): ?ModuleEntry
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
|
||||
$entry->setId((string)$result->getInsertedId());
|
||||
return $entry;
|
||||
}
|
||||
|
||||
private function update(ModuleEntry $entry): ?ModuleEntry
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return null; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
|
||||
return $entry;
|
||||
}
|
||||
|
||||
public function destroy(ModuleEntry $entry): void
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||
}
|
||||
|
||||
}
|
||||
89
core/lib/Resource/ProviderManager.php
Normal file
89
core/lib/Resource/ProviderManager.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Resource;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use KTXF\Resource\Provider\ProviderInterface;
|
||||
|
||||
/**
|
||||
* Provider Registry
|
||||
*
|
||||
* Manages registration and resolution of authentication providers.
|
||||
*/
|
||||
class ProviderManager
|
||||
{
|
||||
private array $registeredProviders = [];
|
||||
private array $resolvedProviders = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly ContainerInterface $container
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Register an authentication provider (called from module boot)
|
||||
*
|
||||
* @param string $type Provider type (e.g., 'authentication', 'storage', 'notification')
|
||||
* @param string $identifier Provider ID (e.g., 'default', 'oidc', 'totp')
|
||||
* @param string $class Fully qualified class name
|
||||
*/
|
||||
public function register(string $type, string $identifier, string $class): void
|
||||
{
|
||||
$this->registeredProviders[$type][$identifier] = $class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a provider
|
||||
*/
|
||||
public function unregister(string $type, string $identifier): void
|
||||
{
|
||||
unset($this->registeredProviders[$type][$identifier]);
|
||||
unset($this->resolvedProviders[$type][$identifier]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider by ID
|
||||
*/
|
||||
public function resolve(string $type, string $identifier): ?ProviderInterface
|
||||
{
|
||||
if (isset($this->resolvedProviders[$type][$identifier])) {
|
||||
return $this->resolvedProviders[$type][$identifier];
|
||||
}
|
||||
|
||||
if (!isset($this->registeredProviders[$type][$identifier])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$provider = $this->container->get($this->registeredProviders[$type][$identifier]);
|
||||
$this->resolvedProviders[$type][$identifier] = $provider;
|
||||
return $provider;
|
||||
} catch (\Exception $e) {
|
||||
error_log("Failed to resolve provider {$identifier}: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve multiple providers
|
||||
*
|
||||
* @param array|null $filter Optional list of provider IDs to return
|
||||
* @return array<string, ProviderInterface>
|
||||
*/
|
||||
public function providers(string $type, ?array $filter = null): array
|
||||
{
|
||||
$requestedProviders = $filter ?? array_keys($this->registeredProviders[$type] ?? []);
|
||||
$result = [];
|
||||
|
||||
foreach ($requestedProviders as $identifier) {
|
||||
$provider = $this->resolve($type, $identifier);
|
||||
if ($provider !== null) {
|
||||
$result[$identifier] = $provider;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
}
|
||||
30
core/lib/Routing/Route.php
Normal file
30
core/lib/Routing/Route.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Routing;
|
||||
|
||||
/**
|
||||
* Value object representing a resolved route.
|
||||
*/
|
||||
class Route
|
||||
{
|
||||
/** @var array<string, string> Route parameters extracted from path */
|
||||
public array $params = [];
|
||||
|
||||
public function __construct(
|
||||
public readonly string $name,
|
||||
public readonly string $method,
|
||||
public readonly string $path,
|
||||
public readonly bool $authenticated,
|
||||
public readonly string $className,
|
||||
public readonly string $classMethodName,
|
||||
public readonly array $classMethodParameters = [],
|
||||
public readonly array $permissions = [],
|
||||
) {}
|
||||
|
||||
public function withParams(array $params): self
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->params = $params;
|
||||
return $clone;
|
||||
}
|
||||
}
|
||||
244
core/lib/Routing/Router.php
Normal file
244
core/lib/Routing/Router.php
Normal file
@@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Routing;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Http\Response\Response;
|
||||
use KTXC\Injection\Container;
|
||||
use KTXC\Module\ModuleManager;
|
||||
use KTXF\Routing\Attributes\AnonymousRoute;
|
||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
use ReflectionMethod;
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
class Router
|
||||
{
|
||||
private Container $container;
|
||||
/** @var array<string,array<string,Route>> */
|
||||
private array $routes = []; // [method][path] => Route
|
||||
private bool $initialized = false;
|
||||
private string $cacheFile;
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ModuleManager $moduleManager,
|
||||
Container $container,
|
||||
#[Inject('rootDir')] private readonly string $rootDir,
|
||||
#[Inject('moduleDir')] private readonly string $moduleDir,
|
||||
#[Inject('environment')] private readonly string $environment
|
||||
)
|
||||
{
|
||||
$this->container = $container;
|
||||
$this->cacheFile = $rootDir . '/var/cache/routes.cache.php';
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
// load cached routes in production
|
||||
if ($this->environment === 'prod' && file_exists($this->cacheFile)) {
|
||||
$data = include $this->cacheFile;
|
||||
if (is_array($data)) {
|
||||
$this->routes = $data;
|
||||
$this->initialized = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// otherwise scan for routes
|
||||
$this->scan();
|
||||
$this->initialized = true;
|
||||
// write cache
|
||||
$dir = dirname($this->cacheFile);
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
file_put_contents($this->cacheFile, '<?php return ' . var_export($this->routes, true) . ';');
|
||||
}
|
||||
|
||||
|
||||
private function scan(): void
|
||||
{
|
||||
// load core controllers
|
||||
foreach (glob($this->rootDir . '/core/lib/Controllers/*.php') as $file) {
|
||||
$this->extract($file);
|
||||
}
|
||||
|
||||
// load module controllers
|
||||
foreach ($this->moduleManager->list(true, true) as $module) {
|
||||
$path = $this->moduleDir . '/' . $module->handle() . '/lib/Controllers';
|
||||
if (is_dir($path)) {
|
||||
foreach (glob($path . '/*.php') as $file) {
|
||||
$this->extract($file, '/m/' . $module->handle());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function extract(string $file, string $routePrefix = ''): void
|
||||
{
|
||||
$contents = file_get_contents($file);
|
||||
if ($contents === false) return;
|
||||
// extract namespace
|
||||
if (!preg_match('#namespace\\s+([^;]+);#', $contents, $nsM)) return;
|
||||
$ns = trim($nsM[1]);
|
||||
// extract class names
|
||||
if (!preg_match_all('#class\\s+(\\w+)#', $contents, $cM)) return;
|
||||
foreach ($cM[1] as $class) {
|
||||
$fqcn = $ns . '\\' . $class;
|
||||
try {
|
||||
if (!class_exists($fqcn)) {
|
||||
continue;
|
||||
}
|
||||
require_once $file;
|
||||
$reflectionClass = new ReflectionClass($fqcn);
|
||||
if ($reflectionClass->isAbstract()) continue;
|
||||
foreach ($reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC) as $reflectionMethod) {
|
||||
$attributes = array_merge(
|
||||
$reflectionMethod->getAttributes(AnonymousRoute::class),
|
||||
$reflectionMethod->getAttributes(AuthenticatedRoute::class)
|
||||
);
|
||||
foreach ($attributes as $attribute) {
|
||||
$route = $attribute->newInstance();
|
||||
$httpPath = $routePrefix . $route->path;
|
||||
foreach ($route->methods as $httpMethod) {
|
||||
$this->routes[$httpMethod][$httpPath] = new Route(
|
||||
method: $httpMethod,
|
||||
path: $httpPath,
|
||||
name: $route->name,
|
||||
authenticated: $route instanceof AuthenticatedRoute,
|
||||
className: $reflectionClass->getName(),
|
||||
classMethodName: $reflectionMethod->getName(),
|
||||
classMethodParameters: $reflectionMethod->getParameters(),
|
||||
permissions: $route instanceof AuthenticatedRoute ? $route->permissions : [],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Route collection failed', ['file' => $file, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a Request to a Route, or return null if no match.
|
||||
* Supports exact matches and simple {param} patterns.
|
||||
* Prioritizes: 1) exact matches, 2) specific patterns, 3) catch-all patterns
|
||||
*/
|
||||
public function match(Request $request): ?Route
|
||||
{
|
||||
if (!$this->initialized) {
|
||||
$this->initialize();
|
||||
}
|
||||
$method = $request->getMethod();
|
||||
$path = $request->getPathInfo();
|
||||
// Exact match first
|
||||
if (isset($this->routes[$method][$path])) {
|
||||
return $this->routes[$method][$path];
|
||||
}
|
||||
// Pattern matching - separate catch-all from specific patterns
|
||||
$specificPatterns = [];
|
||||
$catchAllPattern = null;
|
||||
foreach ($this->routes[$method] ?? [] as $routePath => $routeObj) {
|
||||
if (str_contains($routePath, '{')) {
|
||||
// Check if this is a catch-all pattern (e.g., /{path})
|
||||
if (preg_match('#^/\{[^/]+\}$#', $routePath)) {
|
||||
$catchAllPattern = [$routePath, $routeObj];
|
||||
} else {
|
||||
$specificPatterns[] = [$routePath, $routeObj];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try specific patterns first
|
||||
foreach ($specificPatterns as [$routePath, $routeObj]) {
|
||||
$pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>[^/]+)', $routePath);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
if (preg_match($pattern, $path, $m)) {
|
||||
$params = [];
|
||||
foreach ($m as $k => $v) {
|
||||
if (is_string($k)) { $params[$k] = $v; }
|
||||
}
|
||||
return $routeObj->withParams($params);
|
||||
}
|
||||
}
|
||||
// Try catch-all pattern last
|
||||
if ($catchAllPattern !== null) {
|
||||
[$routePath, $routeObj] = $catchAllPattern;
|
||||
$pattern = preg_replace('#\{([a-zA-Z_][a-zA-Z0-9_]*)\}#', '(?P<$1>.*)', $routePath);
|
||||
$pattern = '#^' . $pattern . '$#';
|
||||
if (preg_match($pattern, $path, $m)) {
|
||||
$params = [];
|
||||
foreach ($m as $k => $v) {
|
||||
if (is_string($k)) { $params[$k] = $v; }
|
||||
}
|
||||
return $routeObj->withParams($params);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, full body for array params.
|
||||
*/
|
||||
public function dispatch(Route $route, Request $request): ?Response
|
||||
{
|
||||
// extract controller and method
|
||||
$routeControllerName = $route->className;
|
||||
$routeControllerMethod = $route->classMethodName;
|
||||
$routeControllerParameters = $route->classMethodParameters;
|
||||
// instantiate controller
|
||||
if ($this->container->has($routeControllerName)) {
|
||||
$instance = $this->container->get($routeControllerName);
|
||||
} else {
|
||||
$instance = new $routeControllerName();
|
||||
}
|
||||
try {
|
||||
$requestParameters = $request->getPayload();
|
||||
} catch (\Throwable) {
|
||||
// ignore payload errors
|
||||
}
|
||||
$reflectionMethod = new \ReflectionMethod($routeControllerName, $routeControllerMethod);
|
||||
$routeParams = $route->params ?? [];
|
||||
$callArgs = [];
|
||||
foreach ($reflectionMethod->getParameters() as $reflectionParameter) {
|
||||
$reflectionParameterName = $reflectionParameter->getName();
|
||||
$reflectionParameterType = $reflectionParameter->getType();
|
||||
// if parameter matches request class, use current request
|
||||
if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), Request::class, true)) {
|
||||
$callArgs[] = $request;
|
||||
continue;
|
||||
}
|
||||
// if method parameter matches a route path param, use that (highest priority)
|
||||
if (array_key_exists($reflectionParameterName, $routeParams)) {
|
||||
$callArgs[] = $routeParams[$reflectionParameterName];
|
||||
continue;
|
||||
}
|
||||
// if method parameter matches a request param, use that
|
||||
if ($requestParameters->has($reflectionParameterName)) {
|
||||
// if parameter is a class implementing JsonDeserializable, call jsonDeserialize on it
|
||||
if ($reflectionParameterType && $reflectionParameterType instanceof \ReflectionNamedType && !$reflectionParameterType->isBuiltin() && is_a($reflectionParameterType->getName(), JsonDeserializable::class, true)) {
|
||||
$type = $reflectionParameterType->getName();
|
||||
$object = new $type();
|
||||
if ($object instanceof JsonDeserializable) {
|
||||
$object->jsonDeserialize($requestParameters->get($reflectionParameterName));
|
||||
$callArgs[] = $object;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// otherwise, use the raw value
|
||||
$callArgs[] = $requestParameters->get($reflectionParameterName);
|
||||
continue;
|
||||
}
|
||||
// if method parameter did not match, but has a default value, use that
|
||||
if ($reflectionParameter->isDefaultValueAvailable()) {
|
||||
$callArgs[] = $reflectionParameter->getDefaultValue();
|
||||
continue;
|
||||
}
|
||||
$callArgs[] = null;
|
||||
}
|
||||
$result = $instance->$routeControllerMethod(...$callArgs);
|
||||
return $result instanceof Response ? $result : null;
|
||||
}
|
||||
|
||||
}
|
||||
180
core/lib/Security/Authentication/AuthenticationRequest.php
Normal file
180
core/lib/Security/Authentication/AuthenticationRequest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Authentication;
|
||||
|
||||
/**
|
||||
* Authentication Request
|
||||
*
|
||||
* Request DTO from controller to AuthenticationManager.
|
||||
* Encapsulates all input data for authentication operations.
|
||||
*/
|
||||
readonly class AuthenticationRequest
|
||||
{
|
||||
// Action types
|
||||
public const ACTION_START = 'start';
|
||||
public const ACTION_IDENTIFY = 'identify';
|
||||
public const ACTION_VERIFY = 'verify';
|
||||
public const ACTION_CHALLENGE = 'challenge';
|
||||
public const ACTION_REDIRECT = 'redirect';
|
||||
public const ACTION_CALLBACK = 'callback';
|
||||
public const ACTION_STATUS = 'status';
|
||||
public const ACTION_CANCEL = 'cancel';
|
||||
public const ACTION_REFRESH = 'refresh';
|
||||
public const ACTION_LOGOUT = 'logout';
|
||||
|
||||
public function __construct(
|
||||
/** Action to perform */
|
||||
public string $action,
|
||||
|
||||
/** Session ID (for ongoing auth flows) */
|
||||
public ?string $sessionId = null,
|
||||
|
||||
/** User identity (email/username) */
|
||||
public ?string $identity = null,
|
||||
|
||||
/** Authentication method/provider ID */
|
||||
public ?string $method = null,
|
||||
|
||||
/** Secret/code/password */
|
||||
public ?string $secret = null,
|
||||
|
||||
/** Callback URL for redirect flows */
|
||||
public ?string $callbackUrl = null,
|
||||
|
||||
/** Return URL after authentication */
|
||||
public ?string $returnUrl = null,
|
||||
|
||||
/** Additional parameters (OIDC callback params, etc.) */
|
||||
public array $params = [],
|
||||
|
||||
/** Token for refresh/logout operations */
|
||||
public ?string $token = null,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Factory Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Create a start request
|
||||
*/
|
||||
public static function start(): self
|
||||
{
|
||||
return new self(action: self::ACTION_START);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an identify request
|
||||
*/
|
||||
public static function identify(string $sessionId, string $identity): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_IDENTIFY,
|
||||
sessionId: $sessionId,
|
||||
identity: $identity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a verify request (password, TOTP code, etc.)
|
||||
*/
|
||||
public static function verify(string $sessionId, string $method, string $secret): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_VERIFY,
|
||||
sessionId: $sessionId,
|
||||
method: $method,
|
||||
secret: $secret,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a begin challenge request
|
||||
*/
|
||||
public static function challenge(string $sessionId, string $method): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_CHALLENGE,
|
||||
sessionId: $sessionId,
|
||||
method: $method,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a begin redirect request
|
||||
*/
|
||||
public static function redirect(
|
||||
string $sessionId,
|
||||
string $method,
|
||||
string $callbackUrl,
|
||||
?string $returnUrl = null
|
||||
): self {
|
||||
return new self(
|
||||
action: self::ACTION_REDIRECT,
|
||||
sessionId: $sessionId,
|
||||
method: $method,
|
||||
callbackUrl: $callbackUrl,
|
||||
returnUrl: $returnUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a callback request (OIDC/SAML return)
|
||||
*/
|
||||
public static function callback(string $sessionId, string $method, array $params): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_CALLBACK,
|
||||
sessionId: $sessionId,
|
||||
method: $method,
|
||||
params: $params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a status request
|
||||
*/
|
||||
public static function status(string $sessionId): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_STATUS,
|
||||
sessionId: $sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cancel request
|
||||
*/
|
||||
public static function cancel(string $sessionId): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_CANCEL,
|
||||
sessionId: $sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a refresh token request
|
||||
*/
|
||||
public static function refresh(string $token): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_REFRESH,
|
||||
token: $token,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logout request
|
||||
*/
|
||||
public static function logout(?string $token = null, bool $allDevices = false): self
|
||||
{
|
||||
return new self(
|
||||
action: self::ACTION_LOGOUT,
|
||||
token: $token,
|
||||
params: ['all_devices' => $allDevices],
|
||||
);
|
||||
}
|
||||
}
|
||||
272
core/lib/Security/Authentication/AuthenticationResponse.php
Normal file
272
core/lib/Security/Authentication/AuthenticationResponse.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security\Authentication;
|
||||
|
||||
/**
|
||||
* Authentication Response
|
||||
*
|
||||
* Response DTO from AuthenticationManager to controller.
|
||||
* Contains all data needed to build the HTTP response.
|
||||
*/
|
||||
readonly class AuthenticationResponse
|
||||
{
|
||||
// Status constants
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_CHALLENGE = 'challenge';
|
||||
public const STATUS_REDIRECT = 'redirect';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
// Error codes
|
||||
public const ERROR_INVALID_REQUEST = 'invalid_request';
|
||||
public const ERROR_INVALID_CREDENTIALS = 'invalid_credentials';
|
||||
public const ERROR_INVALID_PROVIDER = 'invalid_provider';
|
||||
public const ERROR_INVALID_SESSION = 'invalid_session';
|
||||
public const ERROR_SESSION_EXPIRED = 'session_expired';
|
||||
public const ERROR_USER_NOT_FOUND = 'user_not_found';
|
||||
public const ERROR_USER_DISABLED = 'user_disabled';
|
||||
public const ERROR_ACCOUNT_LOCKED = 'account_locked';
|
||||
public const ERROR_RATE_LIMITED = 'rate_limited';
|
||||
public const ERROR_INTERNAL = 'internal_error';
|
||||
|
||||
public function __construct(
|
||||
/** Response status */
|
||||
public string $status,
|
||||
|
||||
/** Suggested HTTP status code */
|
||||
public int $httpStatus = 200,
|
||||
|
||||
/** Session ID (for ongoing flows) */
|
||||
public ?string $sessionId = null,
|
||||
|
||||
/** Current session state */
|
||||
public ?string $sessionState = null,
|
||||
|
||||
/** Serialized user data (on success) */
|
||||
public ?array $user = null,
|
||||
|
||||
/** Auth tokens (on success) */
|
||||
public ?array $tokens = null,
|
||||
|
||||
/** Available authentication methods */
|
||||
public ?array $methods = null,
|
||||
|
||||
/** Challenge information */
|
||||
public ?array $challenge = null,
|
||||
|
||||
/** Redirect URL (for OIDC/SAML) */
|
||||
public ?string $redirectUrl = null,
|
||||
|
||||
/** Return URL (after redirect auth) */
|
||||
public ?string $returnUrl = null,
|
||||
|
||||
/** Error code */
|
||||
public ?string $errorCode = null,
|
||||
|
||||
/** Error message */
|
||||
public ?string $errorMessage = null,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Factory Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Session started response
|
||||
*/
|
||||
public static function started(string $sessionId, array $methods): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_SUCCESS,
|
||||
sessionId: $sessionId,
|
||||
methods: $methods,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* User identified response
|
||||
*/
|
||||
public static function identified(string $sessionId, string $state, array $methods): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_SUCCESS,
|
||||
sessionId: $sessionId,
|
||||
sessionState: $state,
|
||||
methods: $methods,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication successful
|
||||
*/
|
||||
public static function success(array $user, array $tokens): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_SUCCESS,
|
||||
user: $user,
|
||||
tokens: $tokens,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MFA/additional factor required
|
||||
*/
|
||||
public static function pending(string $sessionId, array $methods): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_PENDING,
|
||||
sessionId: $sessionId,
|
||||
methods: $methods,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Challenge sent (SMS, email, etc.)
|
||||
*/
|
||||
public static function challenge(string $sessionId, array $challengeInfo): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_CHALLENGE,
|
||||
sessionId: $sessionId,
|
||||
challenge: $challengeInfo,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect required (OIDC/SAML)
|
||||
*/
|
||||
public static function redirect(string $sessionId, string $redirectUrl): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_REDIRECT,
|
||||
sessionId: $sessionId,
|
||||
redirectUrl: $redirectUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication failed
|
||||
*/
|
||||
public static function failed(
|
||||
string $errorCode,
|
||||
?string $errorMessage = null,
|
||||
int $httpStatus = 401
|
||||
): self {
|
||||
return new self(
|
||||
status: self::STATUS_FAILED,
|
||||
httpStatus: $httpStatus,
|
||||
errorCode: $errorCode,
|
||||
errorMessage: $errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Session cancelled
|
||||
*/
|
||||
public static function cancelled(): self
|
||||
{
|
||||
return new self(
|
||||
status: self::STATUS_CANCELLED,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status check response
|
||||
*/
|
||||
public static function status(
|
||||
string $sessionId,
|
||||
string $state,
|
||||
array $methods,
|
||||
?string $identity = null
|
||||
): self {
|
||||
return new self(
|
||||
status: self::STATUS_SUCCESS,
|
||||
sessionId: $sessionId,
|
||||
sessionState: $state,
|
||||
methods: $methods,
|
||||
user: $identity ? ['identity' => $identity] : null,
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Status Checks
|
||||
// =========================================================================
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SUCCESS;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isRedirect(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_REDIRECT;
|
||||
}
|
||||
|
||||
public function isFailed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED;
|
||||
}
|
||||
|
||||
public function hasTokens(): bool
|
||||
{
|
||||
return $this->tokens !== null && !empty($this->tokens);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Serialization
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Convert to array for JSON response
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = ['status' => $this->status];
|
||||
|
||||
if ($this->sessionId !== null) {
|
||||
$result['session'] = $this->sessionId;
|
||||
}
|
||||
|
||||
if ($this->sessionState !== null) {
|
||||
$result['state'] = $this->sessionState;
|
||||
}
|
||||
|
||||
if ($this->user !== null) {
|
||||
$result['user'] = $this->user;
|
||||
}
|
||||
|
||||
if ($this->methods !== null) {
|
||||
$result['methods'] = $this->methods;
|
||||
}
|
||||
|
||||
if ($this->challenge !== null) {
|
||||
$result['challenge'] = $this->challenge;
|
||||
}
|
||||
|
||||
if ($this->redirectUrl !== null) {
|
||||
$result['redirect_url'] = $this->redirectUrl;
|
||||
}
|
||||
|
||||
if ($this->returnUrl !== null) {
|
||||
$result['return_url'] = $this->returnUrl;
|
||||
}
|
||||
|
||||
if ($this->errorCode !== null) {
|
||||
$result['error_code'] = $this->errorCode;
|
||||
}
|
||||
|
||||
if ($this->errorMessage !== null) {
|
||||
$result['error'] = $this->errorMessage;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
806
core/lib/Security/AuthenticationManager.php
Normal file
806
core/lib/Security/AuthenticationManager.php
Normal file
@@ -0,0 +1,806 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Security;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\Security\Authentication\AuthenticationRequest;
|
||||
use KTXC\Security\Authentication\AuthenticationResponse;
|
||||
use KTXC\Service\TokenService;
|
||||
use KTXC\Service\UserAccountsService;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||
use KTXF\Security\Authentication\AuthenticationSession;
|
||||
use KTXF\Security\Authentication\ProviderContext;
|
||||
|
||||
/**
|
||||
* Authentication Manager
|
||||
*/
|
||||
class AuthenticationManager
|
||||
{
|
||||
private const CACHE_USAGE = 'auth';
|
||||
private string $securityCode;
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenant,
|
||||
private readonly EphemeralCacheInterface $cache,
|
||||
private readonly ProviderManager $providerManager,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly UserAccountsService $userService,
|
||||
) {
|
||||
$this->securityCode = $this->tenant->configuration()->security()->code();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Main Entry Point
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Handle an authentication request
|
||||
*/
|
||||
public function handle(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
return match ($request->action) {
|
||||
AuthenticationRequest::ACTION_START => $this->handleStart(),
|
||||
AuthenticationRequest::ACTION_IDENTIFY => $this->handleIdentify($request),
|
||||
AuthenticationRequest::ACTION_VERIFY => $this->handleVerify($request),
|
||||
AuthenticationRequest::ACTION_CHALLENGE => $this->handleChallenge($request),
|
||||
AuthenticationRequest::ACTION_REDIRECT => $this->handleRedirect($request),
|
||||
AuthenticationRequest::ACTION_CALLBACK => $this->handleCallback($request),
|
||||
AuthenticationRequest::ACTION_STATUS => $this->handleStatus($request),
|
||||
AuthenticationRequest::ACTION_CANCEL => $this->handleCancel($request),
|
||||
AuthenticationRequest::ACTION_REFRESH => $this->handleRefresh($request),
|
||||
AuthenticationRequest::ACTION_LOGOUT => $this->handleLogout($request),
|
||||
default => AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_REQUEST,
|
||||
'Unknown action',
|
||||
400
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Action Handlers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Start a new authentication session
|
||||
*/
|
||||
private function handleStart(): AuthenticationResponse
|
||||
{
|
||||
$methods = $this->methodsConfigured();
|
||||
|
||||
$session = AuthenticationSession::create(
|
||||
$this->tenant->identifier(),
|
||||
AuthenticationSession::STATE_FRESH
|
||||
);
|
||||
|
||||
$this->saveSession($session);
|
||||
|
||||
return AuthenticationResponse::started($session->id, $methods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify user (identity-first flow)
|
||||
*/
|
||||
private function handleIdentify(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
$session = $this->retrieveSession($request->sessionId);
|
||||
|
||||
if ($session === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_SESSION_EXPIRED,
|
||||
'Invalid or expired session',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Return all tenant methods to prevent enumeration
|
||||
// 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->tenant->configuration()->authentication()->methodsMinimal();
|
||||
|
||||
// Store identity in session without validating to prevent enumeration
|
||||
$session->setMethods(array_column($methods, 'id'), $require);
|
||||
$session->setIdentity($request->identity);
|
||||
$this->saveSession($session);
|
||||
|
||||
return AuthenticationResponse::identified($session->id, $session->state(), $methods);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify credentials or challenge response
|
||||
*/
|
||||
private function handleVerify(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
$session = $this->retrieveSession($request->sessionId);
|
||||
|
||||
if ($session === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_SESSION_EXPIRED,
|
||||
'Invalid or expired session',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($session->userIdentity)) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_SESSION,
|
||||
'Identity is required',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$method = $request->method;
|
||||
|
||||
if (!$session->methodEligible($method)) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_REQUEST,
|
||||
'Method not available',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$provider = $this->providerManager->resolve('authentication', $method);
|
||||
if (!$provider instanceof AuthenticationProviderInterface) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_PROVIDER,
|
||||
'Provider not available',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Build provider context
|
||||
$context = $this->buildProviderContext($session, $method);
|
||||
|
||||
// Call appropriate provider method based on provider type
|
||||
$providerMethod = $provider->method();
|
||||
|
||||
if ($providerMethod === AuthenticationProviderInterface::METHOD_CREDENTIAL) {
|
||||
$result = $provider->verify($context, $request->secret);
|
||||
} elseif ($providerMethod === AuthenticationProviderInterface::METHOD_CHALLENGE) {
|
||||
$result = $provider->verifyChallenge($context, $request->secret);
|
||||
} else {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_PROVIDER,
|
||||
'Provider cannot be used for direct verification',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Store any session data from provider
|
||||
if (!empty($result->sessionData)) {
|
||||
$session->setMeta("provider:{$method}", $result->sessionData);
|
||||
}
|
||||
|
||||
if (!$result->isSuccess()) {
|
||||
$this->saveSession($session);
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
'Authentication failed. If you haven\'t set up this method, try another option.',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve user if not yet set
|
||||
if ($session->userIdentifier === null) {
|
||||
$user = $this->userService->fetchByIdentity($session->userIdentity);
|
||||
if ($user === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_USER_NOT_FOUND,
|
||||
'User not found',
|
||||
401
|
||||
);
|
||||
}
|
||||
$session->userIdentifier = $user->getId();
|
||||
}
|
||||
|
||||
// Mark method complete
|
||||
$session->methodCompleted($method);
|
||||
$this->saveSession($session);
|
||||
|
||||
// Check if all required factors are complete
|
||||
if ($session->state() !== AuthenticationSession::STATE_COMPLETE) {
|
||||
$remainingMethods = $this->methodsConfigured($session->methodsCompleted);
|
||||
// Filter out redirect methods - they can't be used as secondary factors
|
||||
$remainingMethods = array_values(array_filter(
|
||||
$remainingMethods,
|
||||
fn($m) => $m['method'] !== 'redirect'
|
||||
));
|
||||
return AuthenticationResponse::pending($session->id, $remainingMethods);
|
||||
}
|
||||
|
||||
// Authentication complete - issue tokens
|
||||
return $this->completeAuthentication($session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin a challenge (SMS, email, TOTP preparation)
|
||||
*/
|
||||
private function handleChallenge(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
$session = $this->retrieveSession($request->sessionId);
|
||||
|
||||
if ($session === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_SESSION_EXPIRED,
|
||||
'Invalid or expired session',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
$method = $request->method;
|
||||
|
||||
// Resolve user identifier if needed
|
||||
if ($session->userIdentifier === null && $session->userIdentity) {
|
||||
$user = $this->userService->fetchByIdentity($session->userIdentity);
|
||||
if ($user) {
|
||||
$session->userIdentifier = $user->getId();
|
||||
$this->saveSession($session);
|
||||
}
|
||||
}
|
||||
|
||||
$provider = $this->providerManager->resolve('authentication', $method);
|
||||
if (!$provider instanceof AuthenticationProviderInterface) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_PROVIDER,
|
||||
'Provider not available',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$context = $this->buildProviderContext($session, $method);
|
||||
$result = $provider->beginChallenge($context);
|
||||
|
||||
// Store any session data from provider
|
||||
if (!empty($result->sessionData)) {
|
||||
$session->setMeta("provider:{$method}", $result->sessionData);
|
||||
$this->saveSession($session);
|
||||
}
|
||||
|
||||
if ($result->isChallenge()) {
|
||||
return AuthenticationResponse::challenge(
|
||||
$session->id,
|
||||
$result->getClientData('challenge', [])
|
||||
);
|
||||
}
|
||||
|
||||
if ($result->isFailed()) {
|
||||
// Generic error to prevent enumeration
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
'Authentication failed. If you haven\'t set up this method, try another option.',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Unexpected result
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INTERNAL,
|
||||
'Unexpected provider response',
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin redirect-based authentication (OIDC/SAML)
|
||||
*/
|
||||
private function handleRedirect(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
$session = $this->retrieveSession($request->sessionId);
|
||||
|
||||
if ($session === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_SESSION_EXPIRED,
|
||||
'Invalid or expired session',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
$method = $request->method;
|
||||
|
||||
$provider = $this->providerManager->resolve('authentication', $method);
|
||||
if (!$provider instanceof AuthenticationProviderInterface) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_PROVIDER,
|
||||
'Provider not available',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
if ($provider->method() !== AuthenticationProviderInterface::METHOD_REDIRECT) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_PROVIDER,
|
||||
'Provider does not support redirect authentication',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$context = $this->buildProviderContext($session, $method);
|
||||
$result = $provider->beginRedirect($context, $request->callbackUrl, $request->returnUrl);
|
||||
|
||||
if ($result->isFailed()) {
|
||||
return AuthenticationResponse::failed(
|
||||
$result->errorCode ?? AuthenticationResponse::ERROR_INTERNAL,
|
||||
$result->errorMessage ?? 'Failed to initiate redirect authentication',
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
// Store provider session data (state, nonce, etc.)
|
||||
$session->setMeta("provider:{$method}", $result->sessionData);
|
||||
$session->setMeta('redirect_method', $method);
|
||||
$this->saveSession($session);
|
||||
|
||||
return AuthenticationResponse::redirect(
|
||||
$session->id,
|
||||
$result->getClientData('redirect_url')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete redirect-based authentication (callback from IdP)
|
||||
*/
|
||||
private function handleCallback(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
$session = $this->retrieveSession($request->sessionId);
|
||||
|
||||
if ($session === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_SESSION_EXPIRED,
|
||||
'Invalid or expired session',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
$method = $request->method;
|
||||
$expectedMethod = $session->getMeta('redirect_method');
|
||||
|
||||
if ($expectedMethod !== $method) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_SESSION,
|
||||
'Provider mismatch',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$provider = $this->providerManager->resolve('authentication', $method);
|
||||
if (!$provider instanceof AuthenticationProviderInterface) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_PROVIDER,
|
||||
'Provider not available',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$context = $this->buildProviderContext($session, $method);
|
||||
$result = $provider->completeRedirect($context, $request->params);
|
||||
|
||||
if ($result->isFailed()) {
|
||||
$this->deleteSession($session->id);
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
$result->errorMessage ?? 'Authentication failed',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
// Provider has already provisioned the user - just get user identifier
|
||||
$userIdentifier = $result->identity['user_identifier'] ?? null;
|
||||
|
||||
if (!$userIdentifier) {
|
||||
$this->deleteSession($session->id);
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INTERNAL,
|
||||
'User provisioning failed',
|
||||
500
|
||||
);
|
||||
}
|
||||
|
||||
// Load user
|
||||
$userData = $this->userService->fetchByIdentifier($userIdentifier);
|
||||
if (!$userData) {
|
||||
$this->deleteSession($session->id);
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_USER_NOT_FOUND,
|
||||
'User not found after provisioning',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->populate($userData, 'users');
|
||||
|
||||
// Set user in session
|
||||
$session->userIdentifier = $user->getId();
|
||||
$session->userIdentity = $user->getIdentity();
|
||||
$session->methodCompleted($method);
|
||||
|
||||
// Check if MFA is required
|
||||
$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
|
||||
$remainingMethods = array_values(array_filter(
|
||||
$remainingMethods,
|
||||
fn($m) => $m['method'] !== 'redirect'
|
||||
));
|
||||
$session->setMethods(array_column($remainingMethods, 'id'), $require);
|
||||
$this->saveSession($session);
|
||||
|
||||
return AuthenticationResponse::pending($session->id, $remainingMethods);
|
||||
}
|
||||
|
||||
// Authentication complete
|
||||
return $this->completeAuthentication($session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session status
|
||||
*/
|
||||
private function handleStatus(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
$session = $this->retrieveSession($request->sessionId);
|
||||
|
||||
if ($session === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_SESSION_EXPIRED,
|
||||
'Session not found or expired',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
$methods = $this->methodsConfigured($session->methodsCompleted);
|
||||
|
||||
return AuthenticationResponse::status(
|
||||
$session->id,
|
||||
$session->state(),
|
||||
$methods,
|
||||
$session->userIdentity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel session
|
||||
*/
|
||||
private function handleCancel(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
if ($request->sessionId) {
|
||||
$this->deleteSession($request->sessionId);
|
||||
}
|
||||
|
||||
return AuthenticationResponse::cancelled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
private function handleRefresh(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
$payload = $this->tokenService->validateToken($request->token, $this->securityCode);
|
||||
|
||||
if (!$payload) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
'Invalid or expired refresh token',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
if (($payload['type'] ?? null) !== 'refresh') {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_INVALID_CREDENTIALS,
|
||||
'Invalid token type',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
$identifier = $payload['identifier'] ?? null;
|
||||
$userData = $this->userService->fetchByIdentifier($identifier);
|
||||
|
||||
if ($userData === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_USER_NOT_FOUND,
|
||||
'User not found',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->populate($userData, 'users');
|
||||
|
||||
$accessToken = $this->tokenService->createToken(
|
||||
[
|
||||
'tenant' => $this->tenant->identifier(),
|
||||
'identifier' => $user->getId(),
|
||||
'identity' => $user->getIdentity(),
|
||||
'label' => $user->getLabel(),
|
||||
'permissions' => $user->getPermissions(),
|
||||
'mfa_verified' => true,
|
||||
],
|
||||
$this->securityCode,
|
||||
900
|
||||
);
|
||||
|
||||
return AuthenticationResponse::success(
|
||||
$this->buildUserData($user),
|
||||
['access' => $accessToken]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
private function handleLogout(AuthenticationRequest $request): AuthenticationResponse
|
||||
{
|
||||
$allDevices = $request->params['all_devices'] ?? false;
|
||||
|
||||
if ($request->token) {
|
||||
$payload = $this->tokenService->validateToken($request->token, $this->securityCode);
|
||||
|
||||
if ($payload) {
|
||||
if ($allDevices && isset($payload['identity'])) {
|
||||
$this->tokenService->blacklistUserTokensBefore($payload['identity'], time());
|
||||
} elseif (isset($payload['jti'], $payload['exp'])) {
|
||||
$this->tokenService->blacklist($payload['jti'], $payload['exp']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AuthenticationResponse::cancelled();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Build provider context from session
|
||||
*/
|
||||
private function buildProviderContext(AuthenticationSession $session, string $method): ProviderContext
|
||||
{
|
||||
return new ProviderContext(
|
||||
tenantId: $session->tenantIdentifier,
|
||||
userIdentifier: $session->userIdentifier,
|
||||
userIdentity: $session->userIdentity,
|
||||
metadata: $session->getMeta("provider:{$method}") ?? [],
|
||||
config: $this->getProviderConfig($method),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider configuration
|
||||
*/
|
||||
private function getProviderConfig(string $method): array
|
||||
{
|
||||
$providers = $this->tenant->configuration()->authentication()->providers();
|
||||
return $providers[$method]['config'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete authentication and issue tokens
|
||||
*/
|
||||
private function completeAuthentication(AuthenticationSession $session): AuthenticationResponse
|
||||
{
|
||||
$userData = $this->userService->fetchByIdentifier($session->userIdentifier);
|
||||
|
||||
if ($userData === null) {
|
||||
return AuthenticationResponse::failed(
|
||||
AuthenticationResponse::ERROR_USER_NOT_FOUND,
|
||||
'User not found',
|
||||
401
|
||||
);
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->populate($userData, 'users');
|
||||
|
||||
$tokens = $this->createTokens($user, count($session->methodsCompleted) > 1);
|
||||
|
||||
$this->deleteSession($session->id);
|
||||
|
||||
return AuthenticationResponse::success(
|
||||
$this->buildUserData($user),
|
||||
$tokens
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build user data for response
|
||||
*/
|
||||
private function buildUserData(User $user): array
|
||||
{
|
||||
return [
|
||||
'identifier' => $user->getId(),
|
||||
'identity' => $user->getIdentity(),
|
||||
'label' => $user->getLabel(),
|
||||
'permissions' => $user->getPermissions(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured authentication methods
|
||||
*/
|
||||
private function methodsConfigured(array $methodsCompleted = []): array
|
||||
{
|
||||
$tenantProviders = $this->tenant->configuration()->authentication()->providers();
|
||||
$methods = [];
|
||||
|
||||
foreach ($tenantProviders as $providerId => $providerConfiguration) {
|
||||
if (!($providerConfiguration['enabled'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($providerId, $methodsCompleted, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$provider = $this->providerManager->resolve('authentication', $providerId);
|
||||
if (!$provider instanceof AuthenticationProviderInterface) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$methods[] = [
|
||||
'id' => $providerId,
|
||||
'method' => $provider->method(),
|
||||
'label' => $providerConfiguration['label'] ?? $provider->label(),
|
||||
'icon' => $providerConfiguration['icon'] ?? $provider->icon() ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create JWT tokens
|
||||
*/
|
||||
private function createTokens(User $user, bool $mfaVerified = false): array
|
||||
{
|
||||
$payload = [
|
||||
'tenant' => $this->tenant->identifier(),
|
||||
'identifier' => $user->getId(),
|
||||
'identity' => $user->getIdentity(),
|
||||
'label' => $user->getLabel(),
|
||||
'permissions' => $user->getPermissions(),
|
||||
'mfa_verified' => $mfaVerified,
|
||||
];
|
||||
|
||||
return [
|
||||
'access' => $this->tokenService->createToken($payload, $this->securityCode, 900),
|
||||
'refresh' => $this->tokenService->createToken(
|
||||
[
|
||||
'tenant' => $payload['tenant'],
|
||||
'identifier' => $payload['identifier'],
|
||||
'identity' => $payload['identity'],
|
||||
'type' => 'refresh',
|
||||
],
|
||||
$this->securityCode,
|
||||
604800
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or provision user from external identity
|
||||
*/
|
||||
private function findOrProvisionUser(
|
||||
string $providerId,
|
||||
array $identity,
|
||||
array $providerConfig
|
||||
): ?User {
|
||||
$userIdentity = $identity['email'] ?? $identity['identity'] ?? null;
|
||||
$externalSubject = $identity['subject'] ?? $identity['sub'] ?? null;
|
||||
$attributes = $identity['attributes'] ?? [];
|
||||
$attributes['identity'] = $userIdentity;
|
||||
$attributes['external_subject'] = $externalSubject;
|
||||
|
||||
/*
|
||||
// Try to find by external subject first
|
||||
if ($externalSubject) {
|
||||
$user = $this->provisioningService->findByExternalIdentity($providerId, $externalSubject);
|
||||
if ($user) {
|
||||
$this->provisioningService->syncProfile(
|
||||
$user,
|
||||
$attributes,
|
||||
$providerConfig['attribute_map'] ?? []
|
||||
);
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find by identity
|
||||
if ($userIdentity) {
|
||||
$existingUser = $this->userService->fetchByIdentity($userIdentity);
|
||||
if ($existingUser) {
|
||||
if ($existingUser->getProvider() === $providerId) {
|
||||
if ($externalSubject) {
|
||||
$this->provisioningService->linkExternalIdentity(
|
||||
$existingUser,
|
||||
$providerId,
|
||||
$externalSubject,
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
$this->provisioningService->syncProfile(
|
||||
$existingUser,
|
||||
$attributes,
|
||||
$providerConfig['attribute_map'] ?? []
|
||||
);
|
||||
return $existingUser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-provision if enabled
|
||||
if ($this->provisioningService->isAutoProvisioningEnabled($providerId)) {
|
||||
return $this->provisioningService->provisionUser(
|
||||
$providerId,
|
||||
$attributes,
|
||||
$providerConfig
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Session Cache Helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Retrieve authentication session from cache
|
||||
*/
|
||||
private function retrieveSession(?string $sessionId): ?AuthenticationSession
|
||||
{
|
||||
if (empty($sessionId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->cache->get($sessionId, CacheScope::Tenant, self::CACHE_USAGE);
|
||||
|
||||
if ($data === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($data instanceof AuthenticationSession) {
|
||||
if ($data->isExpired()) {
|
||||
$this->deleteSession($sessionId);
|
||||
return null;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save authentication session to cache
|
||||
*/
|
||||
private function saveSession(AuthenticationSession $session): bool
|
||||
{
|
||||
$ttl = $session->expiresAt > 0 ? $session->expiresAt - time() : AuthenticationSession::DEFAULT_TTL;
|
||||
|
||||
return $this->cache->set(
|
||||
$session->id,
|
||||
$session,
|
||||
CacheScope::Tenant,
|
||||
self::CACHE_USAGE,
|
||||
max($ttl, 60)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete authentication session from cache
|
||||
*/
|
||||
private function deleteSession(string $sessionId): bool
|
||||
{
|
||||
return $this->cache->delete($sessionId, CacheScope::Tenant, self::CACHE_USAGE);
|
||||
}
|
||||
}
|
||||
124
core/lib/Security/Authorization/PermissionChecker.php
Normal file
124
core/lib/Security/Authorization/PermissionChecker.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Security\Authorization;
|
||||
|
||||
use KTXC\SessionIdentity;
|
||||
|
||||
/**
|
||||
* Permission Checker
|
||||
* Provides granular permission checking with support for wildcards
|
||||
*/
|
||||
class PermissionChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionIdentity $sessionIdentity
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
* Supports wildcards: user_manager.users.* matches all user actions
|
||||
*
|
||||
* @param string $permission Permission to check (e.g., "user_manager.users.create")
|
||||
* @param mixed $resource Optional resource for resource-based permissions
|
||||
* @return bool
|
||||
*/
|
||||
public function can(string $permission, mixed $resource = null): bool
|
||||
{
|
||||
$identity = $this->sessionIdentity->identity();
|
||||
|
||||
if (!$identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get user permissions from identity
|
||||
$userPermissions = $identity->getPermissions() ?? [];
|
||||
|
||||
// Super admin bypass - check for admin role
|
||||
$roles = $identity->getRoles() ?? [];
|
||||
if (in_array('admin', $roles) || in_array('system.admin', $roles)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exact match
|
||||
if (in_array($permission, $userPermissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard match: user_manager.users.* allows user_manager.users.create
|
||||
foreach ($userPermissions as $userPerm) {
|
||||
if (str_ends_with($userPerm, '.*')) {
|
||||
$prefix = substr($userPerm, 0, -2);
|
||||
if (str_starts_with($permission, $prefix . '.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Full wildcard: * grants all permissions
|
||||
if (in_array('*', $userPermissions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has ANY of the permissions (OR logic)
|
||||
*
|
||||
* @param array $permissions Array of permissions to check
|
||||
* @param mixed $resource Optional resource for resource-based permissions
|
||||
* @return bool
|
||||
*/
|
||||
public function canAny(array $permissions, mixed $resource = null): bool
|
||||
{
|
||||
if (empty($permissions)) {
|
||||
return true; // No permissions required
|
||||
}
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if ($this->can($permission, $resource)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has ALL permissions (AND logic)
|
||||
*
|
||||
* @param array $permissions Array of permissions to check
|
||||
* @param mixed $resource Optional resource for resource-based permissions
|
||||
* @return bool
|
||||
*/
|
||||
public function canAll(array $permissions, mixed $resource = null): bool
|
||||
{
|
||||
if (empty($permissions)) {
|
||||
return true; // No permissions required
|
||||
}
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (!$this->can($permission, $resource)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions for the current user
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getUserPermissions(): array
|
||||
{
|
||||
$identity = $this->sessionIdentity->identity();
|
||||
|
||||
if (!$identity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $identity->getPermissions() ?? [];
|
||||
}
|
||||
}
|
||||
92
core/lib/Server.php
Normal file
92
core/lib/Server.php
Normal file
@@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
228
core/lib/Service/ConfigurationService.php
Normal file
228
core/lib/Service/ConfigurationService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Db\Collection;
|
||||
use KTXC\Db\UTCDateTime;
|
||||
use KTXC\SessionTenant;
|
||||
|
||||
class ConfigurationService
|
||||
{
|
||||
// Service constants
|
||||
private const TABLE_NAME = 'system_configuration';
|
||||
// Type constants for configuration values
|
||||
public const TYPE_NULL = 0;
|
||||
public const TYPE_STRING = 1;
|
||||
public const TYPE_INTEGER = 2;
|
||||
public const TYPE_FLOAT = 3;
|
||||
public const TYPE_BOOLEAN = 4;
|
||||
public const TYPE_ARRAY = 5;
|
||||
public const TYPE_JSON = 6;
|
||||
|
||||
private Collection $collection;
|
||||
|
||||
public function __construct(
|
||||
DataStore $store,
|
||||
private readonly SessionTenant $tenant
|
||||
) {
|
||||
// DataStore provides selectCollection method
|
||||
$this->collection = $store->selectCollection(self::TABLE_NAME);
|
||||
$this->collection->createIndex(['did' => 1, 'path' => 1, 'key' => 1], ['unique' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value by path and key
|
||||
*/
|
||||
public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||
if (!$doc) { return $default; }
|
||||
$value = $doc['value'] ?? ($doc['default'] ?? null);
|
||||
if ($value === null) { return $default; }
|
||||
return $this->convertFromDatabase((string)$value, (int)$doc['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a configuration value
|
||||
*/
|
||||
public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$type = $this->determineType($value);
|
||||
$serializedValue = $this->convertToDatabase($value, $type);
|
||||
$serializedDefault = $default !== null ? $this->convertToDatabase($default, $type) : null;
|
||||
$this->collection->updateOne(
|
||||
['did' => $tenant, 'path' => $path, 'key' => $key],
|
||||
['$set' => [
|
||||
'did' => $tenant,
|
||||
'path' => $path,
|
||||
'key' => $key,
|
||||
'value' => $serializedValue,
|
||||
'type' => $type,
|
||||
'default' => $serializedDefault,
|
||||
'updated_at' => $this->bsonUtcDateTime()
|
||||
], '$setOnInsert' => [ 'created_at' => $this->bsonUtcDateTime() ]],
|
||||
['upsert' => true]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configuration values for a specific path
|
||||
*/
|
||||
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$filter = ['did' => $tenant];
|
||||
if ($path !== null) {
|
||||
if ($subset) {
|
||||
$filter['$or'] = [
|
||||
['path' => $path],
|
||||
['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']]
|
||||
];
|
||||
} else {
|
||||
$filter['path'] = $path;
|
||||
}
|
||||
}
|
||||
$cursor = $this->collection->find($filter);
|
||||
$configurations = [];
|
||||
foreach ($cursor as $doc) {
|
||||
$value = $doc['value'] ?? ($doc['default'] ?? null);
|
||||
$convertedValue = $value !== null ? $this->convertFromDatabase((string)$value, (int)$doc['type']) : null;
|
||||
$configurations[$doc['path']] = [$doc['key'] => $convertedValue];
|
||||
}
|
||||
return $configurations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a configuration value
|
||||
*/
|
||||
public function delete(string $path, string $key, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all configuration values for a specific path
|
||||
*/
|
||||
public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
$filter = ['did' => $tenant];
|
||||
if ($includeSubPaths) {
|
||||
$filter['$or'] = [
|
||||
['path' => $path],
|
||||
['path' => ['$regex' => '^' . preg_quote($path, '/') . '/']]
|
||||
];
|
||||
} else {
|
||||
$filter['path'] = $path;
|
||||
}
|
||||
$this->collection->deleteMany($filter);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a configuration exists
|
||||
*/
|
||||
public function exists(string $path, string $key, ?string $tenant = null): bool
|
||||
{
|
||||
if ($tenant === null && !$this->tenant->isConfigured()) {
|
||||
throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
|
||||
} elseif ($tenant === null) {
|
||||
$tenant = $this->tenant->identifier();
|
||||
}
|
||||
|
||||
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the type of a PHP value
|
||||
*/
|
||||
private function determineType(mixed $value): int
|
||||
{
|
||||
return match (true) {
|
||||
is_null($value) => self::TYPE_NULL,
|
||||
is_bool($value) => self::TYPE_BOOLEAN,
|
||||
is_int($value) => self::TYPE_INTEGER,
|
||||
is_float($value) => self::TYPE_FLOAT,
|
||||
is_array($value) => self::TYPE_ARRAY,
|
||||
is_string($value) && $this->isJson($value) => self::TYPE_JSON,
|
||||
default => self::TYPE_STRING
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PHP value to database format
|
||||
*/
|
||||
private function convertToDatabase(mixed $value, int $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
self::TYPE_NULL => '',
|
||||
self::TYPE_BOOLEAN => $value ? '1' : '0',
|
||||
self::TYPE_INTEGER => (string)$value,
|
||||
self::TYPE_FLOAT => (string)$value,
|
||||
self::TYPE_ARRAY, self::TYPE_JSON => json_encode($value),
|
||||
default => (string)$value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a database value to PHP format
|
||||
*/
|
||||
private function convertFromDatabase(string $value, int $type): mixed
|
||||
{
|
||||
return match ($type) {
|
||||
self::TYPE_NULL => null,
|
||||
self::TYPE_BOOLEAN => $value === '1',
|
||||
self::TYPE_INTEGER => (int)$value,
|
||||
self::TYPE_FLOAT => (float)$value,
|
||||
self::TYPE_ARRAY, self::TYPE_JSON => json_decode($value, true),
|
||||
default => $value
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is valid JSON
|
||||
*/
|
||||
private function isJson(string $string): bool
|
||||
{
|
||||
json_decode($string);
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
}
|
||||
/**
|
||||
* Create a UTCDateTime for timestamp fields
|
||||
*/
|
||||
private function bsonUtcDateTime(): UTCDateTime
|
||||
{
|
||||
return UTCDateTime::now();
|
||||
}
|
||||
}
|
||||
630
core/lib/Service/FirewallService.php
Normal file
630
core/lib/Service/FirewallService.php
Normal file
@@ -0,0 +1,630 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
use KTXC\Stores\FirewallStore;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Event\EventBus;
|
||||
use KTXF\Event\SecurityEvent;
|
||||
use KTXF\IpUtils;
|
||||
|
||||
/**
|
||||
* Firewall service for IP/device-based access control
|
||||
*
|
||||
* Features:
|
||||
* - IP allow/block lists per tenant
|
||||
* - CIDR range support
|
||||
* - Device fingerprint blocking
|
||||
* - Automatic blocking on brute force detection
|
||||
* - Event-driven integration
|
||||
*/
|
||||
class FirewallService
|
||||
{
|
||||
// Default thresholds for auto-blocking
|
||||
private const DEFAULT_MAX_AUTH_FAILURES = 5;
|
||||
private const DEFAULT_AUTH_FAILURE_WINDOW = 300; // 5 minutes
|
||||
private const DEFAULT_AUTO_BLOCK_DURATION = 3600; // 1 hour
|
||||
|
||||
// Configuration keys
|
||||
private const CONFIG_MAX_FAILURES = 'firewall.maxAuthFailures';
|
||||
private const CONFIG_FAILURE_WINDOW = 'firewall.authFailureWindow';
|
||||
private const CONFIG_AUTO_BLOCK_DURATION = 'firewall.autoBlockDuration';
|
||||
private const CONFIG_ENABLED = 'firewall.enabled';
|
||||
|
||||
/** @var FirewallRuleObject[]|null */
|
||||
private ?array $rulesCache = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly FirewallStore $store,
|
||||
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']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check firewall rules for a request
|
||||
* Returns a Response if blocked, null if allowed
|
||||
*/
|
||||
public function authorized(Request $request): bool
|
||||
{
|
||||
$ipAddress = $request->getClientIp() ?? '0.0.0.0';
|
||||
$deviceFingerprint = $request->headers->get('X-Device-Fingerprint');
|
||||
|
||||
$result = $this->analyze($ipAddress, $deviceFingerprint);
|
||||
|
||||
if ($result->isBlocked()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request is allowed based on IP and device fingerprint
|
||||
*/
|
||||
public function analyze(
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint = null
|
||||
): FirewallAnalyzeResult {
|
||||
// Check if firewall is enabled for this tenant
|
||||
if (!$this->isEnabled()) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
|
||||
$rules = $this->getActiveRules();
|
||||
|
||||
// First check for explicit allow rules (whitelist takes precedence)
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_ALLOW) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
return new FirewallAnalyzeResult(true, $rule->getId(), 'Explicitly allowed');
|
||||
}
|
||||
}
|
||||
|
||||
// Then check for block rules
|
||||
foreach ($rules as $rule) {
|
||||
if ($rule->getAction() !== FirewallRuleObject::ACTION_BLOCK) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->ruleMatchesRequest($rule, $ipAddress, $deviceFingerprint)) {
|
||||
$this->publishAccessDenied($ipAddress, $deviceFingerprint, $rule);
|
||||
return new FirewallAnalyzeResult(false, $rule->getId(), $rule->getReason());
|
||||
}
|
||||
}
|
||||
|
||||
return new FirewallAnalyzeResult(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rule matches the request
|
||||
*/
|
||||
private function ruleMatchesRequest(
|
||||
FirewallRuleObject $rule,
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint
|
||||
): bool {
|
||||
$type = $rule->getType();
|
||||
$value = $rule->getValue();
|
||||
|
||||
return match ($type) {
|
||||
FirewallRuleObject::TYPE_IP => $ipAddress === $value,
|
||||
FirewallRuleObject::TYPE_IP_RANGE => IpUtils::checkIp($ipAddress, $value),
|
||||
FirewallRuleObject::TYPE_DEVICE => $deviceFingerprint !== null && $deviceFingerprint === $value,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication failure event
|
||||
*/
|
||||
public function handleAuthFailure(SecurityEvent $event): void
|
||||
{
|
||||
$ipAddress = $event->getIpAddress();
|
||||
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||
|
||||
if (!$ipAddress || !$tenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for brute force
|
||||
$windowSeconds = $this->getConfig(
|
||||
self::CONFIG_FAILURE_WINDOW,
|
||||
self::DEFAULT_AUTH_FAILURE_WINDOW
|
||||
);
|
||||
$maxFailures = $this->getConfig(
|
||||
self::CONFIG_MAX_FAILURES,
|
||||
self::DEFAULT_MAX_AUTH_FAILURES
|
||||
);
|
||||
|
||||
$failureCount = $this->store->countRecentFailures(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
$windowSeconds
|
||||
);
|
||||
|
||||
// Include current failure in count
|
||||
$failureCount++;
|
||||
|
||||
if ($failureCount >= $maxFailures) {
|
||||
$this->handleBruteForce($ipAddress, $failureCount, $windowSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle detected brute force attack
|
||||
*/
|
||||
private function handleBruteForce(
|
||||
string $ipAddress,
|
||||
int $failureCount,
|
||||
int $windowSeconds
|
||||
): void {
|
||||
// Publish brute force event
|
||||
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
|
||||
$event->setTenantId($this->tenant->identifier());
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
// Auto-block the IP
|
||||
$blockDuration = $this->getConfig(
|
||||
self::CONFIG_AUTO_BLOCK_DURATION,
|
||||
self::DEFAULT_AUTO_BLOCK_DURATION
|
||||
);
|
||||
|
||||
$this->blockIp(
|
||||
$ipAddress,
|
||||
sprintf('Auto-blocked: %d failed auth attempts in %d seconds', $failureCount, $windowSeconds),
|
||||
null, // System-created
|
||||
$blockDuration
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log security event to firewall logs
|
||||
*/
|
||||
public function logSecurityEvent(SecurityEvent $event): void
|
||||
{
|
||||
$tenantId = $event->getTenantId() ?? $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$log = new FirewallLogObject();
|
||||
$log->setTenantId($tenantId)
|
||||
->setIpAddress($event->getIpAddress())
|
||||
->setDeviceFingerprint($event->getDeviceFingerprint())
|
||||
->setUserAgent($event->getUserAgent())
|
||||
->setRequestPath($event->getRequestPath())
|
||||
->setRequestMethod($event->getRequestMethod())
|
||||
->setEventType($this->mapEventToLogType($event->getName()))
|
||||
->setResult($this->mapEventToResult($event->getName()))
|
||||
->setIdentityId($event->getUserId())
|
||||
->setTimestamp(new \DateTimeImmutable())
|
||||
->setMetadata($event->getData());
|
||||
|
||||
$this->store->createLog($log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map security event name to log event type
|
||||
*/
|
||||
private function mapEventToLogType(string $eventName): string
|
||||
{
|
||||
return match ($eventName) {
|
||||
SecurityEvent::AUTH_FAILURE => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
SecurityEvent::AUTH_SUCCESS => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
SecurityEvent::BRUTE_FORCE_DETECTED => FirewallLogObject::EVENT_BRUTE_FORCE,
|
||||
SecurityEvent::RATE_LIMIT_EXCEEDED => FirewallLogObject::EVENT_RATE_LIMIT,
|
||||
SecurityEvent::ACCESS_DENIED => FirewallLogObject::EVENT_RULE_MATCH,
|
||||
SecurityEvent::SUSPICIOUS_ACTIVITY => FirewallLogObject::EVENT_SUSPICIOUS,
|
||||
default => FirewallLogObject::EVENT_ACCESS_CHECK,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map security event to result
|
||||
*/
|
||||
private function mapEventToResult(string $eventName): string
|
||||
{
|
||||
return match ($eventName) {
|
||||
SecurityEvent::AUTH_SUCCESS,
|
||||
SecurityEvent::ACCESS_GRANTED => FirewallLogObject::RESULT_ALLOWED,
|
||||
default => FirewallLogObject::RESULT_BLOCKED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish access denied event
|
||||
*/
|
||||
private function publishAccessDenied(
|
||||
string $ipAddress,
|
||||
?string $deviceFingerprint,
|
||||
FirewallRuleObject $rule
|
||||
): void {
|
||||
$event = SecurityEvent::accessDenied(
|
||||
$ipAddress,
|
||||
$deviceFingerprint,
|
||||
$rule->getId(),
|
||||
$rule->getReason()
|
||||
);
|
||||
$event->setTenantId($this->tenant->identifier());
|
||||
$this->eventBus->publish($event);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Rule Management
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Block an IP address
|
||||
*/
|
||||
public function blockIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
// Check if already blocked
|
||||
$existing = $this->store->findExactIpRule(
|
||||
$tenantId,
|
||||
$ipAddress,
|
||||
FirewallRuleObject::ACTION_BLOCK
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($ipAddress)
|
||||
->setReason($reason ?? 'Blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt(
|
||||
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||
);
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::IP_BLOCKED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow an IP address (whitelist)
|
||||
*/
|
||||
public function allowIp(
|
||||
string $ipAddress,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP)
|
||||
->setAction(FirewallRuleObject::ACTION_ALLOW)
|
||||
->setValue($ipAddress)
|
||||
->setReason($reason ?? 'Allowed by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::IP_ALLOWED, ['ip' => $ipAddress, 'reason' => $reason]);
|
||||
$event->setIpAddress($ipAddress)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block an IP range (CIDR notation)
|
||||
*/
|
||||
public function blockIpRange(
|
||||
string $cidr,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_IP_RANGE)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($cidr)
|
||||
->setReason($reason ?? 'Range blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a device fingerprint
|
||||
*/
|
||||
public function blockDevice(
|
||||
string $fingerprint,
|
||||
?string $reason = null,
|
||||
?string $createdBy = null,
|
||||
?int $durationSeconds = null
|
||||
): FirewallRuleObject {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
|
||||
}
|
||||
|
||||
$rule = new FirewallRuleObject();
|
||||
$rule->setTenantId($tenantId)
|
||||
->setType(FirewallRuleObject::TYPE_DEVICE)
|
||||
->setAction(FirewallRuleObject::ACTION_BLOCK)
|
||||
->setValue($fingerprint)
|
||||
->setReason($reason ?? 'Device blocked by administrator')
|
||||
->setCreatedBy($createdBy)
|
||||
->setCreatedAt(new \DateTimeImmutable())
|
||||
->setEnabled(true);
|
||||
|
||||
if ($durationSeconds !== null) {
|
||||
$rule->setExpiresAt(
|
||||
(new \DateTimeImmutable())->modify("+{$durationSeconds} seconds")
|
||||
);
|
||||
}
|
||||
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
// Publish event
|
||||
$event = new SecurityEvent(SecurityEvent::DEVICE_BLOCKED, ['device' => $fingerprint, 'reason' => $reason]);
|
||||
$event->setDeviceFingerprint($fingerprint)
|
||||
->setReason($reason)
|
||||
->setTenantId($tenantId);
|
||||
$this->eventBus->publish($event);
|
||||
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a rule by ID
|
||||
*/
|
||||
public function removeRule(string $ruleId): bool
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenant->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->store->destroyRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a rule (soft delete)
|
||||
*/
|
||||
public function disableRule(string $ruleId): bool
|
||||
{
|
||||
$rule = $this->store->fetchRule($ruleId);
|
||||
if (!$rule) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify tenant ownership
|
||||
if ($rule->getTenantId() !== $this->tenant->identifier()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rule->setEnabled(false);
|
||||
$this->store->depositRule($rule);
|
||||
$this->clearRulesCache();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all rules for current tenant
|
||||
*/
|
||||
public function listRules(bool $activeOnly = true): array
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->store->listRules($tenantId, $activeOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get firewall logs for current tenant
|
||||
*/
|
||||
public function getLogs(
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
int $limit = 100
|
||||
): array {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->store->listLogs($tenantId, $ipAddress, $eventType, $result, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocked requests count
|
||||
*/
|
||||
public function getBlockedCount(?\DateTimeImmutable $since = null): int
|
||||
{
|
||||
$tenantId = $this->tenant->identifier();
|
||||
if (!$tenantId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->store->countBlockedRequests($tenantId, $since);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Check if firewall is enabled for current tenant
|
||||
*/
|
||||
private function isEnabled(): bool
|
||||
{
|
||||
return (bool) $this->getConfig(self::CONFIG_ENABLED, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
*/
|
||||
private function getConfig(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$config = $this->tenant->configuration();
|
||||
$parts = explode('.', $key);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
if (!is_array($config) || !array_key_exists($part, $config)) {
|
||||
return $default;
|
||||
}
|
||||
$config = $config[$part];
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active rules (cached)
|
||||
* @return FirewallRuleObject[]
|
||||
*/
|
||||
private function getActiveRules(): array
|
||||
{
|
||||
if ($this->rulesCache === null) {
|
||||
$tenantId = $this->tenant->identifier();
|
||||
$this->rulesCache = $tenantId
|
||||
? $this->store->listRules($tenantId, true)
|
||||
: [];
|
||||
}
|
||||
return $this->rulesCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear rules cache
|
||||
*/
|
||||
private function clearRulesCache(): void
|
||||
{
|
||||
$this->rulesCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup maintenance tasks
|
||||
*/
|
||||
public function cleanup(): array
|
||||
{
|
||||
$expiredRules = $this->store->cleanupExpiredRules();
|
||||
$oldLogs = $this->store->cleanupOldLogs(30);
|
||||
|
||||
return [
|
||||
'expiredRules' => $expiredRules,
|
||||
'oldLogs' => $oldLogs,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a firewall check
|
||||
*/
|
||||
class FirewallAnalyzeResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $allowed,
|
||||
public readonly ?string $ruleId = null,
|
||||
public readonly ?string $reason = null
|
||||
) {}
|
||||
|
||||
public function isAllowed(): bool
|
||||
{
|
||||
return $this->allowed;
|
||||
}
|
||||
|
||||
public function isBlocked(): bool
|
||||
{
|
||||
return !$this->allowed;
|
||||
}
|
||||
}
|
||||
145
core/lib/Service/SecurityService.php
Normal file
145
core/lib/Service/SecurityService.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Http\Request\Request;
|
||||
use KTXC\Models\Identity\User;
|
||||
use KTXC\Resource\ProviderManager;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Security\Authentication\AuthenticationProviderInterface;
|
||||
|
||||
/**
|
||||
* Security Service
|
||||
*
|
||||
* Handles request-level authentication (token validation).
|
||||
* Authentication orchestration is handled by AuthenticationManager.
|
||||
*
|
||||
* This service is used by the Kernel to authenticate incoming requests.
|
||||
*/
|
||||
class SecurityService
|
||||
{
|
||||
private string $securityCode;
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly TokenService $tokenService,
|
||||
private readonly UserAccountsService $userService,
|
||||
private readonly ProviderManager $providerManager,
|
||||
) {
|
||||
$this->securityCode = $this->sessionTenant->configuration()->security()->code();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a request and return the user if valid
|
||||
*
|
||||
* @param Request $request The HTTP request to authenticate
|
||||
* @return User|null The authenticated user, or null if not authenticated
|
||||
*/
|
||||
public function authenticate(Request $request): ?User
|
||||
{
|
||||
$authorization = $request->headers->get('Authorization');
|
||||
$cookieToken = $request->cookies->get('accessToken');
|
||||
|
||||
// Cookie token takes precedence
|
||||
if ($cookieToken) {
|
||||
return $this->authenticateJWT($cookieToken);
|
||||
}
|
||||
|
||||
if ($authorization) {
|
||||
if (str_starts_with($authorization, 'Bearer ')) {
|
||||
$token = substr($authorization, 7);
|
||||
return $this->authenticateBearer($token);
|
||||
}
|
||||
|
||||
if (str_starts_with($authorization, 'Basic ')) {
|
||||
$decoded = base64_decode(substr($authorization, 6) ?: '', true);
|
||||
if ($decoded !== false) {
|
||||
[$identity, $secret] = array_pad(explode(':', $decoded, 2), 2, null);
|
||||
if ($identity !== null && $secret !== null) {
|
||||
return $this->authenticateBasic($identity, $secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate JWT token from cookie or header
|
||||
*/
|
||||
public function authenticateJWT(string $token): ?User
|
||||
{
|
||||
$payload = $this->tokenService->validateToken($token, $this->securityCode);
|
||||
|
||||
if (!$payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify user still exists
|
||||
if ($this->userService->fetchByIdentifier($payload['identifier']) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->populate($payload, 'jwt');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate Bearer token
|
||||
*/
|
||||
public function authenticateBearer(string $token): ?User
|
||||
{
|
||||
return $this->authenticateJWT($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate HTTP Basic header (for API access)
|
||||
* Note: This is for request authentication, not login
|
||||
*/
|
||||
private function authenticateBasic(string $identity, string $credentials): ?User
|
||||
{
|
||||
// For Basic auth headers, we need to validate against the provider
|
||||
// This is a simplified flow for API access
|
||||
$providers = $this->providerManager->providers(AuthenticationProviderInterface::TYPE_AUTHENTICATION);
|
||||
if ($providers === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($providers as $provider) {
|
||||
if ($provider instanceof AuthenticationProviderInterface === false) {
|
||||
continue;
|
||||
}
|
||||
if ($provider->method() !== AuthenticationProviderInterface::METHOD_CREDENTIAL) {
|
||||
continue;
|
||||
}
|
||||
$context = new \KTXF\Security\Authentication\ProviderContext(
|
||||
tenantId: $this->sessionTenant->identifier(),
|
||||
userIdentity: $identity,
|
||||
);
|
||||
$result = $provider->verify($context, $credentials);
|
||||
|
||||
if ($result->isSuccess()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($result) && $result->isSuccess()) {
|
||||
return $this->userService->fetchByIdentity($identity);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token claims (for logout to get jti/exp)
|
||||
*/
|
||||
public function extractTokenClaims(string $token): ?array
|
||||
{
|
||||
return $this->tokenService->validateToken($token, $this->securityCode, checkBlacklist: false);
|
||||
}
|
||||
}
|
||||
19
core/lib/Service/TenantService.php
Normal file
19
core/lib/Service/TenantService.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
use KTXC\Stores\TenantStore;
|
||||
|
||||
class TenantService
|
||||
{
|
||||
public function __construct(protected readonly TenantStore $store)
|
||||
{
|
||||
}
|
||||
|
||||
public function fetchByDomain(string $domain): ?TenantObject
|
||||
{
|
||||
return $this->store->fetchByDomain($domain);
|
||||
}
|
||||
|
||||
}
|
||||
309
core/lib/Service/TokenService.php
Normal file
309
core/lib/Service/TokenService.php
Normal file
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\SessionTenant;
|
||||
use KTXF\Cache\CacheScope;
|
||||
use KTXF\Cache\EphemeralCacheInterface;
|
||||
|
||||
/**
|
||||
* Token Service
|
||||
*
|
||||
* Unified service for JWT token operations including:
|
||||
* - Token creation with configurable expiry and claims
|
||||
* - Token validation with algorithm verification
|
||||
* - Token blacklisting for revocation before natural expiry
|
||||
* - User-wide token invalidation (logout all devices)
|
||||
*
|
||||
* Uses EphemeralCache for blacklist storage.
|
||||
*/
|
||||
class TokenService
|
||||
{
|
||||
private const ALLOWED_ALGORITHMS = ['HS256'];
|
||||
private const CACHE_USAGE_BLACKLIST = 'token_blacklist';
|
||||
private const CACHE_USAGE_USER_BLACKLIST = 'token_user_blacklist';
|
||||
|
||||
private string $algorithm = 'HS256';
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $sessionTenant,
|
||||
private readonly EphemeralCacheInterface $cache,
|
||||
) {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Creation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Generate a unique JWT ID (jti) for token identification
|
||||
*/
|
||||
public function generateJti(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JWT token with the given payload
|
||||
*
|
||||
* @param array $payload The token payload (claims)
|
||||
* @param string $secretKey The secret key for signing
|
||||
* @param int $expirationTime Token lifetime in seconds (default: 1 hour)
|
||||
* @param string|null $jti Optional JWT ID (auto-generated if not provided)
|
||||
* @return string The encoded JWT token
|
||||
*/
|
||||
public function createToken(array $payload, string $secretKey, int $expirationTime = 3600, ?string $jti = null): string
|
||||
{
|
||||
$header = [
|
||||
'typ' => 'JWT',
|
||||
'alg' => $this->algorithm
|
||||
];
|
||||
|
||||
$payload['iat'] = time(); // Issued at
|
||||
$payload['exp'] = time() + $expirationTime; // Expiration
|
||||
|
||||
// Add JWT ID for token identification and revocation support
|
||||
$payload['jti'] = $jti ?? $this->generateJti();
|
||||
|
||||
$headerEncoded = $this->base64UrlEncode(json_encode($header));
|
||||
$payloadEncoded = $this->base64UrlEncode(json_encode($payload));
|
||||
|
||||
$signature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey);
|
||||
|
||||
return $headerEncoded . '.' . $payloadEncoded . '.' . $signature;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Validation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Validate a JWT token and return its payload
|
||||
*
|
||||
* @param string $token The JWT token to validate
|
||||
* @param string $secretKey The secret key for verification
|
||||
* @param bool $checkBlacklist Whether to check the blacklist (default: true)
|
||||
* @return array|null The token payload if valid, null otherwise
|
||||
*/
|
||||
public function validateToken(string $token, string $secretKey, bool $checkBlacklist = true): ?array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$headerEncoded, $payloadEncoded, $signature] = $parts;
|
||||
|
||||
// Decode and validate header first
|
||||
$header = json_decode($this->base64UrlDecode($headerEncoded), true);
|
||||
|
||||
if (!$header) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// SECURITY: Validate algorithm to prevent "none" algorithm and algorithm switching attacks
|
||||
if (!isset($header['alg']) || !in_array($header['alg'], self::ALLOWED_ALGORITHMS, true)) {
|
||||
return null; // Reject tokens with unexpected algorithms
|
||||
}
|
||||
|
||||
// Verify signature using our expected algorithm (not the one in the header)
|
||||
$expectedSignature = $this->createSignature($headerEncoded . '.' . $payloadEncoded, $secretKey);
|
||||
if (!hash_equals($signature, $expectedSignature)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
$payload = json_decode($this->base64UrlDecode($payloadEncoded), true);
|
||||
|
||||
if (!$payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (isset($payload['exp']) && $payload['exp'] < time()) {
|
||||
return null; // Token expired
|
||||
}
|
||||
|
||||
// Check blacklist if enabled
|
||||
if ($checkBlacklist) {
|
||||
// Check if this specific token has been blacklisted (by jti)
|
||||
if (isset($payload['jti']) && $this->isBlacklisted($payload['jti'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if user's tokens have been globally invalidated
|
||||
if (isset($payload['identity'], $payload['iat'])) {
|
||||
if ($this->isUserTokenBlacklisted($payload['identity'], $payload['iat'])) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a token by creating a new one with fresh timestamps
|
||||
*
|
||||
* @param string $token The token to refresh
|
||||
* @param string $secretKey The secret key
|
||||
* @return string|null The new token, or null if original was invalid
|
||||
*/
|
||||
public function refreshToken(string $token, string $secretKey): ?string
|
||||
{
|
||||
$payload = $this->validateToken($token, $secretKey);
|
||||
|
||||
if (!$payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove old timestamps and jti (new token gets new jti)
|
||||
unset($payload['iat'], $payload['exp'], $payload['jti']);
|
||||
|
||||
// Create new token with fresh timestamps and new jti
|
||||
return $this->createToken($payload, $secretKey);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Blacklisting
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Add a token to the blacklist (revoke it)
|
||||
*
|
||||
* @param string $jti The JWT ID to blacklist
|
||||
* @param int $expiresAt Unix timestamp when the token expires (for cleanup)
|
||||
*/
|
||||
public function blacklist(string $jti, int $expiresAt): void
|
||||
{
|
||||
$ttl = max($expiresAt - time(), 60); // Minimum 60 seconds
|
||||
$this->cache->set(
|
||||
$this->getTokenCacheKey($jti),
|
||||
$expiresAt,
|
||||
CacheScope::Tenant,
|
||||
self::CACHE_USAGE_BLACKLIST,
|
||||
$ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is blacklisted
|
||||
*
|
||||
* @param string $jti The JWT ID to check
|
||||
* @return bool True if blacklisted, false otherwise
|
||||
*/
|
||||
public function isBlacklisted(string $jti): bool
|
||||
{
|
||||
return $this->cache->has(
|
||||
$this->getTokenCacheKey($jti),
|
||||
CacheScope::Tenant,
|
||||
self::CACHE_USAGE_BLACKLIST
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a token from the blacklist
|
||||
*
|
||||
* @param string $jti The JWT ID to remove
|
||||
*/
|
||||
public function unblacklist(string $jti): void
|
||||
{
|
||||
$this->cache->delete(
|
||||
$this->getTokenCacheKey($jti),
|
||||
CacheScope::Tenant,
|
||||
self::CACHE_USAGE_BLACKLIST
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blacklist all tokens for a user issued before a timestamp
|
||||
* Used for "logout all devices" functionality
|
||||
*
|
||||
* @param string $identity User identity
|
||||
* @param int $beforeTimestamp Tokens issued before this time are invalid
|
||||
*/
|
||||
public function blacklistUserTokensBefore(string $identity, int $beforeTimestamp): void
|
||||
{
|
||||
// Store for 30 days (longer than any token lifetime)
|
||||
$this->cache->set(
|
||||
$this->getUserCacheKey($identity),
|
||||
$beforeTimestamp,
|
||||
CacheScope::Tenant,
|
||||
self::CACHE_USAGE_USER_BLACKLIST,
|
||||
2592000 // 30 days
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user's token was issued before the blacklist timestamp
|
||||
*
|
||||
* @param string $identity User identity
|
||||
* @param int $issuedAt Token's iat claim
|
||||
* @return bool True if token should be rejected
|
||||
*/
|
||||
public function isUserTokenBlacklisted(string $identity, int $issuedAt): bool
|
||||
{
|
||||
$blacklistBefore = $this->cache->get(
|
||||
$this->getUserCacheKey($identity),
|
||||
CacheScope::Tenant,
|
||||
self::CACHE_USAGE_USER_BLACKLIST
|
||||
);
|
||||
|
||||
if ($blacklistBefore === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $issuedAt < (int) $blacklistBefore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear user's "logout all devices" blacklist
|
||||
*
|
||||
* @param string $identity User identity
|
||||
*/
|
||||
public function clearUserBlacklist(string $identity): void
|
||||
{
|
||||
$this->cache->delete(
|
||||
$this->getUserCacheKey($identity),
|
||||
CacheScope::Tenant,
|
||||
self::CACHE_USAGE_USER_BLACKLIST
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private Helpers
|
||||
// =========================================================================
|
||||
|
||||
private function createSignature(string $data, string $secretKey): string
|
||||
{
|
||||
$signature = hash_hmac('sha256', $data, $secretKey, true);
|
||||
return $this->base64UrlEncode($signature);
|
||||
}
|
||||
|
||||
private function base64UrlEncode(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $data): string
|
||||
{
|
||||
return base64_decode(str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for token blacklist
|
||||
*/
|
||||
private function getTokenCacheKey(string $jti): string
|
||||
{
|
||||
return 'jti_' . hash('sha256', $jti);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for user blacklist
|
||||
*/
|
||||
private function getUserCacheKey(string $identity): string
|
||||
{
|
||||
return 'user_' . hash('sha256', $identity);
|
||||
}
|
||||
}
|
||||
179
core/lib/Service/UserAccountsService.php
Normal file
179
core/lib/Service/UserAccountsService.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\Models\Identity\User;
|
||||
use KTXC\SessionIdentity;
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Stores\UserAccountsStore;
|
||||
|
||||
class UserAccountsService
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly SessionIdentity $userIdentity,
|
||||
private readonly UserAccountsStore $userStore
|
||||
) {
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// User Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List all users with optional filters
|
||||
*/
|
||||
public function listUsers(array $filters = []): array
|
||||
{
|
||||
$users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters);
|
||||
|
||||
// Remove sensitive data
|
||||
foreach ($users as &$user) {
|
||||
unset($user['settings']);
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function fetchByIdentity(string $identifier): User | null
|
||||
{
|
||||
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||
if (!$data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->populate($data, 'users');
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function fetchByIdentifier(string $identifier): array | null
|
||||
{
|
||||
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
|
||||
}
|
||||
|
||||
public function fetchByIdentityRaw(string $identifier): array | null
|
||||
{
|
||||
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
|
||||
}
|
||||
|
||||
public function fetchByProviderSubject(string $provider, string $subject): ?array
|
||||
{
|
||||
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
|
||||
}
|
||||
|
||||
public function createUser(array $userData): array
|
||||
{
|
||||
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
|
||||
}
|
||||
|
||||
public function updateUser(string $uid, array $updates): bool
|
||||
{
|
||||
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
|
||||
}
|
||||
|
||||
public function deleteUser(string $uid): bool
|
||||
{
|
||||
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchProfile(string $uid): ?array
|
||||
{
|
||||
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
|
||||
}
|
||||
|
||||
public function storeProfile(string $uid, array $profileFields): bool
|
||||
{
|
||||
// Get managed fields to filter out read-only fields
|
||||
$user = $this->fetchByIdentifier($uid);
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$managedFields = $user['provider_managed_fields'] ?? [];
|
||||
$editableFields = [];
|
||||
|
||||
// Only include fields that are not managed by provider
|
||||
foreach ($profileFields as $field => $value) {
|
||||
if (!in_array($field, $managedFields)) {
|
||||
$editableFields[$field] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($editableFields)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchSettings(array $settings = []): array | null
|
||||
{
|
||||
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
|
||||
}
|
||||
|
||||
public function storeSettings(array $settings): bool
|
||||
{
|
||||
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if a profile field is editable by the user
|
||||
*
|
||||
* @param string $uid User identifier
|
||||
* @param string $field Profile field name
|
||||
* @return bool True if field is editable, false if managed by provider
|
||||
*/
|
||||
public function isFieldEditable(string $uid, string $field): bool
|
||||
{
|
||||
$user = $this->fetchByIdentifier($uid);
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$managedFields = $user['provider_managed_fields'] ?? [];
|
||||
return !in_array($field, $managedFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get editable fields for a user
|
||||
*
|
||||
* @param string $uid User identifier
|
||||
* @return array Array with field => ['value' => ..., 'editable' => bool, 'provider' => ...]
|
||||
*/
|
||||
public function getEditableFields(string $uid): array
|
||||
{
|
||||
$user = $this->fetchByIdentifier($uid);
|
||||
if (!$user || !isset($user['profile'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$managedFields = $user['provider_managed_fields'] ?? [];
|
||||
$provider = $user['provider'] ?? null;
|
||||
$editable = [];
|
||||
|
||||
foreach ($user['profile'] as $field => $value) {
|
||||
$editable[$field] = [
|
||||
'value' => $value,
|
||||
'editable' => !in_array($field, $managedFields),
|
||||
'provider' => in_array($field, $managedFields) ? $provider : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $editable;
|
||||
}
|
||||
|
||||
}
|
||||
143
core/lib/Service/UserRolesService.php
Normal file
143
core/lib/Service/UserRolesService.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Service;
|
||||
|
||||
use KTXC\SessionTenant;
|
||||
use KTXC\Stores\UserRolesStore;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* User Roles Service - Business logic for user role management
|
||||
*/
|
||||
class UserRolesService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionTenant $tenantIdentity,
|
||||
private readonly UserRolesStore $roleStore,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Role Operations
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List all roles for current tenant
|
||||
*/
|
||||
public function listRoles(): array
|
||||
{
|
||||
return $this->roleStore->listRoles($this->tenantIdentity->identifier());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role by ID
|
||||
*/
|
||||
public function getRole(string $rid): ?array
|
||||
{
|
||||
return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new role
|
||||
*/
|
||||
public function createRole(array $roleData): array
|
||||
{
|
||||
$this->validateRoleData($roleData);
|
||||
|
||||
$this->logger->info('Creating role', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'label' => $roleData['label'] ?? 'Unnamed'
|
||||
]);
|
||||
|
||||
return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing role
|
||||
*/
|
||||
public function updateRole(string $rid, array $updates): bool
|
||||
{
|
||||
// Verify role exists and is not system role
|
||||
$role = $this->getRole($rid);
|
||||
if (!$role) {
|
||||
throw new \InvalidArgumentException('Role not found');
|
||||
}
|
||||
|
||||
if ($role['system'] ?? false) {
|
||||
throw new \InvalidArgumentException('Cannot modify system roles');
|
||||
}
|
||||
|
||||
$this->validateRoleData($updates, false);
|
||||
|
||||
$this->logger->info('Updating role', [
|
||||
'tenant' => $this->tenantIdentity->identifier(),
|
||||
'rid' => $rid
|
||||
]);
|
||||
|
||||
return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a role
|
||||
*/
|
||||
public function deleteRole(string $rid): bool
|
||||
{
|
||||
// Verify role exists and is not system role
|
||||
$role = $this->getRole($rid);
|
||||
if (!$role) {
|
||||
throw new \InvalidArgumentException('Role not found');
|
||||
}
|
||||
|
||||
if ($role['system'] ?? false) {
|
||||
throw new \InvalidArgumentException('Cannot delete system roles');
|
||||
}
|
||||
|
||||
// Check if role is assigned to users
|
||||
$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->tenantIdentity->identifier(),
|
||||
'rid' => $rid
|
||||
]);
|
||||
|
||||
return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user count for a role
|
||||
*/
|
||||
public function getRoleUserCount(string $rid): int
|
||||
{
|
||||
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available permissions from modules
|
||||
* Grouped by category with metadata
|
||||
*/
|
||||
public function availablePermissions(): array
|
||||
{
|
||||
return $this->roleStore->availablePermissions();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Validation
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Validate role data
|
||||
*/
|
||||
private function validateRoleData(array $data, bool $isCreate = true): void
|
||||
{
|
||||
if ($isCreate && empty($data['label'])) {
|
||||
throw new \InvalidArgumentException('Role label is required');
|
||||
}
|
||||
|
||||
if (isset($data['permissions']) && !is_array($data['permissions'])) {
|
||||
throw new \InvalidArgumentException('Permissions must be an array');
|
||||
}
|
||||
}
|
||||
}
|
||||
94
core/lib/SessionIdentity.php
Normal file
94
core/lib/SessionIdentity.php
Normal file
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
125
core/lib/SessionTenant.php
Normal file
125
core/lib/SessionTenant.php
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
309
core/lib/Stores/FirewallStore.php
Normal file
309
core/lib/Stores/FirewallStore.php
Normal file
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Models\Firewall\FirewallRuleObject;
|
||||
use KTXC\Models\Firewall\FirewallLogObject;
|
||||
|
||||
/**
|
||||
* Store for firewall rules and access logs
|
||||
*/
|
||||
class FirewallStore
|
||||
{
|
||||
protected const RULES_COLLECTION = 'firewall_rules';
|
||||
protected const LOGS_COLLECTION = 'firewall_logs';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
) {}
|
||||
|
||||
// ========================================
|
||||
// Rule Operations
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* List all rules for a tenant
|
||||
*/
|
||||
public function listRules(string $tenantId, bool $activeOnly = true): array
|
||||
{
|
||||
$filter = ['tenantId' => $tenantId];
|
||||
|
||||
if ($activeOnly) {
|
||||
$filter['enabled'] = true;
|
||||
$filter['$or'] = [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
];
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||
$list = [];
|
||||
|
||||
foreach ($cursor as $entry) {
|
||||
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
$list[] = $rule;
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find rules by IP address
|
||||
*/
|
||||
public function findRulesByIp(string $tenantId, string $ipAddress): array
|
||||
{
|
||||
$filter = [
|
||||
'tenantId' => $tenantId,
|
||||
'type' => ['$in' => [FirewallRuleObject::TYPE_IP, FirewallRuleObject::TYPE_IP_RANGE]],
|
||||
'enabled' => true,
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
]
|
||||
];
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||
$list = [];
|
||||
|
||||
foreach ($cursor as $entry) {
|
||||
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
$list[] = $rule;
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find rules by device fingerprint
|
||||
*/
|
||||
public function findRulesByDevice(string $tenantId, string $deviceFingerprint): array
|
||||
{
|
||||
$filter = [
|
||||
'tenantId' => $tenantId,
|
||||
'type' => FirewallRuleObject::TYPE_DEVICE,
|
||||
'value' => $deviceFingerprint,
|
||||
'enabled' => true,
|
||||
'$or' => [
|
||||
['expiresAt' => null],
|
||||
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
|
||||
]
|
||||
];
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
|
||||
$list = [];
|
||||
|
||||
foreach ($cursor as $entry) {
|
||||
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
$list[] = $rule;
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific rule by ID
|
||||
*/
|
||||
public function fetchRule(string $id): ?FirewallRuleObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if exact IP rule exists
|
||||
*/
|
||||
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
|
||||
'tenantId' => $tenantId,
|
||||
'type' => FirewallRuleObject::TYPE_IP,
|
||||
'value' => $ipAddress,
|
||||
'action' => $action,
|
||||
'enabled' => true,
|
||||
]);
|
||||
|
||||
if (!$entry) {
|
||||
return null;
|
||||
}
|
||||
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a rule
|
||||
*/
|
||||
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
if ($rule->getId()) {
|
||||
return $this->updateRule($rule);
|
||||
} else {
|
||||
return $this->createRule($rule);
|
||||
}
|
||||
}
|
||||
|
||||
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
$data = $rule->jsonSerialize();
|
||||
unset($data['id']); // Remove id for insert
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
|
||||
$rule->setId((string)$result->getInsertedId());
|
||||
return $rule;
|
||||
}
|
||||
|
||||
private function updateRule(FirewallRuleObject $rule): ?FirewallRuleObject
|
||||
{
|
||||
$id = $rule->getId();
|
||||
if (!$id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $rule->jsonSerialize();
|
||||
unset($data['id']);
|
||||
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
|
||||
['_id' => $id],
|
||||
['$set' => $data]
|
||||
);
|
||||
return $rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a rule
|
||||
*/
|
||||
public function destroyRule(FirewallRuleObject $rule): void
|
||||
{
|
||||
$id = $rule->getId();
|
||||
if (!$id) {
|
||||
return;
|
||||
}
|
||||
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete expired rules
|
||||
*/
|
||||
public function cleanupExpiredRules(): int
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
|
||||
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
|
||||
'expiresAt' => ['$ne' => null]
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Log Operations
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Log a firewall event
|
||||
*/
|
||||
public function createLog(FirewallLogObject $log): FirewallLogObject
|
||||
{
|
||||
$data = $log->jsonSerialize();
|
||||
unset($data['id']);
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
|
||||
$log->setId((string)$result->getInsertedId());
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs for a tenant with optional filters
|
||||
*/
|
||||
public function listLogs(
|
||||
string $tenantId,
|
||||
?string $ipAddress = null,
|
||||
?string $eventType = null,
|
||||
?string $result = null,
|
||||
int $limit = 100,
|
||||
int $offset = 0
|
||||
): array {
|
||||
$filter = ['tenantId' => $tenantId];
|
||||
|
||||
if ($ipAddress !== null) {
|
||||
$filter['ipAddress'] = $ipAddress;
|
||||
}
|
||||
if ($eventType !== null) {
|
||||
$filter['eventType'] = $eventType;
|
||||
}
|
||||
if ($result !== null) {
|
||||
$filter['result'] = $result;
|
||||
}
|
||||
|
||||
$cursor = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->find(
|
||||
$filter,
|
||||
[
|
||||
'sort' => ['timestamp' => -1],
|
||||
'limit' => $limit,
|
||||
'skip' => $offset
|
||||
]
|
||||
);
|
||||
|
||||
$list = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$log = (new FirewallLogObject())->jsonDeserialize((array)$entry);
|
||||
$list[] = $log;
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count recent failures from an IP within a time window
|
||||
*/
|
||||
public function countRecentFailures(
|
||||
string $tenantId,
|
||||
string $ipAddress,
|
||||
int $windowSeconds = 300
|
||||
): int {
|
||||
$since = (new \DateTimeImmutable())->modify("-{$windowSeconds} seconds");
|
||||
|
||||
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments([
|
||||
'tenantId' => $tenantId,
|
||||
'ipAddress' => $ipAddress,
|
||||
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
|
||||
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocked requests count for dashboard
|
||||
*/
|
||||
public function countBlockedRequests(
|
||||
string $tenantId,
|
||||
?\DateTimeImmutable $since = null
|
||||
): int {
|
||||
$filter = [
|
||||
'tenantId' => $tenantId,
|
||||
'result' => FirewallLogObject::RESULT_BLOCKED
|
||||
];
|
||||
|
||||
if ($since !== null) {
|
||||
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
|
||||
}
|
||||
|
||||
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old logs
|
||||
*/
|
||||
public function cleanupOldLogs(int $daysToKeep = 30): int
|
||||
{
|
||||
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
|
||||
|
||||
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
|
||||
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount();
|
||||
}
|
||||
}
|
||||
75
core/lib/Stores/TenantStore.php
Normal file
75
core/lib/Stores/TenantStore.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXC\Models\Tenant\TenantObject;
|
||||
|
||||
class TenantStore
|
||||
{
|
||||
|
||||
protected const COLLECTION_NAME = 'tenants';
|
||||
|
||||
public function __construct(
|
||||
protected readonly DataStore $dataStore
|
||||
) { }
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find();
|
||||
$list = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$entry = (new TenantObject())->jsonDeserialize((array)$entry);
|
||||
$list[$entry->getId()] = $entry;
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function fetch(string $identifier): ?TenantObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['identifier' => $identifier]);
|
||||
if (!$entry) { return null; }
|
||||
return (new TenantObject())->jsonDeserialize((array)$entry);
|
||||
}
|
||||
|
||||
public function fetchByDomain(string $domain): ?TenantObject
|
||||
{
|
||||
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['domains' => $domain]);
|
||||
if (!$entry) { return null; }
|
||||
$entity = new TenantObject();
|
||||
$entity->jsonDeserialize((array)$entry);
|
||||
return $entity;
|
||||
}
|
||||
|
||||
public function deposit(TenantObject $entry): ?TenantObject
|
||||
{
|
||||
if ($entry->getId()) {
|
||||
return $this->update($entry);
|
||||
} else {
|
||||
return $this->create($entry);
|
||||
}
|
||||
}
|
||||
|
||||
private function create(TenantObject $entry): ?TenantObject
|
||||
{
|
||||
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
|
||||
$entry->setId((string)$result->getInsertedId());
|
||||
return $entry;
|
||||
}
|
||||
|
||||
private function update(TenantObject $entry): ?TenantObject
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return null; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
|
||||
return $entry;
|
||||
}
|
||||
|
||||
public function destroy(TenantObject $entry): void
|
||||
{
|
||||
$id = $entry->getId();
|
||||
if (!$id) { return; }
|
||||
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
|
||||
}
|
||||
|
||||
}
|
||||
297
core/lib/Stores/UserAccountsStore.php
Normal file
297
core/lib/Stores/UserAccountsStore.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace KTXC\Stores;
|
||||
|
||||
use KTXC\Db\DataStore;
|
||||
use KTXF\Utile\UUID;
|
||||
|
||||
class UserAccountsStore
|
||||
{
|
||||
|
||||
public function __construct(protected DataStore $store)
|
||||
{ }
|
||||
|
||||
// =========================================================================
|
||||
// User Operations (Full User Object)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List all users for a tenant with optional filters
|
||||
*/
|
||||
public function listUsers(string $tenant, array $filters = []): array
|
||||
{
|
||||
// Build filter
|
||||
$filter = ['tid' => $tenant];
|
||||
|
||||
if (isset($filters['enabled'])) {
|
||||
$filter['enabled'] = (bool)$filters['enabled'];
|
||||
}
|
||||
|
||||
if (isset($filters['role'])) {
|
||||
$filter['roles'] = $filters['role'];
|
||||
}
|
||||
|
||||
// Fetch users with aggregated role data
|
||||
$pipeline = [
|
||||
['$match' => $filter],
|
||||
[
|
||||
'$lookup' => [
|
||||
'from' => 'user_roles',
|
||||
'localField' => 'roles',
|
||||
'foreignField' => 'rid',
|
||||
'as' => 'role_details'
|
||||
]
|
||||
],
|
||||
[
|
||||
'$addFields' => [
|
||||
'permissions' => [
|
||||
'$reduce' => [
|
||||
'input' => [
|
||||
'$map' => [
|
||||
'input' => '$role_details',
|
||||
'as' => 'r',
|
||||
'in' => ['$ifNull' => ['$$r.permissions', []]]
|
||||
]
|
||||
],
|
||||
'initialValue' => [],
|
||||
'in' => ['$setUnion' => ['$$value', '$$this']]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
['$unset' => 'role_details'],
|
||||
['$sort' => ['label' => 1]]
|
||||
];
|
||||
|
||||
$cursor = $this->store->selectCollection('user_accounts')->aggregate($pipeline);
|
||||
|
||||
$users = [];
|
||||
foreach ($cursor as $entry) {
|
||||
$users[] = (array)$entry;
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function fetchByIdentity(string $tenant, string $identity): array | null
|
||||
{
|
||||
|
||||
$pipeline = [
|
||||
[
|
||||
'$match' => [
|
||||
'tid' => $tenant,
|
||||
'identity' => $identity
|
||||
]
|
||||
],
|
||||
[
|
||||
'$lookup' => [
|
||||
'from' => 'user_roles',
|
||||
'localField' => 'roles', // Array field in `users`
|
||||
'foreignField' => 'rid', // Scalar field in `user_roles`
|
||||
'as' => 'role_details'
|
||||
]
|
||||
],
|
||||
// Add flattened, deduplicated permissions while preserving all original user fields
|
||||
[
|
||||
'$addFields' => [
|
||||
'permissions' => [
|
||||
'$reduce' => [
|
||||
'input' => [
|
||||
'$map' => [
|
||||
'input' => '$role_details',
|
||||
'as' => 'r',
|
||||
'in' => [ '$ifNull' => ['$$r.permissions', []] ]
|
||||
]
|
||||
],
|
||||
'initialValue' => [],
|
||||
'in' => [ '$setUnion' => ['$$value', '$$this'] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
// Optionally remove expanded role documents from output
|
||||
[ '$unset' => 'role_details' ]
|
||||
];
|
||||
|
||||
$entry = $this->store->selectCollection('user_accounts')->aggregate($pipeline)->toArray()[0] ?? null;
|
||||
if (!$entry) { return null; }
|
||||
return (array)$entry;
|
||||
}
|
||||
|
||||
public function fetchByIdentifier(string $tenant, string $identifier): array | null
|
||||
{
|
||||
$pipeline = [
|
||||
[
|
||||
'$match' => [
|
||||
'tid' => $tenant,
|
||||
'uid' => $identifier
|
||||
]
|
||||
],
|
||||
[
|
||||
'$lookup' => [
|
||||
'from' => 'user_roles',
|
||||
'localField' => 'roles',
|
||||
'foreignField' => 'rid',
|
||||
'as' => 'role_details'
|
||||
]
|
||||
],
|
||||
[
|
||||
'$addFields' => [
|
||||
'permissions' => [
|
||||
'$reduce' => [
|
||||
'input' => [
|
||||
'$map' => [
|
||||
'input' => '$role_details',
|
||||
'as' => 'r',
|
||||
'in' => [ '$ifNull' => ['$$r.permissions', []] ]
|
||||
]
|
||||
],
|
||||
'initialValue' => [],
|
||||
'in' => [ '$setUnion' => ['$$value', '$$this'] ]
|
||||
]
|
||||
]
|
||||
]
|
||||
],
|
||||
[ '$unset' => 'role_details' ]
|
||||
];
|
||||
|
||||
$entry = $this->store->selectCollection('user_accounts')->aggregate($pipeline)->toArray()[0] ?? null;
|
||||
if (!$entry) { return null; }
|
||||
return (array)$entry;
|
||||
}
|
||||
|
||||
public function fetchByProviderSubject(string $tenant, string $provider, string $subject): array | null
|
||||
{
|
||||
$entry = $this->store->selectCollection('user_accounts')->findOne([
|
||||
'tid' => $tenant,
|
||||
'provider' => $provider,
|
||||
'provider_subject' => $subject
|
||||
]);
|
||||
if (!$entry) { return null; }
|
||||
return (array)$entry;
|
||||
}
|
||||
|
||||
public function createUser(string $tenant, array $userData): array
|
||||
{
|
||||
$userData['tid'] = $tenant;
|
||||
$userData['uid'] = $userData['uid'] ?? UUID::v4();
|
||||
$userData['enabled'] = $userData['enabled'] ?? true;
|
||||
$userData['roles'] = $userData['roles'] ?? [];
|
||||
$userData['profile'] = $userData['profile'] ?? [];
|
||||
$userData['settings'] = $userData['settings'] ?? [];
|
||||
|
||||
$this->store->selectCollection('user_accounts')->insertOne($userData);
|
||||
|
||||
return $this->fetchByIdentifier($tenant, $userData['uid']);
|
||||
}
|
||||
|
||||
public function updateUser(string $tenant, string $uid, array $updates): bool
|
||||
{
|
||||
$result = $this->store->selectCollection('user_accounts')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $uid],
|
||||
['$set' => $updates]
|
||||
);
|
||||
|
||||
return $result->getModifiedCount() > 0;
|
||||
}
|
||||
|
||||
public function deleteUser(string $tenant, string $uid): bool
|
||||
{
|
||||
$result = $this->store->selectCollection('user_accounts')->deleteOne([
|
||||
'tid' => $tenant,
|
||||
'uid' => $uid
|
||||
]);
|
||||
|
||||
return $result->getDeletedCount() > 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile Operations
|
||||
// =========================================================================
|
||||
|
||||
public function fetchProfile(string $tenant, string $uid): ?array
|
||||
{
|
||||
$user = $this->store->selectCollection('user_accounts')->findOne(
|
||||
['tid' => $tenant, 'uid' => $uid],
|
||||
['projection' => ['profile' => 1, 'provider_managed_fields' => 1]]
|
||||
);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'profile' => $user['profile'] ?? [],
|
||||
'provider_managed_fields' => $user['provider_managed_fields'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
public function storeProfile(string $tenant, string $uid, array $profileFields): bool
|
||||
{
|
||||
if (empty($profileFields)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
foreach ($profileFields as $key => $value) {
|
||||
$updates["profile.{$key}"] = $value;
|
||||
}
|
||||
|
||||
$result = $this->store->selectCollection('user_accounts')->updateOne(
|
||||
['tid' => $tenant, 'uid' => $uid],
|
||||
['$set' => $updates]
|
||||
);
|
||||
|
||||
return $result->getModifiedCount() > 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Settings Operations
|
||||
// =========================================================================
|
||||
|
||||
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(
|
||||
['tid' => $tenant, 'uid' => $uid],
|
||||
['projection' => ['settings' => 1]]
|
||||
);
|
||||
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$userSettings = $user['settings'] ?? [];
|
||||
|
||||
if (empty($settings)) {
|
||||
return $userSettings;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($settings as $key) {
|
||||
$result[$key] = $userSettings[$key] ?? null;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function storeSettings(string $tenant, string $uid, array $settings): bool
|
||||
{
|
||||
if (empty($settings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
foreach ($settings as $key => $value) {
|
||||
$updates["settings.{$key}"] = $value;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user